steve-cli 0.3.7__tar.gz → 0.3.9__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.9}/PKG-INFO +76 -4
  2. steve_cli-0.3.7/PKG-INFO → steve_cli-0.3.9/README.md +48 -34
  3. {steve_cli-0.3.7 → steve_cli-0.3.9}/pyproject.toml +39 -4
  4. {steve_cli-0.3.7 → steve_cli-0.3.9}/steve_cli/cli.py +52 -8
  5. steve_cli-0.3.9/steve_cli/decorators/__init__.py +3 -0
  6. steve_cli-0.3.9/steve_cli/decorators/lineage_job.py +67 -0
  7. steve_cli-0.3.9/steve_cli/lineage/__init__.py +14 -0
  8. steve_cli-0.3.9/steve_cli/lineage/adapters/__init__.py +0 -0
  9. steve_cli-0.3.9/steve_cli/lineage/adapters/logging.py +25 -0
  10. steve_cli-0.3.9/steve_cli/lineage/adapters/null.py +8 -0
  11. steve_cli-0.3.9/steve_cli/lineage/adapters/openlineage.py +121 -0
  12. steve_cli-0.3.9/steve_cli/lineage/collector.py +98 -0
  13. steve_cli-0.3.9/steve_cli/lineage/port.py +33 -0
  14. steve_cli-0.3.9/steve_cli/lineage/registry.py +50 -0
  15. steve_cli-0.3.9/steve_cli/lineage/storage.py +133 -0
  16. steve_cli-0.3.9/steve_cli/storage/__init__.py +15 -0
  17. steve_cli-0.3.9/steve_cli/storage/metadata/__init__.py +9 -0
  18. steve_cli-0.3.9/steve_cli/storage/metadata/extractors/__init__.py +0 -0
  19. steve_cli-0.3.9/steve_cli/storage/metadata/extractors/csv.py +39 -0
  20. steve_cli-0.3.9/steve_cli/storage/metadata/extractors/excel.py +44 -0
  21. steve_cli-0.3.9/steve_cli/storage/metadata/extractors/generic.py +28 -0
  22. steve_cli-0.3.9/steve_cli/storage/metadata/extractors/json.py +42 -0
  23. steve_cli-0.3.9/steve_cli/storage/metadata/extractors/parquet.py +47 -0
  24. steve_cli-0.3.9/steve_cli/storage/metadata/port.py +59 -0
  25. steve_cli-0.3.9/steve_cli/storage/metadata/registry.py +71 -0
  26. steve_cli-0.3.9/steve_cli/storage/parquet.py +11 -0
  27. steve_cli-0.3.9/steve_cli/storage/protocol.py +13 -0
  28. steve_cli-0.3.9/steve_cli/storage/s3.py +137 -0
  29. {steve_cli-0.3.7 → steve_cli-0.3.9}/steve_cli/storage.py +22 -1
  30. steve_cli-0.3.9/steve_cli/validation/__init__.py +4 -0
  31. steve_cli-0.3.9/steve_cli/validation/adapters/__init__.py +0 -0
  32. steve_cli-0.3.9/steve_cli/validation/adapters/great_expectations.py +77 -0
  33. steve_cli-0.3.9/steve_cli/validation/adapters/null.py +16 -0
  34. steve_cli-0.3.9/steve_cli/validation/adapters/validoopsie.py +92 -0
  35. steve_cli-0.3.9/steve_cli/validation/port.py +77 -0
  36. steve_cli-0.3.9/steve_cli/validation/registry.py +55 -0
  37. steve_cli-0.3.9/steve_cli.egg-info/PKG-INFO +242 -0
  38. steve_cli-0.3.9/steve_cli.egg-info/SOURCES.txt +42 -0
  39. steve_cli-0.3.9/steve_cli.egg-info/requires.txt +49 -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.9}/setup.cfg +0 -0
  44. {steve_cli-0.3.7 → steve_cli-0.3.9}/steve_cli/__init__.py +0 -0
  45. {steve_cli-0.3.7 → steve_cli-0.3.9}/steve_cli.egg-info/dependency_links.txt +0 -0
  46. {steve_cli-0.3.7 → steve_cli-0.3.9}/steve_cli.egg-info/entry_points.txt +0 -0
  47. {steve_cli-0.3.7 → steve_cli-0.3.9}/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.9
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,51 @@ 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: visidata
48
+ Requires-Dist: visidata>=3.0; extra == "visidata"
49
+ Provides-Extra: all
50
+ Requires-Dist: openlineage-python>=1.0.0; extra == "all"
51
+ Requires-Dist: pyarrow>=17.0.0; extra == "all"
52
+ Requires-Dist: polars>=1.8.2; extra == "all"
53
+ Requires-Dist: validoopsie>=0.1.0; extra == "all"
54
+ Requires-Dist: great-expectations>=0.18.0; extra == "all"
55
+ Requires-Dist: pandas>=2.0.3; extra == "all"
56
+ Requires-Dist: openpyxl>=3.1.0; extra == "all"
57
+ Requires-Dist: visidata>=3.0; extra == "all"
34
58
 
