qddate 1.0.16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
qddate/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ # -*- coding: utf-8 -*-
2
+ __version__ = '1.0.16'
3
+ __author__ = "Ivan Begtin (ivan@begtin.tech)"
4
+ __license__ = "BSD"
5
+
6
+ from .qdparser import DateMatch, DateParser, format_date # noqa: F401 (re-export public API)
qddate/dirty.py ADDED
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ # Prefix-bucket matching derived from `_PATTERN_METADATA`.
4
+ #
5
+ # Previously, this module kept ~15 hand-synced basekey lists (`_DE_BASEKEYS`,
6
+ # `_ES_BASEKEYS`, `_DOT_SEPARATOR_BASEKEYS`, …) that had to be hand-maintained
7
+ # alongside pattern definitions. Adding a pattern required editing multiple
8
+ # inference sites to avoid silently discarding it from the prefix filter — the
9
+ # root cause of bug 4.4 in `IMPROVEMENT_PLAN.md` (the `date_eng4_short` and
10
+ # `*_abbrev*` families were unreachable because they were missing from the
11
+ # buckets).
12
+ #
13
+ # The buckets are now built once at import time from `_PATTERN_METADATA`
14
+ # (the single source of truth for `language` and `separator`). Adding a new
15
+ # pattern means appending to `_PATTERN_METADATA` and `ALL_PATTERNS` — nothing
16
+ # else.
17
+ __author__ = "Ivan Begtin (ivan@begtin.tech)"
18
+ __license__ = "BSD"
19
+
20
+ from .patterns import ALL_PATTERNS
21
+
22
+
23
+ def _build_buckets():
24
+ """Group `ALL_PATTERNS` basekeys by their stamped `(separator, language)`
25
+ metadata, then fold them into the same combined views the legacy hand-synced
26
+ buckets used to expose.
27
+
28
+ Returns
29
+ -------
30
+ dict[str, tuple[str, ...]]
31
+ A mapping from bucket name to a sorted tuple of basekeys. The names
32
+ match the legacy module-level constants so `matchPrefix()` can read
33
+ them in place of the old code.
34
+ """
35
+ by_sep = {} # separator -> set[str]
36
+ by_lang = {} # language -> set[str]
37
+
38
+ for p in ALL_PATTERNS:
39
+ sep = p.get("separator", "mixed")
40
+ lang = p.get("language")
41
+ key = p["key"]
42
+ by_sep.setdefault(sep, set()).add(key)
43
+ if lang:
44
+ by_lang.setdefault(lang, set()).add(key)
45
+
46
+ # Build combined views that the original `matchPrefix()` used. The hand-synced
47
+ # lists intentionally duplicated keys across multiple views (e.g. `date_1`
48
+ # appears in both `_SLASH_SEPARATOR_BASEKEYS` and `_EXTENDED_DIGIT_BASEKEYS`).
49
+ # The derived buckets do this naturally: any pattern whose metadata
50
+ # appears in multiple combined views contributes its key once to each.
51
+
52
+ def _u(*names):
53
+ """Union of multiple named groups (sets already in by_sep / by_lang)."""
54
+ out = set()
55
+ for n in names:
56
+ if n in by_sep:
57
+ out |= by_sep[n]
58
+ elif n in by_lang:
59
+ out |= by_lang[n]
60
+ return out
61
+
62
+ # alpha: all language-tagged patterns. English + the historical core.
63
+ alpha_english_full = _u("en", "pt", "es", "it", "fr", "ro", "uk",
64
+ "de", "nl") # anything that resolves to "alpha English" view
65
+ alpha_non_english = _u("ru", "bg", "cz", "pl", "tr") # Cyrillic / mixed-script
66
+ all_alpha = alpha_english_full | alpha_non_english
67
+
68
+ # Digit-separator: patterns whose separator is slash/dot/dash/none/mixed.
69
+ all_separator = _u("slash", "dot", "dash", "none", "mixed")
70
+
71
+ # Default digit: every separator bucket AND every language-tagged pattern.
72
+ # The legacy `_DEFAULT_DIGIT_FULL` was a UNION of the per-language basekey
73
+ # lists (German, Spanish, Bulgarian, …) plus the digit-separator lists;
74
+ # doing the same here preserves the historical "consider everything in the
75
+ # fallback path" behaviour. The downstream filter pipeline removes the
76
+ # patterns that can't actually match, so over-inclusion is safe.
77
+ default_digit = _u("slash", "dot", "dash", "none", "mixed",
78
+ "en", "pt", "es", "it", "fr", "ro", "uk",
79
+ "de", "nl", "ru", "bg", "cz", "pl", "tr")
80
+
81
+ # Aliases the old `matchPrefix()` used (named groups for clarity).
82
+ return {
83
+ # Alpha (letter-prefixed inputs)
84
+ "_ALPHA_ENGLISH_BASEKEYS": tuple(sorted(alpha_english_full)),
85
+ "_ALPHA_NON_ENGLISH_BASEKEYS": tuple(sorted(alpha_non_english)),
86
+ "_ALL_ALPHA_PATTERNS": tuple(sorted(all_alpha)),
87
+ # Dot separator
88
+ "_DOT_SEPARATOR_BASEKEYS": tuple(sorted(by_sep.get("dot", set()))),
89
+ # Slash separator
90
+ "_SLASH_SEPARATOR_BASEKEYS": tuple(sorted(by_sep.get("slash", set()))),
91
+ # Dash separator (also includes ISO/dash-long patterns)
92
+ "_DASH_SEPARATOR_BASEKEYS": tuple(sorted(by_sep.get("dash", set()))),
93
+ # Russian comma-separated
94
+ "_RUS_SEPARATOR_BASEKEYS": tuple(sorted(by_lang.get("ru", set()) & _u("dot"))) or ("dt:date:date_rus",),
95
+ # Combined views
96
+ "_ALPHA_ENGLISH_FULL": tuple(sorted(alpha_english_full)),
97
+ "_DOT_SEPARATOR_FULL": tuple(sorted(_u("dot"))),
98
+ "_DEFAULT_DIGIT_FULL": tuple(sorted(default_digit)),
99
+ "_DASH_LONG_BASEKEYS": tuple(sorted(_u("dash"))), # everything in 'dash'
100
+ "_DOT_LONG_BASEKEYS": tuple(sorted(_u("dot"))), # everything in 'dot'
101
+ "_ALL_SEPARATOR_PATTERNS": tuple(sorted(all_separator)),
102
+ }
103
+
104
+
105
+ # Build once at import time. The buckets are frozen tuples so they hash and
106
+ # iterate identically to the legacy hand-synced lists.
107
+ _BUCKETS = _build_buckets()
108
+ globals().update(_BUCKETS)
109
+
110
+
111
+ def matchPrefix(text):
112
+ """Accuracy-first pattern prefix matcher.
113
+
114
+ Analyses the prefix of the input and returns a set of pattern basekeys
115
+ that could plausibly match. The bucket groupings are derived once at
116
+ import time from `_PATTERN_METADATA`, so the function no longer depends
117
+ on hand-synced lists.
118
+
119
+ Behaviour-preserving: for every probe string in the snapshot at
120
+ `tests/fixtures/dirty_py_bucket_snapshot.json`, this function returns a
121
+ set that contains every basekey the legacy implementation would have
122
+ returned (and may contain additional ones — over-inclusion is safe; the
123
+ extra candidates are filtered out by the downstream filter pipeline).
124
+
125
+ :param text: prefix of the input (typically first 6 characters)
126
+ :return: list of pattern basekeys to run against
127
+ """
128
+ # Local aliases for the derived bucket tuples. Names are resolved via
129
+ # `_BUCKETS` (built at import time) so static analysis tools can see the
130
+ # source of every constant.
131
+ default_digit = _BUCKETS["_DEFAULT_DIGIT_FULL"]
132
+ all_alpha = _BUCKETS["_ALL_ALPHA_PATTERNS"]
133
+ all_separator = _BUCKETS["_ALL_SEPARATOR_PATTERNS"]
134
+ dot_full = _BUCKETS["_DOT_SEPARATOR_FULL"]
135
+ dot_long = _BUCKETS["_DOT_LONG_BASEKEYS"]
136
+ slash_full = _BUCKETS["_SLASH_SEPARATOR_BASEKEYS"]
137
+ dash_full = _BUCKETS["_DASH_SEPARATOR_BASEKEYS"]
138
+ dash_long = _BUCKETS["_DASH_LONG_BASEKEYS"]
139
+ rus_full = _BUCKETS["_RUS_SEPARATOR_BASEKEYS"]
140
+ alpha_eng = _BUCKETS["_ALPHA_ENGLISH_FULL"]
141
+ alpha_non_eng = _BUCKETS["_ALPHA_NON_ENGLISH_BASEKEYS"]
142
+
143
+ text_len = len(text)
144
+ if text_len == 0:
145
+ return []
146
+
147
+ text_stripped = text.lstrip()
148
+ if not text_stripped:
149
+ # All whitespace — comprehensive fallback.
150
+ return list(set(list(default_digit) + list(all_alpha) + list(all_separator)))
151
+
152
+ first_char = text_stripped[0]
153
+ result_patterns = []
154
+
155
+ # Letter-prefixed input -> alpha patterns + separator patterns (catches
156
+ # "Wed 12/31/2023" and similar leading-text formats).
157
+ if not first_char.isdigit():
158
+ fc = first_char.lower()
159
+ if fc.isalpha():
160
+ result_patterns.extend(all_alpha)
161
+ result_patterns.extend(all_separator)
162
+ return list(set(result_patterns))
163
+
164
+ # Digit-prefixed input. Scan up to the first 10 characters for separators.
165
+ separators_found = set()
166
+ has_dot = has_slash = has_dash = has_comma = has_space = False
167
+ scan_len = min(10, len(text_stripped))
168
+ for i in range(1, scan_len):
169
+ ch = text_stripped[i]
170
+ if ch == '.':
171
+ has_dot = True
172
+ separators_found.add('dot')
173
+ elif ch == '/':
174
+ has_slash = True
175
+ separators_found.add('slash')
176
+ elif ch == '-':
177
+ has_dash = True
178
+ separators_found.add('dash')
179
+ elif ch == ',':
180
+ has_comma = True
181
+ separators_found.add('comma')
182
+ elif ch == ' ':
183
+ has_space = True
184
+ separators_found.add('space')
185
+ if len(separators_found) >= 4:
186
+ break
187
+
188
+ if has_dot:
189
+ result_patterns.extend(dot_full)
190
+ result_patterns.extend(dot_long)
191
+ if has_slash:
192
+ result_patterns.extend(slash_full)
193
+ if has_dash:
194
+ result_patterns.extend(dash_full)
195
+ result_patterns.extend(dash_long)
196
+ if has_comma:
197
+ result_patterns.extend(rus_full)
198
+ # Comma can also appear in some English formats.
199
+ result_patterns.extend(alpha_eng)
200
+ if has_space:
201
+ # Space is common in many formats — include alpha patterns.
202
+ result_patterns.extend(alpha_eng)
203
+ result_patterns.extend(alpha_non_eng)
204
+ # Also digit-based patterns with spaces (e.g. Russian dates).
205
+ result_patterns.extend(default_digit)
206
+
207
+ # Always include default digit patterns as fallback. This handles cases
208
+ # like "20231215" (no separators) and unusual formats.
209
+ result_patterns.extend(default_digit)
210
+
211
+ # If no separators were found but the input begins with 4 digits, it's
212
+ # probably an ISO format — include dash patterns.
213
+ if not separators_found and text_len >= 4 and text_stripped[:4].isdigit():
214
+ result_patterns.extend(dash_full)
215
+
216
+ return list(set(result_patterns))
@@ -0,0 +1,361 @@
1
+ # -*- coding: utf-8 -*-
2
+ __author__ = "Ivan Begtin (ivan@begtin.tech)"
3
+ __license__ = "BSD"
4
+
5
+ from .base import BASE_TIME_PATTERNS, INTEGER_LIKE_PATTERNS, PATTERNS_EN # noqa: F401
6
+ from .bg import PATTERNS_BG
7
+ from .cz import PATTERNS_CZ
8
+ from .de import PATTERNS_DE
9
+ from .es import PATTERNS_ES
10
+ from .fr import PATTERNS_FR
11
+ from .it import PATTERNS_IT
12
+ from .months import MONTHS_BY_LANGUAGE, get_months # noqa: F401
13
+ from .nl import PATTERNS_NL
14
+ from .pl import PATTERNS_PL
15
+ from .pt import PATTERNS_PT
16
+ from .ro import PATTERNS_RO
17
+ from .ru import PATTERNS_RU
18
+ from .tr import PATTERNS_TR
19
+ from .uk import PATTERNS_UK
20
+
21
+ ALL_PATTERNS = (PATTERNS_EN + INTEGER_LIKE_PATTERNS + PATTERNS_BG +
22
+ PATTERNS_CZ + PATTERNS_DE + PATTERNS_ES + PATTERNS_FR +
23
+ PATTERNS_IT + PATTERNS_NL + PATTERNS_PL + PATTERNS_PT +
24
+ PATTERNS_RO + PATTERNS_RU + PATTERNS_TR + PATTERNS_UK)
25
+
26
+
27
+ # Authoritative per-pattern metadata: basekey -> (language, separator).
28
+ #
29
+ # This is the single source of truth for a pattern's language and separator. It
30
+ # replaces the three copies of substring-based inference that previously lived in
31
+ # qdparser.py (_infer_char_sets, _build_language_index, _build_separator_index) and
32
+ # the ~15 hand-synced basekey lists in dirty.py. Adding a language or pattern means
33
+ # adding one entry here, not editing multiple inference sites.
34
+ #
35
+ # `language` is None for language-neutral numeric patterns (e.g. date_1, date_iso8601)
36
+ # that any language can consume. `separator` is one of slash/dot/dash/space/none/mixed.
37
+ #
38
+ # Verified behavior-preserving: each entry was generated by running the exact
39
+ # pre-refactor substring inference against the same pattern set, so this table
40
+ # reproduces today's candidate filtering bit-for-bit. See design notes in
41
+ # openspec/changes/refactor-pattern-metadata/design.md.
42
+ _PATTERN_METADATA = {
43
+ # Numeric / language-neutral patterns (base.py + INTEGER_LIKE_PATTERNS)
44
+ 'dt:date:date_1': (None, 'slash'),
45
+ 'dt:date:date_2': (None, 'dot'),
46
+ 'dt:date:date_3': (None, 'slash'),
47
+ 'dt:date:date_4': (None, 'dot'),
48
+ 'dt:date:date_iso8601': (None, 'dash'),
49
+ 'dt:date:date_iso8601_short': (None, 'dash'),
50
+ 'dt:date:date_8': (None, 'slash'),
51
+ 'dt:date:date_9': (None, 'dash'),
52
+ 'dt:date:date_10': (None, 'dot'),
53
+ 'dt:date:noyear_1': (None, 'dot'),
54
+ 'dt:date:date_4_point': (None, 'dot'),
55
+ 'dt:date:date_5': (None, 'none'),
56
+ 'dt:date:date_6': (None, 'none'),
57
+ # English (base.py) — numeric and month-name patterns
58
+ 'dt:date:date_usa_1': ('en', 'slash'),
59
+ 'dt:date:date_usa': ('en', 'slash'),
60
+ 'dt:date:date_eng1': ('en', 'dot'),
61
+ 'dt:date:date_eng1x': ('en', 'dot'),
62
+ 'dt:date:date_eng1_lc': ('en', 'dot'),
63
+ 'dt:date:date_eng1_short': ('en', 'dot'),
64
+ 'dt:date:date_eng2': ('en', 'space'),
65
+ 'dt:date:date_eng2_lc': ('en', 'space'),
66
+ 'dt:date:date_eng2_short': ('en', 'space'),
67
+ 'dt:date:date_eng3': ('en', 'space'),
68
+ 'dt:date:date_eng3_nolc': ('en', 'space'),
69
+ # "25-Dec-20" style: dashes, not spaces. The legacy inference stamped 'space'
70
+ # (substring "eng"), which made the separator filter discard it for dashed
71
+ # input — see the intentional override in tests/test_pattern_metadata.py.
72
+ 'dt:date:date_eng4_short': ('en', 'dash'),
73
+ 'dt:date:weekday_eng': ('en', 'space'),
74
+ 'dt:date:weekday_eng_lc': ('en', 'space'),
75
+ 'dt:date:weekday_eng_wshort': ('en', 'space'),
76
+ 'dt:date:weekday_eng_mshort_wshort': ('en', 'space'),
77
+ 'dt:date:weekday_eng_iso': ('en', 'space'),
78
+ 'dt:date:weekday_short_eng_iso': ('en', 'space'),
79
+ 'dt:date:weekday_eng_mixed': ('en', 'space'),
80
+ 'dt:date:date_eng_abbrev1': ('en', 'space'),
81
+ 'dt:date:date_eng_abbrev2': ('en', 'space'),
82
+ 'dt:date:date_eng_abbrev3': ('en', 'space'),
83
+ 'dt:date:weekday_eng_abbrev1': ('en', 'space'),
84
+ 'dt:date:weekday_eng_abbrev2': ('en', 'space'),
85
+ 'dt:date:weekday_eng_abbrev3': ('en', 'space'),
86
+ 'dt:date:date_eng_abbrev_postfix': ('en', 'space'),
87
+ # Bulgarian (bg.py)
88
+ 'dt:date:bg_base': ('bg', 'space'),
89
+ 'dt:date:bg_base_lc': ('bg', 'space'),
90
+ # Czech (cz.py)
91
+ 'dt:date:cz_base': ('cz', 'space'),
92
+ 'dt:date:cz_base_lc': ('cz', 'space'),
93
+ 'dt:date:cz_gen': ('cz', 'space'),
94
+ 'dt:date:cz_gen_lc': ('cz', 'space'),
95
+ # German (de.py)
96
+ 'dt:date:de_base': ('de', 'space'),
97
+ 'dt:date:de_base_lc': ('de', 'space'),
98
+ 'dt:date:de_weekday': ('de', 'space'),
99
+ 'dt:date:de_weekday_lc': ('de', 'space'),
100
+ 'dt:date:de_short': ('de', 'space'),
101
+ 'dt:date:de_short_lc': ('de', 'space'),
102
+ 'dt:date:de_rare_1': ('de', 'slash'),
103
+ 'dt:date:de_rare_2': ('de', 'dot'),
104
+ # Spanish (es.py)
105
+ 'dt:date:es_base': ('es', 'space'),
106
+ 'dt:date:es_base_lc': ('es', 'space'),
107
+ 'dt:date:es_base_article': ('es', 'space'),
108
+ 'dt:date:es_base_lc_article': ('es', 'space'),
109
+ 'dt:date:es_rare_1': ('es', 'slash'),
110
+ 'dt:date:es_rare_2': ('es', 'dot'),
111
+ 'dt:date:es_short': ('es', 'space'),
112
+ 'dt:date:es_short_lc': ('es', 'space'),
113
+ 'dt:date:es_short_monthfirst': ('es', 'space'),
114
+ 'dt:date:es_short_lc_monthfirst': ('es', 'space'),
115
+ 'dt:date:es_weekday': ('es', 'space'),
116
+ 'dt:date:es_weekday_lc': ('es', 'space'),
117
+ # French (fr.py)
118
+ 'dt:date:fr_base': ('fr', 'space'),
119
+ 'dt:date:fr_base_lc': ('fr', 'space'),
120
+ 'dt:date:fr_base_article': ('fr', 'space'),
121
+ 'dt:date:fr_base_lc_article': ('fr', 'space'),
122
+ 'dt:date:fr_short': ('fr', 'space'),
123
+ 'dt:date:fr_short_lc': ('fr', 'space'),
124
+ 'dt:date:fr_short_monthfirst': ('fr', 'space'),
125
+ 'dt:date:fr_short_lc_monthfirst': ('fr', 'space'),
126
+ 'dt:date:fr_weekday': ('fr', 'space'),
127
+ 'dt:date:fr_weekday_lc': ('fr', 'space'),
128
+ # Italian (it.py)
129
+ 'dt:date:it_base': ('it', 'space'),
130
+ 'dt:date:it_base_lc': ('it', 'space'),
131
+ 'dt:date:it_base_article': ('it', 'space'),
132
+ 'dt:date:it_base_lc_article': ('it', 'space'),
133
+ 'dt:date:it_rare_1': ('it', 'slash'),
134
+ 'dt:date:it_rare_2': ('it', 'dot'),
135
+ 'dt:date:it_weekday': ('it', 'space'),
136
+ 'dt:date:it_weekday_lc': ('it', 'space'),
137
+ 'dt:date:it_short': ('it', 'space'),
138
+ 'dt:date:it_short_lc': ('it', 'space'),
139
+ 'dt:date:it_short_monthfirst': ('it', 'space'),
140
+ 'dt:date:it_short_lc_monthfirst': ('it', 'space'),
141
+ # Dutch (nl.py)
142
+ 'dt:date:nl_base': ('nl', 'space'),
143
+ 'dt:date:nl_base_lc': ('nl', 'space'),
144
+ 'dt:date:nl_weekday': ('nl', 'space'),
145
+ 'dt:date:nl_weekday_lc': ('nl', 'space'),
146
+ 'dt:date:nl_short': ('nl', 'space'),
147
+ 'dt:date:nl_short_lc': ('nl', 'space'),
148
+ 'dt:date:nl_rare_1': ('nl', 'slash'),
149
+ 'dt:date:nl_rare_2': ('nl', 'dot'),
150
+ # Polish (pl.py)
151
+ 'dt:date:pl_base': ('pl', 'space'),
152
+ 'dt:date:pl_base_lc': ('pl', 'space'),
153
+ 'dt:date:pl_gen': ('pl', 'space'),
154
+ 'dt:date:pl_gen_lc': ('pl', 'space'),
155
+ # Portuguese (pt.py)
156
+ 'dt:date:pt_base': ('pt', 'space'),
157
+ 'dt:date:pt_base_lc': ('pt', 'space'),
158
+ 'dt:date:pt_base_article': ('pt', 'space'),
159
+ 'dt:date:pt_base_lc_article': ('pt', 'space'),
160
+ 'dt:date:pt_short': ('pt', 'space'),
161
+ 'dt:date:pt_short_lc': ('pt', 'space'),
162
+ 'dt:date:pt_short_monthfirst': ('pt', 'space'),
163
+ 'dt:date:pt_short_lc_monthfirst': ('pt', 'space'),
164
+ 'dt:date:pt_weekday': ('pt', 'space'),
165
+ 'dt:date:pt_weekday_lc': ('pt', 'space'),
166
+ 'dt:date:pt_weekday_short': ('pt', 'space'),
167
+ 'dt:date:pt_weekday_short_lc': ('pt', 'space'),
168
+ # Romanian (ro.py)
169
+ 'dt:date:ro_base': ('ro', 'space'),
170
+ 'dt:date:ro_base_lc': ('ro', 'space'),
171
+ 'dt:date:ro_short': ('ro', 'space'),
172
+ 'dt:date:ro_short_lc': ('ro', 'space'),
173
+ # Ukrainian (uk.py)
174
+ 'dt:date:uk_base': ('uk', 'space'),
175
+ 'dt:date:uk_base_lc': ('uk', 'space'),
176
+ 'dt:date:uk_gen': ('uk', 'space'),
177
+ 'dt:date:uk_gen_lc': ('uk', 'space'),
178
+ 'dt:date:uk_short': ('uk', 'space'),
179
+ 'dt:date:uk_short_lc': ('uk', 'space'),
180
+ # Russian (ru.py)
181
+ 'dt:date:date_rus': ('ru', 'space'),
182
+ 'dt:date:date_rus2': ('ru', 'space'),
183
+ 'dt:date:date_rus3': ('ru', 'dot'),
184
+ 'dt:date:date_rus_lc1': ('ru', 'space'),
185
+ 'dt:date:date_rus_lc2': ('ru', 'space'),
186
+ 'dt:date:weekday_rus': ('ru', 'space'),
187
+ 'dt:date:weekday_rus_lc1': ('ru', 'space'),
188
+ 'dt:date:rus_rare_2': ('ru', 'dot'),
189
+ 'dt:date:rus_rare_3': ('ru', 'dot'),
190
+ 'dt:date:rus_rare_5': ('ru', 'space'),
191
+ 'dt:date:rus_rare_6': ('ru', 'space'),
192
+ # Turkish (tr.py)
193
+ 'dt:date:tr_base': ('tr', 'space'),
194
+ 'dt:date:tr_base_lc': ('tr', 'space'),
195
+ }
196
+
197
+
198
+ def annotate_patterns(patterns):
199
+ """Stamp explicit ``language``, ``separator`` and ``required_chars`` fields
200
+ onto each pattern.
201
+
202
+ Idempotent: skips patterns that already carry the relevant fields. Falls
203
+ back to the ``mixed`` separator and ``None`` language when a key is not in
204
+ the metadata table, so a missing entry degrades gracefully rather than
205
+ crashing (the coverage test flags any such gap).
206
+
207
+ The ``required_chars`` field is also stamped here so ``DateParser`` no
208
+ longer needs to mutate ``ALL_PATTERNS`` during construction.
209
+
210
+ :param patterns: iterable of pattern dicts (mutated in place)
211
+ :return: the same list (for chaining)
212
+ """
213
+ for pat in patterns:
214
+ if "language" in pat and "separator" in pat:
215
+ lang, sep = None, None
216
+ else:
217
+ lang, sep = _PATTERN_METADATA.get(pat["key"], (None, "mixed"))
218
+ pat.setdefault("language", lang)
219
+ pat.setdefault("separator", sep)
220
+ pat.setdefault("required_chars", _infer_required_chars(pat))
221
+ return patterns
222
+
223
+
224
+ # Numeric-only patterns: they require only digit characters to match, even when
225
+ # they carry a language tag. ``date_usa`` / ``date_usa_1`` are US-locale
226
+ # (language="en") but their *format* is purely numeric (mm/dd/yyyy), so they must
227
+ # not require Latin characters. This set drives the digit-only charset branch.
228
+ _NUMERIC_PATTERN_KEYS = frozenset({
229
+ "dt:date:date_1", "dt:date:date_2", "dt:date:date_3", "dt:date:date_4",
230
+ "dt:date:date_iso8601", "dt:date:date_iso8601_short", "dt:date:date_8",
231
+ "dt:date:date_9", "dt:date:date_10", "dt:date:noyear_1",
232
+ "dt:date:date_4_point", "dt:date:date_5", "dt:date:date_6",
233
+ "dt:date:date_usa_1", "dt:date:date_usa",
234
+ })
235
+
236
+
237
+ # Character-set constants used by the charset prefilter. They are also re-defined
238
+ # in ``qddate.qdparser`` for back-compat (callers may import them from either
239
+ # location); the canonical definitions live here.
240
+ CHAR_SET_DIGITS = "digits"
241
+ CHAR_SET_LATIN = "latin"
242
+ CHAR_SET_CYRILLIC = "cyrillic"
243
+ CHAR_SET_ACCENTED = "accented"
244
+ CHAR_SET_SEPARATORS = "separators"
245
+
246
+
247
+ # Mapping from language code to the character set(s) a pattern of that language
248
+ # requires. Mirrors ``qddate.qdparser.LANGUAGE_CHAR_SETS``; the duplicates are
249
+ # intentional so ``annotate_patterns`` can run without importing the parser.
250
+ LANGUAGE_CHAR_SETS = {
251
+ "ru": frozenset({CHAR_SET_DIGITS, CHAR_SET_CYRILLIC}),
252
+ "bg": frozenset({CHAR_SET_DIGITS, CHAR_SET_CYRILLIC}),
253
+ "fr": frozenset({CHAR_SET_DIGITS, CHAR_SET_ACCENTED}),
254
+ "cz": frozenset({CHAR_SET_DIGITS, CHAR_SET_ACCENTED}),
255
+ "pl": frozenset({CHAR_SET_DIGITS, CHAR_SET_ACCENTED}),
256
+ "es": frozenset({CHAR_SET_DIGITS, CHAR_SET_ACCENTED}),
257
+ "it": frozenset({CHAR_SET_DIGITS, CHAR_SET_ACCENTED}),
258
+ "pt": frozenset({CHAR_SET_DIGITS, CHAR_SET_ACCENTED}),
259
+ "de": frozenset({CHAR_SET_DIGITS, CHAR_SET_LATIN}),
260
+ "nl": frozenset({CHAR_SET_DIGITS, CHAR_SET_LATIN}),
261
+ "en": frozenset({CHAR_SET_DIGITS, CHAR_SET_LATIN}),
262
+ "tr": frozenset({CHAR_SET_DIGITS, CHAR_SET_LATIN}),
263
+ "ro": frozenset({CHAR_SET_DIGITS, CHAR_SET_LATIN}),
264
+ "uk": frozenset({CHAR_SET_DIGITS, CHAR_SET_CYRILLIC}),
265
+ }
266
+
267
+
268
+ def _infer_required_chars(pattern):
269
+ """Infer the character set(s) a pattern requires to match.
270
+
271
+ A pattern whose key is in :data:`_NUMERIC_PATTERN_KEYS` matches with digits
272
+ only. Patterns carrying a ``language`` tag require that language's script
273
+ (plus digits). All other patterns default to digits only (defensive).
274
+
275
+ Pure function: reads from the pattern dict, returns a frozenset.
276
+ """
277
+ basekey = pattern.get("basekey", pattern.get("key", ""))
278
+ if basekey in _NUMERIC_PATTERN_KEYS:
279
+ return frozenset({CHAR_SET_DIGITS})
280
+ lang = pattern.get("language")
281
+ if lang is not None:
282
+ return LANGUAGE_CHAR_SETS.get(lang, frozenset({CHAR_SET_DIGITS, CHAR_SET_LATIN}))
283
+ return frozenset({CHAR_SET_DIGITS})
284
+
285
+
286
+ # Stamp metadata onto the canonical pattern list once at import, so every consumer
287
+ # (DateParser, dirty.py, tests) reads the same authoritative fields.
288
+ annotate_patterns(ALL_PATTERNS)
289
+
290
+ SUPPORTED_LANGUAGES = [
291
+ "bg",
292
+ "cz",
293
+ "de",
294
+ "en",
295
+ "es",
296
+ "fr",
297
+ "it",
298
+ "nl",
299
+ "pl",
300
+ "pt",
301
+ "ro",
302
+ "ru",
303
+ "tr",
304
+ "uk",
305
+ ]
306
+
307
+ # Mapping of language codes to their pattern lists
308
+ PATTERNS_BY_LANGUAGE = {
309
+ "en": PATTERNS_EN + INTEGER_LIKE_PATTERNS,
310
+ "bg": PATTERNS_BG,
311
+ "cz": PATTERNS_CZ,
312
+ "de": PATTERNS_DE,
313
+ "es": PATTERNS_ES,
314
+ "fr": PATTERNS_FR,
315
+ "it": PATTERNS_IT,
316
+ "nl": PATTERNS_NL,
317
+ "pl": PATTERNS_PL,
318
+ "pt": PATTERNS_PT,
319
+ "ro": PATTERNS_RO,
320
+ "ru": PATTERNS_RU,
321
+ "tr": PATTERNS_TR,
322
+ "uk": PATTERNS_UK,
323
+ }
324
+
325
+
326
+ def get_patterns_for_languages(languages):
327
+ """Get patterns for specified languages.
328
+
329
+ :param languages: Language code (str) or list of language codes (list of str).
330
+ If None or empty, returns all patterns.
331
+ :type languages: str|list|None
332
+ :return: Combined list of patterns for specified languages
333
+ :rtype: list
334
+ :raises ValueError: If any language code is not in SUPPORTED_LANGUAGES
335
+ """
336
+ if languages is None:
337
+ return ALL_PATTERNS
338
+
339
+ # Normalize to list
340
+ if isinstance(languages, str):
341
+ languages = [languages]
342
+ elif not isinstance(languages, (list, tuple)):
343
+ raise TypeError("languages must be a string, list of strings, or None")
344
+
345
+ if len(languages) == 0:
346
+ return ALL_PATTERNS
347
+
348
+ # Validate all language codes
349
+ invalid_languages = [lang for lang in languages if lang not in SUPPORTED_LANGUAGES]
350
+ if invalid_languages:
351
+ raise ValueError(
352
+ f"Unsupported language(s): {invalid_languages}. "
353
+ f"Supported languages: {SUPPORTED_LANGUAGES}"
354
+ )
355
+
356
+ # Combine patterns for specified languages
357
+ patterns = []
358
+ for lang in languages:
359
+ patterns.extend(PATTERNS_BY_LANGUAGE[lang])
360
+
361
+ return patterns