steve-cli 0.3.7__tar.gz → 0.3.8__tar.gz

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.
Files changed (47) hide show
  1. {steve_cli-0.3.7/steve_cli.egg-info → steve_cli-0.3.8}/PKG-INFO +73 -4
  2. steve_cli-0.3.7/PKG-INFO → steve_cli-0.3.8/README.md +48 -34
  3. {steve_cli-0.3.7 → steve_cli-0.3.8}/pyproject.toml +35 -4
  4. {steve_cli-0.3.7 → steve_cli-0.3.8}/steve_cli/cli.py +13 -6
  5. steve_cli-0.3.8/steve_cli/decorators/__init__.py +3 -0
  6. steve_cli-0.3.8/steve_cli/decorators/lineage_job.py +66 -0
  7. steve_cli-0.3.8/steve_cli/lineage/__init__.py +14 -0
  8. steve_cli-0.3.8/steve_cli/lineage/adapters/__init__.py +0 -0
  9. steve_cli-0.3.8/steve_cli/lineage/adapters/logging.py +25 -0
  10. steve_cli-0.3.8/steve_cli/lineage/adapters/null.py +8 -0
  11. steve_cli-0.3.8/steve_cli/lineage/adapters/openlineage.py +121 -0
  12. steve_cli-0.3.8/steve_cli/lineage/collector.py +94 -0
  13. steve_cli-0.3.8/steve_cli/lineage/port.py +33 -0
  14. steve_cli-0.3.8/steve_cli/lineage/registry.py +50 -0
  15. steve_cli-0.3.8/steve_cli/lineage/storage.py +133 -0
  16. steve_cli-0.3.8/steve_cli/storage/__init__.py +15 -0
  17. steve_cli-0.3.8/steve_cli/storage/metadata/__init__.py +9 -0
  18. steve_cli-0.3.8/steve_cli/storage/metadata/extractors/__init__.py +0 -0
  19. steve_cli-0.3.8/steve_cli/storage/metadata/extractors/csv.py +39 -0
  20. steve_cli-0.3.8/steve_cli/storage/metadata/extractors/excel.py +44 -0
  21. steve_cli-0.3.8/steve_cli/storage/metadata/extractors/generic.py +28 -0
  22. steve_cli-0.3.8/steve_cli/storage/metadata/extractors/json.py +42 -0
  23. steve_cli-0.3.8/steve_cli/storage/metadata/extractors/parquet.py +47 -0
  24. steve_cli-0.3.8/steve_cli/storage/metadata/port.py +59 -0
  25. steve_cli-0.3.8/steve_cli/storage/metadata/registry.py +71 -0
  26. steve_cli-0.3.8/steve_cli/storage/parquet.py +11 -0
  27. steve_cli-0.3.8/steve_cli/storage/protocol.py +13 -0
  28. steve_cli-0.3.8/steve_cli/storage/s3.py +137 -0
  29. {steve_cli-0.3.7 → steve_cli-0.3.8}/steve_cli/storage.py +22 -1
  30. steve_cli-0.3.8/steve_cli/validation/__init__.py +4 -0
  31. steve_cli-0.3.8/steve_cli/validation/adapters/__init__.py +0 -0
  32. steve_cli-0.3.8/steve_cli/validation/adapters/great_expectations.py +77 -0
  33. steve_cli-0.3.8/steve_cli/validation/adapters/null.py +16 -0
  34. steve_cli-0.3.8/steve_cli/validation/adapters/validoopsie.py +92 -0
  35. steve_cli-0.3.8/steve_cli/validation/port.py +77 -0
  36. steve_cli-0.3.8/steve_cli/validation/registry.py +55 -0
  37. steve_cli-0.3.8/steve_cli.egg-info/PKG-INFO +239 -0
  38. steve_cli-0.3.8/steve_cli.egg-info/SOURCES.txt +42 -0
  39. steve_cli-0.3.8/steve_cli.egg-info/requires.txt +45 -0
  40. steve_cli-0.3.7/README.md +0 -136
  41. steve_cli-0.3.7/steve_cli.egg-info/SOURCES.txt +0 -11
  42. steve_cli-0.3.7/steve_cli.egg-info/requires.txt +0 -17
  43. {steve_cli-0.3.7 → steve_cli-0.3.8}/setup.cfg +0 -0
  44. {steve_cli-0.3.7 → steve_cli-0.3.8}/steve_cli/__init__.py +0 -0
  45. {steve_cli-0.3.7 → steve_cli-0.3.8}/steve_cli.egg-info/dependency_links.txt +0 -0
  46. {steve_cli-0.3.7 → steve_cli-0.3.8}/steve_cli.egg-info/entry_points.txt +0 -0
  47. {steve_cli-0.3.7 → steve_cli-0.3.8}/steve_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steve-cli