35
59
  # Steve CLI
36
60
 
@@ -134,6 +158,54 @@ Running `steve extract-data` will:
134
158
  - ✅ **Colorful**: Nice colored output for better readability
135
159
  - ✅ **Error handling**: Clear error messages for missing jobs or files
136
160
 
161
+ ## File Metadata Extraction
162
+
163
+ Steve automatically extracts metadata from files read or written via `MetadataRegistry`. The extractor is chosen by file extension — no configuration needed.
164
+
165
+ | Extension | Extractor | Requires |
166
+ |---|---|---|
167
+ | `.parquet`, `.pq` | `ParquetExtractor` | `pip install steve-cli[polars]` |
168
+ | `.csv`, `.tsv`, `.txt` | `CsvExtractor` | stdlib only |
169
+ | `.json`, `.jsonl`, `.ndjson` | `JsonExtractor` | stdlib only |
170
+ | `.xlsx`, `.xls`, `.xlsm` | `ExcelExtractor` | `pip install steve-cli[excel]` |
171
+ | anything else | `GenericExtractor` | stdlib only |
172
+
173
+ ### Adding a custom extractor
174
+
175
+ Implement `MetadataExtractorPort`, declare which extensions it handles, and register it once at startup:
176
+
177
+ ```python
178
+ from steve_cli.storage.metadata.port import MetadataExtractorPort, FileMetadata, ColumnMetadata
179
+ from steve_cli.storage.metadata.registry import MetadataRegistry
180
+
181
+ class AvroExtractor(MetadataExtractorPort):
182
+ extensions = (".avro",)
183
+
184
+ def extract(self, data: bytes, path: str) -> FileMetadata:
185
+ import fastavro, io
186
+ reader = fastavro.reader(io.BytesIO(data))
187
+ schema = reader.writer_schema
188
+ columns = [
189
+ ColumnMetadata(name=f["name"], type=str(f["type"]))
190
+ for f in schema.get("fields", [])
191
+ ]
192
+ records = list(reader)
193
+ return FileMetadata(
194
+ format="avro",
195
+ size_bytes=len(data),
196
+ rows=len(records),
197
+ columns=columns,
198
+ )
199
+
200
+ MetadataRegistry.register("avro", AvroExtractor)
201
+ ```
202
+
203
+ After registration, `MetadataRegistry.extract(data, "output.avro")` picks `AvroExtractor` automatically. You can also force a specific extractor for any file via the env var:
204
+
205
+ ```bash
206
+ METADATA_EXTRACTOR=avro steve jobs run my-job
207
+ ```
208
+
137
209
  ## Why Steve?
138
210
 
