p6flow 0.1.2__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.
p6flow/inference.py ADDED
@@ -0,0 +1,204 @@
1
+ """Type resolution for XER columns.
2
+
3
+ Three-tier fallback:
4
+
5
+ 1. pmSchema.xml (via p6flow.schema.COLUMN_TYPES). If known, use that.
6
+ 2. Naming heuristic + value sniffing must agree. Raise on mismatch.
7
+ 3. If neither has an opinion, fall back to VARCHAR.
8
+
9
+ Tier 2 only runs for columns absent from pmSchema.xml. The heuristic alone
10
+ is fast but wrong for the long tail; the sniffer alone has small-sample
11
+ ambiguity (an all-zeros column could be int or double). Requiring both to
12
+ agree catches confidently-wrong inferences and surfaces them as errors.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from collections.abc import Iterable
19
+
20
+ from .schema import COLUMN_TYPES, ColumnSpec
21
+
22
+ # Order matters: longer/more specific suffixes first.
23
+ _SUFFIX_BUCKETS: tuple[tuple[str, str], ...] = (
24
+ # Compound suffixes that override their tail
25
+ ("_pct_type", "string"),
26
+ ("_hr_cnt", "double"),
27
+ # Integers
28
+ ("_id", "integer"),
29
+ ("_cnt", "integer"),
30
+ ("_num", "integer"),
31
+ ("_seq", "integer"),
32
+ # Doubles
33
+ ("_pct", "double"),
34
+ ("_qty", "double"),
35
+ ("_rate", "double"),
36
+ ("_wt", "double"),
37
+ ("_value", "double"),
38
+ ("_cost", "double"),
39
+ ("_drtn", "double"), # duration
40
+ # Dates
41
+ ("_date", "date"),
42
+ # Flags / single-char codes
43
+ ("_flag", "flag"),
44
+ # Strings
45
+ ("_type", "string"),
46
+ ("_code", "string"),
47
+ ("_name", "string"),
48
+ ("_user", "string"),
49
+ ("_short_name", "string"),
50
+ ("_path", "string"),
51
+ ("_state", "string"),
52
+ ("_guid", "string"),
53
+ )
54
+
55
+ _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$")
56
+
57
+ _BUCKET_TO_DUCKDB = {
58
+ "integer": "BIGINT",
59
+ "double": "DOUBLE",
60
+ "date": "TIMESTAMP",
61
+ "flag": "VARCHAR(1)",
62
+ "string": "VARCHAR",
63
+ }
64
+ _BUCKET_TO_SNOWFLAKE = {
65
+ "integer": "NUMBER(19)",
66
+ "double": "FLOAT",
67
+ "date": "TIMESTAMP_NTZ",
68
+ "flag": "VARCHAR(1)",
69
+ "string": "VARCHAR",
70
+ }
71
+
72
+
73
+ class TypeMismatch(Exception):
74
+ """Naming heuristic and value sniffing disagree on a column's type."""
75
+
76
+
77
+ def heuristic_bucket(field: str) -> str | None:
78
+ """Return a type bucket from the column name suffix, or None."""
79
+ for suffix, bucket in _SUFFIX_BUCKETS:
80
+ if field == suffix.lstrip("_") or field.endswith(suffix):
81
+ return bucket
82
+ return None
83
+
84
+
85
+ def sniff_bucket(values: Iterable[str], max_samples: int = 1000) -> str | None:
86
+ """Return a type bucket from observed values, or None if all empty.
87
+
88
+ Samples up to max_samples non-empty values to avoid O(n) scans of huge tables.
89
+ """
90
+ non_empty: list[str] = []
91
+ for v in values:
92
+ if v != "" and v is not None:
93
+ non_empty.append(v)
94
+ if len(non_empty) >= max_samples:
95
+ break
96
+ if not non_empty:
97
+ return None
98
+
99
+ # Date check first; "2025-01-01" parses as nothing else useful.
100
+ if all(_DATE_RE.match(v) for v in non_empty):
101
+ return "date"
102
+ # Y/N flags
103
+ if all(v in ("Y", "N") for v in non_empty):
104
+ return "flag"
105
+ # Integers (no decimal point, parseable)
106
+ if all(_is_int(v) for v in non_empty):
107
+ return "integer"
108
+ # Floats (parseable as float, possibly with decimal)
109
+ if all(_is_float(v) for v in non_empty):
110
+ return "double"
111
+ return "string"
112
+
113
+
114
+ def _is_int(v: str) -> bool:
115
+ try:
116
+ int(v)
117
+ return True
118
+ except ValueError:
119
+ return False
120
+
121
+
122
+ def _is_float(v: str) -> bool:
123
+ try:
124
+ float(v)
125
+ return True
126
+ except ValueError:
127
+ return False
128
+
129
+
130
+ # What each predicted bucket can cleanly hold. Asymmetric: a column predicted
131
+ # `double` accepts integer-valued data (ints fit in doubles), but a column
132
+ # predicted `integer` does NOT accept floating-point data (would lose precision).
133
+ _ABSORBS: dict[str, frozenset[str]] = {
134
+ "string": frozenset({"string", "flag", "integer", "double", "date"}),
135
+ "double": frozenset({"double", "integer"}),
136
+ "integer": frozenset({"integer"}),
137
+ "flag": frozenset({"flag"}),
138
+ "date": frozenset({"date"}),
139
+ }
140
+
141
+
142
+ def infer_unknown(table: str, field: str, values: Iterable[str]) -> ColumnSpec:
143
+ """Resolve type for a column NOT in COLUMN_TYPES.
144
+
145
+ Raises TypeMismatch if the naming heuristic and value sniffing disagree
146
+ in a way that would lose information. Compatible disagreements (naming
147
+ says double but data is integer) resolve to the wider type. If neither
148
+ has an opinion (no suffix match, all values empty), default to string.
149
+ """
150
+ h = heuristic_bucket(field)
151
+ s = sniff_bucket(values)
152
+ if h is None and s is None:
153
+ bucket = "string"
154
+ elif h is None:
155
+ bucket = s
156
+ elif s is None:
157
+ bucket = h
158
+ elif s in _ABSORBS.get(h, frozenset()):
159
+ bucket = h
160
+ else:
161
+ raise TypeMismatch(
162
+ f"{table}.{field}: naming says {h!r}, sniffing says {s!r}"
163
+ )
164
+ return ColumnSpec(
165
+ duckdb_type=_BUCKET_TO_DUCKDB[bucket],
166
+ snowflake_type=_BUCKET_TO_SNOWFLAKE[bucket],
167
+ nullable=True,
168
+ description="",
169
+ )
170
+
171
+
172
+ def resolve_overrides(
173
+ blocks: Iterable[tuple[str, list[str], Iterable[list[str]]]],
174
+ ) -> dict[tuple[str, str], ColumnSpec]:
175
+ """Build per-XER type overrides for every column not in COLUMN_TYPES.
176
+
177
+ blocks: iterable of (table_name, field_names, row_iter) where each row is
178
+ a list of string cell values aligned with field_names.
179
+
180
+ Returns: {(table, field): ColumnSpec} for every unknown column. Raises
181
+ TypeMismatch on the first disagreement and stops; this is intentional
182
+ fail-fast behavior so the caller learns about new ambiguous columns
183
+ instead of silently mis-typing them.
184
+ """
185
+ overrides: dict[tuple[str, str], ColumnSpec] = {}
186
+ for table, fields, rows in blocks:
187
+ unknown_idx = [
188
+ (i, f)
189
+ for i, f in enumerate(fields)
190
+ if f not in COLUMN_TYPES.get(table, {})
191
+ ]
192
+ if not unknown_idx:
193
+ continue
194
+ # Materialize columns we care about.
195
+ cols: dict[int, list[str]] = {i: [] for i, _ in unknown_idx}
196
+ for row in rows:
197
+ for i in cols:
198
+ if i < len(row):
199
+ cols[i].append(row[i])
200
+ for i, f in unknown_idx:
201
+ if (table, f) in overrides:
202
+ continue
203
+ overrides[(table, f)] = infer_unknown(table, f, cols[i])
204
+ return overrides
p6flow/loader.py ADDED
@@ -0,0 +1,189 @@
1
+ """Load parsed XER tables into a DuckDB connection.
2
+
3
+ Pipeline:
4
+ 1. Materialize all tables (rows in memory, since type inference and
5
+ Arrow registration both need the full row set).
6
+ 2. Resolve type overrides for unknown columns via p6flow.inference.
7
+ Fail fast if naming heuristic and value sniffing disagree.
8
+ 3. Topo-sort tables so FK parents are inserted before children.
9
+ 4. CREATE OR REPLACE TABLE for each one with constraints from schema.py.
10
+ 5. INSERT data with per-column CAST(NULLIF(col, '') AS <type>) so empty
11
+ XER cells become SQL NULL.
12
+
13
+ Returns a per-table row count map for downstream validation.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import duckdb
19
+ import pyarrow as pa
20
+
21
+ from .ddl import duckdb_ddl, topo_sort_tables
22
+ from .inference import resolve_overrides
23
+ from .schema import COLUMN_TYPES
24
+ from .tokenizer import Table # parse_tables imported lazily by callers
25
+
26
+
27
+ class RowArityMismatch(ValueError):
28
+ """A `%R` row had MORE fields than `%F` declared. Format-level violation."""
29
+
30
+
31
+ def _normalize_rows(
32
+ table: str,
33
+ fields: list[str],
34
+ rows: list[list[str]],
35
+ ) -> list[list[str]]:
36
+ """Force every row to len(fields). Pad short rows with '', drop
37
+ trailing empty cells before checking arity, error on real overflow.
38
+
39
+ Short rows are common in P6 exports (trailing optional fields dropped).
40
+ Some exporters also emit a trailing tab on every row, producing an
41
+ extra empty cell that's not a real field. We tolerate that quietly
42
+ and only raise when extra cells carry actual data.
43
+ """
44
+ n = len(fields)
45
+ out: list[list[str]] = []
46
+ for i, row in enumerate(rows):
47
+ # Drop trailing empties before arity check; they're an exporter
48
+ # artifact, not real data.
49
+ while len(row) > n and row[-1] == "":
50
+ row = row[:-1]
51
+ if len(row) > n:
52
+ raise RowArityMismatch(
53
+ f"{table} row {i}: {len(row)} fields but %F declared {n}; "
54
+ f"extra cells: {row[n:]!r}"
55
+ )
56
+ if len(row) < n:
57
+ row = row + [""] * (n - len(row))
58
+ out.append(row)
59
+ return out
60
+
61
+
62
+ def _materialize(
63
+ tables: list[Table],
64
+ ) -> list[tuple[str, list[str], list[list[str]], list[str]]]:
65
+ """Snapshot each parsed table into (name, fields, rows, raws) tuples
66
+ used by downstream type inference, Arrow registration, and the DW
67
+ column path. Trailing empty cells are dropped from rows but raws is
68
+ left untouched (its job is byte-faithful capture)."""
69
+ out = []
70
+ for t in tables:
71
+ rows = _normalize_rows(t.name, t.fields, list(t.rows))
72
+ out.append((t.name, t.fields, rows, list(t.raws)))
73
+ return out
74
+
75
+
76
+ def _cast_select(table: str, fields: list[str], overrides) -> str:
77
+ """Build a SELECT list that casts each string column to its target type.
78
+
79
+ Uses NULLIF(col, '') to convert XER's empty-string convention to SQL NULL.
80
+ The cast type comes from per-XER overrides first, then schema.py, with
81
+ VARCHAR as final fallback.
82
+ """
83
+ parts: list[str] = []
84
+ for f in fields:
85
+ spec = None
86
+ if overrides and (table, f) in overrides:
87
+ spec = overrides[(table, f)]
88
+ if spec is None:
89
+ spec = COLUMN_TYPES.get(table, {}).get(f)
90
+ ddb_type = spec.duckdb_type if spec else "VARCHAR"
91
+ if ddb_type == "VARCHAR" or ddb_type.startswith("VARCHAR("):
92
+ # No cast needed; just NULLIF empty strings. Don't NULLIF
93
+ # 'null' because a VARCHAR column might legitimately hold
94
+ # the literal string "null" (e.g. notes/descriptions).
95
+ parts.append(f'NULLIF("{f}", \'\') AS "{f}"')
96
+ elif ddb_type == "BLOB":
97
+ # CAST(varchar AS BLOB) interprets the string as hex-escaped
98
+ # bytes and rejects raw non-ASCII (e.g. UTF-8 BOM survivors,
99
+ # curly quotes in description fields). encode() takes the
100
+ # string's UTF-8 byte representation directly.
101
+ parts.append(f'encode(NULLIF("{f}", \'\')) AS "{f}"')
102
+ else:
103
+ # Typed casts (BIGINT/DOUBLE/TIMESTAMP/etc.) must treat both
104
+ # '' and the literal string 'null'/'NULL' as SQL NULL. Some
105
+ # P6 exporters emit the string "null" in lieu of empty cells
106
+ # for missing numerics; without this, the cast fails.
107
+ parts.append(
108
+ f"CAST(NULLIF(NULLIF(LOWER(\"{f}\"), 'null'), '') "
109
+ f"AS {ddb_type}) AS \"{f}\""
110
+ )
111
+ return ", ".join(parts)
112
+
113
+
114
+ def load_xer(
115
+ con: duckdb.DuckDBPyConnection,
116
+ tables: list[Table],
117
+ add_dw_columns: bool = False,
118
+ ) -> dict[str, int]:
119
+ """Load every parsed table into the DuckDB connection. Return row counts per table.
120
+
121
+ When add_dw_columns=True, every loaded table gains an `_raw` VARCHAR
122
+ column containing the byte-faithful `%R\\t...` line captured by the
123
+ tokenizer (newline-stripped). Used by the warehouse path so
124
+ consumers can recover the exact source line of any parsed row for
125
+ debugging. Other DW columns (source_xer, source_sha256,
126
+ flow_published_at, exported_at) are constants per file and get
127
+ added at COPY time in output.write_parquet.
128
+ """
129
+ materialized = _materialize(tables)
130
+ rows_by_name = {t: (fs, rs, raws) for t, fs, rs, raws in materialized}
131
+ available = {t for t, _, _, _ in materialized}
132
+
133
+ # Type inference for unknown columns. Fails fast on conflicting evidence.
134
+ # resolve_overrides only needs (name, fields, row_iter); raws are unused.
135
+ overrides = resolve_overrides(
136
+ [(t, fs, rs) for t, fs, rs, _ in materialized]
137
+ )
138
+
139
+ # Topo sort by FK dependencies (parents first; DuckDB enforces FKs at INSERT).
140
+ ordered_pairs = topo_sort_tables([(t, fs) for t, fs, _, _ in materialized])
141
+
142
+ counts: dict[str, int] = {}
143
+ for table, fields in ordered_pairs:
144
+ _, rows, raws = rows_by_name[table]
145
+
146
+ # An empty %T block (zero columns, zero rows) is a real XER
147
+ # quirk; treat it as "not present in this export" rather than
148
+ # creating a column-less table that DuckDB rejects.
149
+ if not fields:
150
+ continue
151
+
152
+ emit_fields = list(fields)
153
+ emit_rows = rows
154
+ if add_dw_columns:
155
+ # Append _raw alongside the parsed cells using the raw line
156
+ # captured at tokenization time. VARCHAR by default since
157
+ # it isn't in COLUMN_TYPES.
158
+ #
159
+ # Defensive: if for some reason raws is shorter than rows
160
+ # (shouldn't happen in well-formed XERs, but guard against
161
+ # future tokenizer changes), fall back to empty string.
162
+ emit_fields = [*fields, "_raw"]
163
+ emit_rows = [
164
+ [*r, raws[i] if i < len(raws) else ""]
165
+ for i, r in enumerate(rows)
166
+ ]
167
+
168
+ # CREATE OR REPLACE the staging table; only emit FKs to tables we'll create.
169
+ con.execute(
170
+ duckdb_ddl(table, emit_fields, overrides, available_tables=available)
171
+ )
172
+
173
+ if not emit_rows:
174
+ counts[table] = 0
175
+ continue
176
+
177
+ # Pivot rows into columns, build an Arrow table of all-string cells,
178
+ # register it, INSERT with casts.
179
+ cols = list(zip(*emit_rows, strict=True))
180
+ arrow_table = pa.table({f: list(cols[i]) for i, f in enumerate(emit_fields)})
181
+ con.register("xer_raw", arrow_table)
182
+ try:
183
+ select_list = _cast_select(table, emit_fields, overrides)
184
+ con.execute(f"INSERT INTO {table} SELECT {select_list} FROM xer_raw")
185
+ finally:
186
+ con.unregister("xer_raw")
187
+
188
+ counts[table] = len(emit_rows)
189
+ return counts
p6flow/output.py ADDED
@@ -0,0 +1,173 @@
1
+ """Parquet writer and manifest builder.
2
+
3
+ Writes one Parquet file per loaded DuckDB table plus a manifest.json
4
+ that captures provenance, row counts, and schema fingerprints. The
5
+ manifest is the cheap "did anything change" diff target across runs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import duckdb
17
+
18
+ from .tokenizer import Header, read_header
19
+ from .validate import FkViolation
20
+
21
+
22
+ def _sha256(path: Path, chunk_size: int = 1 << 20) -> str:
23
+ h = hashlib.sha256()
24
+ with path.open("rb") as f:
25
+ for chunk in iter(lambda: f.read(chunk_size), b""):
26
+ h.update(chunk)
27
+ return h.hexdigest()
28
+
29
+
30
+ def _schema_fingerprint(con: duckdb.DuckDBPyConnection, table: str) -> str:
31
+ """Hash of (column_name, type) tuples in declaration order. Stable
32
+ across runs as long as DDL doesn't drift."""
33
+ rows = con.execute(f'DESCRIBE "{table}"').fetchall()
34
+ payload = "|".join(f"{r[0]}:{r[1]}" for r in rows)
35
+ return hashlib.sha256(payload.encode()).hexdigest()[:16]
36
+
37
+
38
+ def _violation_to_dict(v: FkViolation) -> dict[str, Any]:
39
+ return {
40
+ "child_table": v.child_table,
41
+ "fk_name": v.fk_name,
42
+ "target_table": v.target_table,
43
+ "local_fields": list(v.local_fields),
44
+ "target_fields": list(v.target_fields),
45
+ "kind": v.kind,
46
+ "violating_rows": v.violating_rows,
47
+ "sample_keys": [list(k) for k in v.sample_keys],
48
+ }
49
+
50
+
51
+ def _sql_str_lit(s: str) -> str:
52
+ """Single-quote a value for safe inlining into a DuckDB SQL literal."""
53
+ return "'" + s.replace("'", "''") + "'"
54
+
55
+
56
+ def _dw_select(
57
+ table: str,
58
+ cols_in_table: set[str],
59
+ src_tag: str,
60
+ sha: str,
61
+ export_date_lit: str,
62
+ ) -> str:
63
+ """Build the SELECT projection that adds DW columns to one table.
64
+
65
+ Source tables (parsed from %T blocks) already carry `_raw` from
66
+ load_xer. Derived tables (CALENDAR_WEEKLY, *_UDF, UDF_COLUMN_MAP)
67
+ don't, so we emit `_raw = NULL` to keep every parquet's DW column
68
+ shape uniform across the corpus.
69
+ """
70
+ raw_clause = (
71
+ '"_raw"' if "_raw" in cols_in_table else "CAST(NULL AS VARCHAR) AS _raw"
72
+ )
73
+ base = (
74
+ 'SELECT * EXCLUDE ("_raw")'
75
+ if "_raw" in cols_in_table
76
+ else "SELECT *"
77
+ )
78
+ return (
79
+ f'{base}, '
80
+ f'{_sql_str_lit(src_tag)} AS source_xer, '
81
+ f'{_sql_str_lit(sha)} AS source_sha256, '
82
+ f'CAST((CURRENT_TIMESTAMP AT TIME ZONE \'UTC\') AS TIMESTAMP) '
83
+ f'AS flow_published_at, '
84
+ f'{export_date_lit} AS exported_at, '
85
+ f'{raw_clause} '
86
+ f'FROM "{table}"'
87
+ )
88
+
89
+
90
+ def write_parquet(
91
+ con: duckdb.DuckDBPyConnection,
92
+ out_dir: Path,
93
+ source_xer: Path,
94
+ counts: dict[str, int],
95
+ violations: list[FkViolation] | None = None,
96
+ udf_unknown_types: list[dict] | None = None,
97
+ add_dw_columns: bool = False,
98
+ ) -> dict[str, Any]:
99
+ """Write each DuckDB table to a Parquet file in out_dir, plus manifest.json.
100
+
101
+ Returns the manifest dict for inspection in callers/tests. If
102
+ violations is None, the manifest's "fk_violations" key is omitted
103
+ (validation was not requested for this run). udf_unknown_types is
104
+ only emitted when non-empty.
105
+
106
+ When add_dw_columns is True, every emitted parquet gets data-
107
+ warehouse columns suitable for cross-XER joins:
108
+
109
+ source_xer basename of the input file (no extension)
110
+ source_sha256 hex digest matching the manifest entry
111
+ flow_published_at TIMESTAMP at COPY time (ingestion clock)
112
+ exported_at TIMESTAMP from ERMHDR export_date
113
+ _raw reconstructed `%R\\t...` line per row
114
+ (added in load_xer; only on source tables —
115
+ derived tables like CALENDAR_WEEKLY do not
116
+ have an upstream %R line).
117
+
118
+ The natural cross-XER join key becomes (source_xer, <p6_id>) since
119
+ P6 IDs are stable inside one export but collide across exports.
120
+ """
121
+ out_dir.mkdir(parents=True, exist_ok=True)
122
+ header: Header = read_header(source_xer)
123
+ sha = _sha256(source_xer)
124
+ src_tag = source_xer.stem
125
+ export_date_lit = (
126
+ f"CAST({_sql_str_lit(header.export_date)} AS TIMESTAMP)"
127
+ if header.export_date
128
+ else "CAST(NULL AS TIMESTAMP)"
129
+ )
130
+
131
+ table_entries: list[dict[str, Any]] = []
132
+ for table in sorted(counts):
133
+ parquet_path = out_dir / f"{table}.parquet"
134
+ if add_dw_columns:
135
+ cols_in_table = {
136
+ r[0] for r in con.execute(f'DESCRIBE "{table}"').fetchall()
137
+ }
138
+ select_sql = _dw_select(
139
+ table, cols_in_table, src_tag, sha, export_date_lit
140
+ )
141
+ con.execute(
142
+ f"COPY ({select_sql}) TO '{parquet_path}' "
143
+ "(FORMAT PARQUET, COMPRESSION ZSTD)"
144
+ )
145
+ else:
146
+ con.execute(
147
+ f"COPY \"{table}\" TO '{parquet_path}' "
148
+ "(FORMAT PARQUET, COMPRESSION ZSTD)"
149
+ )
150
+ table_entries.append(
151
+ {
152
+ "name": table,
153
+ "rows": counts[table],
154
+ "parquet": parquet_path.name,
155
+ "schema_fingerprint": _schema_fingerprint(con, table),
156
+ }
157
+ )
158
+
159
+ manifest: dict[str, Any] = {
160
+ "source_xer": str(source_xer.resolve()),
161
+ "source_sha256": _sha256(source_xer),
162
+ "parsed_at": datetime.now(tz=UTC).isoformat(),
163
+ "p6_version": header.version,
164
+ "export_date": header.export_date,
165
+ "project_label": header.project_label,
166
+ "tables": table_entries,
167
+ }
168
+ if violations is not None:
169
+ manifest["fk_violations"] = [_violation_to_dict(v) for v in violations]
170
+ if udf_unknown_types:
171
+ manifest["udf_unknown_types"] = udf_unknown_types
172
+ (out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2))
173
+ return manifest