steve-cli 0.3.6__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.6/steve_cli.egg-info → steve_cli-0.3.8}/PKG-INFO +73 -4
  2. steve_cli-0.3.6/PKG-INFO → steve_cli-0.3.8/README.md +48 -34
  3. {steve_cli-0.3.6 → steve_cli-0.3.8}/pyproject.toml +35 -4
  4. {steve_cli-0.3.6 → steve_cli-0.3.8}/steve_cli/cli.py +17 -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.6 → 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.6/README.md +0 -136
  41. steve_cli-0.3.6/steve_cli.egg-info/SOURCES.txt +0 -11
  42. steve_cli-0.3.6/steve_cli.egg-info/requires.txt +0 -17
  43. {steve_cli-0.3.6 → steve_cli-0.3.8}/setup.cfg +0 -0
  44. {steve_cli-0.3.6 → steve_cli-0.3.8}/steve_cli/__init__.py +0 -0
  45. {steve_cli-0.3.6 → steve_cli-0.3.8}/steve_cli.egg-info/dependency_links.txt +0 -0
  46. {steve_cli-0.3.6 → steve_cli-0.3.8}/steve_cli.egg-info/entry_points.txt +0 -0
  47. {steve_cli-0.3.6 → 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.6
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.6
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.6"
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
 
@@ -319,6 +320,10 @@ def apps_list(apps_file: Optional[Path]):
319
320
  if command:
320
321
  click.echo(f" Command: {click.style(' '.join(command), fg='cyan')}")
321
322
  click.echo(f" Port: {click.style(str(port), fg='magenta')}")
323
+ if pid:
324
+ uf = _url_file(name)
325
+ url = uf.read_text().strip() if uf.exists() else f'http://localhost:{port}'
326
+ click.echo(f" URL: {click.style(url, fg='cyan')}")
322
327
  if env_vars:
323
328
  click.echo(f" Environment: {click.style(f'{len(env_vars)} variables', fg='green')}")
324
329
  click.echo()
@@ -449,16 +454,16 @@ def setup():
449
454
 
450
455
  @setup.command("env")
451
456
  def setup_env():
452
- """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."""
453
458
  cwd = Path.cwd()
454
459
  found: List[Path] = []
455
- for pattern in ["*.enc.env", "*.encoded.env"]:
460
+ for pattern in ["*.enc.env", "*.encrypted.env"]:
456
461
  found.extend(sorted(cwd.glob(pattern)))
457
462
 
458
463
  found = [f for f in found if f.name not in SETUP_IGNORE]
459
464
 
460
465
  if not found:
461
- click.echo("No *.enc.env or *.encoded.env files found.")
466
+ click.echo("No *.enc.env or *.encrypted.env files found.")
462
467
  return
463
468
 
464
469
  for enc_file in found:
@@ -488,7 +493,7 @@ def setup_env():
488
493
  if keys:
489
494
  click.echo(f" 🔑 Keys: {click.style(', '.join(keys), fg='green')}")
490
495
 
491
- stem = enc_file.name.replace(".encoded.env", "").replace(".enc.env", "")
496
+ stem = enc_file.name.replace(".encrypted.env", "").replace(".enc.env", "")
492
497
  out_file = cwd / f"{stem}.env"
493
498
  out_file.write_text(result.stdout)
494
499
 
@@ -550,8 +555,14 @@ def _list_bucket(storage_kwargs: dict, label: str, bucket_name: str) -> None:
550
555
 
551
556
 
552
557
  @main.command("buckets")
553
- 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):
554
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)
555
566
  tiers = ["bronze", "silver", "gold"]
556
567
 
557
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)