139
211
  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.9"
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,33 @@ 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
+ visidata = [
64
+ "visidata>=3.0",
65
+ ]
66
+ all = [
67
+ "openlineage-python>=1.0.0",
68
+ "pyarrow>=17.0.0",
69
+ "polars>=1.8.2",
70
+ "validoopsie>=0.1.0",
71
+ "great-expectations>=0.18.0",
72
+ "pandas>=2.0.3",
73
+ "openpyxl>=3.1.0",
74
+ "visidata>=3.0",
75
+ ]
46
76
 
47
77
  [project.scripts]
48
78
  steve = "steve_cli.cli:main"
@@ -63,3 +93,8 @@ target-version = ['py38']
63
93
 
64
94
  [tool.isort]
65
95
  profile = "black"
96
+
97
+ [dependency-groups]
98
+ dev = [
99
+ "great-expectations>=1.18.2",
100
+ ]
@@ -5,10 +5,11 @@ 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
12
+ import shutil
12
13
  import signal
13
14
  import socket
14
15
  import subprocess
@@ -18,6 +19,7 @@ from pathlib import Path
18
19
  from typing import Dict, List, Any, Optional
19
20
 
20
21
  import click
22
+ from dotenv import load_dotenv, dotenv_values
21
23
  import questionary
22
24
  import yaml
23
25
 
@@ -68,6 +70,10 @@ def run_job_command(job: Dict[str, Any]) -> int:
68
70
  return 1
69
71
 
70
72
  env = os.environ.copy()
73
+ for env_filename in [".env", ".workspaces.env"]:
74
+ env_path = Path.cwd() / env_filename
75
+ if env_path.exists():
76
+ env.update({k: v for k, v in dotenv_values(env_path).items() if v is not None})
71
77
  job_env = job.get('env', {})
72
78
  for key, value in job_env.items():
73
79
  env[key] = str(value)
@@ -368,6 +374,10 @@ def apps_start(app_name: str, apps_file: Optional[Path]):
368
374
  sys.exit(1)
369
375
 
370
376
  env = os.environ.copy()
377
+ for env_filename in [".env", ".workspaces.env"]:
378
+ env_path = config.apps_file.parent / env_filename
379
+ if env_path.exists():
380
+ env.update({k: v for k, v in dotenv_values(env_path).items() if v is not None})
371
381
  for key, value in env_vars.items():
372
382
  env[key] = str(value)
373
383
 
@@ -453,16 +463,16 @@ def setup():
453
463
 
454
464
  @setup.command("env")
455
465
  def setup_env():
456
- """Decrypt SOPS-encoded .env files in current directory and write plaintext .env files."""
466
+ """Decrypt SOPS-encrypted .env files in current directory and write plaintext .env files."""
457
467
  cwd = Path.cwd()
458
468
  found: List[Path] = []
459
- for pattern in ["*.enc.env", "*.encoded.env"]:
469
+ for pattern in ["*.enc.env", "*.encrypted.env"]:
460
470
  found.extend(sorted(cwd.glob(pattern)))
461
471
 
462
472
  found = [f for f in found if f.name not in SETUP_IGNORE]
463
473
 
464
474
  if not found:
465
- click.echo("No *.enc.env or *.encoded.env files found.")
475
+ click.echo("No *.enc.env or *.encrypted.env files found.")
466
476
  return
467
477
 
468
478
  for enc_file in found:
@@ -492,7 +502,7 @@ def setup_env():
492
502
  if keys:
493
503
  click.echo(f" 🔑 Keys: {click.style(', '.join(keys), fg='green')}")
494
504
 
495
- stem = enc_file.name.replace(".encoded.env", "").replace(".enc.env", "")
505
+ stem = enc_file.name.replace(".encrypted.env", "").replace(".enc.env", "")
496
506
  out_file = cwd / f"{stem}.env"
497
507
  out_file.write_text(result.stdout)
498
508
 
@@ -536,7 +546,7 @@ def _print_tree(node: Dict[str, Any], prefix: str = "", is_last: bool = True) ->
536
546
  _print_tree(child, prefix + extension, last)
537
547
 
538
548
 
