Skip to content

Escape locale text in the parser's choice patterns - #1379

Open
Nishuuzz wants to merge 1 commit into
arrow-py:masterfrom
Nishuuzz:fix-locale-choice-regex
Open

Nishuuzz wants to merge 1 commit into
arrow-py:masterfrom
Nishuuzz:fix-locale-choice-regex

Conversation

@Nishuuzz

Copy link
Copy Markdown

Pull Request Checklist

Thank you for taking the time to improve Arrow! Before submitting your pull request, please check all appropriate boxes:

  • 🧪 Added tests for changed code.
  • 🛠️ All tests pass when run locally (run tox or make test to find out!).
  • 🧹 All linting checks pass when run locally (run tox -e lint or make lint to find out!).
  • 📚 Updated documentation for changed code.
  • ⏩ Code is up-to-date with the master branch.

If you have any questions about your code changes or any of the points above, please submit your questions along with the pull request and we will try our best to help!

Description of Changes

_generate_choice_re() builds the MMMM, MMM, dddd, ddd, a and A
patterns by joining the locale's names with | and compiling the result:

return re.compile(r"({})".format("|".join(choices)), flags=flags)

The choices are locale text, but they end up being treated as a pattern. Two
separate things go wrong because of that.

A trailing "." matches any character

Thai day abbreviations are "จ.", "อ.", "พ.", "พฤ.", "ศ.", "ส."
and "อา.". In the compiled alternation "พ." matches the "พฤ" of
"พฤ.", so the capture group comes back as "พฤ", which is not in
day_abbreviations, and parser.py line 643 raises ValueError out of
list.index(). Thai cannot parse its own Thursday or Sunday:

>>> arrow.get("พฤ.", "ddd", locale="th")
ValueError: 'พฤ' is not in list
>>> arrow.get("อา.", "ddd", locale="th")
ValueError: 'อา' is not in list

The same wildcard also means a character that is not part of any abbreviation
is accepted by the regex and then fails the lookup, so callers get ValueError
where ParserError is the documented failure:

>>> arrow.get("พX", "ddd", locale="th")
ValueError: 'พx' is not in list
>>> arrow.get("dlX", "ddd", locale="ca")
ValueError: 'dlx' is not in list
>>> arrow.get("երկX", "ddd", locale="hy")
ValueError: 'երկx' is not in list

Five locales carry a . in data that reaches this function: ca and th in
both day_abbreviations and month_abbreviations, hy in
day_abbreviations and meridians, ms in month_abbreviations, and is in
meridians. The others happen to survive because the wildcard lands on the
real . and the captured text is still a valid name, so they work by luck
rather than by construction.

The alternation takes the first branch that matches

Vietnamese October is "Tháng Mười", which is a prefix of November
"Tháng Mười Một" and December "Tháng Mười Hai". Where something follows the
token the regex is forced to backtrack and finds the longer name, but where
nothing does, the short branch wins and the remaining input is simply left
unconsumed:

>>> arrow.get("Tháng Mười Một", "MMMM", locale="vi").month
10          # November
>>> arrow.get("Tháng Mười Hai", "MMMM", locale="vi").month
10          # December
>>> arrow.get("2013 Tháng Mười Hai", "YYYY MMMM", locale="vi").month
10          # December

No error, just the wrong month. "MMMM YYYY" and "YYYY-MMMM-DD" happen to
be correct today, which is why this has not shown up: the trailing literal
forces the backtrack.

The change

Escape each choice, and sort longest first so a prefix cannot shadow a longer
name regardless of what follows the token. sorted is stable, so choices of
equal length keep their original order and the pattern stays deterministic.
ordinal_day_re is a deliberate pattern and does not go through this
function, so it is untouched.

Verification

I formatted and parsed back every locale-text token for all 81 locales:
MMMM and MMM for all twelve months, dddd and ddd for all seven
weekdays, and A, which is 3199 round trips. Before the change 7 fail, after
it 3 fail:

before                                                     after
  lv  ddd   want=5  'pi'              got isoweekday 1       (unchanged)
  mt  ddd   want=7  'Ħ'               got isoweekday 4       (unchanged)
  si  ddd   want=3  'බදා'             got isoweekday 2       (unchanged)
  th  ddd   want=4  'พฤ.'   ValueError 'พฤ' is not in list    fixed
  th  ddd   want=7  'อา.'   ValueError 'อา' is not in list    fixed
  vi  MMMM  want=11 'Tháng Mười Một'  got month 10            fixed
  vi  MMMM  want=12 'Tháng Mười Hai'  got month 10            fixed

and the three ValueErrors from bogus input become ParserError.

The three that remain are a different defect and I have deliberately left them
alone: lv, mt and si each list the same abbreviation for two different
weekdays, so list.index() can only ever return the first. lv has "pi" for
both Monday (pirmdiena) and Friday (piektdiena); mt has "Ħ" for both
Thursday (Il-Ħamis) and Sunday (Il-Ħadd); si has "බදා" for both Tuesday
and Wednesday, which #1336 already fixes. Those are locale data corrections
rather than a parser bug, so they want their own change — happy to open one for
lv and mt if you would like, and once all three are in it would be worth a
test asserting no locale repeats a day abbreviation.

pytest tests/ goes from 1901 to 1904 passing, 1 skipped, coverage 99.93%,
with no test failing before or after. The three new tests all fail on master
and pass with the change. Worth noting that my first draft of the Vietnamese
test used "YYYY-MMMM-DD" and passed on master, because the trailing -01
forces the backtrack; the test now uses a format where the token is last,
which is what actually reproduces.

On the two unchecked boxes: I could not run make lint, because it drives
pre-commit and neither that nor isort installs in this environment. I ran
black --check (clean), flake8 (clean) and mypy arrow/parser.py, which
reports no new errors — 17 with the change against 20 on master, all
pre-existing. The change touches no imports, so isort has nothing to do. I
did not add documentation because _generate_choice_re is internal and no
documented behaviour changes; the fix makes the parser match what the docs
already promise. Say the word if you would like a changelog entry.

_generate_choice_re() joins the locale's month names, day names and
meridians with "|" and compiles the result, so the data is treated as a
pattern rather than as text. Two things go wrong because of that.

Several locales abbreviate with a trailing ".", which matches any
character. Thai day abbreviations are "จ.", "อ.", "พ.", "พฤ.", "ศ.",
"ส." and "อา.", so "พ." matches the "พฤ" of "พฤ.", the capture group
comes back as "พฤ", and the name lookup then raises ValueError. Thai
cannot parse its own Thursday or Sunday, and a bogus character in that
position is accepted by the regex and fails the same way instead of
raising ParserError.

The alternation also takes the first branch that matches, and some
choices are prefixes of others. Vietnamese October, "Tháng Mười", is a
prefix of November and December, so where nothing follows the token to
force the regex to backtrack, both parse as October and the rest of the
input is left unconsumed.

Escape each choice and order them longest first.
@codecov

codecov Bot commented Oct 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (2224255) to head (6e7408b).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff            @@
##            master     #1379   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           10        10           
  Lines         2315      2315           
  Branches       358       359    +1     
=========================================
  Hits          2315      2315           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@Wkhanex Wkhanex left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked all 12 full month names and abbreviations in 12 locales (en, fr, de, es, vi, th, ca, hy, lv, mt, ru and hu): 288 public-parser checks resolved the intended month/date. This includes the Vietnamese prefix overlap at the end of the input, where no following token forces a longer alternative.

Fourteen additional choice-pattern checks covered literal regex metacharacters and a shorter-prefix/longer-choice pair; all matched the complete literal spelling. In total, 302 standalone cases passed. I did not run the full upstream suite.

I used Python 3.12.14 against commit 6e7408b. These were focused checks, not a full-suite run.

@Nishuuzz

Copy link
Copy Markdown
Author

Thanks for checking it so thoroughly.

One note for whoever picks this up: the red windows-latest (pypy-3.11) check isn't from this change. The job fails while installing dependencies — pip gets a broken connection building pyyaml, so the tests never run:

ERROR: Could not install packages due to an OSError:
  ('Connection broken: IncompleteRead(37256 bytes read, 1054 more expected)')
ERROR: Failed to build 'pyyaml' when installing build dependencies for pyyaml

The same job is the only failure on master at 2224255, which is this branch's base, and on #1380 as well. The other 26 checks pass and coverage is unchanged at 100%.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants