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