539
- def _list_bucket(storage_kwargs: dict, label: str, bucket_name: str) -> None:
549
+ def _list_bucket(storage_kwargs: dict, label: str, bucket_name: str) -> tuple:
540
550
  click.echo(f" {click.style(label, fg='cyan')} ({bucket_name})")
541
551
  try:
542
552
  from steve_cli.storage import S3Storage
@@ -547,15 +557,23 @@ def _list_bucket(storage_kwargs: dict, label: str, bucket_name: str) -> None:
547
557
  else:
548
558
  tree = _build_tree(keys)
549
559
  _print_tree(tree, prefix=" ")
560
+ return storage, keys
550
561
  except EnvironmentError as e:
551
562
  click.echo(f" ⚠️ {e}", err=True)
552
563
  except Exception as e:
553
564
  click.echo(f" ❌ {e}", err=True)
565
+ return None, []
554
566
 
555
567
 
556
568
  @main.command("buckets")
557
- def buckets():
569
+ @click.option('--env-file', '-e', type=click.Path(path_type=Path), multiple=True,
570
+ help='Path to .env file(s). Can be specified multiple times. Defaults to .env and .workspace.env')
571
+ def buckets(env_file: tuple):
558
572
  """List all S3 buckets detected from env variables and show their files as a tree."""
573
+ cwd = Path.cwd()
574
+ env_files = [Path(f) for f in env_file] if env_file else [cwd / ".env", cwd / ".workspace.env"]
575
+ for ef in env_files:
576
+ load_dotenv(ef)
559
577
  tiers = ["bronze", "silver", "gold"]
560
578
 
561
579
  options: List[Dict[str, Any]] = []
@@ -595,7 +613,33 @@ def buckets():
595
613
  sys.exit(0)
596
614
 
597
615
  selected = next(o for o in options if o["label"] == choice)
598
- _list_bucket(selected["kwargs"], selected["tier"], selected["bucket_name"])
616
+ storage, keys = _list_bucket(selected["kwargs"], selected["tier"], selected["bucket_name"])
617
+
618
+ if not storage or not keys:
619
+ return
620
+
621
+ file_choice = questionary.select(
622
+ "View a file (or press Esc to exit):",
623
+ choices=["(done)"] + keys,
624
+ ).ask()
625
+
626
+ if not file_choice or file_choice == "(done)":
627
+ return
628
+
629
+ click.echo(f"\n📄 {click.style(file_choice, fg='cyan')}\n")
630
+ try:
631
+ import tempfile
632
+ data = storage.get_bytes(file_choice)
633
+ suffix = Path(file_choice).suffix
634
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
635
+ tmp.write(data)
636
+ tmp_path = tmp.name
637
+ if not shutil.which("vd"):
638
+ click.secho("visidata not found. Install it with: uv pip install 'steve-cli[visidata]'", fg="yellow")
639
+ return
640
+ subprocess.call(["vd", tmp_path])
641
+ except Exception as e:
642
+ click.secho(f"❌ Could not open file: {e}", fg="red", err=True)
599
643
 
600
644
 
601
645
  if __name__ == '__main__':
@@ -0,0 +1,3 @@
1
+ from .lineage_job import lineage_job
2
+
3
+ __all__ = ["lineage_job"]
@@ -0,0 +1,67 @@
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
+ import sys
49
+ from steve_cli.validation.port import DataQualityError
50
+ if isinstance(exc, (DataQualityError, EnvironmentError)):
51
+ import click
52
+ click.secho(f"ERROR {exc}", fg="red", bold=True, err=True)
53
+ sys.exit(1)
54
+ raise
55
+
56
+ session.complete()
57
+ return result
58
+
59
+ if fn.__module__ == "__main__":
60
+ import logging
61
+ import os
62
+ logging.basicConfig(level=os.getenv("LOG_LEVEL", "WARNING"), format="%(levelname)s %(name)s: %(message)s")
63
+ wrapper()
64
+
65
+ return wrapper
66
+
67
+ 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)