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.
- {steve_cli-0.3.7/steve_cli.egg-info → steve_cli-0.3.8}/PKG-INFO +73 -4
- steve_cli-0.3.7/PKG-INFO → steve_cli-0.3.8/README.md +48 -34
- {steve_cli-0.3.7 → steve_cli-0.3.8}/pyproject.toml +35 -4
- {steve_cli-0.3.7 → steve_cli-0.3.8}/steve_cli/cli.py +13 -6
- steve_cli-0.3.8/steve_cli/decorators/__init__.py +3 -0
- steve_cli-0.3.8/steve_cli/decorators/lineage_job.py +66 -0
- steve_cli-0.3.8/steve_cli/lineage/__init__.py +14 -0
- steve_cli-0.3.8/steve_cli/lineage/adapters/__init__.py +0 -0
- steve_cli-0.3.8/steve_cli/lineage/adapters/logging.py +25 -0
- steve_cli-0.3.8/steve_cli/lineage/adapters/null.py +8 -0
- steve_cli-0.3.8/steve_cli/lineage/adapters/openlineage.py +121 -0
- steve_cli-0.3.8/steve_cli/lineage/collector.py +94 -0
- steve_cli-0.3.8/steve_cli/lineage/port.py +33 -0
- steve_cli-0.3.8/steve_cli/lineage/registry.py +50 -0
- steve_cli-0.3.8/steve_cli/lineage/storage.py +133 -0
- steve_cli-0.3.8/steve_cli/storage/__init__.py +15 -0
- steve_cli-0.3.8/steve_cli/storage/metadata/__init__.py +9 -0
- steve_cli-0.3.8/steve_cli/storage/metadata/extractors/__init__.py +0 -0
- steve_cli-0.3.8/steve_cli/storage/metadata/extractors/csv.py +39 -0
- steve_cli-0.3.8/steve_cli/storage/metadata/extractors/excel.py +44 -0
- steve_cli-0.3.8/steve_cli/storage/metadata/extractors/generic.py +28 -0
- steve_cli-0.3.8/steve_cli/storage/metadata/extractors/json.py +42 -0
- steve_cli-0.3.8/steve_cli/storage/metadata/extractors/parquet.py +47 -0
- steve_cli-0.3.8/steve_cli/storage/metadata/port.py +59 -0
- steve_cli-0.3.8/steve_cli/storage/metadata/registry.py +71 -0
- steve_cli-0.3.8/steve_cli/storage/parquet.py +11 -0
- steve_cli-0.3.8/steve_cli/storage/protocol.py +13 -0
- steve_cli-0.3.8/steve_cli/storage/s3.py +137 -0
- {steve_cli-0.3.7 → steve_cli-0.3.8}/steve_cli/storage.py +22 -1
- steve_cli-0.3.8/steve_cli/validation/__init__.py +4 -0
- steve_cli-0.3.8/steve_cli/validation/adapters/__init__.py +0 -0
- steve_cli-0.3.8/steve_cli/validation/adapters/great_expectations.py +77 -0
- steve_cli-0.3.8/steve_cli/validation/adapters/null.py +16 -0
- steve_cli-0.3.8/steve_cli/validation/adapters/validoopsie.py +92 -0
- steve_cli-0.3.8/steve_cli/validation/port.py +77 -0
- steve_cli-0.3.8/steve_cli/validation/registry.py +55 -0
- steve_cli-0.3.8/steve_cli.egg-info/PKG-INFO +239 -0
- steve_cli-0.3.8/steve_cli.egg-info/SOURCES.txt +42 -0
- steve_cli-0.3.8/steve_cli.egg-info/requires.txt +45 -0
- steve_cli-0.3.7/README.md +0 -136
- steve_cli-0.3.7/steve_cli.egg-info/SOURCES.txt +0 -11
- steve_cli-0.3.7/steve_cli.egg-info/requires.txt +0 -17
- {steve_cli-0.3.7 → steve_cli-0.3.8}/setup.cfg +0 -0
- {steve_cli-0.3.7 → steve_cli-0.3.8}/steve_cli/__init__.py +0 -0
- {steve_cli-0.3.7 → steve_cli-0.3.8}/steve_cli.egg-info/dependency_links.txt +0 -0
- {steve_cli-0.3.7 → steve_cli-0.3.8}/steve_cli.egg-info/entry_points.txt +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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-
|
|
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-
|
|
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", "*.
|
|
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 *.
|
|
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(".
|
|
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
|
-
|
|
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,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,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)
|