ubunye-engine 0.1.1__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.
- ubunye/__init__.py +10 -0
- ubunye/backends/__init__.py +1 -0
- ubunye/backends/spark_backend.py +127 -0
- ubunye/cli/__init__.py +0 -0
- ubunye/cli/lineage.py +304 -0
- ubunye/cli/main.py +444 -0
- ubunye/cli/models.py +301 -0
- ubunye/cli/test_cmd.py +220 -0
- ubunye/compat/__init__.py +0 -0
- ubunye/compat/analytics_engine_shim.py +1 -0
- ubunye/config/__init__.py +6 -0
- ubunye/config/loader.py +136 -0
- ubunye/config/resolver.py +121 -0
- ubunye/config/schema.py +246 -0
- ubunye/core/__init__.py +0 -0
- ubunye/core/interfaces.py +67 -0
- ubunye/core/runtime.py +373 -0
- ubunye/lineage/__init__.py +23 -0
- ubunye/lineage/context.py +154 -0
- ubunye/lineage/hasher.py +149 -0
- ubunye/lineage/recorder.py +175 -0
- ubunye/lineage/storage.py +210 -0
- ubunye/models/__init__.py +17 -0
- ubunye/models/base.py +140 -0
- ubunye/models/gates.py +161 -0
- ubunye/models/loader.py +130 -0
- ubunye/models/registry.py +457 -0
- ubunye/orchestration/__init__.py +0 -0
- ubunye/orchestration/airflow_exporter.py +74 -0
- ubunye/orchestration/base.py +17 -0
- ubunye/orchestration/databricks_expoter.py +69 -0
- ubunye/plugins/__init__.py +0 -0
- ubunye/plugins/ml/__init__.py +0 -0
- ubunye/plugins/ml/adapters.py +35 -0
- ubunye/plugins/ml/base.py +231 -0
- ubunye/plugins/ml/pysparkml.py +47 -0
- ubunye/plugins/ml/sklearn.py +58 -0
- ubunye/plugins/readers/__init__.py +0 -0
- ubunye/plugins/readers/hive.py +25 -0
- ubunye/plugins/readers/jdbc.py +101 -0
- ubunye/plugins/readers/rest_api.py +444 -0
- ubunye/plugins/readers/s3.py +43 -0
- ubunye/plugins/readers/unity.py +46 -0
- ubunye/plugins/transforms/__init__.py +0 -0
- ubunye/plugins/transforms/model_transform.py +188 -0
- ubunye/plugins/transforms/noop.py +14 -0
- ubunye/plugins/writers/__init__.py +0 -0
- ubunye/plugins/writers/jdbc.py +76 -0
- ubunye/plugins/writers/rest_api.py +244 -0
- ubunye/plugins/writers/s3.py +22 -0
- ubunye/plugins/writers/unity.py +98 -0
- ubunye/telemetry/__init__.py +0 -0
- ubunye/telemetry/events.py +131 -0
- ubunye/telemetry/mlflow.py +75 -0
- ubunye/telemetry/monitors.py +54 -0
- ubunye/telemetry/otel.py +106 -0
- ubunye/telemetry/prometheus.py +128 -0
- ubunye_engine-0.1.1.dist-info/METADATA +289 -0
- ubunye_engine-0.1.1.dist-info/RECORD +63 -0
- ubunye_engine-0.1.1.dist-info/WHEEL +5 -0
- ubunye_engine-0.1.1.dist-info/entry_points.txt +27 -0
- ubunye_engine-0.1.1.dist-info/licenses/LICENSE +1 -0
- ubunye_engine-0.1.1.dist-info/top_level.txt +1 -0
ubunye/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Ubunye Engine package."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("ubunye-engine")
|
|
7
|
+
except PackageNotFoundError:
|
|
8
|
+
__version__ = "unknown"
|
|
9
|
+
|
|
10
|
+
__all__ = ["core", "config", "cli", "plugins", "backends"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .spark_backend import SparkBackend as SparkBackend
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Spark backend implementation for Ubunye.
|
|
3
|
+
|
|
4
|
+
- Lazily imports pyspark so users can install the package without Spark.
|
|
5
|
+
- Provides a simple lifecycle: start() / stop().
|
|
6
|
+
- Supports context-manager usage: `with SparkBackend(...) as backend: ...`
|
|
7
|
+
- Guards against double-starts and exposes the effective Spark conf for debugging.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Dict, Optional
|
|
13
|
+
|
|
14
|
+
from ubunye.core.interfaces import Backend
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING: # only for type checkers; no runtime dependency on pyspark
|
|
17
|
+
from pyspark.sql import SparkSession
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SparkBackend(Backend):
|
|
21
|
+
"""Creates and manages a SparkSession for a Ubunye run.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
app_name : str
|
|
26
|
+
Spark application name (appears in Spark UI/history server).
|
|
27
|
+
conf : Optional[Dict[str, str]]
|
|
28
|
+
Spark configuration key-values (e.g., {"spark.master": "yarn"}).
|
|
29
|
+
|
|
30
|
+
Notes
|
|
31
|
+
-----
|
|
32
|
+
- pyspark is imported lazily inside `start()` to keep installation lightweight.
|
|
33
|
+
- `spark` property is only valid after `start()` (or inside the context manager).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, app_name: str = "ubunye", conf: Optional[Dict[str, str]] = None) -> None:
|
|
37
|
+
self._spark: Optional["SparkSession"] = None
|
|
38
|
+
self._app_name = app_name
|
|
39
|
+
self._conf = dict(conf or {})
|
|
40
|
+
|
|
41
|
+
# -------------------------
|
|
42
|
+
# Lifecycle
|
|
43
|
+
# -------------------------
|
|
44
|
+
def start(self) -> None:
|
|
45
|
+
"""Create (or reuse) a SparkSession.
|
|
46
|
+
|
|
47
|
+
Safe to call multiple times; a second call is a no-op if a session already exists.
|
|
48
|
+
"""
|
|
49
|
+
if self._spark is not None:
|
|
50
|
+
return # already started
|
|
51
|
+
|
|
52
|
+
# Lazy import to avoid hard dependency during pip install
|
|
53
|
+
from pyspark.sql import SparkSession # type: ignore
|
|
54
|
+
|
|
55
|
+
builder = SparkSession.builder.appName(self._app_name)
|
|
56
|
+
for k, v in self._conf.items():
|
|
57
|
+
builder = builder.config(k, v)
|
|
58
|
+
self._spark = builder.getOrCreate()
|
|
59
|
+
|
|
60
|
+
def stop(self) -> None:
|
|
61
|
+
"""Stop the SparkSession if running."""
|
|
62
|
+
if self._spark is not None:
|
|
63
|
+
try:
|
|
64
|
+
self._spark.stop()
|
|
65
|
+
finally:
|
|
66
|
+
self._spark = None
|
|
67
|
+
|
|
68
|
+
# Context manager support
|
|
69
|
+
def __enter__(self) -> "SparkBackend":
|
|
70
|
+
self.start()
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
74
|
+
self.stop()
|
|
75
|
+
|
|
76
|
+
def __del__(self) -> None:
|
|
77
|
+
# Best-effort cleanup; not guaranteed to run in all interpreter shutdown scenarios.
|
|
78
|
+
try:
|
|
79
|
+
self.stop()
|
|
80
|
+
except Exception:
|
|
81
|
+
# Avoid noisy destructor exceptions at process teardown.
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
# -------------------------
|
|
85
|
+
# Properties
|
|
86
|
+
# -------------------------
|
|
87
|
+
@property
|
|
88
|
+
def spark(self) -> "SparkSession":
|
|
89
|
+
"""Return the active SparkSession (after `start()`).
|
|
90
|
+
|
|
91
|
+
Raises
|
|
92
|
+
------
|
|
93
|
+
RuntimeError
|
|
94
|
+
If `start()` has not been called.
|
|
95
|
+
"""
|
|
96
|
+
if self._spark is None:
|
|
97
|
+
raise RuntimeError(
|
|
98
|
+
"Spark session not started. Call start() first or use context manager."
|
|
99
|
+
)
|
|
100
|
+
return self._spark
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def is_spark(self) -> bool:
|
|
104
|
+
"""Whether this backend is Spark-based (always True here)."""
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def app_name(self) -> str:
|
|
109
|
+
"""Configured Spark app name."""
|
|
110
|
+
return self._app_name
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def conf_input(self) -> Dict[str, str]:
|
|
114
|
+
"""The configuration dict passed into this backend at construction time."""
|
|
115
|
+
return dict(self._conf)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def conf_effective(self) -> Dict[str, str]:
|
|
119
|
+
"""The *effective* Spark configuration currently applied to the session.
|
|
120
|
+
|
|
121
|
+
Returns an empty dict if the session hasn't been started yet.
|
|
122
|
+
"""
|
|
123
|
+
if self._spark is None:
|
|
124
|
+
return {}
|
|
125
|
+
sc = self._spark.sparkContext
|
|
126
|
+
# Convert list[tuple[str,str]] to dict[str,str]
|
|
127
|
+
return {k: v for (k, v) in sc.getConf().getAll()}
|
ubunye/cli/__init__.py
ADDED
|
File without changes
|
ubunye/cli/lineage.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""CLI commands for inspecting run lineage records.
|
|
2
|
+
|
|
3
|
+
Mounted on the main app as a sub-command group:
|
|
4
|
+
|
|
5
|
+
ubunye lineage show -d DIR -u USECASE -p PKG -t TASK [--run-id ID]
|
|
6
|
+
ubunye lineage list -d DIR -u USECASE -p PKG -t TASK [-n 10]
|
|
7
|
+
ubunye lineage compare -d DIR -u USECASE -p PKG -t TASK --run-id1 ID1 --run-id2 ID2
|
|
8
|
+
ubunye lineage search -d DIR [--status error] [--since 2025-01-01]
|
|
9
|
+
ubunye lineage trace -d DIR -u USECASE -p PKG -t TASK
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
from ubunye.lineage.context import RunContext
|
|
21
|
+
from ubunye.lineage.storage import FileSystemLineageStore
|
|
22
|
+
|
|
23
|
+
lineage_app = typer.Typer(name="lineage", help="Inspect run lineage records.", add_completion=False)
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Shared helpers
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
_LINE = "-" * 60
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _store(lineage_dir: str) -> FileSystemLineageStore:
|
|
33
|
+
return FileSystemLineageStore(lineage_dir)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _task_path(usecase: str, package: str, task: str) -> str:
|
|
37
|
+
return f"{usecase}/{package}/{task}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _fmt_row(ctx: RunContext) -> str:
|
|
41
|
+
duration = f"{ctx.duration_sec:.1f}s" if ctx.duration_sec is not None else "-"
|
|
42
|
+
in_rows = sum(s.row_count or 0 for s in ctx.inputs)
|
|
43
|
+
out_rows = sum(s.row_count or 0 for s in ctx.outputs)
|
|
44
|
+
return (
|
|
45
|
+
f"{ctx.run_id[:8]} {ctx.started_at[:19]} "
|
|
46
|
+
f"{ctx.status:<8} {duration:>7} "
|
|
47
|
+
f"in:{in_rows} out:{out_rows}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# show
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@lineage_app.command("show")
|
|
57
|
+
def show(
|
|
58
|
+
usecase_dir: Path = typer.Option(
|
|
59
|
+
..., "-d", "--usecase-dir", help="Root directory of pipelines."
|
|
60
|
+
),
|
|
61
|
+
usecase: str = typer.Option(..., "-u", "--usecase"),
|
|
62
|
+
package: str = typer.Option(..., "-p", "--package"),
|
|
63
|
+
task: str = typer.Option(..., "-t", "--task"),
|
|
64
|
+
run_id: Optional[str] = typer.Option(
|
|
65
|
+
None, "--run-id", help="Specific run ID (default: latest)."
|
|
66
|
+
),
|
|
67
|
+
lineage_dir: str = typer.Option(".ubunye/lineage", "--lineage-dir"),
|
|
68
|
+
):
|
|
69
|
+
"""Show a run record as formatted JSON (latest or specific run)."""
|
|
70
|
+
store = _store(str(usecase_dir / lineage_dir))
|
|
71
|
+
tp = _task_path(usecase, package, task)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
if run_id:
|
|
75
|
+
ctx = store.load(tp, run_id)
|
|
76
|
+
else:
|
|
77
|
+
runs = store.list_runs(tp, n=1)
|
|
78
|
+
if not runs:
|
|
79
|
+
typer.secho(f"No lineage records found for task '{tp}'.", fg=typer.colors.YELLOW)
|
|
80
|
+
raise typer.Exit(code=1)
|
|
81
|
+
ctx = runs[0]
|
|
82
|
+
except FileNotFoundError as e:
|
|
83
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
84
|
+
raise typer.Exit(code=1)
|
|
85
|
+
|
|
86
|
+
typer.echo(json.dumps(ctx.to_dict(), indent=2))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# list
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@lineage_app.command("list")
|
|
95
|
+
def list_runs(
|
|
96
|
+
usecase_dir: Path = typer.Option(..., "-d", "--usecase-dir"),
|
|
97
|
+
usecase: str = typer.Option(..., "-u", "--usecase"),
|
|
98
|
+
package: str = typer.Option(..., "-p", "--package"),
|
|
99
|
+
task: str = typer.Option(..., "-t", "--task"),
|
|
100
|
+
n: int = typer.Option(10, "-n", "--n", help="Number of recent runs to show."),
|
|
101
|
+
lineage_dir: str = typer.Option(".ubunye/lineage", "--lineage-dir"),
|
|
102
|
+
):
|
|
103
|
+
"""List recent runs for a task (newest first)."""
|
|
104
|
+
store = _store(str(usecase_dir / lineage_dir))
|
|
105
|
+
tp = _task_path(usecase, package, task)
|
|
106
|
+
runs = store.list_runs(tp, n=n)
|
|
107
|
+
|
|
108
|
+
if not runs:
|
|
109
|
+
typer.secho(f"No lineage records found for '{tp}'.", fg=typer.colors.YELLOW)
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
typer.echo(_LINE)
|
|
113
|
+
typer.echo(f"Task: {tp}")
|
|
114
|
+
typer.echo(_LINE)
|
|
115
|
+
typer.echo(f"{'RUN_ID':9} {'STARTED':19} {'STATUS':<8} {'DUR':>7} ROWS")
|
|
116
|
+
typer.echo(_LINE)
|
|
117
|
+
for ctx in runs:
|
|
118
|
+
color = (
|
|
119
|
+
typer.colors.GREEN
|
|
120
|
+
if ctx.status == "success"
|
|
121
|
+
else (typer.colors.RED if ctx.status == "error" else typer.colors.YELLOW)
|
|
122
|
+
)
|
|
123
|
+
typer.secho(_fmt_row(ctx), fg=color)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# compare
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@lineage_app.command("compare")
|
|
132
|
+
def compare(
|
|
133
|
+
usecase_dir: Path = typer.Option(..., "-d", "--usecase-dir"),
|
|
134
|
+
usecase: str = typer.Option(..., "-u", "--usecase"),
|
|
135
|
+
package: str = typer.Option(..., "-p", "--package"),
|
|
136
|
+
task: str = typer.Option(..., "-t", "--task"),
|
|
137
|
+
run_id1: str = typer.Option(..., "--run-id1", help="First run ID."),
|
|
138
|
+
run_id2: str = typer.Option(..., "--run-id2", help="Second run ID."),
|
|
139
|
+
lineage_dir: str = typer.Option(".ubunye/lineage", "--lineage-dir"),
|
|
140
|
+
):
|
|
141
|
+
"""Diff two run records — highlight changes in hashes, row counts, and status."""
|
|
142
|
+
store = _store(str(usecase_dir / lineage_dir))
|
|
143
|
+
tp = _task_path(usecase, package, task)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
a = store.load(tp, run_id1)
|
|
147
|
+
b = store.load(tp, run_id2)
|
|
148
|
+
except FileNotFoundError as e:
|
|
149
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
150
|
+
raise typer.Exit(code=1)
|
|
151
|
+
|
|
152
|
+
typer.echo(_LINE)
|
|
153
|
+
typer.echo(f"Comparing runs for '{tp}'")
|
|
154
|
+
typer.echo(f" A: {a.run_id[:8]} {a.started_at[:19]} {a.status}")
|
|
155
|
+
typer.echo(f" B: {b.run_id[:8]} {b.started_at[:19]} {b.status}")
|
|
156
|
+
typer.echo(_LINE)
|
|
157
|
+
|
|
158
|
+
def _cmp(label: str, va: object, vb: object) -> None:
|
|
159
|
+
if va == vb:
|
|
160
|
+
typer.echo(f" {label}: {va} (unchanged)")
|
|
161
|
+
else:
|
|
162
|
+
typer.secho(f" {label}: {va} → {vb} CHANGED", fg=typer.colors.YELLOW)
|
|
163
|
+
|
|
164
|
+
_cmp("status", a.status, b.status)
|
|
165
|
+
_cmp("duration_sec", a.duration_sec, b.duration_sec)
|
|
166
|
+
_cmp("config_hash", a.config_hash, b.config_hash)
|
|
167
|
+
|
|
168
|
+
# Compare outputs step by step
|
|
169
|
+
a_out = {s.name: s for s in a.outputs}
|
|
170
|
+
b_out = {s.name: s for s in b.outputs}
|
|
171
|
+
all_names = sorted(set(a_out) | set(b_out))
|
|
172
|
+
for name in all_names:
|
|
173
|
+
sa = a_out.get(name)
|
|
174
|
+
sb = b_out.get(name)
|
|
175
|
+
typer.echo(f" Output '{name}':")
|
|
176
|
+
_cmp(" row_count", sa.row_count if sa else None, sb.row_count if sb else None)
|
|
177
|
+
_cmp(" schema_hash", sa.schema_hash if sa else None, sb.schema_hash if sb else None)
|
|
178
|
+
_cmp(" data_hash", sa.data_hash if sa else None, sb.data_hash if sb else None)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# search
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@lineage_app.command("search")
|
|
187
|
+
def search(
|
|
188
|
+
usecase_dir: Path = typer.Option(..., "-d", "--usecase-dir"),
|
|
189
|
+
task: Optional[str] = typer.Option(None, "-t", "--task", help="Restrict to this task name."),
|
|
190
|
+
usecase: Optional[str] = typer.Option(
|
|
191
|
+
None, "-u", "--usecase", help="Restrict to this usecase."
|
|
192
|
+
),
|
|
193
|
+
package: Optional[str] = typer.Option(
|
|
194
|
+
None, "-p", "--package", help="Restrict to this package."
|
|
195
|
+
),
|
|
196
|
+
status: Optional[str] = typer.Option(
|
|
197
|
+
None, "--status", help="Filter by status: success|error|running."
|
|
198
|
+
),
|
|
199
|
+
since: Optional[str] = typer.Option(
|
|
200
|
+
None, "--since", help="Only runs started on or after this ISO date."
|
|
201
|
+
),
|
|
202
|
+
lineage_dir: str = typer.Option(".ubunye/lineage", "--lineage-dir"),
|
|
203
|
+
):
|
|
204
|
+
"""Search all recorded runs across tasks with optional filters."""
|
|
205
|
+
store = _store(str(usecase_dir / lineage_dir))
|
|
206
|
+
|
|
207
|
+
tp = None
|
|
208
|
+
if usecase and package and task:
|
|
209
|
+
tp = _task_path(usecase, package, task)
|
|
210
|
+
elif usecase and package:
|
|
211
|
+
tp = f"{usecase}/{package}"
|
|
212
|
+
elif usecase:
|
|
213
|
+
tp = usecase
|
|
214
|
+
|
|
215
|
+
runs = store.search(task_path=tp, status=status, since=since)
|
|
216
|
+
|
|
217
|
+
if not runs:
|
|
218
|
+
typer.secho("No matching lineage records found.", fg=typer.colors.YELLOW)
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
typer.echo(_LINE)
|
|
222
|
+
typer.echo(f"{'RUN_ID':9} {'TASK PATH':40} {'STARTED':19} {'STATUS':<8} DUR")
|
|
223
|
+
typer.echo(_LINE)
|
|
224
|
+
for ctx in runs:
|
|
225
|
+
color = (
|
|
226
|
+
typer.colors.GREEN
|
|
227
|
+
if ctx.status == "success"
|
|
228
|
+
else (typer.colors.RED if ctx.status == "error" else typer.colors.YELLOW)
|
|
229
|
+
)
|
|
230
|
+
duration = f"{ctx.duration_sec:.1f}s" if ctx.duration_sec is not None else "-"
|
|
231
|
+
typer.secho(
|
|
232
|
+
f"{ctx.run_id[:8]} {ctx.task_path:<40} {ctx.started_at[:19]} "
|
|
233
|
+
f"{ctx.status:<8} {duration}",
|
|
234
|
+
fg=color,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# trace
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@lineage_app.command("trace")
|
|
244
|
+
def trace(
|
|
245
|
+
usecase_dir: Path = typer.Option(..., "-d", "--usecase-dir"),
|
|
246
|
+
usecase: str = typer.Option(..., "-u", "--usecase"),
|
|
247
|
+
package: str = typer.Option(..., "-p", "--package"),
|
|
248
|
+
task: str = typer.Option(..., "-t", "--task"),
|
|
249
|
+
run_id: Optional[str] = typer.Option(None, "--run-id", help="Specific run (default: latest)."),
|
|
250
|
+
lineage_dir: str = typer.Option(".ubunye/lineage", "--lineage-dir"),
|
|
251
|
+
):
|
|
252
|
+
"""Print the input → transform → output data flow graph for a run."""
|
|
253
|
+
store = _store(str(usecase_dir / lineage_dir))
|
|
254
|
+
tp = _task_path(usecase, package, task)
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
if run_id:
|
|
258
|
+
ctx = store.load(tp, run_id)
|
|
259
|
+
else:
|
|
260
|
+
runs = store.list_runs(tp, n=1)
|
|
261
|
+
if not runs:
|
|
262
|
+
typer.secho(f"No lineage records for '{tp}'.", fg=typer.colors.YELLOW)
|
|
263
|
+
raise typer.Exit(code=1)
|
|
264
|
+
ctx = runs[0]
|
|
265
|
+
except FileNotFoundError as e:
|
|
266
|
+
typer.secho(str(e), fg=typer.colors.RED, err=True)
|
|
267
|
+
raise typer.Exit(code=1)
|
|
268
|
+
|
|
269
|
+
typer.echo()
|
|
270
|
+
typer.secho(f"Lineage trace: {ctx.task_path}", bold=True)
|
|
271
|
+
typer.echo(f"Run: {ctx.run_id} [{ctx.status}] {ctx.started_at[:19]}")
|
|
272
|
+
typer.echo(f"Version: {ctx.model} v{ctx.version}")
|
|
273
|
+
typer.echo()
|
|
274
|
+
|
|
275
|
+
# INPUTS
|
|
276
|
+
typer.secho(" INPUTS", fg=typer.colors.CYAN)
|
|
277
|
+
for step in ctx.inputs:
|
|
278
|
+
typer.echo(f" [{step.format}] {step.name}")
|
|
279
|
+
typer.echo(f" location : {step.location}")
|
|
280
|
+
if step.row_count is not None:
|
|
281
|
+
typer.echo(f" rows : {step.row_count:,}")
|
|
282
|
+
if step.schema_hash:
|
|
283
|
+
typer.echo(f" schema : {step.schema_hash}")
|
|
284
|
+
if step.data_hash:
|
|
285
|
+
typer.echo(f" data : {step.data_hash}")
|
|
286
|
+
|
|
287
|
+
# TRANSFORM
|
|
288
|
+
typer.echo()
|
|
289
|
+
typer.secho(" TRANSFORM", fg=typer.colors.CYAN)
|
|
290
|
+
typer.echo(" (transformations.py)")
|
|
291
|
+
|
|
292
|
+
# OUTPUTS
|
|
293
|
+
typer.echo()
|
|
294
|
+
typer.secho(" OUTPUTS", fg=typer.colors.CYAN)
|
|
295
|
+
for step in ctx.outputs:
|
|
296
|
+
typer.echo(f" [{step.format}] {step.name}")
|
|
297
|
+
typer.echo(f" location : {step.location}")
|
|
298
|
+
if step.row_count is not None:
|
|
299
|
+
typer.echo(f" rows : {step.row_count:,}")
|
|
300
|
+
if step.schema_hash:
|
|
301
|
+
typer.echo(f" schema : {step.schema_hash}")
|
|
302
|
+
if step.data_hash:
|
|
303
|
+
typer.echo(f" data : {step.data_hash}")
|
|
304
|
+
typer.echo()
|