3
- Version: 0.3.7
3
+ Version: 0.3.8
4
4
  Summary: A simple CLI tool to run jobs from jobs.yaml with proper environment setup
5
5
  Author: Frank
6
6
  License: MIT
@@ -10,27 +10,48 @@ Classifier: Development Status :: 3 - Alpha
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.8
14
- Classifier: Programming Language :: Python :: 3.9
15
13
  Classifier: Programming Language :: Python :: 3.10
16
14
  Classifier: Programming Language :: Python :: 3.11
17
15
  Classifier: Programming Language :: Python :: 3.12
18
- Requires-Python: >=3.8
16
+ Requires-Python: >=3.10
19
17
  Description-Content-Type: text/markdown
20
18
  Requires-Dist: boto3>=1.37.38
21
19
  Requires-Dist: click>=8.0.0
20
+ Requires-Dist: python-dotenv>=1.0.0
22
21
  Requires-Dist: pyyaml>=6.0
23
22
  Requires-Dist: questionary>=2.0.0
24
23
  Provides-Extra: dev
25
24
  Requires-Dist: pytest>=7.0; extra == "dev"
26
25
  Requires-Dist: black>=22.0; extra == "dev"
27
26
  Requires-Dist: isort>=5.0; extra == "dev"
27
+ Requires-Dist: polars>=1.8.2; extra == "dev"
28
+ Requires-Dist: pyarrow>=17.0.0; extra == "dev"
29
+ Requires-Dist: validoopsie>=0.1.0; extra == "dev"
30
+ Requires-Dist: openlineage-python>=1.0.0; extra == "dev"
28
31
  Provides-Extra: polars
29
32
  Requires-Dist: polars>=1.8.2; extra == "polars"
30
33
  Requires-Dist: pyarrow>=17.0.0; extra == "polars"
31
34
  Provides-Extra: pandas
32
35
  Requires-Dist: pandas>=2.0.3; extra == "pandas"
33
36
  Requires-Dist: pyarrow>=17.0.0; extra == "pandas"
37
+ Provides-Extra: lineage
38
+ Requires-Dist: openlineage-python>=1.0.0; extra == "lineage"
39
+ Provides-Extra: validoopsie
40
+ Requires-Dist: validoopsie>=0.1.0; extra == "validoopsie"
41
+ Requires-Dist: polars>=1.8.2; extra == "validoopsie"
42
+ Provides-Extra: great-expectations
43
+ Requires-Dist: great-expectations>=0.18.0; extra == "great-expectations"
44
+ Requires-Dist: pandas>=2.0.3; extra == "great-expectations"
45
+ Provides-Extra: excel
46
+ Requires-Dist: openpyxl>=3.1.0; extra == "excel"
47
+ Provides-Extra: all
48
+ Requires-Dist: openlineage-python>=1.0.0; extra == "all"
49
+ Requires-Dist: pyarrow>=17.0.0; extra == "all"
50
+ Requires-Dist: polars>=1.8.2; extra == "all"
51
+ Requires-Dist: validoopsie>=0.1.0; extra == "all"
52
+ Requires-Dist: great-expectations>=0.18.0; extra == "all"
53
+ Requires-Dist: pandas>=2.0.3; extra == "all"
54
+ Requires-Dist: openpyxl>=3.1.0; extra == "all"
34
55
 
35
56
  # Steve CLI
36
57
 
@@ -134,6 +155,54 @@ Running `steve extract-data` will:
134
155
  - ✅ **Colorful**: Nice colored output for better readability
135
156
  - ✅ **Error handling**: Clear error messages for missing jobs or files
136
157
 
