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.
Files changed (66) hide show
  1. sunstone_data/__init__.py +36 -0
  2. sunstone_data/_version.py +24 -0
  3. sunstone_data/auth/__init__.py +43 -0
  4. sunstone_data/auth/client_creds.py +239 -0
  5. sunstone_data/auth/pkce.py +307 -0
  6. sunstone_data/auth/store.py +97 -0
  7. sunstone_data/catalog.py +175 -0
  8. sunstone_data/cli/__init__.py +5 -0
  9. sunstone_data/cli/_app.py +26 -0
  10. sunstone_data/cli/_errors.py +31 -0
  11. sunstone_data/cli/commands/__init__.py +22 -0
  12. sunstone_data/cli/commands/data_auth.py +120 -0
  13. sunstone_data/cli/commands/data_import.py +157 -0
  14. sunstone_data/cli/commands/data_info.py +166 -0
  15. sunstone_data/cli/commands/data_publish.py +69 -0
  16. sunstone_data/cli/commands/ls.py +49 -0
  17. sunstone_data/cli/commands/prov.py +58 -0
  18. sunstone_data/cli/commands/refs.py +142 -0
  19. sunstone_data/cli/commands/show.py +139 -0
  20. sunstone_data/cli/commands/snapshots.py +77 -0
  21. sunstone_data/cli/commands/sparql.py +68 -0
  22. sunstone_data/cli/main.py +30 -0
  23. sunstone_data/config.py +250 -0
  24. sunstone_data/discovery.py +317 -0
  25. sunstone_data/env_commands.py +97 -0
  26. sunstone_data/env_section.py +86 -0
  27. sunstone_data/graph/__init__.py +36 -0
  28. sunstone_data/graph/client.py +328 -0
  29. sunstone_data/graph/dataset.py +438 -0
  30. sunstone_data/graph/terms.py +34 -0
  31. sunstone_data/identifiers.py +155 -0
  32. sunstone_data/import_/__init__.py +28 -0
  33. sunstone_data/import_/fetcher.py +259 -0
  34. sunstone_data/import_/owner.py +130 -0
  35. sunstone_data/import_/parser.py +492 -0
  36. sunstone_data/import_/pipeline.py +275 -0
  37. sunstone_data/import_/storage.py +135 -0
  38. sunstone_data/metadata/__init__.py +41 -0
  39. sunstone_data/metadata/builder.py +276 -0
  40. sunstone_data/metadata/models.py +87 -0
  41. sunstone_data/metadata/provenance.py +171 -0
  42. sunstone_data/metadata/provenance_rdf.py +102 -0
  43. sunstone_data/plugin.py +62 -0
  44. sunstone_data/publish.py +134 -0
  45. sunstone_data/query.py +624 -0
  46. sunstone_data/resolve.py +178 -0
  47. sunstone_data/url.py +78 -0
  48. sunstone_data/utils/__init__.py +12 -0
  49. sunstone_data/utils/checksum.py +14 -0
  50. sunstone_data/utils/encoding.py +16 -0
  51. sunstone_data/utils/naming.py +38 -0
  52. sunstone_data/versioning/__init__.py +17 -0
  53. sunstone_data/versioning/schema_evolution.py +180 -0
  54. sunstone_data_platform-1.0.0.dist-info/METADATA +302 -0
  55. sunstone_data_platform-1.0.0.dist-info/RECORD +66 -0
  56. sunstone_data_platform-1.0.0.dist-info/WHEEL +4 -0
  57. sunstone_data_platform-1.0.0.dist-info/entry_points.txt +2 -0
  58. sunstone_pipeline/__init__.py +1 -0
  59. sunstone_pipeline/assets/__init__.py +11 -0
  60. sunstone_pipeline/assets/mirrors.py +123 -0
  61. sunstone_pipeline/assets/transforms.py +74 -0
  62. sunstone_pipeline/definitions.py +25 -0
  63. sunstone_pipeline/io_manager.py +56 -0
  64. sunstone_pipeline/resources/__init__.py +6 -0
  65. sunstone_pipeline/resources/iceberg.py +29 -0
  66. sunstone_pipeline/resources/refs.py +88 -0
