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 ADDED
@@ -0,0 +1,149 @@
1
+ """
2
+ pinky_core
3
+ =============
4
+ Core Python utilities for Snowflake development.
5
+
6
+ Modules
7
+ -------
8
+ fmt Pure formatting helpers (text, dates, numbers, FR addresses).
9
+ files File pattern normalization for S3 and MFT path analysis.
10
+ schedule Business-day calendar (FR) and cron utilities for SPs and DAGs.
11
+ security Path and identifier sanitisation for safe dynamic SQL.
12
+ sql SQL parsing helpers powered by sqlglot (Snowflake dialect).
13
+ validate French/international data validation (email, phone, SIRET, NIR, IBAN/BIC).
14
+ xml XML utilities for Snowflake VARIANT — UDF handlers and helpers.
15
+
16
+ No Snowflake connection required — importable in any context (CI, local, UDF).
17
+ Optional deps for validate: email-validator, phonenumbers, python-stdnum.
18
+ Optional deps for address: usaddress.
19
+ Optional deps for schedule: croniter (next_schedule, is_in_schedule).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ # files — pure file pattern normalization, always available
25
+ from pinky_core.files import (
26
+ FileDetection,
27
+ detect_file_pattern,
28
+ )
29
+
30
+ # fmt — pure formatting, always available
31
+ from pinky_core.fmt import (
32
+ AddressComponents,
33
+ AfnorAddress,
34
+ deduplicate_headers,
35
+ format_boolean,
36
+ format_date,
37
+ format_datetime,
38
+ format_duration,
39
+ format_fraction,
40
+ format_number,
41
+ format_period,
42
+ format_stars,
43
+ format_trend,
44
+ iso3_to_iso2,
45
+ normalize_city_fr,
46
+ normalize_text,
47
+ parse_address,
48
+ period_to_date,
49
+ to_camel_case,
50
+ to_upper_snake_case,
51
+ )
52
+
53
+ # schedule — pure calendar + cron, always available (croniter optional)
54
+ from pinky_core.schedule import (
55
+ business_days_between,
56
+ cron_to_human,
57
+ is_business_day,
58
+ is_in_schedule,
59
+ next_business_day,
60
+ next_schedule,
61
+ public_holidays_fr,
62
+ )
63
+
64
+ # security — pure sanitisation, always available
65
+ from pinky_core.security import (
66
+ safe_filepath,
67
+ validate_identifier,
68
+ )
69
+
70
+ # sql — pure SQL parsing, always available
71
+ from pinky_core.sql import (
72
+ is_select,
73
+ split_sql_statements,
74
+ )
75
+
76
+ # validate — lazy optional deps (email-validator, phonenumbers, python-stdnum)
77
+ from pinky_core.validate import (
78
+ EmailResult,
79
+ FrIdResult,
80
+ IbanBicResult,
81
+ NirResult,
82
+ PhoneResult,
83
+ VatResult,
84
+ standardize_email,
85
+ standardize_phone,
86
+ validate_iban_bic,
87
+ validate_nir,
88
+ validate_siret,
89
+ validate_vat_eu,
90
+ )
91
+
92
+ # xml — XML/VARIANT navigation UDF handlers, always available (lxml optional for xml_path_str)
93
+ from pinky_core.xml import xml_path, xml_path_str
94
+
95
+ __all__ = [
96
+ # files
97
+ "FileDetection",
98
+ "detect_file_pattern",
99
+ # fmt
100
+ "AddressComponents",
101
+ "AfnorAddress",
102
+ "deduplicate_headers",
103
+ "iso3_to_iso2",
104
+ "normalize_city_fr",
105
+ "normalize_text",
106
+ "format_boolean",
107
+ "format_date",
108
+ "format_datetime",
109
+ "format_duration",
110
+ "format_fraction",
111
+ "format_number",
112
+ "format_period",
113
+ "format_stars",
114
+ "format_trend",
115
+ "parse_address",
116
+ "period_to_date",
117
+ "to_camel_case",
118
+ "to_upper_snake_case",
119
+ # schedule
120
+ "public_holidays_fr",
121
+ "is_business_day",
122
+ "next_business_day",
123
+ "business_days_between",
124
+ "cron_to_human",
125
+ "next_schedule",
126
+ "is_in_schedule",
127
+ # security
128
+ "safe_filepath",
129
+ "validate_identifier",
130
+ # sql
131
+ "is_select",
132
+ "split_sql_statements",
133
+ # xml
134
+ "xml_path",
135
+ "xml_path_str",
136
+ # validate
137
+ "EmailResult",
138
+ "FrIdResult",
139
+ "IbanBicResult",
140
+ "NirResult",
141
+ "PhoneResult",
142
+ "standardize_email",
143
+ "standardize_phone",
144
+ "VatResult",
145
+ "validate_iban_bic",
146
+ "validate_nir",
147
+ "validate_siret",
148
+ "validate_vat_eu",
149
+ ]
pinky_core/files.py ADDED
@@ -0,0 +1,178 @@
1
+ """File pattern normalization utilities for S3 and MFT path analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass
8
+
9
+ # ISO 3166-1 alpha-2 codes (pipe-separated, used in regex alternation)
10
+ _ISO2 = (
11
+ "AD|AE|AF|AG|AI|AL|AM|AO|AQ|AR|AS|AT|AU|AW|AX|AZ|BA|BB|BD|BE|BF|BG|BH|BI|BJ|BL|"
12
+ "BM|BN|BO|BQ|BR|BS|BT|BV|BW|BY|BZ|CA|CC|CD|CF|CG|CH|CI|CK|CL|CM|CN|CO|CR|CU|CV|"
13
+ "CW|CX|CY|CZ|DE|DJ|DK|DM|DO|DZ|EC|EE|EG|EH|ER|ES|ET|FI|FJ|FK|FM|FO|FR|GA|GB|GD|"
14
+ "GE|GF|GG|GH|GI|GL|GM|GN|GP|GQ|GR|GS|GT|GU|GW|GY|HK|HM|HN|HR|HT|HU|ID|IE|IL|IM|"
15
+ "IN|IO|IQ|IR|IS|IT|JE|JM|JO|JP|KE|KG|KH|KI|KM|KN|KP|KR|KW|KY|KZ|LA|LB|LC|LI|LK|"
16
+ "LR|LS|LT|LU|LV|LY|MA|MC|MD|ME|MF|MG|MH|MK|ML|MM|MN|MO|MP|MQ|MR|MS|MT|MU|MV|MW|"
17
+ "MX|MY|MZ|NA|NC|NE|NF|NG|NI|NL|NO|NP|NR|NU|NZ|OM|PA|PE|PF|PG|PH|PK|PL|PM|PN|PR|"
18
+ "PS|PT|PW|PY|QA|RE|RO|RS|RU|RW|SA|SB|SC|SD|SE|SG|SH|SI|SJ|SK|SL|SM|SN|SO|SR|SS|"
19
+ "ST|SV|SX|SY|SZ|TC|TD|TF|TG|TH|TJ|TK|TL|TM|TN|TO|TR|TT|TV|TW|TZ|UA|UG|UM|US|UY|"
20
+ "UZ|VA|VC|VE|VG|VI|VN|VU|WF|WS|YE|YT|ZA|ZM|ZW|UK"
21
+ )
22
+
23
+ # ISO 3166-1 alpha-3 codes (pipe-separated, used in regex alternation)
24
+ _ISO3 = (
25
+ "ABW|AFG|AGO|AIA|ALA|ALB|AND|ARE|ARG|ARM|ASM|ATA|ATF|ATG|AUS|AUT|AZE|BDI|BEL|BEN|"
26
+ "BES|BFA|BGD|BGR|BHR|BHS|BIH|BLM|BLR|BLZ|BMU|BOL|BRA|BRB|BRN|BTN|BVT|BWA|CAF|CAN|"
27
+ "CCK|CHE|CHL|CHN|CIV|CMR|COD|COG|COK|COL|COM|CPV|CRI|CUB|CUW|CXR|CYM|CYP|CZE|DEU|"
28
+ "DJI|DMA|DNK|DOM|DZA|ECU|EGY|ERI|ESH|ESP|EST|ETH|FIN|FJI|FLK|FRA|FRO|FSM|GAB|GBR|"
29
+ "GEO|GGY|GHA|GIB|GIN|GLP|GMB|GNB|GNQ|GRC|GRD|GRL|GTM|GUF|GUM|GUY|HKG|HMD|HND|HRV|"
30
+ "HTI|HUN|IDN|IMN|IND|IOT|IRL|IRN|IRQ|ISL|ISR|ITA|JAM|JEY|JOR|JPN|KAZ|KEN|KGZ|KHM|"
31
+ "KIR|KNA|KOR|KWT|LAO|LBN|LBR|LBY|LCA|LIE|LKA|LSO|LTU|LUX|LVA|MAC|MAF|MAR|MCO|MDA|"
32
+ "MDG|MDV|MEX|MHL|MKD|MLI|MLT|MMR|MNE|MNG|MNP|MOZ|MRT|MSR|MTQ|MUS|MWI|MYS|MYT|NAM|"
33
+ "NCL|NER|NFK|NGA|NIC|NIU|NLD|NOR|NPL|NRU|NZL|OMN|PAK|PAN|PCN|PER|PHL|PLW|PNG|POL|"
34
+ "PRI|PRK|PRT|PRY|PSE|PYF|QAT|REU|ROU|RUS|RWA|SAU|SDN|SEN|SGP|SGS|SHN|SJM|SLB|SLE|"
35
+ "SLV|SMR|SOM|SPM|SRB|SSD|STP|SUR|SVK|SVN|SWE|SWZ|SXM|SYC|SYR|TCA|TCD|TGO|THA|TJK|"
36
+ "TKL|TKM|TLS|TON|TTO|TUN|TUR|TUV|TWN|TZA|UGA|UKR|UMI|URY|USA|UZB|VAT|VCT|VEN|VGB|"
37
+ "VIR|VNM|VUT|WLF|WSM|YEM|ZAF|ZMB|ZWE"
38
+ )
39
+
40
+
41
+ @dataclass
42
+ class FileDetection:
43
+ """A custom detection rule for :func:`detect_file_pattern`.
44
+
45
+ Attributes:
46
+ pattern: Regex applied to the working string (after prior substitutions).
47
+ replacement: Replacement string, e.g. ``"{COST_CENTER}"`` or ``"<SAP>"``.
48
+ substring: If given, the rule is skipped when this literal is absent from
49
+ the *original* basename — cheap fast-path for context-specific rules.
50
+ """
51
+
52
+ pattern: str
53
+ replacement: str
54
+ substring: str | None = None
55
+
56
+
57
+ def detect_file_pattern(
58
+ filepath: str,
59
+ custom_detections: list[FileDetection] | None = None,
60
+ ) -> str:
61
+ """Normalize a filename to a canonical pattern by replacing variable segments.
62
+
63
+ Replaces dates, timestamps, UUIDs, ISO country codes, structured business
64
+ identifiers (SIRET, SIREN, IBAN, BIC, VAT), and generic numeric IDs with
65
+ named placeholders — enabling grouping of structurally similar filenames
66
+ regardless of the specific value they carry.
67
+
68
+ Detection is purely structural: no business-context hint is required.
69
+ Substitutions run most-specific first to avoid double-replacement.
70
+
71
+ Placeholder conventions:
72
+
73
+ - ``{...}`` — temporal or generic numeric variable (date, timestamp, UUID, ID)
74
+ - ``<...>`` — typed structured identifier (country code, SIRET, IBAN…)
75
+
76
+ Substitution order:
77
+
78
+ 1. Timestamps (most digits, most specific)
79
+ 2. UUIDs
80
+ 3. Dates (delimited, then bare YYYYMMDD)
81
+ 4. Year-month combinations
82
+ 5. ISO country codes (alpha-3 before alpha-2)
83
+ 6. Structured business identifiers (IBAN, SIRET, SIREN, VAT_FR)
84
+ 7. Generic numeric segments (least specific)
85
+
86
+ Note: BIC/SWIFT codes (e.g. ``BNPAFRPP``) are structurally indistinguishable
87
+ from 8-letter uppercase words and cannot be detected reliably without a bank
88
+ directory. BIC detection is intentionally excluded.
89
+
90
+ Args:
91
+ filepath: Full file path or bare filename to normalize. The basename
92
+ (last ``/``-separated segment) is extracted automatically before
93
+ substitution, so both ``"EMPLOYEES_20240115.csv"`` and
94
+ ``"s3://bucket/feed/EMPLOYEES_20240115.csv"`` produce the same output.
95
+ custom_detections: Optional list of :class:`FileDetection` rules applied
96
+ *before* built-in substitutions. Each rule's ``substring`` filter is
97
+ tested against the **full** ``filepath`` (not just the basename) so
98
+ that directory-level context (e.g. ``"workday"`` in the S3 prefix) can
99
+ gate project-specific patterns.
100
+
101
+ Returns:
102
+ Pattern string for the basename, with variable segments replaced by
103
+ placeholders.
104
+
105
+ Examples:
106
+ >>> detect_file_pattern("EMPLOYEES_20240115.csv")
107
+ 'EMPLOYEES_{DATE}.csv'
108
+ >>> detect_file_pattern("s3://bucket/feed/EMPLOYEES_20240115.csv")
109
+ 'EMPLOYEES_{DATE}.csv'
110
+ >>> detect_file_pattern("PAYROLL_55208131700027.csv")
111
+ 'PAYROLL_{SIRET}.csv'
112
+ >>> detect_file_pattern("TRANSFER_FR7630004000031234567890143.csv")
113
+ 'TRANSFER_{IBAN}.csv'
114
+ """
115
+ basename = os.path.basename(filepath) if "/" in filepath else filepath
116
+ r = basename
117
+
118
+ # 0. Caller-supplied rules — run before all built-ins
119
+ # substring is tested against the original filepath so directory context is available
120
+ if custom_detections:
121
+ for rule in custom_detections:
122
+ if rule.substring is None or rule.substring in filepath:
123
+ r = re.sub(rule.pattern, rule.replacement, r)
124
+
125
+ # 1. Timestamps — anchored to avoid matching inside longer digit sequences
126
+ r = re.sub(r"(?<![0-9])[0-9]{8}[-._/:][0-9]{6}(?![0-9])", "{TIMESTAMP}", r)
127
+ r = re.sub(r"(?<![0-9])(19|20)[0-9]{12}(?![0-9])", "{TIMESTAMP_YYYYMMDDHHMMSS}", r)
128
+ r = re.sub(r"(?<![0-9])(19|20)[0-9]{10}(?![0-9])", "{TIMESTAMP_YYYYMMDDHHMM}", r)
129
+
130
+ # 2. UUIDs (self-anchoring via hyphens)
131
+ r = re.sub(
132
+ r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}",
133
+ "{UUID}",
134
+ r,
135
+ )
136
+
137
+ # 3. Dates — delimited formats before bare YYYYMMDD, all anchored
138
+ r = re.sub(r"(?<![0-9])[0-9]{4}[-._/][0-9]{2}[-._/][0-9]{2}(?![0-9])", "{DATE}", r)
139
+ r = re.sub(r"(?<![0-9])[0-9]{2}[-._/][0-9]{2}[-._/][0-9]{4}(?![0-9])", "{DATE}", r)
140
+ r = re.sub(r"(?<![0-9])(19|20)[0-9]{6}(?![0-9])", "{DATE}", r)
141
+
142
+ # 4. Year-month — delimited before bare YYYYMM, all anchored
143
+ r = re.sub(
144
+ r"(?<![0-9])(19|20)[0-9]{2}[-._/](0[1-9]|1[0-2])(?![0-9])", "{YEAR_MONTH}", r
145
+ )
146
+ r = re.sub(
147
+ r"(?<![0-9])(0[1-9]|1[0-2])[-._/](19|20)[0-9]{2}(?![0-9])", "{YEAR_MONTH}", r
148
+ )
149
+ r = re.sub(r"(?<![0-9])(19|20)[0-9]{2}(0[1-9]|1[0-2])(?![0-9])", "{YEAR_MONTH}", r)
150
+ r = re.sub(r"(?<![0-9])(0[1-9]|1[0-2])(19|20)[0-9]{2}(?![0-9])", "{YEAR_MONTH}", r)
151
+
152
+ # 5. ISO country codes — alpha-3 before alpha-2 (more specific first)
153
+ r = re.sub(f"_({_ISO3})_", "_<ISO3_COUNTRY>_", r)
154
+ r = re.sub(f"^({_ISO3})_", "<ISO3_COUNTRY>_", r)
155
+ r = re.sub(f"_({_ISO2})_", "_<ISO2_COUNTRY>_", r)
156
+ r = re.sub(f"^({_ISO2})_", "<ISO2_COUNTRY>_", r)
157
+
158
+ # 6. Structured business identifiers — context-free, anchored by non-alphanumeric boundaries
159
+ # IBAN: 2-letter country + 2-digit check + 11-30 alphanum (run before VAT_FR)
160
+ r = re.sub(r"(?<![A-Z0-9])[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}(?![A-Z0-9])", "{IBAN}", r)
161
+ # FR VAT number: FR + 2 alphanum + 9 digits (run after IBAN to avoid partial match)
162
+ r = re.sub(r"(?<![A-Z0-9])FR[A-Z0-9]{2}[0-9]{9}(?![A-Z0-9])", "{VAT_FR}", r)
163
+ # SIRET: exactly 14 digits — timestamps starting with 19/20 already consumed above
164
+ r = re.sub(r"(?<![0-9])[0-9]{14}(?![0-9])", "{SIRET}", r)
165
+ # SIREN: exactly 9 digits
166
+ r = re.sub(r"(?<![0-9])[0-9]{9}(?![0-9])", "{SIREN}", r)
167
+
168
+ # 7. Generic numeric segments (least specific, run last)
169
+ r = re.sub(r"_([0-9]{6,})_", "_{ID}_", r)
170
+ r = re.sub(r"_([0-9]+)_", "_{NUM}_", r)
171
+ r = re.sub(r"_([0-9]+)\.", "_{NUM}.", r)
172
+ r = re.sub(r"^([0-9]+)_", "{NUM}_", r)
173
+ # Numbers immediately after a closing placeholder brace
174
+ r = re.sub(r"\}([0-9]+)", "}{NUM}", r)
175
+ # Trailing number before extension (not preceded by > or })
176
+ r = re.sub(r"([^}>])([0-9]+)\.([a-z]+)$", r"\1{NUM}.\3", r)
177
+
178
+ return r