158
+ ## File Metadata Extraction
159
+
160
+ Steve automatically extracts metadata from files read or written via `MetadataRegistry`. The extractor is chosen by file extension — no configuration needed.
161
+
162
+ | Extension | Extractor | Requires |
163
+ |---|---|---|
164
+ | `.parquet`, `.pq` | `ParquetExtractor` | `pip install steve-cli[polars]` |
165
+ | `.csv`, `.tsv`, `.txt` | `CsvExtractor` | stdlib only |
166
+ | `.json`, `.jsonl`, `.ndjson` | `JsonExtractor` | stdlib only |
167
+ | `.xlsx`, `.xls`, `.xlsm` | `ExcelExtractor` | `pip install steve-cli[excel]` |
168
+ | anything else | `GenericExtractor` | stdlib only |
169
+
170
+ ### Adding a custom extractor
171
+
172
+ Implement `MetadataExtractorPort`, declare which extensions it handles, and register it once at startup:
173
+
174
+ ```python
175
+ from steve_cli.storage.metadata.port import MetadataExtractorPort, FileMetadata, ColumnMetadata
176
+ from steve_cli.storage.metadata.registry import MetadataRegistry
177
+
178
+ class AvroExtractor(MetadataExtractorPort):
179
+ extensions = (".avro",)
180
+
181
+ def extract(self, data: bytes, path: str) -> FileMetadata:
182
+ import fastavro, io
183
+ reader = fastavro.reader(io.BytesIO(data))
184
+ schema = reader.writer_schema
185
+ columns = [
186
+ ColumnMetadata(name=f["name"], type=str(f["type"]))
187
+ for f in schema.get("fields", [])
188
+ ]
189
+ records = list(reader)
190
+ return FileMetadata(
191
+ format="avro",
192
+ size_bytes=len(data),
193
+ rows=len(records),
194
+ columns=columns,
195
+ )
196
+
197
+ MetadataRegistry.register("avro", AvroExtractor)
198
+ ```
199
+
200
+ After registration, `MetadataRegistry.extract(data, "output.avro")` picks `AvroExtractor` automatically. You can also force a specific extractor for any file via the env var:
201
+
202
+ ```bash
203
+ METADATA_EXTRACTOR=avro steve jobs run my-job
204
+ ```
205
+
137
206
  ## Why Steve?
138
207
 
139
208
  Named after Steve Jobs - because it helps you run **jobs** locally! 😄
@@ -1,37 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: steve-cli
3
- Version: 0.3.7
4
- Summary: A simple CLI tool to run jobs from jobs.yaml with proper environment setup
5
- Author: Frank
6
- License: MIT
7
- Project-URL: Homepage, https://github.com/7frank/ds-steve-cli
8
- Project-URL: Repository, https://github.com/7frank/ds-steve-cli
9
- Classifier: Development Status :: 3 - Alpha
10
- Classifier: Intended Audience :: Developers
11
- Classifier: License :: OSI Approved :: MIT License
12
- Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.8
14
- Classifier: Programming Language :: Python :: 3.9
15
- Classifier: Programming Language :: Python :: 3.10
16
- Classifier: Programming Language :: Python :: 3.11
17
- Classifier: Programming Language :: Python :: 3.12
18
- Requires-Python: >=3.8
19
- Description-Content-Type: text/markdown
20
- Requires-Dist: boto3>=1.37.38
21
- Requires-Dist: click>=8.0.0
22
- Requires-Dist: pyyaml>=6.0
23
- Requires-Dist: questionary>=2.0.0
24
- Provides-Extra: dev
25
- Requires-Dist: pytest>=7.0; extra == "dev"
26
- Requires-Dist: black>=22.0; extra == "dev"
27
- Requires-Dist: isort>=5.0; extra == "dev"
28
- Provides-Extra: polars
29
- Requires-Dist: polars>=1.8.2; extra == "polars"
30
- Requires-Dist: pyarrow>=17.0.0; extra == "polars"
31
- Provides-Extra: pandas
32
- Requires-Dist: pandas>=2.0.3; extra == "pandas"
33
- Requires-Dist: pyarrow>=17.0.0; extra == "pandas"
34
-
35
1
  # Steve CLI
36
2
 
37
3
  A simple CLI tool to run jobs from `jobs.yaml` with proper environment setup. Perfect for local development and testing of automation kernel jobs.
@@ -134,6 +100,54 @@ Running `steve extract-data` will:
134
100
  - ✅ **Colorful**: Nice colored output for better readability
135
101
  - ✅ **Error handling**: Clear error messages for missing jobs or files
