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/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)