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/__init__.py +3 -0
- p6flow/calendar.py +363 -0
- p6flow/cli.py +117 -0
- p6flow/ddl.py +182 -0
- p6flow/inference.py +204 -0
- p6flow/loader.py +189 -0
- p6flow/output.py +173 -0
- p6flow/schema.py +6876 -0
- p6flow/tokenizer.py +276 -0
- p6flow/udf.py +303 -0
- p6flow/validate.py +212 -0
- p6flow-0.1.2.dist-info/METADATA +69 -0
- p6flow-0.1.2.dist-info/RECORD +17 -0
- p6flow-0.1.2.dist-info/WHEEL +4 -0
- p6flow-0.1.2.dist-info/entry_points.txt +2 -0
- p6flow-0.1.2.dist-info/licenses/LICENSE +165 -0
- p6flow-0.1.2.dist-info/licenses/LICENSE-GPL +674 -0
p6flow/__init__.py
ADDED
p6flow/calendar.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Parser for P6's CALENDAR.clndr_data paren-tree blob.
|
|
2
|
+
|
|
3
|
+
The format is a small custom tree language. Each node looks like:
|
|
4
|
+
|
|
5
|
+
(<flags>||<name>(<attrs>)(<children>))
|
|
6
|
+
|
|
7
|
+
where <flags> is a fixed prefix we ignore (`0`), <name> identifies the
|
|
8
|
+
node type or index, <attrs> is pipe-separated key|value pairs, and
|
|
9
|
+
<children> contains zero or more sibling nodes. Inter-node whitespace
|
|
10
|
+
is `\\x7f\\x7f` plus regular ASCII whitespace, all stripped.
|
|
11
|
+
|
|
12
|
+
Top-level layout:
|
|
13
|
+
|
|
14
|
+
CalendarData
|
|
15
|
+
DaysOfWeek
|
|
16
|
+
1, 2, 3, 4, 5, 6, 7 (1=Sunday, 7=Saturday)
|
|
17
|
+
0, 1, ... (segment index, attrs s|HH:MM|f|HH:MM)
|
|
18
|
+
VIEW (display metadata, ignored)
|
|
19
|
+
Exceptions
|
|
20
|
+
0, 1, ... (exception index, attrs d|<epoch_days>)
|
|
21
|
+
0, 1, ... (optional segments overriding the day)
|
|
22
|
+
|
|
23
|
+
P6 epoch is Excel's 1899-12-30, validated empirically against
|
|
24
|
+
calendars whose exception sets contained recognizable US holidays
|
|
25
|
+
(Independence Day, Christmas, Memorial Day, Labor Day): every
|
|
26
|
+
candidate origin except 1899-12-30 placed those dates a day or
|
|
27
|
+
more off the actual calendar.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from datetime import date, time, timedelta
|
|
34
|
+
|
|
35
|
+
import duckdb
|
|
36
|
+
import pyarrow as pa
|
|
37
|
+
|
|
38
|
+
P6_EPOCH = date(1899, 12, 30)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class WeeklySegment:
|
|
43
|
+
day_of_week: int # 1=Sunday, 7=Saturday
|
|
44
|
+
segment_idx: int
|
|
45
|
+
start_time: time
|
|
46
|
+
end_time: time
|
|
47
|
+
hours: float
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class ExceptionDay:
|
|
52
|
+
exception_date: date
|
|
53
|
+
segments: tuple[WeeklySegment, ...] # may be empty (full non-work day)
|
|
54
|
+
hours_worked: float
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class CalendarPayload:
|
|
59
|
+
weekly: tuple[WeeklySegment, ...]
|
|
60
|
+
exceptions: tuple[ExceptionDay, ...]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---- Tree parsing -------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class _Node:
|
|
68
|
+
name: str
|
|
69
|
+
attrs: dict[str, str]
|
|
70
|
+
children: tuple[_Node, ...]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _strip_whitespace(s: str) -> str:
|
|
74
|
+
out = []
|
|
75
|
+
for ch in s:
|
|
76
|
+
if ch in "\x7f \t\r\n":
|
|
77
|
+
continue
|
|
78
|
+
out.append(ch)
|
|
79
|
+
return "".join(out)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _read_balanced(s: str, i: int) -> tuple[str, int]:
|
|
83
|
+
"""Read the body of a balanced (...) starting at s[i]=='('.
|
|
84
|
+
Return (body_without_outer_parens, index_after_closing_paren)."""
|
|
85
|
+
if s[i] != "(":
|
|
86
|
+
raise ValueError(f"expected '(' at {i}, got {s[i]!r}")
|
|
87
|
+
depth = 1
|
|
88
|
+
j = i + 1
|
|
89
|
+
while depth > 0:
|
|
90
|
+
if j >= len(s):
|
|
91
|
+
raise ValueError(f"unbalanced parens starting at {i}")
|
|
92
|
+
c = s[j]
|
|
93
|
+
if c == "(":
|
|
94
|
+
depth += 1
|
|
95
|
+
elif c == ")":
|
|
96
|
+
depth -= 1
|
|
97
|
+
if depth > 0:
|
|
98
|
+
j += 1
|
|
99
|
+
return s[i + 1 : j], j + 1
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _parse_attrs(body: str) -> dict[str, str]:
|
|
103
|
+
"""Parse `k1|v1|k2|v2|...` into a dict. Empty body -> empty dict."""
|
|
104
|
+
if not body:
|
|
105
|
+
return {}
|
|
106
|
+
parts = body.split("|")
|
|
107
|
+
if len(parts) % 2 != 0:
|
|
108
|
+
raise ValueError(f"odd number of attr fields: {body!r}")
|
|
109
|
+
return dict(zip(parts[0::2], parts[1::2], strict=True))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# Real P6 clndr_data nests at most ~5 levels (week / day / exception).
|
|
113
|
+
# A hard cap defangs maliciously deep blobs that would otherwise blow
|
|
114
|
+
# Python's default recursion limit (~1000) and could segfault on builds
|
|
115
|
+
# with smaller stacks.
|
|
116
|
+
_MAX_NESTING_DEPTH = 64
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _parse_node(s: str, i: int, depth: int = 0) -> tuple[_Node, int]:
|
|
120
|
+
"""Parse one node starting at s[i]=='('."""
|
|
121
|
+
if depth > _MAX_NESTING_DEPTH:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"clndr_data nesting exceeds depth limit ({_MAX_NESTING_DEPTH})"
|
|
124
|
+
)
|
|
125
|
+
if s[i] != "(":
|
|
126
|
+
raise ValueError(f"expected '(' at {i}")
|
|
127
|
+
i += 1
|
|
128
|
+
# Header runs to the first inner '('.
|
|
129
|
+
j = s.index("(", i)
|
|
130
|
+
header = s[i:j]
|
|
131
|
+
# Header format: "<flags>||<name>". We tolerate missing prefix.
|
|
132
|
+
name = header.split("||", 1)[-1]
|
|
133
|
+
i = j
|
|
134
|
+
attrs_body, i = _read_balanced(s, i)
|
|
135
|
+
children_body, i = _read_balanced(s, i)
|
|
136
|
+
if s[i] != ")":
|
|
137
|
+
raise ValueError(f"expected ')' at {i}, got {s[i]!r}")
|
|
138
|
+
i += 1
|
|
139
|
+
|
|
140
|
+
children: list[_Node] = []
|
|
141
|
+
k = 0
|
|
142
|
+
while k < len(children_body):
|
|
143
|
+
if children_body[k] == "(":
|
|
144
|
+
child, k = _parse_node(children_body, k, depth + 1)
|
|
145
|
+
children.append(child)
|
|
146
|
+
else:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"unexpected character {children_body[k]!r} between sibling nodes"
|
|
149
|
+
)
|
|
150
|
+
return _Node(name, _parse_attrs(attrs_body), tuple(children)), i
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _parse_root(s: str) -> _Node:
|
|
154
|
+
s = _strip_whitespace(s)
|
|
155
|
+
if not s:
|
|
156
|
+
raise ValueError("empty clndr_data after whitespace strip")
|
|
157
|
+
root, end = _parse_node(s, 0)
|
|
158
|
+
if end != len(s):
|
|
159
|
+
raise ValueError(f"trailing data after root: {s[end:end+20]!r}")
|
|
160
|
+
return root
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---- Domain extraction --------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_time(hhmm: str) -> time:
|
|
167
|
+
"""`08:00` -> time(8, 0). P6 also emits `24:00` for midnight-rollover
|
|
168
|
+
(a full-day segment finishing at end of day); normalize that to
|
|
169
|
+
23:59:59.999 since `time` doesn't accept hour=24."""
|
|
170
|
+
hh, mm = hhmm.split(":")
|
|
171
|
+
h, m = int(hh), int(mm)
|
|
172
|
+
if h == 24 and m == 0:
|
|
173
|
+
return time(23, 59, 59, 999000)
|
|
174
|
+
return time(h, m)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _hours_between(start: time, end: time) -> float:
|
|
178
|
+
"""Decimal hours from start to end on the same day."""
|
|
179
|
+
|
|
180
|
+
def _to_seconds(t: time) -> int:
|
|
181
|
+
return t.hour * 3600 + t.minute * 60 + t.second + t.microsecond // 1000
|
|
182
|
+
|
|
183
|
+
return (_to_seconds(end) - _to_seconds(start)) / 3600.0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _extract_segments(parent: _Node) -> list[WeeklySegment]:
|
|
187
|
+
"""Walk a day or exception node's children to pull work segments.
|
|
188
|
+
|
|
189
|
+
Caller fills in `day_of_week` after; we return per-segment tuples
|
|
190
|
+
with day_of_week=0 as a placeholder.
|
|
191
|
+
"""
|
|
192
|
+
out: list[WeeklySegment] = []
|
|
193
|
+
for seg in parent.children:
|
|
194
|
+
if "s" not in seg.attrs or "f" not in seg.attrs:
|
|
195
|
+
continue
|
|
196
|
+
start = _parse_time(seg.attrs["s"])
|
|
197
|
+
end = _parse_time(seg.attrs["f"])
|
|
198
|
+
out.append(
|
|
199
|
+
WeeklySegment(
|
|
200
|
+
day_of_week=0,
|
|
201
|
+
segment_idx=int(seg.name),
|
|
202
|
+
start_time=start,
|
|
203
|
+
end_time=end,
|
|
204
|
+
hours=_hours_between(start, end),
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
return out
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _find_child(parent: _Node, name: str) -> _Node | None:
|
|
211
|
+
for c in parent.children:
|
|
212
|
+
if c.name == name:
|
|
213
|
+
return c
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def parse_clndr_data(blob: bytes | str) -> CalendarPayload:
|
|
218
|
+
"""Parse a CALENDAR.clndr_data blob into typed weekly + exception rows.
|
|
219
|
+
|
|
220
|
+
Accepts bytes (decoded as latin-1 since the format is ASCII-only)
|
|
221
|
+
or a pre-decoded string.
|
|
222
|
+
"""
|
|
223
|
+
text = blob.decode("latin-1") if isinstance(blob, bytes) else blob
|
|
224
|
+
root = _parse_root(text)
|
|
225
|
+
|
|
226
|
+
weekly: list[WeeklySegment] = []
|
|
227
|
+
days = _find_child(root, "DaysOfWeek")
|
|
228
|
+
if days is not None:
|
|
229
|
+
for day_node in days.children:
|
|
230
|
+
day_of_week = int(day_node.name)
|
|
231
|
+
for seg in _extract_segments(day_node):
|
|
232
|
+
weekly.append(
|
|
233
|
+
WeeklySegment(
|
|
234
|
+
day_of_week=day_of_week,
|
|
235
|
+
segment_idx=seg.segment_idx,
|
|
236
|
+
start_time=seg.start_time,
|
|
237
|
+
end_time=seg.end_time,
|
|
238
|
+
hours=seg.hours,
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
exceptions: list[ExceptionDay] = []
|
|
243
|
+
exc = _find_child(root, "Exceptions")
|
|
244
|
+
if exc is not None:
|
|
245
|
+
for ex_node in exc.children:
|
|
246
|
+
if "d" not in ex_node.attrs:
|
|
247
|
+
continue
|
|
248
|
+
epoch_days = int(ex_node.attrs["d"])
|
|
249
|
+
ex_date = P6_EPOCH + timedelta(days=epoch_days)
|
|
250
|
+
segs = _extract_segments(ex_node)
|
|
251
|
+
hours_worked = sum(s.hours for s in segs)
|
|
252
|
+
exceptions.append(
|
|
253
|
+
ExceptionDay(
|
|
254
|
+
exception_date=ex_date,
|
|
255
|
+
segments=tuple(segs),
|
|
256
|
+
hours_worked=hours_worked,
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
return CalendarPayload(weekly=tuple(weekly), exceptions=tuple(exceptions))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---- DuckDB materialization ---------------------------------------
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def materialize_calendar_derived(
|
|
267
|
+
con: duckdb.DuckDBPyConnection,
|
|
268
|
+
) -> dict[str, int]:
|
|
269
|
+
"""Parse every CALENDAR.clndr_data blob and create two derived tables:
|
|
270
|
+
|
|
271
|
+
CALENDAR_WEEKLY (clndr_id, day_of_week, segment_idx, start_time,
|
|
272
|
+
end_time, hours)
|
|
273
|
+
CALENDAR_EXCEPTION (clndr_id, exception_date, segment_idx, start_time,
|
|
274
|
+
end_time, hours_worked, is_full_segment)
|
|
275
|
+
|
|
276
|
+
Exception rows mirror weekly: one row per work segment, plus one
|
|
277
|
+
summary row per exception day with segment_idx=NULL when the day
|
|
278
|
+
is a pure non-work day (so consumers can quickly filter holidays).
|
|
279
|
+
|
|
280
|
+
Returns row counts for both tables. Skips silently if CALENDAR
|
|
281
|
+
isn't loaded (legacy XER without it).
|
|
282
|
+
"""
|
|
283
|
+
loaded = {r[0] for r in con.execute("SHOW TABLES").fetchall()}
|
|
284
|
+
if "CALENDAR" not in loaded:
|
|
285
|
+
return {}
|
|
286
|
+
|
|
287
|
+
weekly_rows: list[dict] = []
|
|
288
|
+
exception_rows: list[dict] = []
|
|
289
|
+
|
|
290
|
+
for clndr_id, blob in con.execute(
|
|
291
|
+
"SELECT clndr_id, clndr_data FROM CALENDAR WHERE clndr_data IS NOT NULL"
|
|
292
|
+
).fetchall():
|
|
293
|
+
payload = parse_clndr_data(blob)
|
|
294
|
+
for seg in payload.weekly:
|
|
295
|
+
weekly_rows.append({
|
|
296
|
+
"clndr_id": clndr_id,
|
|
297
|
+
"day_of_week": seg.day_of_week,
|
|
298
|
+
"segment_idx": seg.segment_idx,
|
|
299
|
+
"start_time": seg.start_time,
|
|
300
|
+
"end_time": seg.end_time,
|
|
301
|
+
"hours": seg.hours,
|
|
302
|
+
})
|
|
303
|
+
for ex in payload.exceptions:
|
|
304
|
+
if not ex.segments:
|
|
305
|
+
# Pure non-work day. Single row with NULL segment fields.
|
|
306
|
+
exception_rows.append({
|
|
307
|
+
"clndr_id": clndr_id,
|
|
308
|
+
"exception_date": ex.exception_date,
|
|
309
|
+
"segment_idx": None,
|
|
310
|
+
"start_time": None,
|
|
311
|
+
"end_time": None,
|
|
312
|
+
"hours_worked": 0.0,
|
|
313
|
+
})
|
|
314
|
+
else:
|
|
315
|
+
for seg in ex.segments:
|
|
316
|
+
exception_rows.append({
|
|
317
|
+
"clndr_id": clndr_id,
|
|
318
|
+
"exception_date": ex.exception_date,
|
|
319
|
+
"segment_idx": seg.segment_idx,
|
|
320
|
+
"start_time": seg.start_time,
|
|
321
|
+
"end_time": seg.end_time,
|
|
322
|
+
"hours_worked": seg.hours,
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
weekly_schema = pa.schema([
|
|
326
|
+
("clndr_id", pa.int64()),
|
|
327
|
+
("day_of_week", pa.int32()),
|
|
328
|
+
("segment_idx", pa.int32()),
|
|
329
|
+
("start_time", pa.time64("us")),
|
|
330
|
+
("end_time", pa.time64("us")),
|
|
331
|
+
("hours", pa.float64()),
|
|
332
|
+
])
|
|
333
|
+
exception_schema = pa.schema([
|
|
334
|
+
("clndr_id", pa.int64()),
|
|
335
|
+
("exception_date", pa.date32()),
|
|
336
|
+
("segment_idx", pa.int32()),
|
|
337
|
+
("start_time", pa.time64("us")),
|
|
338
|
+
("end_time", pa.time64("us")),
|
|
339
|
+
("hours_worked", pa.float64()),
|
|
340
|
+
])
|
|
341
|
+
|
|
342
|
+
weekly_table = pa.Table.from_pylist(weekly_rows, schema=weekly_schema)
|
|
343
|
+
exception_table = pa.Table.from_pylist(exception_rows, schema=exception_schema)
|
|
344
|
+
|
|
345
|
+
con.register("calendar_weekly_raw", weekly_table)
|
|
346
|
+
con.register("calendar_exception_raw", exception_table)
|
|
347
|
+
try:
|
|
348
|
+
con.execute(
|
|
349
|
+
"CREATE OR REPLACE TABLE CALENDAR_WEEKLY AS "
|
|
350
|
+
"SELECT * FROM calendar_weekly_raw"
|
|
351
|
+
)
|
|
352
|
+
con.execute(
|
|
353
|
+
"CREATE OR REPLACE TABLE CALENDAR_EXCEPTION AS "
|
|
354
|
+
"SELECT * FROM calendar_exception_raw"
|
|
355
|
+
)
|
|
356
|
+
finally:
|
|
357
|
+
con.unregister("calendar_weekly_raw")
|
|
358
|
+
con.unregister("calendar_exception_raw")
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
"CALENDAR_WEEKLY": len(weekly_rows),
|
|
362
|
+
"CALENDAR_EXCEPTION": len(exception_rows),
|
|
363
|
+
}
|
p6flow/cli.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""p6flow: command-line entry point.
|
|
2
|
+
|
|
3
|
+
p6flow INPUT [INPUT ...] --out DIR
|
|
4
|
+
|
|
5
|
+
Each input XER becomes a subdirectory under --out containing one Parquet
|
|
6
|
+
per table plus manifest.json. Multi-input runs do not merge: PKs collide
|
|
7
|
+
across XERs since separate projects can reuse internal numeric ID spaces.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
import duckdb
|
|
16
|
+
|
|
17
|
+
from .calendar import materialize_calendar_derived
|
|
18
|
+
from .loader import load_xer
|
|
19
|
+
from .output import write_parquet
|
|
20
|
+
from .tokenizer import parse_tables
|
|
21
|
+
from .udf import materialize_udf_wide
|
|
22
|
+
from .validate import FkViolation, validate_fks
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _print_violations(violations: list[FkViolation]) -> None:
|
|
26
|
+
if not violations:
|
|
27
|
+
click.echo(" fk-validation: clean")
|
|
28
|
+
return
|
|
29
|
+
click.echo(f" fk-validation: {len(violations)} violation(s)")
|
|
30
|
+
for v in violations:
|
|
31
|
+
local = ".".join(v.local_fields)
|
|
32
|
+
target = ".".join(v.target_fields)
|
|
33
|
+
sample = ", ".join("(" + ",".join(k) + ")" for k in v.sample_keys[:3])
|
|
34
|
+
click.echo(
|
|
35
|
+
f" [{v.kind}] {v.child_table}.{local} -> "
|
|
36
|
+
f"{v.target_table}.{target}: {v.violating_rows} row(s); "
|
|
37
|
+
f"sample: {sample}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@click.command(name="p6flow")
|
|
42
|
+
@click.argument(
|
|
43
|
+
"inputs",
|
|
44
|
+
nargs=-1,
|
|
45
|
+
required=True,
|
|
46
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
47
|
+
)
|
|
48
|
+
@click.option(
|
|
49
|
+
"--out",
|
|
50
|
+
"out_dir",
|
|
51
|
+
required=True,
|
|
52
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
53
|
+
help="Output directory; one subdirectory per input XER.",
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--validate/--no-validate",
|
|
57
|
+
default=True,
|
|
58
|
+
help="Run FK validation after load and persist into manifest.json.",
|
|
59
|
+
)
|
|
60
|
+
@click.option(
|
|
61
|
+
"--add-dw-columns/--no-add-dw-columns",
|
|
62
|
+
default=False,
|
|
63
|
+
help="Append warehouse columns to every emitted parquet: "
|
|
64
|
+
"source_xer, source_sha256, flow_published_at, created_at, _raw. "
|
|
65
|
+
"Use for cross-XER loads where P6 IDs collide between exports.",
|
|
66
|
+
)
|
|
67
|
+
@click.option(
|
|
68
|
+
"--quiet/--no-quiet",
|
|
69
|
+
default=False,
|
|
70
|
+
help="Suppress per-table progress output.",
|
|
71
|
+
)
|
|
72
|
+
def parse(
|
|
73
|
+
inputs: tuple[Path, ...],
|
|
74
|
+
out_dir: Path,
|
|
75
|
+
validate: bool,
|
|
76
|
+
add_dw_columns: bool,
|
|
77
|
+
quiet: bool,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Parse XER files into typed Parquet."""
|
|
80
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
for src in inputs:
|
|
82
|
+
sub = out_dir / src.stem
|
|
83
|
+
if not quiet:
|
|
84
|
+
click.echo(f"parsing {src.name}")
|
|
85
|
+
tables = list(parse_tables(src))
|
|
86
|
+
con = duckdb.connect(":memory:")
|
|
87
|
+
counts = load_xer(con, tables, add_dw_columns=add_dw_columns)
|
|
88
|
+
counts.update(materialize_calendar_derived(con))
|
|
89
|
+
udf_counts, udf_unknown = materialize_udf_wide(con)
|
|
90
|
+
counts.update(udf_counts)
|
|
91
|
+
violations = validate_fks(con) if validate else None
|
|
92
|
+
manifest = write_parquet(
|
|
93
|
+
con,
|
|
94
|
+
sub,
|
|
95
|
+
src,
|
|
96
|
+
counts,
|
|
97
|
+
violations=violations,
|
|
98
|
+
udf_unknown_types=udf_unknown,
|
|
99
|
+
add_dw_columns=add_dw_columns,
|
|
100
|
+
)
|
|
101
|
+
if not quiet:
|
|
102
|
+
total = sum(counts.values())
|
|
103
|
+
click.echo(
|
|
104
|
+
f" -> {sub} ({len(counts)} tables, {total} rows, "
|
|
105
|
+
f"P6 v{manifest['p6_version']})"
|
|
106
|
+
)
|
|
107
|
+
if udf_unknown:
|
|
108
|
+
click.echo(
|
|
109
|
+
f" udf-unknown-types: {len(udf_unknown)} "
|
|
110
|
+
f"(see manifest.json#udf_unknown_types)"
|
|
111
|
+
)
|
|
112
|
+
if violations is not None:
|
|
113
|
+
_print_violations(violations)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
parse()
|
p6flow/ddl.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Runtime DDL generation.
|
|
2
|
+
|
|
3
|
+
The codegen module p6flow.schema provides every column EPPM knows about
|
|
4
|
+
at a given version. This module reads the actual %F field list from a parsed
|
|
5
|
+
XER block and emits DDL using only those columns, with types looked up from
|
|
6
|
+
COLUMN_TYPES and a VARCHAR fallback for anything the schema doesn't know about
|
|
7
|
+
(legacy P6 columns, Oracle-removed columns, etc.).
|
|
8
|
+
|
|
9
|
+
Two flavors:
|
|
10
|
+
|
|
11
|
+
duckdb_ddl(table, fields) for the local staging DB
|
|
12
|
+
snowflake_ddl(table, fields) for the remote target
|
|
13
|
+
|
|
14
|
+
Both return a single SQL string. PRIMARY KEY constraints are included when
|
|
15
|
+
every PK column is present in the field list. FOREIGN KEY constraints are
|
|
16
|
+
included when both the local and target columns are present. Self-FKs are
|
|
17
|
+
skipped because DuckDB enforces FKs at INSERT and offers no deferred mode.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from .schema import COLUMN_TYPES, FOREIGN_KEYS, PRIMARY_KEYS, ColumnSpec
|
|
23
|
+
|
|
24
|
+
VARCHAR_FALLBACK = ColumnSpec(
|
|
25
|
+
duckdb_type="VARCHAR",
|
|
26
|
+
snowflake_type="VARCHAR",
|
|
27
|
+
nullable=True,
|
|
28
|
+
description="",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
OverrideMap = dict[tuple[str, str], ColumnSpec]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _spec(table: str, field: str, overrides: OverrideMap | None = None) -> ColumnSpec:
|
|
35
|
+
if overrides and (table, field) in overrides:
|
|
36
|
+
return overrides[(table, field)]
|
|
37
|
+
return COLUMN_TYPES.get(table, {}).get(field, VARCHAR_FALLBACK)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _sql_quote(s: str) -> str:
|
|
41
|
+
return s.replace("\\", "\\\\").replace("'", "''")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _common_constraints(
|
|
45
|
+
table: str,
|
|
46
|
+
fields: list[str],
|
|
47
|
+
available_tables: set[str] | None = None,
|
|
48
|
+
) -> list[str]:
|
|
49
|
+
"""Constraints both DDL flavors agree on (text only).
|
|
50
|
+
|
|
51
|
+
`available_tables` filters FK references: only emit a FK if its target
|
|
52
|
+
table is actually being loaded. Without this filter, DDL would reference
|
|
53
|
+
parent tables that don't exist in DuckDB and the CREATE would fail.
|
|
54
|
+
"""
|
|
55
|
+
field_set = set(fields)
|
|
56
|
+
out: list[str] = []
|
|
57
|
+
pk = PRIMARY_KEYS.get(table)
|
|
58
|
+
if pk and all(c in field_set for c in pk):
|
|
59
|
+
out.append(
|
|
60
|
+
f"CONSTRAINT pk_{table.lower()} PRIMARY KEY ({', '.join(pk)})"
|
|
61
|
+
)
|
|
62
|
+
for fk in FOREIGN_KEYS.get(table, []):
|
|
63
|
+
if fk.target_table == table:
|
|
64
|
+
continue # skip self-FKs
|
|
65
|
+
if available_tables is not None and fk.target_table not in available_tables:
|
|
66
|
+
continue
|
|
67
|
+
if not all(c in field_set for c in fk.local_fields):
|
|
68
|
+
continue
|
|
69
|
+
target_cols = COLUMN_TYPES.get(fk.target_table, {})
|
|
70
|
+
if not all(c in target_cols for c in fk.target_fields):
|
|
71
|
+
continue
|
|
72
|
+
out.append(
|
|
73
|
+
f"CONSTRAINT {fk.name} FOREIGN KEY "
|
|
74
|
+
f"({', '.join(fk.local_fields)}) REFERENCES "
|
|
75
|
+
f"{fk.target_table} ({', '.join(fk.target_fields)})"
|
|
76
|
+
)
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def duckdb_ddl(
|
|
81
|
+
table: str,
|
|
82
|
+
fields: list[str],
|
|
83
|
+
overrides: OverrideMap | None = None,
|
|
84
|
+
available_tables: set[str] | None = None,
|
|
85
|
+
enforce_fks: bool = False,
|
|
86
|
+
enforce_not_null: bool = False,
|
|
87
|
+
) -> str:
|
|
88
|
+
"""CREATE TABLE + COMMENT ON statements for DuckDB staging.
|
|
89
|
+
|
|
90
|
+
Defaults are permissive because XER is a partial export, not a self-
|
|
91
|
+
contained snapshot. Real XERs routinely reference projects, calendars,
|
|
92
|
+
and resources that aren't included in the export. NOT NULL is also
|
|
93
|
+
relaxed because some columns marked NOT NULL in the v26 schema are
|
|
94
|
+
optional in older P6 versions and arrive empty.
|
|
95
|
+
|
|
96
|
+
FK validation belongs in a downstream SELECT-based step that reports
|
|
97
|
+
violations as warnings, not as DDL constraints that block load.
|
|
98
|
+
"""
|
|
99
|
+
col_lines: list[str] = []
|
|
100
|
+
for f in fields:
|
|
101
|
+
s = _spec(table, f, overrides)
|
|
102
|
+
null_clause = " NOT NULL" if (enforce_not_null and not s.nullable) else ""
|
|
103
|
+
# Quote every column name. Real XERs occasionally have malformed
|
|
104
|
+
# %F lines where two field names get joined with a space (e.g.
|
|
105
|
+
# "create_user update_user"); without quoting, that emits invalid
|
|
106
|
+
# SQL. Quoting also future-proofs against names that collide with
|
|
107
|
+
# SQL reserved words.
|
|
108
|
+
col_lines.append(f' "{f}" {s.duckdb_type}{null_clause}')
|
|
109
|
+
constraints = _common_constraints(table, fields, available_tables)
|
|
110
|
+
if not enforce_fks:
|
|
111
|
+
constraints = [c for c in constraints if c.startswith("CONSTRAINT pk_")]
|
|
112
|
+
col_lines.extend(f" {c}" for c in constraints)
|
|
113
|
+
|
|
114
|
+
out: list[str] = [
|
|
115
|
+
f"CREATE OR REPLACE TABLE {table} (",
|
|
116
|
+
",\n".join(col_lines),
|
|
117
|
+
");",
|
|
118
|
+
]
|
|
119
|
+
for f in fields:
|
|
120
|
+
s = _spec(table, f, overrides)
|
|
121
|
+
if s.description:
|
|
122
|
+
out.append(
|
|
123
|
+
f"COMMENT ON COLUMN {table}.\"{f}\" IS "
|
|
124
|
+
f"'{_sql_quote(s.description)}';"
|
|
125
|
+
)
|
|
126
|
+
return "\n".join(out)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def snowflake_ddl(
|
|
130
|
+
table: str,
|
|
131
|
+
fields: list[str],
|
|
132
|
+
overrides: OverrideMap | None = None,
|
|
133
|
+
available_tables: set[str] | None = None,
|
|
134
|
+
) -> str:
|
|
135
|
+
"""CREATE TABLE with inline COMMENT clauses for Snowflake."""
|
|
136
|
+
col_lines: list[str] = []
|
|
137
|
+
for f in fields:
|
|
138
|
+
s = _spec(table, f, overrides)
|
|
139
|
+
null_clause = "" if s.nullable else " NOT NULL"
|
|
140
|
+
comment = (
|
|
141
|
+
f" COMMENT '{_sql_quote(s.description)}'" if s.description else ""
|
|
142
|
+
)
|
|
143
|
+
col_lines.append(f' "{f}" {s.snowflake_type}{null_clause}{comment}')
|
|
144
|
+
col_lines.extend(
|
|
145
|
+
f" {c}" for c in _common_constraints(table, fields, available_tables)
|
|
146
|
+
)
|
|
147
|
+
return "\n".join(
|
|
148
|
+
[
|
|
149
|
+
f"CREATE OR REPLACE TABLE {table} (",
|
|
150
|
+
",\n".join(col_lines),
|
|
151
|
+
");",
|
|
152
|
+
]
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def topo_sort_tables(blocks: list[tuple[str, list[str]]]) -> list[tuple[str, list[str]]]:
|
|
157
|
+
"""Order parsed blocks so every FK parent appears before its children.
|
|
158
|
+
|
|
159
|
+
DuckDB needs this since it enforces FKs at INSERT time. blocks is a list
|
|
160
|
+
of (table_name, %F field list). Returns the same items reordered.
|
|
161
|
+
"""
|
|
162
|
+
by_name = {t: (t, fs) for t, fs in blocks}
|
|
163
|
+
seen: set[str] = set()
|
|
164
|
+
visiting: set[str] = set()
|
|
165
|
+
out: list[tuple[str, list[str]]] = []
|
|
166
|
+
|
|
167
|
+
def visit(name: str) -> None:
|
|
168
|
+
if name in seen or name not in by_name:
|
|
169
|
+
return
|
|
170
|
+
if name in visiting:
|
|
171
|
+
return # cycle; let caller proceed
|
|
172
|
+
visiting.add(name)
|
|
173
|
+
for fk in FOREIGN_KEYS.get(name, []):
|
|
174
|
+
if fk.target_table != name:
|
|
175
|
+
visit(fk.target_table)
|
|
176
|
+
visiting.discard(name)
|
|
177
|
+
seen.add(name)
|
|
178
|
+
out.append(by_name[name])
|
|
179
|
+
|
|
180
|
+
for t, _ in blocks:
|
|
181
|
+
visit(t)
|
|
182
|
+
return out
|