136
102
 
103
+ ## File Metadata Extraction
104
+
105
+ Steve automatically extracts metadata from files read or written via `MetadataRegistry`. The extractor is chosen by file extension — no configuration needed.
106
+
107
+ | Extension | Extractor | Requires |
108
+ |---|---|---|
109
+ | `.parquet`, `.pq` | `ParquetExtractor` | `pip install steve-cli[polars]` |
110
+ | `.csv`, `.tsv`, `.txt` | `CsvExtractor` | stdlib only |
111
+ | `.json`, `.jsonl`, `.ndjson` | `JsonExtractor` | stdlib only |
112
+ | `.xlsx`, `.xls`, `.xlsm` | `ExcelExtractor` | `pip install steve-cli[excel]` |
113
+ | anything else | `GenericExtractor` | stdlib only |
114
+
115
+ ### Adding a custom extractor
116
+
117
+ Implement `MetadataExtractorPort`, declare which extensions it handles, and register it once at startup:
118
+
119
+ ```python
120
+ from steve_cli.storage.metadata.port import MetadataExtractorPort, FileMetadata, ColumnMetadata
121
+ from steve_cli.storage.metadata.registry import MetadataRegistry
122
+
123
+ class AvroExtractor(MetadataExtractorPort):
124
+ extensions = (".avro",)
125
+
126
+ def extract(self, data: bytes, path: str) -> FileMetadata:
127
+ import fastavro, io
128
+ reader = fastavro.reader(io.BytesIO(data))
129
+ schema = reader.writer_schema
130
+ columns = [
131
+ ColumnMetadata(name=f["name"], type=str(f["type"]))
132
+ for f in schema.get("fields", [])
133
+ ]
134
+ records = list(reader)
135
+ return FileMetadata(
136
+ format="avro",
137
+ size_bytes=len(data),
138
+ rows=len(records),
139
+ columns=columns,
140
+ )
141
+
142
+ MetadataRegistry.register("avro", AvroExtractor)
143
+ ```
144
+
145
+ After registration, `MetadataRegistry.extract(data, "output.avro")` picks `AvroExtractor` automatically. You can also force a specific extractor for any file via the env var:
146
+
147
+ ```bash
148
+ METADATA_EXTRACTOR=avro steve jobs run my-job
149
+ ```
150
+
137
151
  ## Why Steve?
138
152
 
139
153
  Named after Steve Jobs - because it helps you run **jobs** locally! 😄
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "steve-cli"
8
- version = "0.3.7"
8
+ version = "0.3.8"
9
9
  description = "A simple CLI tool to run jobs from jobs.yaml with proper environment setup"
10
10
  readme = "README.md"
11
11
  license = {text = "MIT"}
@@ -15,16 +15,15 @@ classifiers = [
15
15
  "Intended Audience :: Developers",
16
16
  "License :: OSI Approved :: MIT License",
17
17
  "Programming Language :: Python :: 3",
18
- "Programming Language :: Python :: 3.8",
19
- "Programming Language :: Python :: 3.9",
20
18
  "Programming Language :: Python :: 3.10",
21
19
  "Programming Language :: Python :: 3.11",
22
20
  "Programming Language :: Python :: 3.12",
23
21
  ]
24
- requires-python = ">=3.8"
22
+ requires-python = ">=3.10"
25
23
  dependencies = [
26
24
  "boto3>=1.37.38",
27
25
  "click>=8.0.0",
26
+ "python-dotenv>=1.0.0",
28
27
  "pyyaml>=6.0",
29
28
  "questionary>=2.0.0",
30
29
  ]
@@ -34,6 +33,10 @@ dev = [
34
33
  "pytest>=7.0",
35
34
  "black>=22.0",
36
35
  "isort>=5.0",
36
+ "polars>=1.8.2",
37
+ "pyarrow>=17.0.0",
38
+ "validoopsie>=0.1.0",
39
+ "openlineage-python>=1.0.0",
37
40
  ]
