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 +6 -0
- qddate/dirty.py +216 -0
- qddate/patterns/__init__.py +361 -0
- qddate/patterns/base.py +776 -0
- qddate/patterns/bg.py +126 -0
- qddate/patterns/cz.py +132 -0
- qddate/patterns/de.py +229 -0
- qddate/patterns/es.py +302 -0
- qddate/patterns/fingerprint.py +305 -0
- qddate/patterns/fr.py +265 -0
- qddate/patterns/it.py +295 -0
- qddate/patterns/months.py +277 -0
- qddate/patterns/nl.py +226 -0
- qddate/patterns/pl.py +136 -0
- qddate/patterns/pt.py +329 -0
- qddate/patterns/ro.py +88 -0
- qddate/patterns/ru.py +296 -0
- qddate/patterns/tr.py +67 -0
- qddate/patterns/uk.py +70 -0
- qddate/py.typed +0 -0
- qddate/qdparser.py +1620 -0
- qddate/relative.py +306 -0
- qddate-1.0.16.dist-info/METADATA +186 -0
- qddate-1.0.16.dist-info/RECORD +28 -0
- qddate-1.0.16.dist-info/WHEEL +5 -0
- qddate-1.0.16.dist-info/licenses/AUTHORS.md +6 -0
- qddate-1.0.16.dist-info/licenses/LICENSE +29 -0
- qddate-1.0.16.dist-info/top_level.txt +1 -0
qddate/__init__.py
ADDED
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
|