pinky-core 0.1.0.dev1__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.
- pinky_core/__init__.py +149 -0
- pinky_core/files.py +178 -0
- pinky_core/fmt.py +729 -0
- pinky_core/schedule.py +230 -0
- pinky_core/security.py +52 -0
- pinky_core/sql.py +83 -0
- pinky_core/sql_custom.py +78 -0
- pinky_core/validate.py +575 -0
- pinky_core/xml.py +451 -0
- pinky_core-0.1.0.dev1.dist-info/METADATA +87 -0
- pinky_core-0.1.0.dev1.dist-info/RECORD +13 -0
- pinky_core-0.1.0.dev1.dist-info/WHEEL +4 -0
- pinky_core-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
pinky_core/fmt.py
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pinky_core.fmt
|
|
3
|
+
=================
|
|
4
|
+
Pure formatting utility functions.
|
|
5
|
+
|
|
6
|
+
No external dependencies — importable without a Snowflake connection.
|
|
7
|
+
Safe to use in SP/UDF handlers, Streamlit apps, and local scripts.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import date, datetime
|
|
16
|
+
|
|
17
|
+
# =============================================================================
|
|
18
|
+
# Dates
|
|
19
|
+
# =============================================================================
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def format_date(d: date | datetime | None, fmt: str = "%d/%m/%Y") -> str:
|
|
23
|
+
"""Format a date as a readable string.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
d: Date to format. Returns "" if None.
|
|
27
|
+
fmt: strftime format string. Defaults to dd/mm/yyyy.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Formatted date string, or "" if d is None.
|
|
31
|
+
"""
|
|
32
|
+
if d is None:
|
|
33
|
+
return ""
|
|
34
|
+
return d.strftime(fmt)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_datetime(d: datetime | None, fmt: str = "%d/%m/%Y %H:%M") -> str:
|
|
38
|
+
"""Format a datetime as a readable string with time.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
d: Datetime to format. Returns "" if None.
|
|
42
|
+
fmt: strftime format string. Defaults to dd/mm/yyyy HH:MM.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Formatted datetime string, or "" if d is None.
|
|
46
|
+
"""
|
|
47
|
+
if d is None:
|
|
48
|
+
return ""
|
|
49
|
+
return d.strftime(fmt)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def format_period(period: str) -> str:
|
|
53
|
+
"""Format a period string as MM/YYYY.
|
|
54
|
+
|
|
55
|
+
Accepts:
|
|
56
|
+
- "032026" (MMYYYY) → "03/2026"
|
|
57
|
+
- "2026-03" (YYYY-MM) → "03/2026"
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
period: Period string to format.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Formatted period MM/YYYY, or the original string if format is unrecognised.
|
|
64
|
+
"""
|
|
65
|
+
if len(period) == 6 and period.isdigit():
|
|
66
|
+
return f"{period[:2]}/{period[2:]}"
|
|
67
|
+
if len(period) == 7 and "-" in period:
|
|
68
|
+
y, m = period.split("-")
|
|
69
|
+
return f"{m}/{y}"
|
|
70
|
+
return period
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def period_to_date(period: str) -> date:
|
|
74
|
+
"""Convert a period string to a date object (first day of the month).
|
|
75
|
+
|
|
76
|
+
Accepts:
|
|
77
|
+
- "032026" (MMYYYY) → date(2026, 3, 1)
|
|
78
|
+
- "2026-03" (YYYY-MM) → date(2026, 3, 1)
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
period: Period string to convert.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Date of the first day of the month.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: If the format is not recognised.
|
|
88
|
+
"""
|
|
89
|
+
if len(period) == 6 and period.isdigit():
|
|
90
|
+
return date(int(period[2:]), int(period[:2]), 1)
|
|
91
|
+
if len(period) == 7 and "-" in period:
|
|
92
|
+
y, m = period.split("-")
|
|
93
|
+
return date(int(y), int(m), 1)
|
|
94
|
+
raise ValueError(f"Unrecognised period format: {period!r}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# =============================================================================
|
|
98
|
+
# Numbers
|
|
99
|
+
# =============================================================================
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def format_number(num: float | int | None, num_type: str = "count") -> str:
|
|
103
|
+
"""Format a number using compact notation (K, M).
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
num: Number to format. Returns "" if None.
|
|
107
|
+
num_type: One of:
|
|
108
|
+
- "count" → compact integer (K, M)
|
|
109
|
+
- "amount" → compact amount with € suffix
|
|
110
|
+
- "percent" → value already in % (25.0 → "25.0 %")
|
|
111
|
+
- "percent_decimal" → decimal value x 100 (0.25 → "25.0 %")
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Formatted string with appropriate suffix.
|
|
115
|
+
"""
|
|
116
|
+
if num is None or (isinstance(num, float) and math.isnan(num)):
|
|
117
|
+
return ""
|
|
118
|
+
if num_type == "percent_decimal":
|
|
119
|
+
return f"{num * 100:.1f} %"
|
|
120
|
+
if num_type == "percent":
|
|
121
|
+
return f"{num:.1f} %"
|
|
122
|
+
suffix = " €" if num_type == "amount" else ""
|
|
123
|
+
sign = "-" if num < 0 else ""
|
|
124
|
+
abs_num = abs(num)
|
|
125
|
+
if abs_num >= 1_000_000:
|
|
126
|
+
val = abs_num / 1_000_000
|
|
127
|
+
return (
|
|
128
|
+
f"{sign}{val:.1f} M{suffix}" if val % 1 else f"{sign}{int(val)} M{suffix}"
|
|
129
|
+
)
|
|
130
|
+
if abs_num >= 1_000:
|
|
131
|
+
val = abs_num / 1_000
|
|
132
|
+
return (
|
|
133
|
+
f"{sign}{val:.1f} K{suffix}" if val % 1 else f"{sign}{int(val)} K{suffix}"
|
|
134
|
+
)
|
|
135
|
+
return f"{sign}{round(abs_num)}{suffix}"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def format_duration(seconds: float | int | None) -> str:
|
|
139
|
+
"""Format a duration in seconds as a human-readable string.
|
|
140
|
+
|
|
141
|
+
Examples:
|
|
142
|
+
45 → "45s"
|
|
143
|
+
125 → "2min 05s"
|
|
144
|
+
3661 → "1h 01min"
|
|
145
|
+
90000 → "1d 1h"
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
seconds: Duration in seconds. Returns "" if None.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Formatted duration string.
|
|
152
|
+
"""
|
|
153
|
+
if seconds is None or (isinstance(seconds, float) and math.isnan(seconds)):
|
|
154
|
+
return ""
|
|
155
|
+
s = int(abs(seconds))
|
|
156
|
+
sign = "-" if seconds < 0 else ""
|
|
157
|
+
if s < 60:
|
|
158
|
+
return f"{sign}{s}s"
|
|
159
|
+
if s < 3600:
|
|
160
|
+
return f"{sign}{s // 60}min {s % 60:02d}s"
|
|
161
|
+
if s < 86400:
|
|
162
|
+
return f"{sign}{s // 3600}h {(s % 3600) // 60:02d}min"
|
|
163
|
+
days = s // 86400
|
|
164
|
+
hours = (s % 86400) // 3600
|
|
165
|
+
return f"{sign}{days}d {hours}h"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def format_fraction(
|
|
169
|
+
numerator: float | None,
|
|
170
|
+
denominator: float | None,
|
|
171
|
+
) -> str:
|
|
172
|
+
"""Format a ratio as a readable fraction with percentage.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
numerator: Numerator. Returns "" if None.
|
|
176
|
+
denominator: Denominator. Returns "" if None or zero.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
String "X / Y (Z.Z%)", or "" if inputs are invalid.
|
|
180
|
+
"""
|
|
181
|
+
if (
|
|
182
|
+
numerator is None
|
|
183
|
+
or denominator is None
|
|
184
|
+
or denominator == 0
|
|
185
|
+
or (isinstance(numerator, float) and math.isnan(numerator))
|
|
186
|
+
or (isinstance(denominator, float) and math.isnan(denominator))
|
|
187
|
+
):
|
|
188
|
+
return ""
|
|
189
|
+
pct = numerator / denominator * 100
|
|
190
|
+
return f"{round(numerator)} / {round(denominator)} ({pct:.1f}%)"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def format_trend(current: float | None, previous: float | None) -> str:
|
|
194
|
+
"""Format a trend with a directional arrow.
|
|
195
|
+
|
|
196
|
+
Examples:
|
|
197
|
+
(120, 100) → "↑ +20.0%"
|
|
198
|
+
(80, 100) → "↓ -20.0%"
|
|
199
|
+
(100, 100) → "→ 0.0%"
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
current: Current value. Returns "" if None.
|
|
203
|
+
previous: Previous value. Returns "" if None or zero.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Trend string with arrow and percentage change.
|
|
207
|
+
"""
|
|
208
|
+
if (
|
|
209
|
+
current is None
|
|
210
|
+
or previous is None
|
|
211
|
+
or previous == 0
|
|
212
|
+
or (isinstance(current, float) and math.isnan(current))
|
|
213
|
+
or (isinstance(previous, float) and math.isnan(previous))
|
|
214
|
+
):
|
|
215
|
+
return ""
|
|
216
|
+
delta_pct = (current - previous) / abs(previous) * 100
|
|
217
|
+
if delta_pct > 0:
|
|
218
|
+
return f"↑ +{delta_pct:.1f}%"
|
|
219
|
+
if delta_pct < 0:
|
|
220
|
+
return f"↓ {delta_pct:.1f}%"
|
|
221
|
+
return "→ 0.0%"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def format_stars(value: float | int | None, max_stars: int = 5) -> str:
|
|
225
|
+
"""Format a rating as unicode stars.
|
|
226
|
+
|
|
227
|
+
Examples:
|
|
228
|
+
3.5 → "★★★☆☆"
|
|
229
|
+
5 → "★★★★★"
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
value: Rating value. Returns "" if None.
|
|
233
|
+
max_stars: Maximum number of stars. Defaults to 5.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
String of filled and empty stars.
|
|
237
|
+
"""
|
|
238
|
+
if value is None:
|
|
239
|
+
return ""
|
|
240
|
+
filled = min(round(value), max_stars)
|
|
241
|
+
return "★" * filled + "☆" * (max_stars - filled)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def format_boolean(value: bool | None, style: str = "check") -> str:
|
|
245
|
+
"""Format a boolean as a readable icon (tri-state: True / False / None).
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
value: Boolean value. None = indeterminate.
|
|
249
|
+
style: Rendering style, one of:
|
|
250
|
+
- "check" → ✅ / ❌ / ☑️
|
|
251
|
+
- "yesno" → Yes / No / —
|
|
252
|
+
- "onoff" → ON / OFF / —
|
|
253
|
+
- "dot" → 🟢 / 🔴 / ⚪
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
Formatted string for the given style.
|
|
257
|
+
"""
|
|
258
|
+
styles: dict[str, tuple[str, str, str]] = {
|
|
259
|
+
"check": ("✅", "❌", "☑️"),
|
|
260
|
+
"yesno": ("Yes", "No", "—"),
|
|
261
|
+
"onoff": ("ON", "OFF", "—"),
|
|
262
|
+
"dot": ("🟢", "🔴", "⚪"),
|
|
263
|
+
}
|
|
264
|
+
true_val, false_val, none_val = styles.get(style, styles["check"])
|
|
265
|
+
if value is None:
|
|
266
|
+
return none_val
|
|
267
|
+
return true_val if value else false_val
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# =============================================================================
|
|
271
|
+
# Strings
|
|
272
|
+
# =============================================================================
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def to_upper_snake_case(name: str) -> str:
|
|
276
|
+
"""Convert a string to UPPER_SNAKE_CASE for Snowflake identifiers.
|
|
277
|
+
|
|
278
|
+
Uses ``unidecode`` to transliterate accented and non-ASCII characters,
|
|
279
|
+
then collapses any run of non-alphanumeric characters to a single ``_``.
|
|
280
|
+
|
|
281
|
+
Examples:
|
|
282
|
+
"NAF 2025\\nsous-classes" → "NAF_2025_SOUS_CLASSES"
|
|
283
|
+
"Intitulés" → "INTITULES"
|
|
284
|
+
"first name" → "FIRST_NAME"
|
|
285
|
+
"Ñoño" → "NONO"
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
name: String to convert.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
UPPER_SNAKE_CASE string safe for use as a Snowflake identifier.
|
|
292
|
+
"""
|
|
293
|
+
from unidecode import unidecode
|
|
294
|
+
|
|
295
|
+
name = unidecode(name)
|
|
296
|
+
name = re.sub(r"[^a-zA-Z0-9]+", "_", name)
|
|
297
|
+
return name.strip("_").upper()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def to_camel_case(name: str) -> str:
|
|
301
|
+
"""Convert UPPER_SNAKE_CASE or snake_case to camelCase.
|
|
302
|
+
|
|
303
|
+
Useful for building Snowflake OBJECT keys that follow JSON/camelCase
|
|
304
|
+
conventions (e.g. UDF return dicts consumed by downstream SQL).
|
|
305
|
+
|
|
306
|
+
Examples:
|
|
307
|
+
"is_valid_email" → "isValidEmail"
|
|
308
|
+
"COUNTRY_CODE" → "countryCode"
|
|
309
|
+
"phone" → "phone"
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
name: Snake-case or upper-snake-case string to convert.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
camelCase string.
|
|
316
|
+
"""
|
|
317
|
+
parts = name.lower().split("_")
|
|
318
|
+
return parts[0] + "".join(p.capitalize() for p in parts[1:])
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def normalize_text(text: str | None) -> str | None:
|
|
322
|
+
"""Normalize a string for postal/administrative use.
|
|
323
|
+
|
|
324
|
+
Applies unidecode transliteration, uppercases, then replaces any run of
|
|
325
|
+
non-alphanumeric characters with a single space. Returns ``None`` for
|
|
326
|
+
blank input.
|
|
327
|
+
|
|
328
|
+
Equivalent to the ``udf_standardize`` pattern used in ADP/Workday flows.
|
|
329
|
+
|
|
330
|
+
Examples:
|
|
331
|
+
"Résidence de l'Étoile" → "RESIDENCE DE L ETOILE"
|
|
332
|
+
"Saint-Étienne" → "SAINT ETIENNE"
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
text: Raw string to normalize.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
Normalized string, or ``None`` if input is ``None`` or blank.
|
|
339
|
+
"""
|
|
340
|
+
from unidecode import unidecode
|
|
341
|
+
|
|
342
|
+
if not text or not text.strip():
|
|
343
|
+
return None
|
|
344
|
+
normalized = re.sub(r"[^A-Z0-9 ]+", " ", unidecode(text).upper())
|
|
345
|
+
return re.sub(r" +", " ", normalized).strip() or None
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def normalize_city_fr(city: str | None) -> str | None:
|
|
349
|
+
"""Normalize a French city name to its ADP/AFNOR abbreviated form.
|
|
350
|
+
|
|
351
|
+
Applies :func:`normalize_text` then substitutes ``SAINT`` → ``ST`` and
|
|
352
|
+
``SAINTE`` → ``STE`` (whole words only, not inside longer words).
|
|
353
|
+
|
|
354
|
+
Examples:
|
|
355
|
+
"Saint-Étienne" → "ST ETIENNE"
|
|
356
|
+
"Sainte-Marie" → "STE MARIE"
|
|
357
|
+
"Villesaint" → "VILLESAINT" (not a whole word, unchanged)
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
city: Raw city name.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
Normalized city string, or ``None`` if input is ``None`` or blank.
|
|
364
|
+
"""
|
|
365
|
+
normalized = normalize_text(city)
|
|
366
|
+
if not normalized:
|
|
367
|
+
return None
|
|
368
|
+
normalized = re.sub(r"\bSAINTE\b", "STE", normalized)
|
|
369
|
+
normalized = re.sub(r"\bSAINT\b", "ST", normalized)
|
|
370
|
+
return normalized
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def iso3_to_iso2(code: str) -> str:
|
|
374
|
+
"""Convert an ISO 3166-1 alpha-3 country code to alpha-2.
|
|
375
|
+
|
|
376
|
+
Returns the input unchanged if it is already alpha-2 or unknown.
|
|
377
|
+
|
|
378
|
+
Examples:
|
|
379
|
+
"FRA" → "FR"
|
|
380
|
+
"USA" → "US"
|
|
381
|
+
"FR" → "FR"
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
code: ISO 3166-1 alpha-2 or alpha-3 country code (case-insensitive).
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
ISO 3166-1 alpha-2 country code.
|
|
388
|
+
"""
|
|
389
|
+
upper = code.strip().upper()
|
|
390
|
+
return _ISO3_TO_ISO2.get(upper, upper)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def deduplicate_headers(headers: list[str]) -> list[str]:
|
|
394
|
+
"""Deduplicate a list of column headers by appending a counter suffix.
|
|
395
|
+
|
|
396
|
+
Example:
|
|
397
|
+
["A", "B", "A", "A"] → ["A", "B", "A_1", "A_2"]
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
headers: List of header strings (already normalised).
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
List with duplicates resolved by appending _1, _2, etc.
|
|
404
|
+
"""
|
|
405
|
+
seen: dict[str, int] = {}
|
|
406
|
+
result: list[str] = []
|
|
407
|
+
for h in headers:
|
|
408
|
+
if h in seen:
|
|
409
|
+
seen[h] += 1
|
|
410
|
+
result.append(f"{h}_{seen[h]}")
|
|
411
|
+
else:
|
|
412
|
+
seen[h] = 0
|
|
413
|
+
result.append(h)
|
|
414
|
+
return result
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# =============================================================================
|
|
418
|
+
# Address parsing
|
|
419
|
+
# =============================================================================
|
|
420
|
+
|
|
421
|
+
# ISO 3166-1 alpha-3 → alpha-2 for common countries
|
|
422
|
+
_ISO3_TO_ISO2: dict[str, str] = {
|
|
423
|
+
"FRA": "FR",
|
|
424
|
+
"USA": "US",
|
|
425
|
+
"DEU": "DE",
|
|
426
|
+
"BEL": "BE",
|
|
427
|
+
"CHE": "CH",
|
|
428
|
+
"GBR": "GB",
|
|
429
|
+
"ITA": "IT",
|
|
430
|
+
"ESP": "ES",
|
|
431
|
+
"NLD": "NL",
|
|
432
|
+
"LUX": "LU",
|
|
433
|
+
"PRT": "PT",
|
|
434
|
+
"AUT": "AT",
|
|
435
|
+
"POL": "PL",
|
|
436
|
+
"SWE": "SE",
|
|
437
|
+
"DNK": "DK",
|
|
438
|
+
"NOR": "NO",
|
|
439
|
+
"FIN": "FI",
|
|
440
|
+
"CAN": "CA",
|
|
441
|
+
"AUS": "AU",
|
|
442
|
+
"NZL": "NZ",
|
|
443
|
+
"JPN": "JP",
|
|
444
|
+
"CHN": "CN",
|
|
445
|
+
"IND": "IN",
|
|
446
|
+
"BRA": "BR",
|
|
447
|
+
"MEX": "MX",
|
|
448
|
+
"MAR": "MA",
|
|
449
|
+
"DZA": "DZ",
|
|
450
|
+
"TUN": "TN",
|
|
451
|
+
"SEN": "SN",
|
|
452
|
+
"ZAF": "ZA",
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@dataclass
|
|
457
|
+
class AfnorAddress:
|
|
458
|
+
"""AFNOR NF Z10-011 normalised address components (FR only).
|
|
459
|
+
|
|
460
|
+
All string fields are unidecode + uppercase. Analogous to ``e164_format``
|
|
461
|
+
on phone numbers — use these fields when the target system (ADP, La Poste…)
|
|
462
|
+
requires the normalised form.
|
|
463
|
+
|
|
464
|
+
Attributes:
|
|
465
|
+
number: Street number (``"12"``).
|
|
466
|
+
repetition_index: Abbreviated suffix — ``"B"`` (BIS), ``"T"`` (TER),
|
|
467
|
+
``"Q"`` (QUATER).
|
|
468
|
+
street_type: Uppercase street type — ``"RUE"``, ``"AVENUE"``…
|
|
469
|
+
street_name: Uppercase street name — ``"DES LILAS"``.
|
|
470
|
+
zip_code: Postal code (unchanged).
|
|
471
|
+
city: Uppercase city — ``"PARIS"``.
|
|
472
|
+
"""
|
|
473
|
+
|
|
474
|
+
number: str | None = None
|
|
475
|
+
repetition_index: str | None = None
|
|
476
|
+
street_type: str | None = None
|
|
477
|
+
street_name: str | None = None
|
|
478
|
+
zip_code: str | None = None
|
|
479
|
+
city: str | None = None
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@dataclass
|
|
483
|
+
class AddressComponents:
|
|
484
|
+
"""Parsed components of a postal address.
|
|
485
|
+
|
|
486
|
+
Raw fields preserve the original casing of the input.
|
|
487
|
+
``afnor`` contains the same fields normalised per NF Z10-011 (FR only).
|
|
488
|
+
|
|
489
|
+
Attributes:
|
|
490
|
+
number: Street number (``"12"``).
|
|
491
|
+
repetition_index: Repetition suffix, normalised — ``"BIS"``, ``"TER"``,
|
|
492
|
+
``"QUATER"`` (FR) or occupancy identifier (US).
|
|
493
|
+
street_type: Street type in original casing (``"Rue"``, ``"avenue"``…).
|
|
494
|
+
street_name: Street name in original casing (``"des Lilas"``).
|
|
495
|
+
zip_code: Postal code — provided or extracted from the address string.
|
|
496
|
+
city: City name in original casing.
|
|
497
|
+
afnor: AFNOR-normalised fields (FR only) — see ``AfnorAddress``.
|
|
498
|
+
raw: Original input; always set.
|
|
499
|
+
"""
|
|
500
|
+
|
|
501
|
+
number: str | None = None
|
|
502
|
+
repetition_index: str | None = None
|
|
503
|
+
street_type: str | None = None
|
|
504
|
+
street_name: str | None = None
|
|
505
|
+
zip_code: str | None = None
|
|
506
|
+
city: str | None = None
|
|
507
|
+
afnor: AfnorAddress | None = None
|
|
508
|
+
raw: str | None = None
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# ── FR ────────────────────────────────────────────────────────────────────────
|
|
512
|
+
|
|
513
|
+
_FR_SOURCE_INDICE = ["B", "BI", "BIS", "T", "TE", "TER", "C", "Q", "QUATER", "D"]
|
|
514
|
+
_FR_CIBLE_INDICE = [
|
|
515
|
+
"BIS",
|
|
516
|
+
"BIS",
|
|
517
|
+
"BIS",
|
|
518
|
+
"TER",
|
|
519
|
+
"TER",
|
|
520
|
+
"TER",
|
|
521
|
+
"TER",
|
|
522
|
+
"QUATER",
|
|
523
|
+
"QUATER",
|
|
524
|
+
"QUATER",
|
|
525
|
+
]
|
|
526
|
+
_FR_AFNOR_INDICE = {"BIS": "B", "TER": "T", "QUATER": "Q"}
|
|
527
|
+
_FR_RE_NUMBER = re.compile(
|
|
528
|
+
r"^((\d+)( ?(B|b|bis|BIS|t|T|ter|TER|quater|QUATER|C|D|E|c|d|e|f|F) )?) ?(.+)?"
|
|
529
|
+
)
|
|
530
|
+
_FR_RE_ZIP_CITY = re.compile(r"^(.*?)\s+(\d{5})\s+(.+)$")
|
|
531
|
+
_FR_STREET_TYPES = [
|
|
532
|
+
"avenue",
|
|
533
|
+
"boulevard",
|
|
534
|
+
"chemin",
|
|
535
|
+
"cité",
|
|
536
|
+
"cite",
|
|
537
|
+
"impasse",
|
|
538
|
+
"lieu-dit",
|
|
539
|
+
"lieu dit",
|
|
540
|
+
"passage",
|
|
541
|
+
"place",
|
|
542
|
+
"promenade",
|
|
543
|
+
"quai",
|
|
544
|
+
"résidence",
|
|
545
|
+
"residence",
|
|
546
|
+
"rond-point",
|
|
547
|
+
"rond point",
|
|
548
|
+
"route",
|
|
549
|
+
"ruelle",
|
|
550
|
+
"rue",
|
|
551
|
+
"traverse",
|
|
552
|
+
"voie",
|
|
553
|
+
"allée",
|
|
554
|
+
"allee",
|
|
555
|
+
"cour",
|
|
556
|
+
"gr",
|
|
557
|
+
"hameau",
|
|
558
|
+
]
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _afnor_norm(s: str | None) -> str | None:
|
|
562
|
+
"""Unidecode + uppercase + collapse non-alphanumeric to spaces."""
|
|
563
|
+
from unidecode import unidecode
|
|
564
|
+
|
|
565
|
+
if not s:
|
|
566
|
+
return None
|
|
567
|
+
return (
|
|
568
|
+
re.sub(r" +", " ", re.sub(r"[^A-Z0-9 ]", " ", unidecode(s).upper())).strip()
|
|
569
|
+
or None
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _parse_fr(
|
|
574
|
+
address: str,
|
|
575
|
+
zip_code: str | None = None,
|
|
576
|
+
city: str | None = None,
|
|
577
|
+
) -> AddressComponents:
|
|
578
|
+
bloc = address.strip()
|
|
579
|
+
street_line = bloc
|
|
580
|
+
|
|
581
|
+
# Extract zip_code / city from full address when not supplied
|
|
582
|
+
if not zip_code and not city:
|
|
583
|
+
m_full = _FR_RE_ZIP_CITY.match(bloc)
|
|
584
|
+
if m_full:
|
|
585
|
+
street_line = m_full.group(1).strip()
|
|
586
|
+
zip_code = m_full.group(2)
|
|
587
|
+
city = m_full.group(3).strip()
|
|
588
|
+
|
|
589
|
+
# Number + repetition index (normalised to BIS/TER/QUATER, not abbreviated yet)
|
|
590
|
+
number: str | None = None
|
|
591
|
+
repetition_index: str | None = None
|
|
592
|
+
m = _FR_RE_NUMBER.match(street_line)
|
|
593
|
+
if m:
|
|
594
|
+
tmp = str(m.group(1)).replace(" ", "").lower()
|
|
595
|
+
num_str = re.sub(r"[^0-9\-]", "", tmp)
|
|
596
|
+
number = num_str or None
|
|
597
|
+
raw_indice = re.sub(r"[0-9\-]", "", tmp).upper()
|
|
598
|
+
if raw_indice:
|
|
599
|
+
try:
|
|
600
|
+
repetition_index = _FR_CIBLE_INDICE[_FR_SOURCE_INDICE.index(raw_indice)]
|
|
601
|
+
except ValueError:
|
|
602
|
+
repetition_index = raw_indice
|
|
603
|
+
|
|
604
|
+
# Street type — original casing, earliest position
|
|
605
|
+
street_type: str | None = None
|
|
606
|
+
lower = street_line.lower()
|
|
607
|
+
earliest = len(lower)
|
|
608
|
+
type_end = 0
|
|
609
|
+
for t in _FR_STREET_TYPES:
|
|
610
|
+
pos = lower.find(t + " ")
|
|
611
|
+
if pos != -1 and pos < earliest:
|
|
612
|
+
earliest = pos
|
|
613
|
+
type_end = pos + len(t)
|
|
614
|
+
street_type = street_line[pos:type_end] # original casing
|
|
615
|
+
|
|
616
|
+
# Street name — everything after the type in original casing
|
|
617
|
+
street_name: str | None = (
|
|
618
|
+
street_line[type_end:].strip() or None if street_type else None
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
# AfnorAddress — normalised per NF Z10-011
|
|
622
|
+
afnor = AfnorAddress(
|
|
623
|
+
number=number,
|
|
624
|
+
repetition_index=_FR_AFNOR_INDICE.get(repetition_index, repetition_index)
|
|
625
|
+
if repetition_index
|
|
626
|
+
else None,
|
|
627
|
+
street_type=_afnor_norm(street_type),
|
|
628
|
+
street_name=_afnor_norm(street_name),
|
|
629
|
+
zip_code=zip_code,
|
|
630
|
+
city=_afnor_norm(city),
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
return AddressComponents(
|
|
634
|
+
number=number,
|
|
635
|
+
repetition_index=repetition_index,
|
|
636
|
+
street_type=street_type,
|
|
637
|
+
street_name=street_name,
|
|
638
|
+
zip_code=zip_code,
|
|
639
|
+
city=city,
|
|
640
|
+
afnor=afnor,
|
|
641
|
+
raw=address,
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
# ── US ────────────────────────────────────────────────────────────────────────
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _parse_us(
|
|
649
|
+
address: str,
|
|
650
|
+
zip_code: str | None = None,
|
|
651
|
+
city: str | None = None,
|
|
652
|
+
) -> AddressComponents:
|
|
653
|
+
try:
|
|
654
|
+
import usaddress
|
|
655
|
+
|
|
656
|
+
tagged, _ = usaddress.tag(address)
|
|
657
|
+
post_type = (tagged.get("StreetNamePostType") or "").upper() or None
|
|
658
|
+
pre_type = (tagged.get("StreetNamePreType") or "").upper() or None
|
|
659
|
+
return AddressComponents(
|
|
660
|
+
number=tagged.get("AddressNumber"),
|
|
661
|
+
repetition_index=tagged.get("OccupancyIdentifier"),
|
|
662
|
+
street_type=post_type or pre_type,
|
|
663
|
+
street_name=tagged.get("StreetName"),
|
|
664
|
+
zip_code=zip_code or tagged.get("ZipCode"),
|
|
665
|
+
city=city or tagged.get("PlaceName"),
|
|
666
|
+
raw=address,
|
|
667
|
+
)
|
|
668
|
+
except ImportError:
|
|
669
|
+
return AddressComponents(zip_code=zip_code, city=city, raw=address)
|
|
670
|
+
except Exception:
|
|
671
|
+
return AddressComponents(zip_code=zip_code, city=city, raw=address)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
# ── Dispatch ──────────────────────────────────────────────────────────────────
|
|
675
|
+
|
|
676
|
+
_COUNTRY_PARSERS = {
|
|
677
|
+
"FR": _parse_fr,
|
|
678
|
+
"US": _parse_us,
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def parse_address(
|
|
683
|
+
address: str,
|
|
684
|
+
country_code: str = "FR",
|
|
685
|
+
zip_code: str | None = None,
|
|
686
|
+
city: str | None = None,
|
|
687
|
+
) -> AddressComponents:
|
|
688
|
+
"""Parse a postal address into structured components.
|
|
689
|
+
|
|
690
|
+
Args:
|
|
691
|
+
address: Raw address string. Can be the street line only
|
|
692
|
+
(``"12 BIS RUE DES LILAS"``) or a full address including
|
|
693
|
+
postal code and city (``"12 BIS RUE DES LILAS 75019 PARIS"``).
|
|
694
|
+
When the full address is passed and ``zip_code`` / ``city``
|
|
695
|
+
are omitted, they are extracted automatically (FR only).
|
|
696
|
+
country_code: ISO 3166-1 alpha-2 (``"FR"``, ``"US"``) or alpha-3
|
|
697
|
+
(``"FRA"``, ``"USA"``). Case-insensitive.
|
|
698
|
+
Countries without a dedicated parser return
|
|
699
|
+
``AddressComponents(raw=address)``.
|
|
700
|
+
zip_code: Postal code from the source data. Overrides extraction
|
|
701
|
+
from ``address``.
|
|
702
|
+
city: City name from the source data. Overrides extraction
|
|
703
|
+
from ``address``.
|
|
704
|
+
|
|
705
|
+
Returns:
|
|
706
|
+
``AddressComponents`` with parsed fields. ``raw`` is always set to
|
|
707
|
+
the original input. ``afnor`` contains the NF Z10-011 normalised
|
|
708
|
+
street line for FR addresses.
|
|
709
|
+
|
|
710
|
+
Note:
|
|
711
|
+
US parsing requires the optional ``usaddress`` package::
|
|
712
|
+
|
|
713
|
+
pip install pinky-core[address]
|
|
714
|
+
"""
|
|
715
|
+
if not address or not address.strip():
|
|
716
|
+
return AddressComponents(zip_code=zip_code, city=city, raw=address or None)
|
|
717
|
+
|
|
718
|
+
code = country_code.strip().upper()
|
|
719
|
+
if len(code) == 3:
|
|
720
|
+
code = _ISO3_TO_ISO2.get(code, code)
|
|
721
|
+
|
|
722
|
+
parser = _COUNTRY_PARSERS.get(code)
|
|
723
|
+
if parser:
|
|
724
|
+
try:
|
|
725
|
+
return parser(address, zip_code=zip_code, city=city)
|
|
726
|
+
except Exception:
|
|
727
|
+
return AddressComponents(zip_code=zip_code, city=city, raw=address)
|
|
728
|
+
|
|
729
|
+
return AddressComponents(zip_code=zip_code, city=city, raw=address)
|