38
41
  polars = [
39
42
  "polars>=1.8.2",
@@ -43,6 +46,29 @@ pandas = [
43
46
  "pandas>=2.0.3",
44
47
  "pyarrow>=17.0.0",
45
48
  ]
49
+ lineage = [
50
+ "openlineage-python>=1.0.0",
51
+ ]
52
+ validoopsie = [
53
+ "validoopsie>=0.1.0",
54
+ "polars>=1.8.2",
55
+ ]
56
+ great-expectations = [
57
+ "great-expectations>=0.18.0",
58
+ "pandas>=2.0.3",
59
+ ]
60
+ excel = [
61
+ "openpyxl>=3.1.0",
62
+ ]
63
+ all = [
64
+ "openlineage-python>=1.0.0",
65
+ "pyarrow>=17.0.0",
66
+ "polars>=1.8.2",
67
+ "validoopsie>=0.1.0",
68
+ "great-expectations>=0.18.0",
69
+ "pandas>=2.0.3",
70
+ "openpyxl>=3.1.0",
71
+ ]
46
72
 
47
73
  [project.scripts]
48
74
  steve = "steve_cli.cli:main"
@@ -63,3 +89,8 @@ target-version = ['py38']
63
89
 
64
90
  [tool.isort]
65
91
  profile = "black"
92
+
93
+ [dependency-groups]
94
+ dev = [
95
+ "great-expectations>=1.18.2",
96
+ ]
@@ -5,7 +5,7 @@ Steve CLI - A simple tool to run jobs from jobs.yaml with proper environment set
5
5
  Usage:
6
6
  steve jobs ls List all available jobs
7
7
  steve jobs run <job-name> Run a job with its environment variables
8
- steve setup all Decrypt SOPS-encoded .env files
8
+ steve setup all Decrypt SOPS-encrypted .env files
9
9
  """
10
10
 
11
11
  import os
@@ -18,6 +18,7 @@ from pathlib import Path
18
18
  from typing import Dict, List, Any, Optional
19
19
 
20
20
  import click
21
+ from dotenv import load_dotenv
21
22
  import questionary
22
23
  import yaml
23
24
 
@@ -453,16 +454,16 @@ def setup():
453
454
 
454
455
  @setup.command("env")
455
456
  def setup_env():
456
- """Decrypt SOPS-encoded .env files in current directory and write plaintext .env files."""
457
+ """Decrypt SOPS-encrypted .env files in current directory and write plaintext .env files."""
457
458
  cwd = Path.cwd()
458
459
  found: List[Path] = []
459
- for pattern in ["*.enc.env", "*.encoded.env"]:
460
+ for pattern in ["*.enc.env", "*.encrypted.env"]:
460
461
  found.extend(sorted(cwd.glob(pattern)))
461
462
 
462
463
  found = [f for f in found if f.name not in SETUP_IGNORE]
463
464
 
464
465
  if not found:
465
- click.echo("No *.enc.env or *.encoded.env files found.")
466
+ click.echo("No *.enc.env or *.encrypted.env files found.")
466
467
  return
467
468
 
468
469
  for enc_file in found:
@@ -492,7 +493,7 @@ def setup_env():
492
493
  if keys:
493
494
  click.echo(f" 🔑 Keys: {click.style(', '.join(keys), fg='green')}")
494
495
 
495
- stem = enc_file.name.replace(".encoded.env", "").replace(".enc.env", "")
496
+ stem = enc_file.name.replace(".encrypted.env", "").replace(".enc.env", "")
496
497
  out_file = cwd / f"{stem}.env"
497
498
  out_file.write_text(result.stdout)
498
499
 
@@ -554,8 +555,14 @@ def _list_bucket(storage_kwargs: dict, label: str, bucket_name: str) -> None:
554
555
 
555
556
 
556
557
  @main.command("buckets")
557
- def buckets():
558
+ @click.option('--env-file', '-e', type=click.Path(path_type=Path), multiple=True,
559
+ help='Path to .env file(s). Can be specified multiple times. Defaults to .env and .workspace.env')
560
+ def buckets(env_file: tuple):
558
561
  """List all S3 buckets detected from env variables and show their files as a tree."""
562
+ cwd = Path.cwd()
563
+ env_files = [Path(f) for f in env_file] if env_file else [cwd / ".env", cwd / ".workspace.env"]
564
+ for ef in env_files:
565
+ load_dotenv(ef)
559
566
  tiers = ["bronze", "silver", "gold"]
560
567
 
561
568
  options: List[Dict[str, Any]] = []
@@ -0,0 +1,3 @@
1
+ from .lineage_job import lineage_job
2
+
3
+ __all__ = ["lineage_job"]
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import inspect
5
+ from pathlib import Path
6
+ from typing import Any, Callable
7
+
8
+ from steve_cli.lineage.collector import make_session
9
+ from steve_cli.storage.s3 import S3Storage
10
+ from steve_cli.lineage.storage import LineageStorage
11
+
12
+
13
+ def lineage_job(
14
+ name: str | None = None,
15
+ namespace: str | None = None,
16
+ lineage_provider: str | None = None,
17
+ lineage_enabled: bool = True,
18
+ ) -> Callable:
19
+ def decorator(fn: Callable) -> Callable:
20
+ if name:
21
+ job_name = name
22
+ else:
23
+ caller_file = inspect.getfile(fn)
24
+ stem = Path(caller_file).stem
25
+ job_name = f"{stem}.{fn.__name__}"
26
+
27
+ @functools.wraps(fn)
28
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
29
+ session = make_session(
30
+ namespace=namespace,
31
+ job_name=job_name,
32
+ provider=lineage_provider,
33
+ enabled=lineage_enabled,
34
+ )
35
+
36
+ session.start()
37
+
38
+ def get_storage(tier: str = "bronze", workspace: str | None = None) -> LineageStorage:
39
+ return LineageStorage(
40
+ storage=lambda: S3Storage(tier=tier, workspace=workspace),
41
+ session=session,
42
+ )
43
+
44
+ try:
45
+ result = fn(*args, get_storage=get_storage, **kwargs)
46
+ except Exception as exc:
47
+ session.fail(exc)
48
+ from steve_cli.validation.port import DataQualityError
49
+ if isinstance(exc, DataQualityError):
50
+ import sys
51
+ print(f"ERROR {exc}", file=sys.stderr)
52
+ sys.exit(1)
53
+ raise
54
+
55
+ session.complete()
56
+ return result
57
+
58
+ if fn.__module__ == "__main__":
59
+ import logging
60
+ import os
61
+ logging.basicConfig(level=os.getenv("LOG_LEVEL", "WARNING"), format="%(levelname)s %(name)s: %(message)s")
62
+ wrapper()
63
+
64
+ return wrapper
65
+
66
+ return decorator
@@ -0,0 +1,14 @@
1
+ from .port import LineagePort, LineageEvent, DatasetRef
2
+ from .collector import LineageSession, make_session
3
+ from .registry import LineageRegistry
4
+ from .storage import LineageStorage
5
+
6
+ __all__ = [
7
+ "LineagePort",
8
+ "LineageEvent",
9
+ "DatasetRef",
10
+ "LineageSession",
11
+ "make_session",
12
+ "LineageRegistry",
13
+ "LineageStorage",
14
+ ]
File without changes
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+
6
+ from steve_cli.lineage.port import LineageEvent, LineagePort
7
+
8
+ logger = logging.getLogger("steve_cli.lineage")
9
+
10
+
11
+ class LoggingLineageAdapter(LineagePort):
12
+ def emit(self, event: LineageEvent) -> None:
13
+ logger.info(
14
+ "lineage event",
15
+ extra={
16
+ "lineage": {
17
+ "state": event.state,
18
+ "job": event.job_name,
19
+ "namespace": event.namespace,
20
+ "run_id": event.run_id,
21
+ "inputs": [{"namespace": d.namespace, "name": d.name} for d in event.inputs],
22
+ "outputs": [{"namespace": d.namespace, "name": d.name} for d in event.outputs],
23
+ }
24
+ },
25
+ )
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from steve_cli.lineage.port import LineageEvent, LineagePort
4
+
5
+
6
+ class NullLineageAdapter(LineagePort):
7
+ def emit(self, event: LineageEvent) -> None:
8
+ pass
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from steve_cli.lineage.port import DatasetRef, LineageEvent, LineagePort
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class OpenLineageAdapter(LineagePort):
11
+ def __init__(self, url: str):
12
+ try:
13
+ from openlineage.client import OpenLineageClient
14
+ from openlineage.client.transport.http import HttpConfig, HttpTransport
15
+ except ImportError as exc:
16
+ raise ImportError(
17
+ "openlineage-python is required. Install it with: pip install steve-cli[lineage]"
18
+ ) from exc
19
+
20
+ # Strip any path suffix — OpenLineageClient expects just the base URL (scheme+host+port)
21
+ from urllib.parse import urlparse
22
+ parsed = urlparse(url)
23
+ base_url = f"{parsed.scheme}://{parsed.netloc}"
24
+
25
+ transport = HttpTransport(HttpConfig.from_dict({"url": base_url}))
26
+ self._client = OpenLineageClient(transport=transport)
27
+
28
+ def emit(self, event: LineageEvent) -> None:
29
+ from openlineage.client.event_v2 import Dataset, InputDataset, Job, Run, RunEvent, RunState
30
+ from openlineage.client.facet import (
31
+ Assertion,
32
+ ColumnMetric,
33
+ DataQualityAssertionsDatasetFacet,
34
+ DataQualityMetricsInputDatasetFacet,
35
+ ErrorMessageRunFacet,
36
+ SchemaDatasetFacet,
37
+ SchemaField,
38
+ StorageDatasetFacet,
39
+ )
40
+
41
+ run_facets = {}
42
+ if "errorMessage" in event.run_facets:
43
+ err = event.run_facets["errorMessage"]
44
+ stack_trace = None
45
+ if err.get("description"):
46
+ stack_trace = "\n".join(
47
+ f" [{a['column'] or a['assertion']}] {a['message']}"
48
+ for a in err["description"]
49
+ )
50
+ run_facets["errorMessage"] = ErrorMessageRunFacet(
51
+ message=err.get("message", ""),
52
+ programmingLanguage=err.get("programmingLanguage", "python"),
53
+ stackTrace=stack_trace,
54
+ )
55
+
56
+ def _build_dataset_facets(raw: dict) -> dict:
57
+ facets: dict = {}
58
+ if "schema" in raw:
59
+ s = raw["schema"]
60
+ facets["schema"] = SchemaDatasetFacet(
61
+ fields=[SchemaField(name=f["name"], type=f.get("type", "string"), description=f.get("description")) for f in s.get("fields", [])]
62
+ )
63
+ if "storage" in raw:
64
+ st = raw["storage"]
65
+ facets["storage"] = StorageDatasetFacet(
66
+ storageLayer=st.get("storageLayer", "s3"),
67
+ fileFormat=st.get("fileFormat", ""),
68
+ )
69
+ return facets
70
+
71
+ def _build_input_facets(raw: dict) -> dict:
72
+ input_facets: dict = {}
73
+ if "dataQualityAssertions" in raw:
74
+ assertions = [
75
+ Assertion(assertion=a["assertion"], success=a.get("success", False), column=a.get("column"))
76
+ for a in raw["dataQualityAssertions"]
77
+ ]
78
+ if assertions:
79
+ input_facets["dataQualityAssertions"] = DataQualityAssertionsDatasetFacet(assertions=assertions)
80
+ if "dataQualityMetrics" in raw:
81
+ m = raw["dataQualityMetrics"]
82
+ col_metrics = {
83
+ col: ColumnMetric(nullCount=metrics.get("nullCount"))
84
+ for col, metrics in m.get("columnMetrics", {}).items()
85
+ }
86
+ input_facets["dataQualityMetrics"] = DataQualityMetricsInputDatasetFacet(
87
+ rowCount=m.get("rowCount"),
88
+ columnMetrics=col_metrics if col_metrics else None,
89
+ )
90
+ return input_facets
91
+
92
+ def _to_ol_input(ref: DatasetRef) -> InputDataset:
93
+ raw = ref.facets or {}
94
+ input_facets = _build_input_facets(raw)
95
+ return InputDataset(
96
+ namespace=ref.namespace,
97
+ name=ref.name,
98
+ facets=_build_dataset_facets(raw),
99
+ inputFacets=input_facets if input_facets else None,
100
+ )
101
+
102
+ def _to_ol_output(ref: DatasetRef) -> Dataset:
103
+ return Dataset(
104
+ namespace=ref.namespace,
105
+ name=ref.name,
106
+ facets=_build_dataset_facets(ref.facets) if ref.facets else {},
107
+ )
108
+
109
+ ol_event = RunEvent(
110
+ eventType=getattr(RunState, event.state),
111
+ eventTime=event.event_time,
112
+ run=Run(runId=event.run_id, facets=run_facets),
113
+ job=Job(namespace=event.namespace, name=event.job_name),
114
+ inputs=[_to_ol_input(d) for d in event.inputs],
115
+ outputs=[_to_ol_output(d) for d in event.outputs],
116
+ )
117
+
118
+ try:
119
+ self._client.emit(ol_event)
120
+ except Exception as exc:
121
+ logger.warning("Failed to emit lineage event to Marquez: %s", exc)
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import uuid
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict
7
+
8
+ from .port import DatasetRef, LineageEvent, LineagePort
9
+ from .registry import LineageRegistry
10
+
11
+
12
+ @dataclass
13
+ class LineageSession:
14
+ namespace: str
15
+ job_name: str
16
+ port: LineagePort
17
+ run_id: str = field(default_factory=lambda: str(uuid.uuid4()))
18
+ _inputs: set[str] = field(default_factory=set)
19
+ _outputs: set[str] = field(default_factory=set)
20
+ _facets: Dict[str, Dict[str, Any]] = field(default_factory=dict)
21
+
22
+ def record_read(self, dataset: str, facets: Dict[str, Any] | None = None) -> None:
23
+ self._inputs.add(dataset)
24
+ if facets:
25
+ self._facets.setdefault(dataset, {}).update(facets)
26
+
27
+ def record_write(self, dataset: str, facets: Dict[str, Any] | None = None) -> None:
28
+ self._outputs.add(dataset)
29
+ if facets:
30
+ self._facets.setdefault(dataset, {}).update(facets)
31
+
32
+ def attach_facets(self, dataset: str, facets: Dict[str, Any]) -> None:
33
+ self._facets.setdefault(dataset, {}).update(facets)
34
+
35
+ def attach_validation(self, dataset: str, result: Any) -> None:
36
+ facet = result.to_openlineage_facet()
37
+ self._facets.setdefault(dataset, {}).update(facet)
38
+
39
+ def _build_event(self, state: str, run_facets: dict | None = None) -> LineageEvent:
40
+ return LineageEvent(
41
+ job_name=self.job_name,
42
+ namespace=self.namespace,
43
+ run_id=self.run_id,
44
+ state=state,
45
+ inputs=[DatasetRef(namespace=self.namespace, name=x, facets=self._facets.get(x, {})) for x in self._inputs],
46
+ outputs=[DatasetRef(namespace=self.namespace, name=x, facets=self._facets.get(x, {})) for x in self._outputs],
47
+ run_facets=run_facets or {},
48
+ )
49
+
50
+ def start(self) -> None:
51
+ self.port.emit(self._build_event("START"))
52
+
53
+ def complete(self) -> None:
54
+ self.port.emit(self._build_event("COMPLETE"))
55
+
56
+ def fail(self, error: Exception) -> None:
57
+ from steve_cli.validation.port import DataQualityError
58
+ error_facet: Dict[str, Any] = {
59
+ "message": str(error),
60
+ "programmingLanguage": "python",
61
+ }
62
+ if isinstance(error, DataQualityError):
63
+ error_facet["description"] = [
64
+ {"assertion": f.check_name, "column": f.column, "message": f.message}
65
+ for f in error.failures
66
+ ]
67
+ self.port.emit(self._build_event("FAIL", run_facets={"errorMessage": error_facet}))
68
+
69
+
70
+ def make_session(
71
+ namespace: str | None = None,
72
+ job_name: str | None = None,
73
+ provider: str | None = None,
74
+ enabled: bool = True,
75
+ ) -> LineageSession:
76
+ ns = namespace or os.getenv("OPENLINEAGE_NAMESPACE", "default")
77
+ jn = job_name or os.getenv("OPENLINEAGE_JOB_NAME", "unknown_job")
78
+
79
+ if not enabled:
80
+ from .adapters.null import NullLineageAdapter
81
+
82
+ port: LineagePort = NullLineageAdapter()
83
+ else:
84
+ url = os.getenv("OPENLINEAGE_URL", "http://localhost:5000/api/v1/lineage")
85
+ prov = provider or os.getenv("LINEAGE_PROVIDER", "openlineage")
86
+ kwargs = {"url": url} if prov == "openlineage" else {}
87
+ try:
88
+ port = LineageRegistry.create(prov, **kwargs)
89
+ except Exception:
90
+ from .adapters.null import NullLineageAdapter
91
+
92
+ port = NullLineageAdapter()
93
+
94
+ return LineageSession(namespace=ns, job_name=jn, port=port)