steve-cli 0.5.5__tar.gz → 1.0.1__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.5.5 → steve_cli-1.0.1}/PKG-INFO +3 -1
- {steve_cli-0.5.5 → steve_cli-1.0.1}/README.md +2 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/pyproject.toml +1 -1
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/auth.py +10 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/cli.py +253 -2
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/decorators/lineage_job.py +5 -1
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/storage/__init__.py +2 -0
- steve_cli-1.0.1/steve_cli/storage/branch.py +40 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/storage/s3.py +43 -18
- steve_cli-1.0.1/steve_cli/storage/trino.py +432 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli.egg-info/PKG-INFO +3 -1
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli.egg-info/SOURCES.txt +1 -0
- steve_cli-0.5.5/steve_cli/storage/trino.py +0 -239
- {steve_cli-0.5.5 → steve_cli-1.0.1}/setup.cfg +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/__init__.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/decorators/__init__.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/lineage/__init__.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/lineage/adapters/__init__.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/lineage/adapters/composite.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/lineage/adapters/dataproductregistry.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/lineage/adapters/logging.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/lineage/adapters/null.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/lineage/adapters/openlineage.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/lineage/collector.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/lineage/port.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/lineage/registry.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/lineage/storage.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/ontology.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/policies/__init__.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/policies/client.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/storage/metadata/__init__.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/storage/metadata/extractors/__init__.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/storage/metadata/extractors/csv.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/storage/metadata/extractors/excel.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/storage/metadata/extractors/generic.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/storage/metadata/extractors/json.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/storage/metadata/extractors/parquet.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/storage/metadata/port.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/storage/metadata/registry.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/storage/parquet.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/storage/protocol.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/storage.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/validation/__init__.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/validation/adapters/__init__.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/validation/adapters/great_expectations.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/validation/adapters/null.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/validation/adapters/validoopsie.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/validation/port.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/validation/registry.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli/vkg.py +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli.egg-info/dependency_links.txt +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli.egg-info/entry_points.txt +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/steve_cli.egg-info/requires.txt +0 -0
- {steve_cli-0.5.5 → steve_cli-1.0.1}/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
|
+
Version: 1.0.1
|
|
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
|
|
@@ -67,6 +67,8 @@ Requires-Dist: visidata>=3.0; extra == "all"
|
|
|
67
67
|
|
|
68
68
|
A CLI tool for the data platform. Commands are organized by concern: general workspace automation, data lakehouse operations, semantic knowledge graphs, and governance.
|
|
69
69
|
|
|
70
|
+
> **Upgrading from 0.x?** See [CHANGELOG.md](./CHANGELOG.md) — v1.0.0 introduces branch-isolated storage with breaking path changes that require a one-time data migration.
|
|
71
|
+
|
|
70
72
|
## Installation
|
|
71
73
|
|
|
72
74
|
```bash
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
A CLI tool for the data platform. Commands are organized by concern: general workspace automation, data lakehouse operations, semantic knowledge graphs, and governance.
|
|
4
4
|
|
|
5
|
+
> **Upgrading from 0.x?** See [CHANGELOG.md](./CHANGELOG.md) — v1.0.0 introduces branch-isolated storage with breaking path changes that require a one-time data migration.
|
|
6
|
+
|
|
5
7
|
## Installation
|
|
6
8
|
|
|
7
9
|
```bash
|
|
@@ -21,6 +21,8 @@ def save_credentials(
|
|
|
21
21
|
token: str,
|
|
22
22
|
base_url: Optional[str] = None,
|
|
23
23
|
workspace_name: Optional[str] = None,
|
|
24
|
+
expires_at: Optional[str] = None,
|
|
25
|
+
s3_endpoint: Optional[str] = None,
|
|
24
26
|
) -> None:
|
|
25
27
|
_CREDENTIALS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
26
28
|
creds = load_credentials()
|
|
@@ -30,6 +32,10 @@ def save_credentials(
|
|
|
30
32
|
entry["base_url"] = base_url.rstrip("/")
|
|
31
33
|
if workspace_name:
|
|
32
34
|
entry["workspace_name"] = workspace_name
|
|
35
|
+
if expires_at:
|
|
36
|
+
entry["expires_at"] = expires_at
|
|
37
|
+
if s3_endpoint:
|
|
38
|
+
entry["s3_endpoint"] = s3_endpoint
|
|
33
39
|
workspaces[workspace_id] = entry
|
|
34
40
|
creds["workspaces"] = workspaces
|
|
35
41
|
_CREDENTIALS_FILE.write_text(json.dumps(creds, indent=2))
|
|
@@ -63,6 +69,10 @@ def get_token(workspace_id: Optional[str] = None) -> Optional[str]:
|
|
|
63
69
|
return _get_workspace_entry(workspace_id).get("token")
|
|
64
70
|
|
|
65
71
|
|
|
72
|
+
def get_s3_endpoint(workspace_id: Optional[str] = None) -> Optional[str]:
|
|
73
|
+
return _get_workspace_entry(workspace_id).get("s3_endpoint")
|
|
74
|
+
|
|
75
|
+
|
|
66
76
|
def get_service_url(service: str, workspace_id: Optional[str] = None) -> Optional[str]:
|
|
67
77
|
entry = _get_workspace_entry(workspace_id)
|
|
68
78
|
base_url = entry.get("base_url")
|
|
@@ -963,9 +963,10 @@ def policies_apply(file: Path | None):
|
|
|
963
963
|
@click.option("--workspace", "workspace_id", default=None, help="Workspace ID (defaults to WORKSPACE_ID from .env)")
|
|
964
964
|
@click.option("--url", "base_url", default=None, help="Platform root host, e.g. jds-dev.internal.jambit.io")
|
|
965
965
|
@click.option("--workspace-name", "workspace_name_opt", default=None, help="Workspace name")
|
|
966
|
+
@click.option("--expires", "expires_at", default=None, help="Token expiry date/time (e.g. 2026-12-31 or ISO 8601). Informational only.")
|
|
966
967
|
@click.option('--env-file', '-e', type=click.Path(path_type=Path), multiple=True,
|
|
967
968
|
help='Path to .env file(s). Defaults to .env and .workspaces.env')
|
|
968
|
-
def login(token: str | None, workspace_id: str | None, base_url: str | None, workspace_name_opt: str | None, env_file: tuple):
|
|
969
|
+
def login(token: str | None, workspace_id: str | None, base_url: str | None, workspace_name_opt: str | None, expires_at: str | None, env_file: tuple):
|
|
969
970
|
"""Print CLI access URLs or save a service principal token."""
|
|
970
971
|
cwd = Path.cwd()
|
|
971
972
|
env_files = [Path(f) for f in env_file] if env_file else [cwd / ".env", cwd / ".workspaces.env"]
|
|
@@ -984,9 +985,27 @@ def login(token: str | None, workspace_id: str | None, base_url: str | None, wor
|
|
|
984
985
|
click.secho("Could not resolve workspace ID. Set WORKSPACE_ID in .env or pass --workspace.", fg="red", err=True)
|
|
985
986
|
raise SystemExit(1)
|
|
986
987
|
click.echo(f"\nYou are about to log in to workspace {click.style(workspace_name, fg='cyan', bold=True)} ({resolved_workspace})")
|
|
987
|
-
save_credentials(resolved_workspace, token, base_url=base_url, workspace_name=workspace_name)
|
|
988
|
+
save_credentials(resolved_workspace, token, base_url=base_url, workspace_name=workspace_name, expires_at=expires_at)
|
|
988
989
|
if base_url:
|
|
989
990
|
click.echo(f"Platform: {base_url}")
|
|
991
|
+
|
|
992
|
+
s3_endpoint: str | None = None
|
|
993
|
+
if base_url:
|
|
994
|
+
try:
|
|
995
|
+
import urllib.request as _urlreq
|
|
996
|
+
config_url = f"https://datameshx.{base_url}/api/v1/cli/config"
|
|
997
|
+
req = _urlreq.Request(config_url, headers={"Authorization": f"Bearer {token}"})
|
|
998
|
+
with _urlreq.urlopen(req, timeout=5) as _r:
|
|
999
|
+
import json as _json
|
|
1000
|
+
cfg = _json.loads(_r.read().decode())
|
|
1001
|
+
s3_endpoint = cfg.get("s3Endpoint")
|
|
1002
|
+
except Exception as _e:
|
|
1003
|
+
click.secho(f"Could not fetch platform config (s3Endpoint will use .env fallback): {_e}", fg="yellow", err=True)
|
|
1004
|
+
|
|
1005
|
+
if s3_endpoint:
|
|
1006
|
+
save_credentials(resolved_workspace, token, base_url=base_url, workspace_name=workspace_name, expires_at=expires_at, s3_endpoint=s3_endpoint)
|
|
1007
|
+
click.echo(f"S3 endpoint: {s3_endpoint}")
|
|
1008
|
+
|
|
990
1009
|
click.secho(f"Logged in. Credentials saved to ~/.steve/credentials.json", fg="green")
|
|
991
1010
|
return
|
|
992
1011
|
|
|
@@ -1022,6 +1041,238 @@ def logout(workspace_id: str | None, env_file: tuple):
|
|
|
1022
1041
|
click.secho("All credentials removed.", fg="green")
|
|
1023
1042
|
|
|
1024
1043
|
|
|
1044
|
+
@main.command("status")
|
|
1045
|
+
@click.option('--env-file', '-e', type=click.Path(path_type=Path), multiple=True,
|
|
1046
|
+
help='Path to .env file(s). Defaults to .env and .workspaces.env')
|
|
1047
|
+
@click.option('--no-check', is_flag=True, default=False, help='Skip connectivity checks.')
|
|
1048
|
+
def status(env_file: tuple, no_check: bool):
|
|
1049
|
+
"""Show current steve-cli environment, storage paths, and connectivity."""
|
|
1050
|
+
import importlib.metadata
|
|
1051
|
+
import urllib.request
|
|
1052
|
+
|
|
1053
|
+
cwd = Path.cwd()
|
|
1054
|
+
env_files = [Path(f) for f in env_file] if env_file else [cwd / ".env", cwd / ".workspaces.env"]
|
|
1055
|
+
for ef in env_files:
|
|
1056
|
+
load_dotenv(ef)
|
|
1057
|
+
|
|
1058
|
+
try:
|
|
1059
|
+
version = importlib.metadata.version("steve-cli")
|
|
1060
|
+
except Exception:
|
|
1061
|
+
version = "unknown"
|
|
1062
|
+
|
|
1063
|
+
def _section(title: str) -> None:
|
|
1064
|
+
click.echo(f"\n{click.style(f'━━ {title} ', fg='bright_black')}{'━' * max(0, 48 - len(title))}")
|
|
1065
|
+
|
|
1066
|
+
def _row(label: str, value: str, value_color: str = "white") -> None:
|
|
1067
|
+
click.echo(f" {click.style(label.ljust(14), fg='bright_black')} {click.style(value, fg=value_color)}")
|
|
1068
|
+
|
|
1069
|
+
def _check(label: str, url: str, timeout: int = 2, token: str = "", suffix: str = "") -> None:
|
|
1070
|
+
if no_check:
|
|
1071
|
+
_row(label, "(skipped)", "bright_black")
|
|
1072
|
+
return
|
|
1073
|
+
try:
|
|
1074
|
+
req = urllib.request.Request(url)
|
|
1075
|
+
if token:
|
|
1076
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
1077
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
1078
|
+
_row(label, f"✓ reachable ({url}){suffix}", "green")
|
|
1079
|
+
return r.read()
|
|
1080
|
+
except urllib.error.HTTPError:
|
|
1081
|
+
_row(label, f"✓ reachable ({url}){suffix}", "green")
|
|
1082
|
+
except Exception as e:
|
|
1083
|
+
_row(label, f"✗ unreachable ({url}){suffix}: {e}", "red")
|
|
1084
|
+
|
|
1085
|
+
click.echo(f"\n{click.style('●', fg='green')} Steve CLI {click.style('v' + version, fg='cyan', bold=True)}")
|
|
1086
|
+
|
|
1087
|
+
from steve_cli.storage.branch import get_branch_prefix
|
|
1088
|
+
import subprocess as _sp
|
|
1089
|
+
|
|
1090
|
+
branch = get_branch_prefix()
|
|
1091
|
+
steve_branch_env = os.getenv("STEVE_BRANCH", "").strip()
|
|
1092
|
+
gh_head = os.getenv("GITHUB_HEAD_REF", "").strip() or os.getenv("GITHUB_REF_NAME", "").strip()
|
|
1093
|
+
if steve_branch_env:
|
|
1094
|
+
branch_source = f"STEVE_BRANCH={steve_branch_env!r}"
|
|
1095
|
+
elif gh_head:
|
|
1096
|
+
branch_source = f"GITHUB env ({gh_head})"
|
|
1097
|
+
else:
|
|
1098
|
+
try:
|
|
1099
|
+
r = _sp.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True, timeout=3)
|
|
1100
|
+
branch_source = "git" if r.returncode == 0 and r.stdout.strip() else "fallback"
|
|
1101
|
+
except Exception:
|
|
1102
|
+
branch_source = "fallback"
|
|
1103
|
+
|
|
1104
|
+
workspace_id = os.getenv("WORKSPACE_ID", "")
|
|
1105
|
+
workspace_name = os.getenv("WORKSPACE_NAME", "")
|
|
1106
|
+
session = os.getenv("SESSION_NAME", "") or socket.gethostname()
|
|
1107
|
+
|
|
1108
|
+
from steve_cli.auth import _get_workspace_entry, get_service_url
|
|
1109
|
+
import datetime as _dt
|
|
1110
|
+
entry = _get_workspace_entry(workspace_id or None)
|
|
1111
|
+
token = entry.get("token", "")
|
|
1112
|
+
expires_at = entry.get("expires_at", "")
|
|
1113
|
+
if token:
|
|
1114
|
+
expiry_warn = False
|
|
1115
|
+
expiry_str = ", expires never" if not expires_at else ""
|
|
1116
|
+
if expires_at:
|
|
1117
|
+
try:
|
|
1118
|
+
exp = _dt.datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
|
1119
|
+
now = _dt.datetime.now(_dt.timezone.utc) if exp.tzinfo else _dt.datetime.now()
|
|
1120
|
+
delta = exp - now
|
|
1121
|
+
total_seconds = int(delta.total_seconds())
|
|
1122
|
+
if total_seconds < 0:
|
|
1123
|
+
s = abs(total_seconds)
|
|
1124
|
+
if s < 3600:
|
|
1125
|
+
ago = f"{s // 60}m ago"
|
|
1126
|
+
elif s < 86400:
|
|
1127
|
+
ago = f"{s // 3600}h ago"
|
|
1128
|
+
else:
|
|
1129
|
+
ago = f"{s // 86400}d ago"
|
|
1130
|
+
expiry_str = f", expired {ago}"
|
|
1131
|
+
expiry_warn = True
|
|
1132
|
+
elif total_seconds < 3600:
|
|
1133
|
+
expiry_str = f", expires in {total_seconds // 60}m ⚠"
|
|
1134
|
+
expiry_warn = True
|
|
1135
|
+
elif total_seconds < 86400:
|
|
1136
|
+
expiry_str = f", expires in {total_seconds // 3600}h ⚠"
|
|
1137
|
+
expiry_warn = True
|
|
1138
|
+
elif delta.days <= 7:
|
|
1139
|
+
expiry_str = f", expires in {delta.days}d ⚠"
|
|
1140
|
+
expiry_warn = True
|
|
1141
|
+
else:
|
|
1142
|
+
expiry_str = f", expires {exp.strftime('%Y-%m-%d %H:%M')}"
|
|
1143
|
+
except Exception:
|
|
1144
|
+
expiry_str = f", expires {expires_at}"
|
|
1145
|
+
token_display = f"✓ logged in (stp_***{token[-4:]}{expiry_str})"
|
|
1146
|
+
token_color = "yellow" if expiry_warn else "green"
|
|
1147
|
+
else:
|
|
1148
|
+
token_display = "✗ not logged in — run: steve login"
|
|
1149
|
+
token_color = "yellow"
|
|
1150
|
+
|
|
1151
|
+
_section("Identity")
|
|
1152
|
+
_row("Branch", f"{branch} ({branch_source})", "cyan")
|
|
1153
|
+
_row("Workspace ID", workspace_id or "(not set)", "white" if workspace_id else "yellow")
|
|
1154
|
+
_row("Workspace", workspace_name or "(not set)", "white" if workspace_name else "yellow")
|
|
1155
|
+
_row("Session", session, "white")
|
|
1156
|
+
if token and not no_check:
|
|
1157
|
+
_base_url = entry.get("base_url", "")
|
|
1158
|
+
if _base_url:
|
|
1159
|
+
try:
|
|
1160
|
+
_req = urllib.request.Request(
|
|
1161
|
+
f"https://datameshx.{_base_url}/api/v1/cli/config",
|
|
1162
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
1163
|
+
)
|
|
1164
|
+
with urllib.request.urlopen(_req, timeout=4) as _r:
|
|
1165
|
+
_r.read()
|
|
1166
|
+
_row("Auth", f"✓ token valid (stp_***{token[-4:]}{expiry_str})", "green" if not expiry_warn else "yellow")
|
|
1167
|
+
except urllib.error.HTTPError as _e:
|
|
1168
|
+
if _e.code == 401:
|
|
1169
|
+
_row("Auth", f"✗ token invalid/revoked (stp_***{token[-4:]}{expiry_str})", "red")
|
|
1170
|
+
else:
|
|
1171
|
+
_row("Auth", f"✓ token valid (stp_***{token[-4:]}{expiry_str})", "green" if not expiry_warn else "yellow")
|
|
1172
|
+
except Exception as _e:
|
|
1173
|
+
_row("Auth", f"{token_display} (validation failed: {_e})", token_color)
|
|
1174
|
+
else:
|
|
1175
|
+
_row("Auth", token_display, token_color)
|
|
1176
|
+
else:
|
|
1177
|
+
_row("Auth", token_display, token_color)
|
|
1178
|
+
|
|
1179
|
+
tiers = ["bronze", "silver", "gold"]
|
|
1180
|
+
s3_rows = []
|
|
1181
|
+
for tier in tiers:
|
|
1182
|
+
tier_up = tier.upper()
|
|
1183
|
+
bucket = os.getenv(f"{tier_up}_BUCKET", "")
|
|
1184
|
+
if bucket:
|
|
1185
|
+
s3_rows.append((f"S3 {tier}", f"s3://{bucket}/_store/{branch}/"))
|
|
1186
|
+
for ws in _detect_workspaces():
|
|
1187
|
+
for tier in tiers:
|
|
1188
|
+
bucket = os.getenv(f"{ws}_BUCKET_{tier.upper()}", "")
|
|
1189
|
+
if bucket:
|
|
1190
|
+
s3_rows.append((f"S3 {ws.lower()}/{tier}", f"s3://{bucket}/_store/{branch}/"))
|
|
1191
|
+
|
|
1192
|
+
_section("Storage Paths")
|
|
1193
|
+
if s3_rows:
|
|
1194
|
+
for label, path in s3_rows:
|
|
1195
|
+
_row(label, path, "cyan")
|
|
1196
|
+
else:
|
|
1197
|
+
_row("S3", "(no bucket env vars found)", "yellow")
|
|
1198
|
+
_row("Iceberg base", f"_iceberg/{branch}/", "cyan")
|
|
1199
|
+
|
|
1200
|
+
trino_endpoint = get_service_url("trino", workspace_id or None)
|
|
1201
|
+
resolved_workspace = workspace_name or workspace_id or ""
|
|
1202
|
+
trino_catalog = os.getenv("TRINO_CATALOG", "minio")
|
|
1203
|
+
trino_schema_explicit = os.getenv("TRINO_SCHEMA", "")
|
|
1204
|
+
|
|
1205
|
+
_section("Trino")
|
|
1206
|
+
if trino_endpoint:
|
|
1207
|
+
_row("Endpoint", trino_endpoint, "cyan")
|
|
1208
|
+
else:
|
|
1209
|
+
_row("Endpoint", "✗ not configured (login required)", "yellow")
|
|
1210
|
+
_row("Catalog", trino_catalog, "white")
|
|
1211
|
+
for tier in tiers:
|
|
1212
|
+
if trino_schema_explicit:
|
|
1213
|
+
schema = trino_schema_explicit
|
|
1214
|
+
elif resolved_workspace:
|
|
1215
|
+
ws_clean = resolved_workspace.lower().replace("-", "_")
|
|
1216
|
+
schema = f"{ws_clean}_{tier}__{branch}"
|
|
1217
|
+
else:
|
|
1218
|
+
schema = f"(workspace not set — set WORKSPACE_NAME)"
|
|
1219
|
+
_row(f"Schema {tier}", schema, "cyan" if resolved_workspace or trino_schema_explicit else "yellow")
|
|
1220
|
+
if trino_schema_explicit:
|
|
1221
|
+
break
|
|
1222
|
+
|
|
1223
|
+
lakekeeper = os.getenv("LAKEKEEPER_ENDPOINT", "") or get_service_url("lakekeeper", workspace_id or None)
|
|
1224
|
+
|
|
1225
|
+
_section("Connectivity")
|
|
1226
|
+
_cred_s3 = entry.get("s3_endpoint", "")
|
|
1227
|
+
_env_s3 = os.getenv("S3_ENDPOINT", "")
|
|
1228
|
+
if _cred_s3:
|
|
1229
|
+
s3_endpoint = _cred_s3
|
|
1230
|
+
s3_source = "credentials"
|
|
1231
|
+
elif _env_s3:
|
|
1232
|
+
s3_endpoint = _env_s3
|
|
1233
|
+
s3_source = "S3_ENDPOINT env"
|
|
1234
|
+
else:
|
|
1235
|
+
s3_endpoint = "http://localhost:9000"
|
|
1236
|
+
s3_source = "default"
|
|
1237
|
+
_check("S3 endpoint", s3_endpoint, suffix=f" (from {s3_source})")
|
|
1238
|
+
if trino_endpoint:
|
|
1239
|
+
if no_check:
|
|
1240
|
+
_row("Trino", "(skipped)", "bright_black")
|
|
1241
|
+
else:
|
|
1242
|
+
try:
|
|
1243
|
+
import json as _json
|
|
1244
|
+
req = urllib.request.Request(f"{trino_endpoint}/v1/info")
|
|
1245
|
+
if token:
|
|
1246
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
1247
|
+
req.add_header("X-Trino-User", os.getenv("TRINO_USER", "admin"))
|
|
1248
|
+
with urllib.request.urlopen(req, timeout=3) as r:
|
|
1249
|
+
info = _json.loads(r.read())
|
|
1250
|
+
version_str = info.get("nodeVersion", {}).get("version", "?")
|
|
1251
|
+
state = info.get("state", "?")
|
|
1252
|
+
uptime = info.get("uptime", "")
|
|
1253
|
+
detail = f"v{version_str} {state.lower()}" + (f", up {uptime}" if uptime else "")
|
|
1254
|
+
_row("Trino", f"✓ reachable ({detail}) {trino_endpoint}", "green")
|
|
1255
|
+
except Exception as e:
|
|
1256
|
+
_row("Trino", f"✗ unreachable: {e}", "red")
|
|
1257
|
+
else:
|
|
1258
|
+
_row("Trino", "✗ not configured", "yellow")
|
|
1259
|
+
if lakekeeper:
|
|
1260
|
+
_check("Lakekeeper", f"{lakekeeper}/catalog/v1/config")
|
|
1261
|
+
else:
|
|
1262
|
+
_row("Lakekeeper", "(not configured — set LAKEKEEPER_ENDPOINT or login with --url)", "bright_black")
|
|
1263
|
+
|
|
1264
|
+
_section("Environment")
|
|
1265
|
+
for ef in env_files:
|
|
1266
|
+
state = "✓ found" if ef.exists() else "✗ missing"
|
|
1267
|
+
color = "green" if ef.exists() else "bright_black"
|
|
1268
|
+
_row(ef.name, state, color)
|
|
1269
|
+
_row("STEVE_BRANCH", repr(steve_branch_env) if steve_branch_env else "(not set — auto-detected)", "white" if steve_branch_env else "bright_black")
|
|
1270
|
+
detected_ws = _detect_workspaces()
|
|
1271
|
+
if detected_ws:
|
|
1272
|
+
_row("Upstream ws", ", ".join(detected_ws), "white")
|
|
1273
|
+
click.echo()
|
|
1274
|
+
|
|
1275
|
+
|
|
1025
1276
|
@main.command("upgrade")
|
|
1026
1277
|
def upgrade():
|
|
1027
1278
|
"""Upgrade steve-cli to the latest version."""
|
|
@@ -67,8 +67,12 @@ def lineage_job(
|
|
|
67
67
|
import click
|
|
68
68
|
from steve_cli.validation.port import DataQualityError
|
|
69
69
|
if isinstance(exc, (DataQualityError, EnvironmentError)):
|
|
70
|
-
click.secho(f"ERROR {exc}", fg="red", bold=True, err=True)
|
|
70
|
+
click.secho(f"ERROR [{job_name}] {exc}", fg="red", bold=True, err=True)
|
|
71
71
|
sys.exit(1)
|
|
72
|
+
click.secho(
|
|
73
|
+
f"ERROR [{job_name}] {type(exc).__name__}: {exc}",
|
|
74
|
+
fg="red", bold=True, err=True,
|
|
75
|
+
)
|
|
72
76
|
raise
|
|
73
77
|
|
|
74
78
|
session.complete()
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from .branch import get_branch_prefix
|
|
1
2
|
from .protocol import Storage
|
|
2
3
|
from .s3 import S3Storage
|
|
3
4
|
from .trino import TrinoStorage
|
|
@@ -5,6 +6,7 @@ from .parquet import ParquetMetadata, extract_parquet_metadata
|
|
|
5
6
|
from .metadata import FileMetadata, ColumnMetadata, MetadataExtractorPort, MetadataRegistry
|
|
6
7
|
|
|
7
8
|
__all__ = [
|
|
9
|
+
"get_branch_prefix",
|
|
8
10
|
"Storage",
|
|
9
11
|
"S3Storage",
|
|
10
12
|
"TrinoStorage",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
_MAIN_BRANCHES = {"main", "master"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _sanitize(branch: str) -> str:
|
|
14
|
+
branch = branch.lower().strip()
|
|
15
|
+
branch = re.sub(r"[^a-z0-9]+", "_", branch)
|
|
16
|
+
return branch.strip("_") or "main"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_branch_prefix() -> str:
|
|
20
|
+
for env_var in ("STEVE_BRANCH", "GITHUB_HEAD_REF", "GITHUB_REF_NAME"):
|
|
21
|
+
value = os.getenv(env_var, "").strip()
|
|
22
|
+
if value:
|
|
23
|
+
return _sanitize(value)
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
result = subprocess.run(
|
|
27
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
28
|
+
capture_output=True,
|
|
29
|
+
text=True,
|
|
30
|
+
timeout=3,
|
|
31
|
+
)
|
|
32
|
+
if result.returncode == 0:
|
|
33
|
+
branch = result.stdout.strip()
|
|
34
|
+
if branch and branch != "HEAD":
|
|
35
|
+
return _sanitize(branch)
|
|
36
|
+
except Exception as exc:
|
|
37
|
+
logger.debug("git branch detection failed: %s", exc)
|
|
38
|
+
|
|
39
|
+
logger.warning("Could not detect git branch; defaulting to 'main'")
|
|
40
|
+
return "main"
|
|
@@ -3,25 +3,35 @@ from __future__ import annotations
|
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
5
|
from pathlib import Path, PurePosixPath
|
|
6
|
-
from typing import List
|
|
7
6
|
|
|
8
7
|
import boto3
|
|
9
8
|
from botocore.client import Config
|
|
10
9
|
from botocore.exceptions import ClientError, EndpointConnectionError
|
|
11
10
|
|
|
11
|
+
from .branch import get_branch_prefix
|
|
12
|
+
from steve_cli.auth import get_s3_endpoint
|
|
13
|
+
|
|
12
14
|
logger = logging.getLogger(__name__)
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
class S3Storage:
|
|
16
18
|
def __init__(self, tier: str = "bronze", workspace: str | None = None):
|
|
17
19
|
self.tier = tier.lower()
|
|
20
|
+
self._branch = get_branch_prefix()
|
|
18
21
|
self.endpoint, self.bucket, access_key, secret_key, required_vars = (
|
|
19
22
|
self._load_config(self.tier, workspace)
|
|
20
23
|
)
|
|
21
24
|
|
|
25
|
+
cred_endpoint = get_s3_endpoint()
|
|
26
|
+
if cred_endpoint:
|
|
27
|
+
self.endpoint = cred_endpoint
|
|
28
|
+
self._endpoint_source = "credentials"
|
|
29
|
+
else:
|
|
30
|
+
self._endpoint_source = "env" if os.getenv("S3_ENDPOINT") else "default"
|
|
31
|
+
|
|
22
32
|
missing = [v for v in required_vars if not os.getenv(v)]
|
|
23
33
|
if missing:
|
|
24
|
-
raise
|
|
34
|
+
raise OSError(
|
|
25
35
|
f"Missing required environment variables for {self.tier} tier: "
|
|
26
36
|
f"{', '.join(missing)}"
|
|
27
37
|
)
|
|
@@ -67,33 +77,48 @@ class S3Storage:
|
|
|
67
77
|
]
|
|
68
78
|
return endpoint, bucket, access_key, secret_key, required
|
|
69
79
|
|
|
70
|
-
|
|
71
|
-
def _key(path: str) -> str:
|
|
80
|
+
def _key(self, path: str) -> str:
|
|
72
81
|
if not path:
|
|
73
|
-
return ""
|
|
74
|
-
|
|
82
|
+
return f"_store/{self._branch}/"
|
|
83
|
+
clean = str(PurePosixPath(path).as_posix().lstrip("/"))
|
|
84
|
+
return f"_store/{self._branch}/{clean}"
|
|
75
85
|
|
|
76
86
|
def _object_location(self, path: str) -> str:
|
|
77
87
|
return f"{self.endpoint}/{self.bucket}/{self._key(path)}"
|
|
78
88
|
|
|
79
89
|
def _handle_client_error(self, error: ClientError, path: str, operation: str) -> None:
|
|
80
90
|
code = error.response["Error"]["Code"]
|
|
91
|
+
key = self._key(path)
|
|
92
|
+
tier_upper = self.tier.upper()
|
|
93
|
+
location = f"s3://{self.bucket}/{key} at {self.endpoint}"
|
|
81
94
|
if code == "AccessDenied":
|
|
82
|
-
logger.error("Access
|
|
95
|
+
logger.error("S3Storage: Access Denied when %s [%s]", operation, location)
|
|
96
|
+
raise OSError(
|
|
97
|
+
f"S3Storage: {operation} failed — Access Denied [{location}] "
|
|
98
|
+
f"(check {tier_upper}_ACCESS_KEY / {tier_upper}_SECRET_KEY have permission)"
|
|
99
|
+
)
|
|
83
100
|
elif code == "NoSuchBucket":
|
|
84
|
-
logger.error("
|
|
101
|
+
logger.error("S3Storage: bucket not found [bucket=%s, endpoint=%s]", self.bucket, self.endpoint)
|
|
102
|
+
raise OSError(
|
|
103
|
+
f"S3Storage: {operation} failed — bucket '{self.bucket}' not found at {self.endpoint}"
|
|
104
|
+
)
|
|
85
105
|
elif code == "NoSuchKey":
|
|
86
|
-
logger.warning("
|
|
106
|
+
logger.warning("S3Storage: key not found when %s [%s]", operation, location)
|
|
107
|
+
raise OSError(
|
|
108
|
+
f"S3Storage: {operation} failed — key not found [s3://{self.bucket}/{key}]"
|
|
109
|
+
)
|
|
87
110
|
else:
|
|
88
|
-
logger.error("
|
|
89
|
-
|
|
111
|
+
logger.error("S3Storage: %s error when %s [%s]: %s", code, operation, location, error)
|
|
112
|
+
raise OSError(
|
|
113
|
+
f"S3Storage: {operation} failed — {code} [s3://{self.bucket}/{key} at {self.endpoint}]"
|
|
114
|
+
)
|
|
90
115
|
|
|
91
116
|
def put_file(self, local_path: str, path: str) -> None:
|
|
92
117
|
try:
|
|
93
118
|
self.client.upload_file(local_path, self.bucket, self._key(path))
|
|
94
119
|
logger.info("Uploaded %s", self._object_location(path))
|
|
95
120
|
except EndpointConnectionError:
|
|
96
|
-
raise
|
|
121
|
+
raise OSError(f"S3Storage: cannot connect to S3 endpoint at {self.endpoint} (uploading {path})")
|
|
97
122
|
except ClientError as e:
|
|
98
123
|
self._handle_client_error(e, path, "uploading")
|
|
99
124
|
|
|
@@ -103,7 +128,7 @@ class S3Storage:
|
|
|
103
128
|
self.client.download_file(self.bucket, self._key(path), local_path)
|
|
104
129
|
logger.info("Downloaded %s", self._object_location(path))
|
|
105
130
|
except EndpointConnectionError:
|
|
106
|
-
raise
|
|
131
|
+
raise OSError(f"S3Storage: cannot connect to S3 endpoint at {self.endpoint} (downloading {path})")
|
|
107
132
|
except ClientError as e:
|
|
108
133
|
self._handle_client_error(e, path, "downloading")
|
|
109
134
|
|
|
@@ -112,7 +137,7 @@ class S3Storage:
|
|
|
112
137
|
self.client.put_object(Bucket=self.bucket, Key=self._key(path), Body=data)
|
|
113
138
|
logger.info("Stored %s", self._object_location(path))
|
|
114
139
|
except EndpointConnectionError:
|
|
115
|
-
raise
|
|
140
|
+
raise OSError(f"S3Storage: cannot connect to S3 endpoint at {self.endpoint} (writing {path})")
|
|
116
141
|
except ClientError as e:
|
|
117
142
|
self._handle_client_error(e, path, "writing")
|
|
118
143
|
|
|
@@ -122,16 +147,16 @@ class S3Storage:
|
|
|
122
147
|
logger.info("Retrieved %s", self._object_location(path))
|
|
123
148
|
return obj["Body"].read()
|
|
124
149
|
except EndpointConnectionError:
|
|
125
|
-
raise
|
|
150
|
+
raise OSError(f"S3Storage: cannot connect to S3 endpoint at {self.endpoint} (reading {path})")
|
|
126
151
|
except ClientError as e:
|
|
127
152
|
self._handle_client_error(e, path, "reading")
|
|
128
153
|
|
|
129
|
-
def list(self, prefix: str = "") ->
|
|
130
|
-
keys:
|
|
154
|
+
def list(self, prefix: str = "") -> list[str]:
|
|
155
|
+
keys: list[str] = []
|
|
131
156
|
paginator = self.client.get_paginator("list_objects_v2")
|
|
132
157
|
for page in paginator.paginate(Bucket=self.bucket, Prefix=self._key(prefix)):
|
|
133
158
|
keys.extend(obj["Key"] for obj in page.get("Contents", []))
|
|
134
159
|
return keys
|
|
135
160
|
|
|
136
|
-
def list_all(self) ->
|
|
161
|
+
def list_all(self) -> list[str]:
|
|
137
162
|
return self.list()
|