Parse dates wrapped in harmless extra text (Close #518) - #1356
Conversation
dateparser.parse() returned None for strings such as "Actualisé le 17 avril 2019" (fr) or "Published on 16 April 2019" (en): an unrecognised leading/trailing word makes the whole string fail the locale applicability check (Dictionary.are_tokens_valid), so no locale is ever tried and parsing gives up before it starts. When strict parsing of the whole string finds nothing and the language is known (given or detected), retry after stripping unrecognised words from the leading/trailing edges. Stripping is applied only when the surviving core is still a valid date for that locale AND it contains a month, weekday or relative expression, so genuine non-dates and bare numbers are not rescued and interior noise still fails. The fallback lives in dateparser.parse() only; get_date_data(), translation, applicability and search_dates() are left untouched, so language detection and date search keep their existing strictness. Related to scrapinghub#521: its search_dates facet is a separate translate_search chunking behaviour and is not addressed here; its parse facet ("Il giorno ...") additionally needs handling of a dangling unit word ("giorno" -> "day") and is left as a known limitation. Close scrapinghub#518 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #1356 +/- ##
==========================================
+ Coverage 97.07% 97.10% +0.03%
==========================================
Files 236 236
Lines 2975 3008 +33
==========================================
+ Hits 2888 2921 +33
Misses 87 87 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Opus 5 feedback |
Address review feedback on scrapinghub#1356: - Implement the fallback inside DateDataParser.get_date_data() behind a new IGNORE_SURROUNDING_TEXT setting (default off), so parse() and get_date_data() agree and every language hint source (languages, locales, region, DEFAULT_LANGUAGES, detect_languages_function, or none at all) behaves consistently. search_dates and language detection are unaffected by default. - Strip unknown edge tokens inside Locale.translate()/is_applicable() instead of re-joining raw tokens and re-entering the pipeline, so numerals translation, normalization and simplifications run exactly once and no fragile "".join() is needed. - Drop the month/weekday anchor heuristic and the magic KNOWN_WORD_TOKENS[:19] slice: the setting is explicit opt-in, so purely numeric dates, date_formats and relative expressions work too. - Parameterise _get_applicable_locales() instead of duplicating the locale enumeration; delete the dead code paths flagged in review (unreachable TypeError, no-op previous_locales write, dead default parameter). - Move parse()-level tests to test_clean_api.py, add locale-level tests to test_languages.py and parser-level tests to test_date.py; lock in that get_date_data and search_dates stay strict by default. - Document the new setting in docs/settings.rst. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks @AdrianAtZyte for the thorough review — the verification work (fuzz corpus, the reachability table, mapping each uncovered line to a design problem) made it very clear what to change. I've reworked the PR in 8bb8acb following your main recommendation, and updated the PR description to match. Point by point: The parse()/get_date_data() split — gone. The behavior is now an explicit opt-in setting, Inconsistent reachability — the fallback reuses the same False positives — now opt-in and documented. Too narrow — the month/weekday anchor heuristic (and the magic Code-level issues — addressed mostly by deletion:
Tests, docs, coverage — the |
Opus feedback: |
…s edges Follow-up to the review of scrapinghub#1356 (issue scrapinghub#518). When IGNORE_SURROUNDING_TEXT retries parsing after dropping unrecognized tokens from the edges of the string, Dictionary._strip_unknown_edge_tokens() judged a token "known" via token.isdigit(), a relative-expression match, or membership in the locale dictionary. The only timezone-shaped strings the dictionary lists are ["am", "pm", "UTC", "GMT", "Z"], so every other recognized zone abbreviation (EST, PST, CET, ...) was treated as noise and stripped off the trailing edge before translation. As a result parse('Updated 23 March 2000 1:21 PM EST', settings={'IGNORE_SURROUNDING_TEXT': True}) silently dropped the timezone, while the same string without the leading "Updated " kept it -- a silent correctness bug, not a missing feature. - Add dateparser.timezone_parser.is_timezone_token(): a case-insensitive *full* match on the stripped token, unlike the case-sensitive, unanchored prefix match of word_is_tz(). Edge tokens reaching _strip_unknown_edge_tokens are already lowercased and may carry embedded whitespace (e.g. " est"), and a fullmatch keeps a noise word such as "actualise" from spuriously matching the ACT zone. - Keep a recognized timezone at the trailing edge only: a timezone follows the date it qualifies and does not precede it, so the leading edge keeps stripping unconditionally. The choice, and the limitation that the timezone is kept only when it is the final token (trailing prose after it merges the tokens and drops it), are stated in the method docstring, inline comments and the docs. - are_tokens_valid() now calls the shared _is_known_token() helper instead of restating the same per-token condition, so the two cannot drift. - Document the actual stripping rule in docs/settings.rst: stripping stops at the first token the locale recognizes as a number, relative expression or dictionary word, so an incidental edge number blocks the feature (e.g. 'invoice 12345 paid on 3 March 2019' -> None); point such input at search_dates(). Clarify in the warning that a trailing timezone is applied only as the last token, while a leading, trailing-followed-by-text, or unrecognized one is dropped. - Tests: assert a wrapped trailing timezone (EST, PST, JST, and a parenthesized GMT) yields the same tz-aware instant as the bare date, checking the timezone *name* so a case cannot pass by the dropped offset coinciding with the local timezone; pin that only the trailing edge keeps a timezone; lock both the "trailing timezone followed by more text is dropped" and "edge number blocks stripping" limitations; unit-test is_timezone_token against lowercased, space-padded and look-alike tokens. Refs scrapinghub#518, scrapinghub#1356 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
dateparser.parse()returnsNonefor strings that are a perfectly good date wrapped in a bit of harmless leading (or trailing) text, e.g.'Actualisé le 17 avril 2019'(issue #518). The problem is not French-specific — the same happens in any language.This PR adds an explicit, opt-in
IGNORE_SURROUNDING_TEXTsetting (default:False) implemented insideDateDataParser.get_date_data():Closes #518.
Root cause
parse()requires the whole string to be recognised: before a locale is even tried,Locale.is_applicable()→Dictionary.are_tokens_valid()demands that every token be a number, a relative expression, or a dictionary word. A single unknown word such asActualisémakes the locale non-applicable, so parsing gives up before it starts.Design
The setting is honored by
get_date_data()itself, soparse()andget_date_data()always agree, and it works the same regardless of how the language is known (languages=,locales=,region=,DEFAULT_LANGUAGES,detect_languages_function, or full-scan when no hint is given).When the setting is enabled and the regular, whole-string parse found nothing,
get_date_data()retries the same locale iteration withignore_surrounding_text=True:Locale.is_applicable(..., ignore_surrounding_text=True)validates the tokens afterDictionary._strip_unknown_edge_tokens()removes unrecognised tokens from the leading/trailing edges. An unrecognised token in the interior still makes the locale non-applicable, so'17 foobar avril 2019'stays unparsed.Locale.translate(..., ignore_surrounding_text=True)drops the same edge tokens inside the single, normal translation pass (numerals → normalization → simplifications → split → strip → translate → join), then parsing proceeds exactly as usual. The result is identical to parsing the wrapped date without its surrounding text.Because stripping happens inside the regular pipeline, there is no re-simplification, no re-joining of raw tokens, and
date_formats,STRICT_PARSING,REQUIRE_PARTS,PREFER_*etc. compose naturally. Purely numeric dates ('published 2019-04-16 ok') and relative expressions ('asdf 3 hours ago asdf') are handled too — no month/weekday anchor is required, since enabling the setting is an explicit request for this behavior.Why this is safe
search_dates()and language detection keep their existing strictness; tests lock this in.'23 March 2000, 1:21 PM CET'still parses timezone-aware.docs/settings.rst, including the caveat that strings merely containing date-like words (e.g.'Chapter 12 March of the Penguins') produce a date when the setting is enabled, and the suggestion to combine it withSTRICT_PARSING/REQUIRE_PARTS.Tests
tests/test_clean_api.py:parse()-level tests — the issue examples, numeric/date_formats/relative cases, every language-hint source, default-off locks, non-date guards, the documented caveat, and asearch_datesstrictness lock.tests/test_date.py:get_date_data()-level tests, includingtry_previous_localesand the default-strict lock.tests/test_languages.py:Locale.is_applicable/translateunit tests forignore_surrounding_text, asserting the stripped translation equals the translation of the unwrapped date.Full suite:
24114 passed, 20 skipped, 1 xfailed. 100% patch coverage; ruff/ruff-format clean; docs build clean.Known limitations
'更新日 2019年4月17日 です'(ja),更新日splits into更新+ the known token日, which leaves a dangling "day" unit the parser cannot drop (same class of problem as theparse()facet of search_dates cannot find complete date #521, out of scope here).'2019年4月17日 です'works.region='FR'withoutlanguagescannot parse French with or without this setting, because the loader has no<lang>-FRlocale forfr(plainfrcovers France); that is pre-existing loader behavior, unrelated to this PR.languages=['fr'], region=...andlocales=['fr-CA']work with the setting and are tested.