@@ -0,0 +1,77 @@
1
+ """``sunstone data history`` — the Iceberg snapshot log (spec 180 §8).
2
+
3
+ This reads Iceberg table metadata through the catalog, not the graph server.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+
10
+ import typer
11
+
12
+ from sunstone_data.cli._app import console, data_app
13
+ from sunstone_data.cli._errors import cli_errors
14
+
15
+
16
+ @data_app.command("history")
17
+ def history_cmd(
18
+ table: str = typer.Argument(..., help="Table reference (namespace.table[@branch])"),
19
+ branch: str = typer.Option("main", "--branch", "-b", help="Catalog branch to read"),
20
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
21
+ ) -> None:
22
+ """Show a table's Iceberg snapshot log, oldest snapshot first.
23
+
24
+ Examples:
25
+ sunstone data history un.health.cases
26
+ sunstone data history un.health.cases@experiment --json
27
+ """
28
+ with cli_errors(json_output):
29
+ from datetime import datetime, timezone
30
+
31
+ from sunstone_data.catalog import get_catalog
32
+ from sunstone_data.query import TableRef
33
+
34
+ ref = TableRef.parse(table)
35
+ iceberg_table = get_catalog(ref.version_ref or branch).load_table(f"{ref.namespace}.{ref.table}")
36
+
37
+ rows = []
38
+ for entry in iceberg_table.history():
39
+ snapshot = iceberg_table.snapshot_by_id(entry.snapshot_id)
40
+ summary = getattr(snapshot, "summary", None)
41
+ operation = getattr(summary, "operation", None)
42
+ parent = getattr(snapshot, "parent_snapshot_id", None)
43
+ rows.append(
44
+ {
45
+ "snapshot_id": str(entry.snapshot_id),
46
+ "parent_snapshot_id": str(parent) if parent is not None else None,
47
+ "timestamp": datetime.fromtimestamp(entry.timestamp_ms / 1000, tz=timezone.utc).isoformat(),
48
+ "operation": getattr(operation, "value", operation),
49
+ "summary": dict(getattr(summary, "additional_properties", {}) or {}),
50
+ }
51
+ )
52
+
53
+ if json_output:
54
+ print(json.dumps(rows, indent=2))
55
+ return
56
+
57
+ if not rows:
58
+ console.print("[yellow]No snapshots found[/yellow]")
59
+ return
60
+
61
+ from rich.table import Table
62
+
63
+ out = Table(box=None, pad_edge=False)
64
+ out.add_column("Timestamp", style="cyan")
65
+ out.add_column("Snapshot")
66
+ out.add_column("Parent")
67
+ out.add_column("Operation", style="magenta")
68
+ out.add_column("Records added", justify="right")
69
+ for row in rows:
70
+ out.add_row(
71
+ row["timestamp"],
72
+ row["snapshot_id"],
73
+ row["parent_snapshot_id"] or "",
74
+ row["operation"] or "",
75
+ row["summary"].get("added-records", ""),
76
+ )
77
+ console.print(out)
@@ -0,0 +1,68 @@
1
+ """``sunstone data sparql`` — run a query through the graph-server proxy (spec 180 §8).
2
+
3
+ `execute_query` is also what ``sunstone data lineage`` runs its PROV query
4
+ with, so both commands render SPARQL results the same way.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import typer
10
+
11
+ from sunstone_data.cli._app import console, data_app
12
+ from sunstone_data.cli._errors import cli_errors
13
+
14
+
15
+ def execute_query(query: str, json_output: bool, empty_message: str = "No results") -> None:
16
+ """Run `query` through the proxy and write the answer to stdout.
17
+
18
+ With `json_output` the response body is passed through untouched. A
19
+ SPARQL results document is rendered as a table, an ASK answer as
20
+ ``true``/``false``, and anything else (CONSTRUCT, DESCRIBE, an error
21
+ page) as its raw text.
22
+ """
23
+ from sunstone_data.graph.client import get_graph_client
24
+
25
+ response = get_graph_client().sparql(query)
26
+
27
+ if json_output:
28
+ print(response.text)
29
+ return
30
+
31
+ try:
32
+ payload = response.json()
33
+ except ValueError:
34
+ print(response.text)
35
+ return
36
+
37
+ if "boolean" in payload:
38
+ console.print(str(payload["boolean"]).lower())
39
+ return
40
+
41
+ bindings = payload.get("results", {}).get("bindings", [])
42
+ if not bindings:
43
+ console.print(f"[yellow]{empty_message}[/yellow]")
44
+ return
45
+
46
+ from rich.table import Table
47
+
48
+ variables = payload.get("head", {}).get("vars") or sorted({k for b in bindings for k in b})
49
+ table = Table(box=None, pad_edge=False)
50
+ for variable in variables:
51
+ table.add_column(variable, style="cyan", overflow="fold")
52
+ for binding in bindings:
53
+ table.add_row(*(binding.get(v, {}).get("value", "") for v in variables))
54
+ console.print(table)
55
+
56
+
57
+ @data_app.command("sparql")
58
+ def sparql_cmd(
59
+ query: str = typer.Argument(..., help="SPARQL query text"),
60
+ json_output: bool = typer.Option(False, "--json", help="Print the raw response body"),
61
+ ) -> None:
62
+ """Run a SPARQL query through the graph-server proxy.
63
+
64
+ Examples:
65
+ sunstone data sparql 'SELECT * WHERE { GRAPH ?g { ?s ?p ?o } } LIMIT 10'
66
+ """
67
+ with cli_errors(json_output):
68
+ execute_query(query, json_output)
@@ -0,0 +1,30 @@
1
+ """Entry point for the ``sunstone`` CLI.
2
+
3
+ Kept intentionally thin so that ``sunstone`` / ``sunstone --help`` and the
4
+ sunstone-py-owned subcommands (``sunstone env`` etc.) don't pay the cost
5
+ of pulling pyiceberg, pandas, s3fs, and the rest of the data-platform
6
+ stack just to print a usage line.
7
+
8
+ Layout:
9
+
10
+ * :mod:`sunstone_data.cli._app` — defines ``app``, ``data_app``, ``console``
11
+ (Typer + Rich only, ~1ms to import).
12
+ * :mod:`sunstone_data.cli.commands` — one module per ``sunstone data ...``
13
+ subcommand. Each module registers its command at import time via
14
+ ``@data_app.command(...)`` and lazy-imports its heavy dependencies
15
+ inside the function body.
16
+
17
+ This module re-exports ``app`` (the entry point) and ``data_app`` (used by
18
+ :mod:`sunstone_data.plugin`).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from sunstone_data.cli import commands # noqa: F401 (import side-effect: registers commands)
24
+ from sunstone_data.cli._app import app, console, data_app
25
+
26
+ __all__ = ["app", "console", "data_app"]
27
+
28
+
29
+ if __name__ == "__main__":
30
+ app()
@@ -0,0 +1,250 @@
1
+ """Configuration loading for Sunstone Data Platform."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from dataclasses import dataclass
8
+ from functools import lru_cache
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from dotenv import load_dotenv
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Settings:
19
+ """Platform configuration settings."""
20
+
21
+ # Iceberg REST catalog
22
+ catalog_uri: str = "http://localhost:8181"
23
+ warehouse: str = "s3://iceberg/"
24
+
25
+ # graph-server base URL. Identity settings are discovered from it at
26
+ # ``/ui/config.json``; see sunstone_data.discovery.
27
+ graph_url: str = ""
28
+
29
+ # OAuth2 client-credentials (service) identity. Setting both selects the
30
+ # client-credentials flow over any cached human login; see
31
+ # sunstone_data.auth.client_creds.
32
+ client_id: str = ""
33
+ client_secret: str = ""
34
+
35
+ # S3-compatible storage (RustFS locally, Hetzner/R2 remote)
36
+ s3_endpoint: str = "http://localhost:9000"
37
+ s3_access_key: str = ""
38
+ s3_secret_key: str = ""
39
+ s3_region: str = "us-east-1" # "auto" for Cloudflare R2
40
+
41
+ # GCS storage
42
+ gcs_project_id: str = ""
43
+ gcs_endpoint: str = "" # empty = real GCS; set for fake-gcs-server
44
+
45
+ # Data-owner organization modeling (import + org tree)
46
+ org_roots: tuple[str, ...] = ()
47
+ owner_rules_path: str = ""
48
+ org_base_uri: str = "https://sunstone.institute/org"
49
+
50
+ # Base IRI the Iceberg REST catalog mints dataset IRIs under. Must match
51
+ # the catalog's own DATA_BASE_IRI (CatalogConfig::from_env in
52
+ # crates/iceberg-catalog/src/app.rs, same default), or named-version
53
+ # lookups address a graph the catalog never wrote.
54
+ data_base_iri: str = "https://data.sunstoneinstitute.ai"
55
+
56
+ @property
57
+ def storage_backend(self) -> str:
58
+ """Return 'gcs' for gs:// warehouses, 's3' otherwise."""
59
+ return "gcs" if self.warehouse.startswith("gs://") else "s3"
60
+
61
+
62
+ def _parse_org_roots(raw: str) -> tuple[str, ...]:
63
+ """Split a comma-separated ``SUNSTONE_ORG_ROOTS`` value into a tuple.
64
+
65
+ Empty / unset yields an empty tuple, which disables root-allowlist
66
+ enforcement (any owner root is accepted).
67
+ """
68
+ return tuple(part.strip() for part in raw.split(",") if part.strip())
69
+
70
+
71
+ def _r2_defaults() -> dict[str, str]:
72
+ """Derive S3 client settings from ``R2_*`` env vars (docker ``r2`` profile).
73
+
74
+ The ``r2`` docker-compose profile is configured entirely from four
75
+ ``R2_*`` variables (see ``.env.r2.example``). A PyIceberg/DuckDB client
76
+ reads Iceberg data files directly from object storage, so it needs the
77
+ same bucket, endpoint, and credentials Nessie was given. Rather than make
78
+ R2 users duplicate those four values into ``S3_*``/``ICEBERG_WAREHOUSE``,
79
+ derive them here. Explicit ``S3_*``/``ICEBERG_WAREHOUSE``/``CATALOG_URI``
80
+ env vars still take precedence over these derived defaults.
81
+
82
+ Returns an empty dict when ``R2_ACCOUNT_ID``/``R2_BUCKET`` are not set, so
83
+ non-R2 setups are unaffected.
84
+ """
85
+ account_id = os.getenv("R2_ACCOUNT_ID", "")
86
+ bucket = os.getenv("R2_BUCKET", "")
87
+ if not (account_id and bucket):
88
+ return {}
89
+ return {
90
+ "catalog_uri": "http://localhost:19122",
91
+ "warehouse": f"s3://{bucket}/",
92
+ "s3_endpoint": f"https://{account_id}.r2.cloudflarestorage.com",
93
+ "s3_access_key": os.getenv("R2_ACCESS_KEY_ID", ""),
94
+ "s3_secret_key": os.getenv("R2_SECRET_ACCESS_KEY", ""),
95
+ "s3_region": "auto", # R2 ignores region but the SDK requires a value
96
+ }
97
+
98
+
99
+ def _try_resolve_environment() -> Any | None:
100
+ """Resolve the active environment via sunstone-py.
101
+
102
+ Returns an ``Environment`` (sunstone-py 1.12+) if available, else None.
103
+ Never raises — missing sunstone-py or resolution errors are logged at
104
+ debug level and return None so the caller falls back to ``.env`` files.
105
+ """
106
+ try:
107
+ from sunstone.env import resolve_environment
108
+
109
+ return resolve_environment()
110
+ except ImportError:
111
+ logger.debug("sunstone-py not installed, skipping environment resolution")
112
+ return None
113
+ except Exception:
114
+ logger.debug("Failed to resolve environment", exc_info=True)
115
+ return None
116
+
117
+
118
+ def current_environment_name() -> str:
119
+ """Name of the active sunstone-py environment, or ``"default"`` if none is active.
120
+
121
+ Per-environment caches (discovery, login sessions) are keyed by this name.
122
+ """
123
+ env = _try_resolve_environment()
124
+ return getattr(env, "name", None) or "default"
125
+
126
+
127
+ def _section_field(env: Any, attr: str) -> str | None:
128
+ """Read ``attr`` from the data-platform section if present.
129
+
130
+ Returns ``None`` when either the section is missing (legacy config that
131
+ only declared top-level keys) or the field on the section is unset.
132
+ Callers should fall back to ``os.environ`` in that case.
133
+ """
134
+ try:
135
+ section = env.section("data-platform")
136
+ except KeyError:
137
+ return None
138
+ value = getattr(section, attr, None)
139
+ return value or None
140
+
141
+
142
+ @lru_cache(maxsize=1)
143
+ def get_settings() -> Settings:
144
+ """Load settings, preferring sunstone-py's active environment over ``.env`` vars.
145
+
146
+ Resolution order (highest precedence wins):
147
+ 1. Real ``os.environ`` entries set by the user / CI.
148
+ 2. The active sunstone-py ``Environment``'s ``data-platform`` section.
149
+ 3. ``.env`` file in the project root.
150
+ 4. Built-in defaults.
151
+
152
+ Storage settings are not discovered. graph-server's ``/ui/config.json``
153
+ publishes identity settings only, so ``warehouse`` and the S3/GCS fields
154
+ resolve from the section, env vars, or built-in defaults.
155
+
156
+ Legacy configs that declared ``graph_url`` etc. at the *top level* of an
157
+ ``[environments.<name>]`` block (rather than under
158
+ ``[environments.<name>."data-platform"]``) are handled transparently:
159
+ sunstone-py's ``Environment.activate()`` flattens top-level scalars into
160
+ ``os.environ`` (e.g. ``CATALOG_URL``, ``S3_ENDPOINT``) so the ``os.environ``
161
+ fallback covers them. Top-level CLI callbacks call ``activate_environment``
162
+ automatically; library callers that want the same behaviour should call
163
+ ``sunstone.env.activate_environment()`` once before ``get_settings()``.
164
+
165
+ Settings are cached after first load. Call ``get_settings.cache_clear()``
166
+ to force a reload (e.g. in tests).
167
+ """
168
+ env = _try_resolve_environment()
169
+ if env is not None:
170
+ # The data-platform section is the new home for typed fields. Each
171
+ # lookup falls back to the corresponding flattened env var so that
172
+ # both new section-style and legacy top-level configs work.
173
+ graph_url = _section_field(env, "graph_url") or os.environ.get("GRAPH_URL") or ""
174
+ client_id = _section_field(env, "client_id") or os.environ.get("SUNSTONE_CLIENT_ID") or ""
175
+ client_secret = _section_field(env, "client_secret") or os.environ.get("SUNSTONE_CLIENT_SECRET") or ""
176
+ section_s3_endpoint = _section_field(env, "s3_endpoint")
177
+ section_s3_access_key = _section_field(env, "s3_access_key")
178
+ section_s3_secret_key = _section_field(env, "s3_secret_key")
179
+
180
+ # The Iceberg REST catalog is a different service from graph_url, so
181
+ # it keeps its own env var rather than being derived from the graph
182
+ # URL. ``CATALOG_URL`` is a legacy flattened spelling, still read.
183
+ catalog_uri = os.environ.get("CATALOG_URI") or os.environ.get("CATALOG_URL") or "http://localhost:8181"
184
+ warehouse = os.getenv("ICEBERG_WAREHOUSE") or "s3://iceberg/"
185
+ gcs_project_id = os.getenv("GCS_PROJECT_ID") or ""
186
+ gcs_endpoint = os.getenv("GCS_ENDPOINT") or ""
187
+ s3_endpoint = section_s3_endpoint or os.environ.get("S3_ENDPOINT") or ""
188
+ s3_access_key = section_s3_access_key or os.environ.get("S3_ACCESS_KEY") or ""
189
+ s3_secret_key = section_s3_secret_key or os.environ.get("S3_SECRET_KEY") or ""
190
+ s3_region = os.environ.get("S3_REGION") or "us-east-1"
191
+
192
+ return Settings(
193
+ catalog_uri=catalog_uri,
194
+ warehouse=warehouse,
195
+ graph_url=graph_url,
196
+ client_id=client_id,
197
+ client_secret=client_secret,
198
+ s3_endpoint=s3_endpoint,
199
+ s3_access_key=s3_access_key,
200
+ s3_secret_key=s3_secret_key,
201
+ s3_region=s3_region,
202
+ gcs_project_id=gcs_project_id,
203
+ gcs_endpoint=gcs_endpoint,
204
+ org_roots=_parse_org_roots(os.getenv("SUNSTONE_ORG_ROOTS", "")),
205
+ owner_rules_path=os.getenv("SUNSTONE_OWNER_RULES_PATH", ""),
206
+ org_base_uri=os.getenv("SUNSTONE_ORG_BASE_URI", "https://sunstone.institute/org"),
207
+ data_base_iri=os.getenv("DATA_BASE_IRI", "https://data.sunstoneinstitute.ai"),
208
+ )
209
+
210
+ # Fallback: load from .env file
211
+ project_root = Path(__file__).parent.parent.parent
212
+ root_env = project_root / ".env"
213
+
214
+ if root_env.exists():
215
+ load_dotenv(root_env)
216
+
217
+ # R2 profile: a present R2_* set seeds the warehouse/endpoint/creds the
218
+ # client needs. Explicit S3_*/ICEBERG_WAREHOUSE/CATALOG_URI vars override.
219
+ r2 = _r2_defaults()
220
+
221
+ catalog_uri = os.getenv("CATALOG_URI") or r2.get("catalog_uri") or "http://localhost:8181"
222
+ return Settings(
223
+ catalog_uri=catalog_uri,
224
+ warehouse=os.getenv("ICEBERG_WAREHOUSE") or r2.get("warehouse") or "s3://iceberg/",
225
+ graph_url=os.getenv("GRAPH_URL", ""),
226
+ client_id=os.getenv("SUNSTONE_CLIENT_ID", ""),
227
+ client_secret=os.getenv("SUNSTONE_CLIENT_SECRET", ""),
228
+ s3_endpoint=os.getenv("S3_ENDPOINT") or r2.get("s3_endpoint") or "http://localhost:9000",
229
+ s3_access_key=(
230
+ os.getenv("S3_ACCESS_KEY")
231
+ or r2.get("s3_access_key")
232
+ or os.getenv("OBJECT_STORE_ACCESS_KEY")
233
+ or os.getenv("AWS_ACCESS_KEY_ID")
234
+ or ""
235
+ ),
236
+ s3_secret_key=(
237
+ os.getenv("S3_SECRET_KEY")
238
+ or r2.get("s3_secret_key")
239
+ or os.getenv("OBJECT_STORE_SECRET_KEY")
240
+ or os.getenv("AWS_SECRET_ACCESS_KEY")
241
+ or ""
242
+ ),
243
+ s3_region=os.getenv("S3_REGION") or r2.get("s3_region") or "us-east-1",
244
+ gcs_project_id=os.getenv("GCS_PROJECT_ID", ""),
245
+ gcs_endpoint=os.getenv("GCS_ENDPOINT", ""),
246
+ org_roots=_parse_org_roots(os.getenv("SUNSTONE_ORG_ROOTS", "")),
247
+ owner_rules_path=os.getenv("SUNSTONE_OWNER_RULES_PATH", ""),
248
+ org_base_uri=os.getenv("SUNSTONE_ORG_BASE_URI", "https://sunstone.institute/org"),
249
+ data_base_iri=os.getenv("DATA_BASE_IRI", "https://data.sunstoneinstitute.ai"),
250
+ )