sunstone-data-platform 1.0.0__py3-none-any.whl
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.
- sunstone_data/__init__.py +36 -0
- sunstone_data/_version.py +24 -0
- sunstone_data/auth/__init__.py +43 -0
- sunstone_data/auth/client_creds.py +239 -0
- sunstone_data/auth/pkce.py +307 -0
- sunstone_data/auth/store.py +97 -0
- sunstone_data/catalog.py +175 -0
- sunstone_data/cli/__init__.py +5 -0
- sunstone_data/cli/_app.py +26 -0
- sunstone_data/cli/_errors.py +31 -0
- sunstone_data/cli/commands/__init__.py +22 -0
- sunstone_data/cli/commands/data_auth.py +120 -0
- sunstone_data/cli/commands/data_import.py +157 -0
- sunstone_data/cli/commands/data_info.py +166 -0
- sunstone_data/cli/commands/data_publish.py +69 -0
- sunstone_data/cli/commands/ls.py +49 -0
- sunstone_data/cli/commands/prov.py +58 -0
- sunstone_data/cli/commands/refs.py +142 -0
- sunstone_data/cli/commands/show.py +139 -0
- sunstone_data/cli/commands/snapshots.py +77 -0
- sunstone_data/cli/commands/sparql.py +68 -0
- sunstone_data/cli/main.py +30 -0
- sunstone_data/config.py +250 -0
- sunstone_data/discovery.py +317 -0
- sunstone_data/env_commands.py +97 -0
- sunstone_data/env_section.py +86 -0
- sunstone_data/graph/__init__.py +36 -0
- sunstone_data/graph/client.py +328 -0
- sunstone_data/graph/dataset.py +438 -0
- sunstone_data/graph/terms.py +34 -0
- sunstone_data/identifiers.py +155 -0
- sunstone_data/import_/__init__.py +28 -0
- sunstone_data/import_/fetcher.py +259 -0
- sunstone_data/import_/owner.py +130 -0
- sunstone_data/import_/parser.py +492 -0
- sunstone_data/import_/pipeline.py +275 -0
- sunstone_data/import_/storage.py +135 -0
- sunstone_data/metadata/__init__.py +41 -0
- sunstone_data/metadata/builder.py +276 -0
- sunstone_data/metadata/models.py +87 -0
- sunstone_data/metadata/provenance.py +171 -0
- sunstone_data/metadata/provenance_rdf.py +102 -0
- sunstone_data/plugin.py +62 -0
- sunstone_data/publish.py +134 -0
- sunstone_data/query.py +624 -0
- sunstone_data/resolve.py +178 -0
- sunstone_data/url.py +78 -0
- sunstone_data/utils/__init__.py +12 -0
- sunstone_data/utils/checksum.py +14 -0
- sunstone_data/utils/encoding.py +16 -0
- sunstone_data/utils/naming.py +38 -0
- sunstone_data/versioning/__init__.py +17 -0
- sunstone_data/versioning/schema_evolution.py +180 -0
- sunstone_data_platform-1.0.0.dist-info/METADATA +302 -0
- sunstone_data_platform-1.0.0.dist-info/RECORD +66 -0
- sunstone_data_platform-1.0.0.dist-info/WHEEL +4 -0
- sunstone_data_platform-1.0.0.dist-info/entry_points.txt +2 -0
- sunstone_pipeline/__init__.py +1 -0
- sunstone_pipeline/assets/__init__.py +11 -0
- sunstone_pipeline/assets/mirrors.py +123 -0
- sunstone_pipeline/assets/transforms.py +74 -0
- sunstone_pipeline/definitions.py +25 -0
- sunstone_pipeline/io_manager.py +56 -0
- sunstone_pipeline/resources/__init__.py +6 -0
- sunstone_pipeline/resources/iceberg.py +29 -0
- sunstone_pipeline/resources/refs.py +88 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Per-environment cache for a human login session.
|
|
2
|
+
|
|
3
|
+
Holds the OAuth2 refresh token from ``sunstone data login`` so a user logs in
|
|
4
|
+
once per environment. Access tokens are never stored here — they are minted on
|
|
5
|
+
demand and kept in memory only.
|
|
6
|
+
|
|
7
|
+
The file lives next to the discovery cache but one level down, so the two
|
|
8
|
+
never collide::
|
|
9
|
+
|
|
10
|
+
$XDG_CACHE_HOME/sunstone/data-platform/auth/{env_name}.json
|
|
11
|
+
|
|
12
|
+
It is written atomically and chmod'ed ``0600``: a refresh token is a
|
|
13
|
+
long-lived credential, so no other user on the machine may read it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import tempfile
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import asdict, dataclass, field, fields
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
#: Mode a session file must end up with — owner read/write only.
|
|
26
|
+
SESSION_FILE_MODE = 0o600
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Session:
|
|
31
|
+
"""A cached human login session for one environment."""
|
|
32
|
+
|
|
33
|
+
refresh_token: str
|
|
34
|
+
"""OAuth2 refresh token, exchanged for access tokens on demand."""
|
|
35
|
+
|
|
36
|
+
issuer: str
|
|
37
|
+
"""OIDC issuer the refresh token was minted by."""
|
|
38
|
+
|
|
39
|
+
client_id: str
|
|
40
|
+
"""OAuth2 client id the refresh token belongs to."""
|
|
41
|
+
|
|
42
|
+
cached_at: float = field(default_factory=time.time)
|
|
43
|
+
"""Unix time the session was written."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _auth_dir() -> Path:
|
|
47
|
+
base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
|
|
48
|
+
return Path(base) / "sunstone" / "data-platform" / "auth"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def session_path(env_name: str) -> Path:
|
|
52
|
+
"""Per-env session file path under XDG_CACHE_HOME (or ~/.cache)."""
|
|
53
|
+
return _auth_dir() / f"{env_name}.json"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def write_session(env_name: str, session: Session) -> Path:
|
|
57
|
+
"""Persist a login session, atomically and mode 0600.
|
|
58
|
+
|
|
59
|
+
Returns the path written.
|
|
60
|
+
"""
|
|
61
|
+
path = session_path(env_name)
|
|
62
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
|
64
|
+
try:
|
|
65
|
+
os.chmod(tmp_name, SESSION_FILE_MODE)
|
|
66
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
67
|
+
json.dump(asdict(session), f, indent=2)
|
|
68
|
+
os.replace(tmp_name, path)
|
|
69
|
+
except Exception:
|
|
70
|
+
Path(tmp_name).unlink(missing_ok=True)
|
|
71
|
+
raise
|
|
72
|
+
# mkstemp is already 0600, but the mode is a security property of this
|
|
73
|
+
# file — assert it on the final path rather than trusting the tempfile.
|
|
74
|
+
os.chmod(path, SESSION_FILE_MODE)
|
|
75
|
+
return path
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_session(env_name: str) -> Session | None:
|
|
79
|
+
"""Load a cached login session. Returns None if missing or unreadable."""
|
|
80
|
+
try:
|
|
81
|
+
with open(session_path(env_name), encoding="utf-8") as f:
|
|
82
|
+
data = json.load(f)
|
|
83
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
84
|
+
return None
|
|
85
|
+
if not isinstance(data, dict):
|
|
86
|
+
return None
|
|
87
|
+
field_names = {f.name for f in fields(Session)}
|
|
88
|
+
kwargs = {k: v for k, v in data.items() if k in field_names}
|
|
89
|
+
try:
|
|
90
|
+
return Session(**kwargs)
|
|
91
|
+
except TypeError:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def clear_session(env_name: str) -> None:
|
|
96
|
+
"""Remove the cached session for an environment, if any."""
|
|
97
|
+
session_path(env_name).unlink(missing_ok=True)
|
sunstone_data/catalog.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""PyIceberg catalog connection to the Iceberg REST catalog."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from pyiceberg.catalog import load_catalog
|
|
5
|
+
from pyiceberg.catalog.rest import RestCatalog
|
|
6
|
+
from pyiceberg.exceptions import NamespaceAlreadyExistsError
|
|
7
|
+
|
|
8
|
+
from sunstone_data.config import Settings, current_environment_name, get_settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _resolve_token(settings: Settings) -> str | None:
|
|
12
|
+
"""Return a bearer token for iceberg-catalog, or None if none is configured.
|
|
13
|
+
|
|
14
|
+
iceberg-catalog shares graph-server's auth middleware, which always fails
|
|
15
|
+
closed on authentication: a missing or unverifiable token is a 401 even
|
|
16
|
+
with ``AUTH_ENFORCE=false`` (that only stages the role decision). So every
|
|
17
|
+
request PyIceberg makes needs a real token, resolved the same way
|
|
18
|
+
:func:`sunstone_data.graph.client.get_graph_client` resolves one.
|
|
19
|
+
|
|
20
|
+
Unlike ``GraphClient``, PyIceberg's ``RestCatalog`` has no per-request
|
|
21
|
+
refresh hook — it reads the static ``token`` property once at construction
|
|
22
|
+
— so this resolves once, synchronously, before the catalog is built.
|
|
23
|
+
"""
|
|
24
|
+
from sunstone_data.auth.client_creds import resolve_access_token
|
|
25
|
+
|
|
26
|
+
with httpx.Client() as client:
|
|
27
|
+
return resolve_access_token(settings, current_environment_name(), client)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _build_catalog_properties(settings: Settings, branch: str, token: str | None = None) -> dict:
|
|
31
|
+
"""Build catalog properties dict for the given settings and branch.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
settings: Platform configuration settings.
|
|
35
|
+
branch: Branch to connect to.
|
|
36
|
+
token: Bearer token PyIceberg sends as ``Authorization: Bearer ...``.
|
|
37
|
+
Omitted from the properties when None.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Dict of catalog properties suitable for passing to load_catalog.
|
|
41
|
+
"""
|
|
42
|
+
props: dict = {
|
|
43
|
+
"type": "rest",
|
|
44
|
+
"uri": f"{settings.catalog_uri}/iceberg/{branch}/",
|
|
45
|
+
"warehouse": settings.warehouse,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if token is not None:
|
|
49
|
+
props["token"] = token
|
|
50
|
+
|
|
51
|
+
if settings.storage_backend == "gcs":
|
|
52
|
+
if settings.gcs_project_id:
|
|
53
|
+
props["gcs.project-id"] = settings.gcs_project_id
|
|
54
|
+
if settings.gcs_endpoint:
|
|
55
|
+
props["gcs.service.host"] = settings.gcs_endpoint
|
|
56
|
+
else:
|
|
57
|
+
props["s3.endpoint"] = settings.s3_endpoint
|
|
58
|
+
props["s3.access-key-id"] = settings.s3_access_key
|
|
59
|
+
props["s3.secret-access-key"] = settings.s3_secret_key
|
|
60
|
+
props["s3.region"] = settings.s3_region
|
|
61
|
+
props["s3.path-style-access"] = "true"
|
|
62
|
+
|
|
63
|
+
return props
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_catalog(branch: str = "main") -> RestCatalog:
|
|
67
|
+
"""Create PyIceberg catalog connection to the Iceberg REST catalog.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
branch: Branch to connect to. Defaults to "main".
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
RestCatalog: PyIceberg REST catalog connected to the Iceberg REST catalog.
|
|
74
|
+
"""
|
|
75
|
+
settings = get_settings()
|
|
76
|
+
return load_catalog("iceberg", **_build_catalog_properties(settings, branch, _resolve_token(settings)))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def create_namespace_if_not_exists(catalog: RestCatalog, namespace: str) -> tuple[str, ...]:
|
|
80
|
+
"""Create ``namespace`` and any missing parent levels.
|
|
81
|
+
|
|
82
|
+
A dotted namespace like ``un.health`` is a *nested* Iceberg namespace whose
|
|
83
|
+
parent ``un`` must exist first — the catalog rejects creating a child under a
|
|
84
|
+
missing parent. We create each level of the hierarchy in order, ignoring
|
|
85
|
+
levels that already exist. (Owner root-prefixing routinely produces nested
|
|
86
|
+
namespaces, so single-level creation is not enough.)
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
catalog: PyIceberg REST catalog instance.
|
|
90
|
+
namespace: Dot-separated namespace name (e.g. ``un.health``).
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
tuple: The namespace identifier as a tuple of its levels.
|
|
94
|
+
"""
|
|
95
|
+
parts = tuple(namespace.split("."))
|
|
96
|
+
for depth in range(1, len(parts) + 1):
|
|
97
|
+
level = parts[:depth]
|
|
98
|
+
try:
|
|
99
|
+
catalog.create_namespace(level)
|
|
100
|
+
except NamespaceAlreadyExistsError:
|
|
101
|
+
pass
|
|
102
|
+
return parts
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_table_metadata(table_identifier: str) -> dict[str, str]:
|
|
106
|
+
"""Get metadata properties from Iceberg table.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
table_identifier: Full table identifier (namespace.table).
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Dict of metadata properties (sunstone.import.* and sunstone.prov.*).
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
ValueError: If table not found.
|
|
116
|
+
"""
|
|
117
|
+
catalog = get_catalog()
|
|
118
|
+
|
|
119
|
+
# Parse identifier
|
|
120
|
+
parts = table_identifier.split(".")
|
|
121
|
+
if len(parts) != 2:
|
|
122
|
+
raise ValueError(f"Invalid table identifier: {table_identifier}. Expected namespace.table")
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
table = catalog.load_table(table_identifier)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
raise ValueError(f"Table not found: {table_identifier}") from e
|
|
128
|
+
|
|
129
|
+
# Extract sunstone.* properties
|
|
130
|
+
props = {}
|
|
131
|
+
for key, value in table.metadata.properties.items():
|
|
132
|
+
if key.startswith("sunstone."):
|
|
133
|
+
props[key] = value
|
|
134
|
+
|
|
135
|
+
return props
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def list_tables_with_metadata(namespace: str | None = None) -> list[dict]:
|
|
139
|
+
"""List tables with basic metadata.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
namespace: Optional namespace filter. Lists all if None.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
List of dicts with table_identifier and basic metadata.
|
|
146
|
+
"""
|
|
147
|
+
catalog = get_catalog()
|
|
148
|
+
|
|
149
|
+
results = []
|
|
150
|
+
if namespace:
|
|
151
|
+
namespaces = [namespace]
|
|
152
|
+
else:
|
|
153
|
+
namespaces = [ns[0] for ns in catalog.list_namespaces()]
|
|
154
|
+
|
|
155
|
+
for ns in namespaces:
|
|
156
|
+
for table_id in catalog.list_tables(ns):
|
|
157
|
+
# table_id is a tuple like ('namespace', 'table_name')
|
|
158
|
+
identifier = f"{table_id[0]}.{table_id[1]}"
|
|
159
|
+
try:
|
|
160
|
+
table = catalog.load_table(identifier)
|
|
161
|
+
props = table.metadata.properties
|
|
162
|
+
results.append(
|
|
163
|
+
{
|
|
164
|
+
"identifier": identifier,
|
|
165
|
+
"source": props.get("sunstone.import.source", "unknown"),
|
|
166
|
+
"timestamp": props.get("sunstone.import.timestamp", "unknown"),
|
|
167
|
+
"format": props.get("sunstone.import.format", "unknown"),
|
|
168
|
+
"row_count": props.get("sunstone.import.row_count", "?"),
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
except Exception:
|
|
172
|
+
# Skip tables that can't be loaded
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
return results
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Shared Typer app, sub-app, and Rich console.
|
|
2
|
+
|
|
3
|
+
Kept deliberately light: only ``typer`` and ``rich.console`` are imported
|
|
4
|
+
here so that loading the entry point (``sunstone_data.cli.main``) doesn't
|
|
5
|
+
drag in pyiceberg, pandas, s3fs, or any other data-platform internals.
|
|
6
|
+
Heavy imports live inside the individual command bodies under
|
|
7
|
+
:mod:`sunstone_data.cli.commands`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="sunstone",
|
|
17
|
+
help="Sunstone Data Platform CLI",
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
data_app = typer.Typer(help="Data operations")
|
|
22
|
+
app.add_typer(data_app, name="data")
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
__all__ = ["app", "data_app", "console"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""One error-reporting shape for every ``sunstone data`` command.
|
|
2
|
+
|
|
3
|
+
Without ``--json`` a failure is a red one-liner; with ``--json`` it is an
|
|
4
|
+
error object on stdout, so a script never has to parse Rich output. Either
|
|
5
|
+
way the process exits 1.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from sunstone_data.cli._app import console
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@contextmanager
|
|
20
|
+
def cli_errors(json_output: bool) -> Iterator[None]:
|
|
21
|
+
"""Report anything raised inside the block and exit 1."""
|
|
22
|
+
try:
|
|
23
|
+
yield
|
|
24
|
+
except typer.Exit:
|
|
25
|
+
raise # a command asking for a specific exit code, not a failure
|
|
26
|
+
except Exception as e:
|
|
27
|
+
if json_output:
|
|
28
|
+
print(json.dumps({"error": str(e), "type": type(e).__name__}))
|
|
29
|
+
else:
|
|
30
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
31
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""CLI sub-command modules for ``sunstone data ...``.
|
|
2
|
+
|
|
3
|
+
Importing this package runs each command module, which decorates its
|
|
4
|
+
function with ``@data_app.command(...)``. Decorators only register a
|
|
5
|
+
function reference; nothing inside the function body executes until the
|
|
6
|
+
command is actually invoked. The heavy imports (pyiceberg, s3fs, the
|
|
7
|
+
versioning stack, ...) live inside each function body, so the entry
|
|
8
|
+
point can register the whole CLI surface in well under 100ms.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from sunstone_data.cli.commands import ( # noqa: F401 (import side-effect: registers commands)
|
|
12
|
+
data_auth,
|
|
13
|
+
data_import,
|
|
14
|
+
data_info,
|
|
15
|
+
data_publish,
|
|
16
|
+
ls,
|
|
17
|
+
prov,
|
|
18
|
+
refs,
|
|
19
|
+
show,
|
|
20
|
+
snapshots,
|
|
21
|
+
sparql,
|
|
22
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""``sunstone data login`` / ``logout`` / ``whoami`` — human sign-in sessions.
|
|
2
|
+
|
|
3
|
+
``login`` runs the PKCE browser flow (:mod:`sunstone_data.auth.pkce`) and
|
|
4
|
+
caches only the refresh token, per environment, mode 0600. Access tokens are
|
|
5
|
+
minted on demand and never touch disk, so ``whoami`` reports what was cached,
|
|
6
|
+
not live token claims.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from sunstone_data.cli._app import console, data_app
|
|
14
|
+
|
|
15
|
+
_ENV_OPTION_HELP = "Environment to act on (defaults to the active sunstone environment)."
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _resolve_env(env: str | None) -> str:
|
|
19
|
+
from sunstone_data.config import current_environment_name
|
|
20
|
+
|
|
21
|
+
return env or current_environment_name()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@data_app.command("login")
|
|
25
|
+
def login_cmd(
|
|
26
|
+
env: str | None = typer.Option(None, "--env", help=_ENV_OPTION_HELP),
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Sign in to graph-server in a browser and cache the session."""
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
from sunstone_data.auth import pkce, store
|
|
32
|
+
from sunstone_data.auth.client_creds import discover_authorization_endpoint, discover_token_endpoint
|
|
33
|
+
from sunstone_data.config import get_settings
|
|
34
|
+
from sunstone_data.discovery import get_discovered
|
|
35
|
+
|
|
36
|
+
env_name = _resolve_env(env)
|
|
37
|
+
try:
|
|
38
|
+
settings = get_settings()
|
|
39
|
+
# graph-server publishes the issuer and the client id its own login
|
|
40
|
+
# button uses (``/ui/config.json``) — "configuration is discovered,
|
|
41
|
+
# not typed" (spec 180 §4).
|
|
42
|
+
discovered = get_discovered(env_name, settings.graph_url)
|
|
43
|
+
if discovered is None or not discovered.issuer or not discovered.client_id:
|
|
44
|
+
raise RuntimeError(
|
|
45
|
+
f"No OIDC issuer and client id discovered for environment {env_name!r}; "
|
|
46
|
+
f"cannot authenticate (graph_url={settings.graph_url!r})"
|
|
47
|
+
)
|
|
48
|
+
issuer = discovered.issuer
|
|
49
|
+
client_id = discovered.client_id
|
|
50
|
+
with httpx.Client() as client:
|
|
51
|
+
authorization_endpoint = discover_authorization_endpoint(issuer, client)
|
|
52
|
+
token_endpoint = discover_token_endpoint(issuer, client)
|
|
53
|
+
|
|
54
|
+
def open_browser(url: str) -> None:
|
|
55
|
+
import webbrowser
|
|
56
|
+
|
|
57
|
+
console.print(f"Opening your browser to sign in.\nIf it does not open, visit:\n {url}")
|
|
58
|
+
webbrowser.open(url)
|
|
59
|
+
|
|
60
|
+
tokens = pkce.login(
|
|
61
|
+
authorization_endpoint=authorization_endpoint,
|
|
62
|
+
token_endpoint=token_endpoint,
|
|
63
|
+
client_id=client_id,
|
|
64
|
+
client=client,
|
|
65
|
+
open_browser=open_browser,
|
|
66
|
+
)
|
|
67
|
+
store.write_session(
|
|
68
|
+
env_name,
|
|
69
|
+
store.Session(
|
|
70
|
+
refresh_token=tokens.refresh_token,
|
|
71
|
+
issuer=issuer,
|
|
72
|
+
client_id=client_id,
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
77
|
+
raise typer.Exit(1)
|
|
78
|
+
console.print(f"[green]Logged in to '{env_name}'.[/green]")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@data_app.command("logout")
|
|
82
|
+
def logout_cmd(
|
|
83
|
+
env: str | None = typer.Option(None, "--env", help=_ENV_OPTION_HELP),
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Forget the cached session for an environment."""
|
|
86
|
+
from sunstone_data.auth import store
|
|
87
|
+
|
|
88
|
+
env_name = _resolve_env(env)
|
|
89
|
+
was_logged_in = store.read_session(env_name) is not None
|
|
90
|
+
store.clear_session(env_name)
|
|
91
|
+
if was_logged_in:
|
|
92
|
+
console.print(f"Logged out of '{env_name}'.")
|
|
93
|
+
else:
|
|
94
|
+
console.print(f"Not logged in to '{env_name}'; nothing to do.")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@data_app.command("whoami")
|
|
98
|
+
def whoami_cmd(
|
|
99
|
+
env: str | None = typer.Option(None, "--env", help=_ENV_OPTION_HELP),
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Show the cached session for an environment.
|
|
102
|
+
|
|
103
|
+
Reports what ``login`` stored. The refresh token itself is never printed.
|
|
104
|
+
"""
|
|
105
|
+
import time
|
|
106
|
+
|
|
107
|
+
from sunstone_data.auth import store
|
|
108
|
+
|
|
109
|
+
env_name = _resolve_env(env)
|
|
110
|
+
session = store.read_session(env_name)
|
|
111
|
+
if session is None:
|
|
112
|
+
console.print(f"Not logged in to '{env_name}'. Run `sunstone data login`.")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
age_hours = max(0.0, time.time() - session.cached_at) / 3600
|
|
116
|
+
age = f"{age_hours * 60:.0f}m ago" if age_hours < 1 else f"{age_hours:.1f}h ago"
|
|
117
|
+
console.print(f"Environment: {env_name}")
|
|
118
|
+
console.print(f"Issuer: {session.issuer}")
|
|
119
|
+
console.print(f"Client: {session.client_id}")
|
|
120
|
+
console.print(f"Logged in: {age}")
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""``sunstone data import`` — bring CSV/Excel files into the platform."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from sunstone_data.cli._app import console, data_app
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _format_result(result, as_json: bool = False, branch: str = "main") -> None:
|
|
14
|
+
"""Pretty-print (or JSON-print) a single ImportResult."""
|
|
15
|
+
from sunstone_data.metadata.provenance_rdf import activity_iri
|
|
16
|
+
from sunstone_data.query import TableRef
|
|
17
|
+
from sunstone_data.resolve import dataset_iri
|
|
18
|
+
|
|
19
|
+
iri = dataset_iri(TableRef(namespace=result.namespace, table=result.table_name, branch=branch))
|
|
20
|
+
activity = activity_iri(iri, result.metadata.activity_id)
|
|
21
|
+
|
|
22
|
+
if as_json:
|
|
23
|
+
print(
|
|
24
|
+
json.dumps(
|
|
25
|
+
{
|
|
26
|
+
"table": f"{result.namespace}.{result.table_name}",
|
|
27
|
+
"rows": result.row_count,
|
|
28
|
+
"columns": result.column_count,
|
|
29
|
+
"checksum": result.checksum,
|
|
30
|
+
"original": result.original_path,
|
|
31
|
+
"already_existed": result.already_existed,
|
|
32
|
+
"branch": branch,
|
|
33
|
+
"dataset_iri": iri,
|
|
34
|
+
"snapshot_id": result.snapshot_id,
|
|
35
|
+
"activity_iri": activity,
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
if result.already_existed:
|
|
42
|
+
console.print(f"[yellow]Already imported:[/yellow] {result.namespace}.{result.table_name}")
|
|
43
|
+
else:
|
|
44
|
+
console.print(f"[green]Imported:[/green] {result.namespace}.{result.table_name}")
|
|
45
|
+
|
|
46
|
+
console.print(f" Rows: {result.row_count:,}, Columns: {result.column_count}")
|
|
47
|
+
console.print(f" Schema: {result.schema_summary}")
|
|
48
|
+
console.print(f" Checksum: {result.checksum[:16]}...")
|
|
49
|
+
console.print(f" Snapshot: {result.snapshot_id}")
|
|
50
|
+
console.print(f" Dataset: [cyan]{iri}[/cyan]")
|
|
51
|
+
console.print(f" Activity: [cyan]{activity}[/cyan]")
|
|
52
|
+
if branch != "main":
|
|
53
|
+
console.print(f" Branch: [cyan]{branch}[/cyan]")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@data_app.command("import")
|
|
57
|
+
def import_cmd(
|
|
58
|
+
source: str = typer.Argument(..., help="Local file path or URL to import"),
|
|
59
|
+
name: str | None = typer.Option(None, "--name", "-n", help="Override table name"),
|
|
60
|
+
namespace: str | None = typer.Option(None, "--namespace", help="Target namespace"),
|
|
61
|
+
owner: str | None = typer.Option(
|
|
62
|
+
None,
|
|
63
|
+
"--owner",
|
|
64
|
+
help="Owning organization path, e.g. un/who/emissions-div. Overrides the source-URL rules file.",
|
|
65
|
+
),
|
|
66
|
+
tag: list[str] | None = typer.Option(None, "--tag", "-t", help="Add tags (key:value)"),
|
|
67
|
+
branch: str = typer.Option("main", "--branch", "-b", help="Branch to import to"),
|
|
68
|
+
force_schema: bool = typer.Option(False, "--force-schema", help="Allow unsafe schema changes"),
|
|
69
|
+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Import CSV or Excel file into the data platform."""
|
|
72
|
+
from rich.progress import (
|
|
73
|
+
BarColumn,
|
|
74
|
+
DownloadColumn,
|
|
75
|
+
Progress,
|
|
76
|
+
SpinnerColumn,
|
|
77
|
+
TextColumn,
|
|
78
|
+
TimeRemainingColumn,
|
|
79
|
+
TransferSpeedColumn,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
from sunstone_data.import_ import run_import
|
|
83
|
+
|
|
84
|
+
is_url = source.startswith(("http://", "https://"))
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
if sys.stdout.isatty() and not json_output:
|
|
88
|
+
with Progress(
|
|
89
|
+
SpinnerColumn(),
|
|
90
|
+
TextColumn("[progress.description]{task.description}"),
|
|
91
|
+
BarColumn(),
|
|
92
|
+
DownloadColumn(),
|
|
93
|
+
TransferSpeedColumn(),
|
|
94
|
+
TimeRemainingColumn(),
|
|
95
|
+
console=console,
|
|
96
|
+
transient=True,
|
|
97
|
+
) as progress:
|
|
98
|
+
if is_url:
|
|
99
|
+
task = progress.add_task("Downloading...", total=None)
|
|
100
|
+
else:
|
|
101
|
+
task = progress.add_task("Reading...", total=None)
|
|
102
|
+
|
|
103
|
+
def update_progress(downloaded: int, total: int | None) -> None:
|
|
104
|
+
if total:
|
|
105
|
+
progress.update(task, completed=downloaded, total=total)
|
|
106
|
+
else:
|
|
107
|
+
progress.update(task, advance=0)
|
|
108
|
+
|
|
109
|
+
result = run_import(
|
|
110
|
+
source=source,
|
|
111
|
+
name_override=name,
|
|
112
|
+
namespace_override=namespace,
|
|
113
|
+
owner=owner,
|
|
114
|
+
tags=tag,
|
|
115
|
+
branch=branch,
|
|
116
|
+
force_schema=force_schema,
|
|
117
|
+
progress_callback=update_progress,
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
result = run_import(
|
|
121
|
+
source=source,
|
|
122
|
+
name_override=name,
|
|
123
|
+
namespace_override=namespace,
|
|
124
|
+
owner=owner,
|
|
125
|
+
tags=tag,
|
|
126
|
+
branch=branch,
|
|
127
|
+
force_schema=force_schema,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if isinstance(result, list):
|
|
131
|
+
for r in result:
|
|
132
|
+
_format_result(r, json_output, branch)
|
|
133
|
+
else:
|
|
134
|
+
_format_result(result, json_output, branch)
|
|
135
|
+
|
|
136
|
+
except FileNotFoundError as e:
|
|
137
|
+
if json_output:
|
|
138
|
+
print(json.dumps({"error": str(e)}))
|
|
139
|
+
else:
|
|
140
|
+
console.print(f"[red]File not found:[/red] {e}")
|
|
141
|
+
raise typer.Exit(1)
|
|
142
|
+
|
|
143
|
+
except ValueError as e:
|
|
144
|
+
if json_output:
|
|
145
|
+
print(json.dumps({"error": str(e)}))
|
|
146
|
+
else:
|
|
147
|
+
console.print(f"[red]Invalid input:[/red] {e}")
|
|
148
|
+
raise typer.Exit(1)
|
|
149
|
+
|
|
150
|
+
except Exception as e:
|
|
151
|
+
if json_output:
|
|
152
|
+
print(json.dumps({"error": str(e), "type": type(e).__name__}))
|
|
153
|
+
else:
|
|
154
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
155
|
+
if console.is_terminal:
|
|
156
|
+
console.print_exception()
|
|
157
|
+
raise typer.Exit(1)
|