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,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)
@@ -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,5 @@
1
+ """CLI module for Sunstone Data Platform."""
2
+
3
+ from sunstone_data.cli.main import app
4
+
5
+ __all__ = ["app"]
@@ -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)