From 06ca032deff4d5eec281af4030f34b46e9fe7adc Mon Sep 17 00:00:00 2001 From: Serhii A Date: Wed, 29 Jul 2026 10:06:11 +0200 Subject: [PATCH] Do not read the day as a two-digit year after the year position (#1358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Improve Czech (cs) language data (#1172) - Add month locative/dative forms (lednu, únoru, březnu, dubnu, květnu, červnu, červenci, srpnu, říjnu, prosinci) for dates like "v lednu 2023" - Add July abbreviation "črv" - Fix "za měsíc/týden/rok" not parsing: add accusative simplification patterns (previous patterns only matched instrumental -em forms) - Add tests covering the new expressions Co-Authored-By: Claude Sonnet 4.6 (1M context) * Do not read the day as a two-digit year after the year position (Close #519) In year-first date orders (e.g. YMD, used by the Japanese locale), parse_number tried the %y directive on every two-digit number, so in "4月20日 18:10" the day 20 was consumed as the year 2020 and the day was silently filled from the current date. If a component that the date order places after the year has already been found, the year position in the date string has already been passed, so a two-digit number is now read as a year only if it cannot be any other component (e.g. 99 in "4-99"). --------- Co-authored-by: Claude Sonnet 4.6 (1M context) --- dateparser/parser.py | 70 +++++++++++++++++++++++++-------------- tests/test_date_parser.py | 51 ++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 24 deletions(-) diff --git a/dateparser/parser.py b/dateparser/parser.py index 1f56515fc..1f3e4719c 100644 --- a/dateparser/parser.py +++ b/dateparser/parser.py @@ -649,31 +649,53 @@ def parse_number(token, skip_component=None): k: self.num_directives[k] for k in ("month", "day", "year") } - for component, directives in num_directives.items(): - if skip_component == component: - continue - for directive in directives: - try: - do = self._get_date_obj(token, directive) - prev_value = getattr(self, component, None) - if not prev_value: - return set_and_return(token, type, component, do) - else: - try: - prev_token, prev_type = getattr( - self, "_token_%s" % component - ) - if prev_type == type: - do = self._get_date_obj(prev_token, directive) - except ValueError: - self.unset_tokens.append( - (prev_token, prev_type, component) - ) + def try_directives(skip_directive=None): + for component, directives in num_directives.items(): + if skip_component == component: + continue + for directive in directives: + if directive == skip_directive: + continue + try: + do = self._get_date_obj(token, directive) + prev_value = getattr(self, component, None) + if not prev_value: return set_and_return(token, type, component, do) - except ValueError: - pass - else: - raise ValueError("Unable to parse: %s" % token) + else: + try: + prev_token, prev_type = getattr( + self, "_token_%s" % component + ) + if prev_type == type: + do = self._get_date_obj(prev_token, directive) + except ValueError: + self.unset_tokens.append( + (prev_token, prev_type, component) + ) + return set_and_return(token, type, component, do) + except ValueError: + pass + else: + raise ValueError("Unable to parse: %s" % token) + + order = list(self.ordered_num_directives) + components_after_year = order[order.index("year") + 1 :] + year_position_already_passed = any( + getattr(self, component) is not None + for component in components_after_year + ) + if year_position_already_passed: + # A component that the date order places after the year has + # already been found, so the year position in the date string + # has already been passed and this number cannot be a + # two-digit year: in "4月20日" ("April 20"), 20 is the day, + # not the year 2020 (#519). Read it as a two-digit year only + # if it cannot be anything else (e.g. 99 in "4-99"). + try: + return try_directives(skip_directive="%y") + except ValueError: + pass + return try_directives() def parse_alpha(token, skip_component=None): type = 1 diff --git a/tests/test_date_parser.py b/tests/test_date_parser.py index 012c8a956..b879017c0 100644 --- a/tests/test_date_parser.py +++ b/tests/test_date_parser.py @@ -1186,6 +1186,57 @@ def test_order(self, date_string, expected=None, order=None): self.then_date_was_parsed_by_date_parser() self.then_date_obj_exactly_is(expected) + @parameterized.expand( + [ + # Japanese dates without a year: the day must not be read as + # a two-digit year just because the year comes first (#519). + param( + "4月20日 18:10", + expected=datetime(2019, 4, 20, 18, 10), + languages=["ja"], + ), + param( + "3月8日", + expected=datetime(2019, 3, 8, 0, 0), + languages=["ja"], + ), + # The root cause is locale-independent: any year-first date + # order must not consume the day as a year either. + param( + "4-20 18:10", + expected=datetime(2019, 4, 20, 18, 10), + settings={"DATE_ORDER": "YMD"}, + ), + # A number that cannot be a day is still parsed as a year. + param( + "4-99", + expected=datetime(1999, 4, 27, 0, 0), + settings={"DATE_ORDER": "YMD"}, + ), + # Fully specified dates keep their meaning. + param( + "2020年4月20日 18:10", + expected=datetime(2020, 4, 20, 18, 10), + languages=["ja"], + ), + param( + "19年4月20日", + expected=datetime(2019, 4, 20, 0, 0), + languages=["ja"], + ), + ] + ) + def test_two_digit_day_is_not_confused_with_year( + self, date_string, expected=None, languages=None, settings=None + ): + self.given_parser( + languages=languages, + settings={"RELATIVE_BASE": datetime(2019, 6, 27), **(settings or {})}, + ) + self.when_date_is_parsed(date_string) + self.then_date_was_parsed_by_date_parser() + self.then_date_obj_exactly_is(expected) + @parameterized.expand( [ param("201508", expected=datetime(2015, 8, 20, 0, 0), order="DYM"),