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,317 @@
|
|
|
1
|
+
"""Discovery and caching of data-platform identity configuration.
|
|
2
|
+
|
|
3
|
+
graph-server publishes the identity settings a client needs at
|
|
4
|
+
``/ui/config.json``. That endpoint needs no auth, so a user configures one
|
|
5
|
+
setting — the graph-server base URL — and the rest is discovered. From the
|
|
6
|
+
``issuer``, standard OIDC discovery supplies the remaining OAuth2 endpoints.
|
|
7
|
+
|
|
8
|
+
Clients fetch this once and cache it locally with a TTL.
|
|
9
|
+
|
|
10
|
+
Payload (served by ``crates/graph-server/src/ui.rs``)::
|
|
11
|
+
|
|
12
|
+
{
|
|
13
|
+
"issuer": "https://auth.example.com/realms/sunstone",
|
|
14
|
+
"clientId": "example-cli",
|
|
15
|
+
"sparqlUrl": "/sparql",
|
|
16
|
+
"branch": "main",
|
|
17
|
+
"limits": {...}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
There is no schema version to check. Unknown keys are ignored and missing
|
|
21
|
+
keys become ``None``, so the server can add fields without breaking clients.
|
|
22
|
+
``sparqlUrl`` is served as a relative path and is resolved here against the
|
|
23
|
+
base URL, so callers always get an absolute URL.
|
|
24
|
+
|
|
25
|
+
This does *not* describe object storage. Warehouse location and S3/GCS
|
|
26
|
+
credentials are configured explicitly (env vars, ``.env``, or the
|
|
27
|
+
``data-platform`` section) — see :mod:`sunstone_data.config`.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
import os
|
|
35
|
+
import tempfile
|
|
36
|
+
import time
|
|
37
|
+
import urllib.error
|
|
38
|
+
import urllib.request
|
|
39
|
+
from dataclasses import asdict, dataclass, fields
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any
|
|
42
|
+
from urllib.parse import urljoin, urlsplit, urlunsplit
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
CONFIG_PATH = "/ui/config.json"
|
|
47
|
+
DEFAULT_TTL_SECONDS = 16 * 3600
|
|
48
|
+
FETCH_TIMEOUT_SECONDS = 10.0
|
|
49
|
+
|
|
50
|
+
#: TOML subtable name owned by the data-platform EnvSectionProvider.
|
|
51
|
+
SECTION_NAME = "data-platform"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class DiscoveredConfig:
|
|
56
|
+
"""Identity config published by graph-server, with TTL bookkeeping."""
|
|
57
|
+
|
|
58
|
+
issuer: str | None = None
|
|
59
|
+
"""OIDC issuer URL. Standard OIDC discovery on it supplies the rest."""
|
|
60
|
+
|
|
61
|
+
client_id: str | None = None
|
|
62
|
+
"""OAuth2 client id the CLI authenticates as."""
|
|
63
|
+
|
|
64
|
+
sparql_url: str | None = None
|
|
65
|
+
"""Absolute SPARQL endpoint URL, resolved against the graph-server base."""
|
|
66
|
+
|
|
67
|
+
fetched_at: float = 0.0
|
|
68
|
+
expires_at: float = 0.0
|
|
69
|
+
source_url: str = ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def discovery_url(graph_url: str) -> str:
|
|
73
|
+
"""Build the config URL for a graph-server base URL."""
|
|
74
|
+
parts = urlsplit(graph_url)
|
|
75
|
+
if not parts.scheme or not parts.netloc:
|
|
76
|
+
raise ValueError(f"Invalid graph-server URL: {graph_url!r}")
|
|
77
|
+
return urlunsplit((parts.scheme, parts.netloc, CONFIG_PATH, "", ""))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _cache_dir() -> Path:
|
|
81
|
+
base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
|
|
82
|
+
return Path(base) / "sunstone" / "data-platform"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cache_path(env_name: str) -> Path:
|
|
86
|
+
"""Per-env cache file path under XDG_CACHE_HOME (or ~/.cache)."""
|
|
87
|
+
return _cache_dir() / f"{env_name}.json"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _ttl_seconds() -> int:
|
|
91
|
+
raw = os.environ.get("SUNSTONE_DATA_DISCOVERY_TTL_SECONDS")
|
|
92
|
+
if raw is None:
|
|
93
|
+
return DEFAULT_TTL_SECONDS
|
|
94
|
+
try:
|
|
95
|
+
return max(0, int(raw))
|
|
96
|
+
except ValueError:
|
|
97
|
+
logger.warning("Invalid SUNSTONE_DATA_DISCOVERY_TTL_SECONDS=%r; using default", raw)
|
|
98
|
+
return DEFAULT_TTL_SECONDS
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _parse_payload(raw: bytes, source_url: str) -> dict:
|
|
102
|
+
try:
|
|
103
|
+
payload = json.loads(raw)
|
|
104
|
+
except json.JSONDecodeError as e:
|
|
105
|
+
raise ValueError(f"Discovery response from {source_url} is not valid JSON: {e}") from e
|
|
106
|
+
if not isinstance(payload, dict):
|
|
107
|
+
raise ValueError(f"Discovery response from {source_url} is not a JSON object")
|
|
108
|
+
return payload
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def fetch_discovery(graph_url: str, *, timeout: float = FETCH_TIMEOUT_SECONDS) -> DiscoveredConfig:
|
|
112
|
+
"""Fetch and parse graph-server's config payload. Raises on any failure."""
|
|
113
|
+
url = discovery_url(graph_url)
|
|
114
|
+
request = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
115
|
+
try:
|
|
116
|
+
with urllib.request.urlopen(request, timeout=timeout) as resp:
|
|
117
|
+
raw = resp.read()
|
|
118
|
+
except urllib.error.HTTPError as e:
|
|
119
|
+
raise RuntimeError(f"Discovery HTTP {e.code} from {url}: {e.reason}") from e
|
|
120
|
+
except urllib.error.URLError as e:
|
|
121
|
+
raise RuntimeError(f"Discovery request to {url} failed: {e.reason}") from e
|
|
122
|
+
except TimeoutError as e:
|
|
123
|
+
raise RuntimeError(f"Discovery request to {url} timed out after {timeout}s") from e
|
|
124
|
+
|
|
125
|
+
payload = _parse_payload(raw, url)
|
|
126
|
+
sparql = payload.get("sparqlUrl") or None
|
|
127
|
+
now = time.time()
|
|
128
|
+
return DiscoveredConfig(
|
|
129
|
+
issuer=payload.get("issuer") or None,
|
|
130
|
+
client_id=payload.get("clientId") or None,
|
|
131
|
+
# Served relative (``/sparql``); urljoin leaves an absolute one alone.
|
|
132
|
+
sparql_url=urljoin(url, sparql) if sparql else None,
|
|
133
|
+
fetched_at=now,
|
|
134
|
+
expires_at=now + _ttl_seconds(),
|
|
135
|
+
source_url=url,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _write_cache_atomic(path: Path, config: DiscoveredConfig) -> None:
|
|
140
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
|
142
|
+
try:
|
|
143
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
144
|
+
json.dump(asdict(config), f, indent=2)
|
|
145
|
+
os.replace(tmp_name, path)
|
|
146
|
+
except Exception:
|
|
147
|
+
Path(tmp_name).unlink(missing_ok=True)
|
|
148
|
+
raise
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def write_cache(env_name: str, config: DiscoveredConfig) -> Path:
|
|
152
|
+
"""Persist a discovered config to the per-env cache file."""
|
|
153
|
+
path = cache_path(env_name)
|
|
154
|
+
_write_cache_atomic(path, config)
|
|
155
|
+
return path
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def read_cache(env_name: str, *, ignore_expiry: bool = False) -> DiscoveredConfig | None:
|
|
159
|
+
"""Load a cached discovered config. Returns None if missing or expired."""
|
|
160
|
+
path = cache_path(env_name)
|
|
161
|
+
try:
|
|
162
|
+
with open(path, encoding="utf-8") as f:
|
|
163
|
+
data = json.load(f)
|
|
164
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
165
|
+
return None
|
|
166
|
+
if not isinstance(data, dict):
|
|
167
|
+
return None
|
|
168
|
+
field_names = {f.name for f in fields(DiscoveredConfig)}
|
|
169
|
+
kwargs = {k: v for k, v in data.items() if k in field_names}
|
|
170
|
+
try:
|
|
171
|
+
config = DiscoveredConfig(**kwargs)
|
|
172
|
+
except TypeError:
|
|
173
|
+
return None
|
|
174
|
+
if not ignore_expiry and config.expires_at and time.time() >= config.expires_at:
|
|
175
|
+
return None
|
|
176
|
+
return config
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _discovery_disabled() -> bool:
|
|
180
|
+
return os.environ.get("SUNSTONE_DATA_DISABLE_DISCOVERY", "").strip().lower() in ("1", "true", "yes")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def get_discovered(
|
|
184
|
+
env_name: str,
|
|
185
|
+
graph_url: str,
|
|
186
|
+
*,
|
|
187
|
+
force_refresh: bool = False,
|
|
188
|
+
) -> DiscoveredConfig | None:
|
|
189
|
+
"""Return cached discovered config, fetching if absent, stale, or forced.
|
|
190
|
+
|
|
191
|
+
Behavior:
|
|
192
|
+
- ``SUNSTONE_DATA_DISABLE_DISCOVERY=1``: short-circuits to ``None`` even
|
|
193
|
+
when forced (use to skip discovery in tests or offline runs).
|
|
194
|
+
- Cache hit (fresh) and not forced: return cached.
|
|
195
|
+
- Forced or cache miss/stale: fetch. On success, cache and return.
|
|
196
|
+
- Forced + fetch fails: raise.
|
|
197
|
+
- Unforced + fetch fails: log warning, return stale cache if any
|
|
198
|
+
(with ignore_expiry), else None.
|
|
199
|
+
"""
|
|
200
|
+
if _discovery_disabled():
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
if not force_refresh:
|
|
204
|
+
cached = read_cache(env_name)
|
|
205
|
+
if cached is not None:
|
|
206
|
+
return cached
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
config = fetch_discovery(graph_url)
|
|
210
|
+
except (RuntimeError, ValueError):
|
|
211
|
+
if force_refresh:
|
|
212
|
+
raise
|
|
213
|
+
stale = read_cache(env_name, ignore_expiry=True)
|
|
214
|
+
if stale is not None:
|
|
215
|
+
logger.warning("Discovery fetch failed; using stale cache for env %r", env_name)
|
|
216
|
+
return stale
|
|
217
|
+
logger.warning("Discovery fetch failed and no cache available for env %r", env_name)
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
write_cache(env_name, config)
|
|
221
|
+
return config
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def clear_cache(env_name: str | None = None) -> None:
|
|
225
|
+
"""Remove cache files. With no arg, removes all."""
|
|
226
|
+
if env_name is None:
|
|
227
|
+
directory = _cache_dir()
|
|
228
|
+
if directory.is_dir():
|
|
229
|
+
for p in directory.glob("*.json"):
|
|
230
|
+
p.unlink(missing_ok=True)
|
|
231
|
+
else:
|
|
232
|
+
cache_path(env_name).unlink(missing_ok=True)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass(frozen=True)
|
|
236
|
+
class InitResult:
|
|
237
|
+
"""Outcome of a single env in :func:`init_environments`."""
|
|
238
|
+
|
|
239
|
+
name: str
|
|
240
|
+
graph_url: str
|
|
241
|
+
refreshed: bool # True when discovery fetch succeeded
|
|
242
|
+
refresh_error: str | None # str on fetch failure, None otherwise
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _graph_url(env_def: dict[str, Any]) -> str | None:
|
|
246
|
+
"""Return an env definition's graph_url.
|
|
247
|
+
|
|
248
|
+
sunstone-py 1.12+ keeps it in the ``data-platform`` subtable. Top-level
|
|
249
|
+
keys are still accepted for configs written by older versions or by hand.
|
|
250
|
+
"""
|
|
251
|
+
section = env_def.get(SECTION_NAME) if isinstance(env_def.get(SECTION_NAME), dict) else {}
|
|
252
|
+
return section.get("graph_url") or env_def.get("graph_url")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def init_environments(
|
|
256
|
+
*,
|
|
257
|
+
user_config: Path | None = None,
|
|
258
|
+
system_config: Path | None = None,
|
|
259
|
+
project_config: Path | None = None,
|
|
260
|
+
) -> list[InitResult]:
|
|
261
|
+
"""Cache discovery for every configured environment that has a graph_url.
|
|
262
|
+
|
|
263
|
+
The package ships no environment URLs. Managed machines get them from the
|
|
264
|
+
system config (``/etc/sunstone/data_platform.toml``, delivered by Fleet
|
|
265
|
+
from the fleet-config repo); others declare them in user or project
|
|
266
|
+
config, or set ``GRAPH_URL``.
|
|
267
|
+
"""
|
|
268
|
+
from sunstone.env import list_environments
|
|
269
|
+
|
|
270
|
+
envs = list_environments(
|
|
271
|
+
system_config=system_config,
|
|
272
|
+
user_config=user_config,
|
|
273
|
+
project_config=project_config,
|
|
274
|
+
)
|
|
275
|
+
results: list[InitResult] = []
|
|
276
|
+
for name, env_def in envs.items():
|
|
277
|
+
graph_url = _graph_url(env_def)
|
|
278
|
+
if not graph_url:
|
|
279
|
+
continue
|
|
280
|
+
refreshed = False
|
|
281
|
+
refresh_error: str | None = None
|
|
282
|
+
try:
|
|
283
|
+
if get_discovered(name, graph_url, force_refresh=True) is not None:
|
|
284
|
+
refreshed = True
|
|
285
|
+
except (RuntimeError, ValueError) as e:
|
|
286
|
+
refresh_error = str(e)
|
|
287
|
+
results.append(InitResult(name=name, graph_url=graph_url, refreshed=refreshed, refresh_error=refresh_error))
|
|
288
|
+
return results
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def refresh_environment(
|
|
292
|
+
name: str,
|
|
293
|
+
*,
|
|
294
|
+
user_config: Path | None = None,
|
|
295
|
+
system_config: Path | None = None,
|
|
296
|
+
project_config: Path | None = None,
|
|
297
|
+
) -> DiscoveredConfig:
|
|
298
|
+
"""Force a discovery fetch and cache write for the named env.
|
|
299
|
+
|
|
300
|
+
Raises if the env is not defined, lacks a graph_url, or the fetch fails.
|
|
301
|
+
"""
|
|
302
|
+
from sunstone.env import list_environments
|
|
303
|
+
|
|
304
|
+
envs = list_environments(
|
|
305
|
+
system_config=system_config,
|
|
306
|
+
user_config=user_config,
|
|
307
|
+
project_config=project_config,
|
|
308
|
+
)
|
|
309
|
+
if name not in envs:
|
|
310
|
+
raise ValueError(f"Environment '{name}' is not defined in any config file")
|
|
311
|
+
graph_url = _graph_url(envs[name])
|
|
312
|
+
if not graph_url:
|
|
313
|
+
raise ValueError(f"Environment '{name}' has no graph_url")
|
|
314
|
+
config = get_discovered(name, graph_url, force_refresh=True)
|
|
315
|
+
if config is None:
|
|
316
|
+
raise RuntimeError(f"Discovery for '{name}' returned no config")
|
|
317
|
+
return config
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Register data-platform-specific commands on sunstone-py's ``env`` CLI group.
|
|
2
|
+
|
|
3
|
+
Importing this module attaches ``init`` and ``refresh`` subcommands to
|
|
4
|
+
``sunstone.cli.env_app``. The base sunstone-py library knows nothing about
|
|
5
|
+
catalogs or discovery; those concerns live here in sunstone-data.
|
|
6
|
+
|
|
7
|
+
The plugin module (:mod:`sunstone_data.plugin`) imports this at module load
|
|
8
|
+
so registration happens during entry-point discovery, before sunstone-py's
|
|
9
|
+
top-level CLI dispatches.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_registered = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _register() -> None:
|
|
22
|
+
global _registered
|
|
23
|
+
if _registered:
|
|
24
|
+
return
|
|
25
|
+
# Set the flag before any work so we never re-enter, even if imports raise.
|
|
26
|
+
_registered = True
|
|
27
|
+
try:
|
|
28
|
+
import typer
|
|
29
|
+
from sunstone.cli import env_app
|
|
30
|
+
except Exception:
|
|
31
|
+
# sunstone-py CLI is not available in this process (e.g. library-only
|
|
32
|
+
# usage). Nothing to register; this is fine.
|
|
33
|
+
logger.debug("sunstone.cli.env_app not available; skipping env command registration", exc_info=True)
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
from sunstone_data.discovery import init_environments, refresh_environment
|
|
37
|
+
|
|
38
|
+
@env_app.command("init")
|
|
39
|
+
def env_init() -> None: # noqa: D401 — Typer command docstring becomes --help text
|
|
40
|
+
"""Cache discovery for every configured environment.
|
|
41
|
+
|
|
42
|
+
Environments come from config files, not from this package. Managed
|
|
43
|
+
machines get them from /etc/sunstone/data_platform.toml.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
results = init_environments()
|
|
47
|
+
except (OSError, RuntimeError) as e:
|
|
48
|
+
typer.echo(f"Error: {e}", err=True)
|
|
49
|
+
raise typer.Exit(1)
|
|
50
|
+
|
|
51
|
+
if not results:
|
|
52
|
+
typer.echo(
|
|
53
|
+
"No environment with a graph_url is configured. Managed machines get one from "
|
|
54
|
+
"/etc/sunstone/data_platform.toml; otherwise add one with 'sunstone env add'.",
|
|
55
|
+
err=True,
|
|
56
|
+
)
|
|
57
|
+
raise typer.Exit(1)
|
|
58
|
+
|
|
59
|
+
any_refresh_failed = False
|
|
60
|
+
for r in results:
|
|
61
|
+
if r.refreshed:
|
|
62
|
+
typer.echo(f"Discovery cached for '{r.name}' ({r.graph_url})")
|
|
63
|
+
else:
|
|
64
|
+
any_refresh_failed = True
|
|
65
|
+
detail = f": {r.refresh_error}" if r.refresh_error else ""
|
|
66
|
+
typer.echo(f"Warning: discovery fetch failed for '{r.name}'{detail}", err=True)
|
|
67
|
+
|
|
68
|
+
if any_refresh_failed:
|
|
69
|
+
typer.echo(
|
|
70
|
+
"Re-run with 'sunstone env refresh <name>' once graph-server is reachable.",
|
|
71
|
+
err=True,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@env_app.command("refresh")
|
|
75
|
+
def env_refresh(
|
|
76
|
+
name: str = typer.Argument(..., help="Environment name to refresh"),
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Force-fetch graph-server's config payload and update the cache."""
|
|
79
|
+
try:
|
|
80
|
+
config = refresh_environment(name)
|
|
81
|
+
except (OSError, RuntimeError, ValueError) as e:
|
|
82
|
+
typer.echo(f"Error: {e}", err=True)
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
typer.echo(f"Refreshed discovery for '{name}' from {config.source_url}")
|
|
85
|
+
if config.issuer:
|
|
86
|
+
typer.echo(f" issuer: {config.issuer}")
|
|
87
|
+
if config.client_id:
|
|
88
|
+
typer.echo(f" client id: {config.client_id}")
|
|
89
|
+
if config.sparql_url:
|
|
90
|
+
typer.echo(f" sparql: {config.sparql_url}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Register at module import so the env commands attach during entry-point
|
|
94
|
+
# discovery, before the top-level CLI dispatches the user's command. The
|
|
95
|
+
# idempotency guard above makes any later call (e.g. from
|
|
96
|
+
# ``SunstoneDataPlugin.cli_groups``) a safe no-op.
|
|
97
|
+
_register()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Typed environment section schema for the data platform.
|
|
2
|
+
|
|
3
|
+
sunstone-py 1.12+ replaces the typed ``DataEnvironment`` with a generic
|
|
4
|
+
``Environment(name, source, vars, sections)``. Plugins own typed slices of
|
|
5
|
+
the config via the ``EnvSectionProvider`` protocol: each plugin claims a
|
|
6
|
+
TOML subtable name and returns a callable that validates the subtable.
|
|
7
|
+
|
|
8
|
+
This module defines the ``data-platform`` section. A user config that
|
|
9
|
+
declares::
|
|
10
|
+
|
|
11
|
+
[environments.dev."data-platform"]
|
|
12
|
+
graph_url = "https://graph.example.com"
|
|
13
|
+
s3_endpoint = "http://localhost:9000"
|
|
14
|
+
s3_access_key = "rustfsadmin"
|
|
15
|
+
s3_secret_key = "rustfsadmin12345"
|
|
16
|
+
|
|
17
|
+
is resolved by sunstone-py into ``env.section("data-platform")``
|
|
18
|
+
returning a :class:`DataPlatformSection`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Callable
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class DataPlatformSection:
|
|
29
|
+
"""Typed data-platform config slice from the active environment.
|
|
30
|
+
|
|
31
|
+
All fields are optional; consumers should treat unset (``None``)
|
|
32
|
+
fields as "fall back to ``os.environ`` or a built-in default".
|
|
33
|
+
|
|
34
|
+
Extra unknown keys in the TOML subtable raise ``TypeError`` at
|
|
35
|
+
construction time, which sunstone-py wraps into a ``ValueError``
|
|
36
|
+
indicating which environment / section caused the failure.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
graph_url: str | None = None
|
|
40
|
+
"""graph-server base URL — the one setting a user has to configure.
|
|
41
|
+
|
|
42
|
+
``GET {graph_url}/ui/config.json`` supplies the identity settings
|
|
43
|
+
(``issuer``, ``clientId``, ``sparqlUrl``); see
|
|
44
|
+
:mod:`sunstone_data.discovery`.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
client_id: str | None = None
|
|
48
|
+
"""OAuth2 client id for the client-credentials (service) auth flow.
|
|
49
|
+
|
|
50
|
+
Pair with ``client_secret``. Setting both makes the client authenticate
|
|
51
|
+
as a service (spec §4 Pattern B) instead of using a cached human login,
|
|
52
|
+
so CI is never silently authenticated as whoever last logged in.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
client_secret: str | None = None
|
|
56
|
+
"""OAuth2 client secret. Pair with ``client_id``."""
|
|
57
|
+
|
|
58
|
+
s3_endpoint: str | None = None
|
|
59
|
+
"""S3-compatible endpoint URL (e.g. RustFS).
|
|
60
|
+
|
|
61
|
+
Object storage is not discoverable — graph-server publishes nothing
|
|
62
|
+
about it — but PyIceberg and DuckDB read data files straight from the
|
|
63
|
+
bucket, so these stay user-configurable.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
s3_access_key: str | None = None
|
|
67
|
+
"""S3 access key id. Pair with ``s3_secret_key``."""
|
|
68
|
+
|
|
69
|
+
s3_secret_key: str | None = None
|
|
70
|
+
"""S3 secret access key. Pair with ``s3_access_key``."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class DataPlatformSectionProvider:
|
|
74
|
+
"""``EnvSectionProvider`` implementation for the data-platform plugin.
|
|
75
|
+
|
|
76
|
+
Registered via the ``sunstone.plugins`` entry point as part of
|
|
77
|
+
:class:`sunstone_data.plugin.SunstoneDataPlugin`.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
SECTION_NAME = "data-platform"
|
|
81
|
+
|
|
82
|
+
def env_section_name(self) -> str:
|
|
83
|
+
return self.SECTION_NAME
|
|
84
|
+
|
|
85
|
+
def env_section_model(self) -> Callable[..., DataPlatformSection]:
|
|
86
|
+
return DataPlatformSection
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""RDF graph vocabulary and canonical serialization.
|
|
2
|
+
|
|
3
|
+
`terms` holds the namespace IRIs for every RDF vocabulary this project uses.
|
|
4
|
+
`dataset` is the only place that turns a set of triples into bytes: sorted
|
|
5
|
+
N-Triples, with deterministic fragment IRIs standing in for blank nodes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from sunstone_data.graph.dataset import (
|
|
9
|
+
IRI,
|
|
10
|
+
DatasetDescription,
|
|
11
|
+
DatasetVersion,
|
|
12
|
+
Graph,
|
|
13
|
+
Literal,
|
|
14
|
+
Triple,
|
|
15
|
+
build_graph,
|
|
16
|
+
description_to_graph,
|
|
17
|
+
fragment_iri,
|
|
18
|
+
graph_to_description,
|
|
19
|
+
parse_ntriples,
|
|
20
|
+
serialize,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"IRI",
|
|
25
|
+
"DatasetDescription",
|
|
26
|
+
"DatasetVersion",
|
|
27
|
+
"Graph",
|
|
28
|
+
"Literal",
|
|
29
|
+
"Triple",
|
|
30
|
+
"build_graph",
|
|
31
|
+
"description_to_graph",
|
|
32
|
+
"fragment_iri",
|
|
33
|
+
"graph_to_description",
|
|
34
|
+
"parse_ntriples",
|
|
35
|
+
"serialize",
|
|
36
|
+
]
|