sunstone-data-platform 1.2.0__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/.gitignore +1 -0
  2. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/PKG-INFO +7 -4
  3. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/README.md +5 -3
  4. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/pyproject.toml +1 -0
  5. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/_version.py +2 -2
  6. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/auth/__init__.py +3 -2
  7. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/auth/pkce.py +1 -1
  8. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/auth/stepup.py +1 -1
  9. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/auth/store.py +65 -8
  10. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/cli/_app.py +4 -1
  11. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/cli/commands/data_auth.py +9 -9
  12. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/cli/main.py +4 -4
  13. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/config.py +9 -2
  14. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/env_section.py +7 -0
  15. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/graph/client.py +2 -2
  16. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/plugin.py +4 -4
  17. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/__init__.py +0 -0
  18. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/auth/client_creds.py +0 -0
  19. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/catalog.py +0 -0
  20. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/cli/__init__.py +0 -0
  21. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/cli/_errors.py +0 -0
  22. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/cli/commands/__init__.py +0 -0
  23. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/cli/commands/data_import.py +0 -0
  24. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/cli/commands/data_info.py +0 -0
  25. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/cli/commands/data_publish.py +0 -0
  26. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/cli/commands/ls.py +0 -0
  27. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/cli/commands/prov.py +0 -0
  28. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/cli/commands/refs.py +0 -0
  29. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/cli/commands/show.py +0 -0
  30. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/cli/commands/snapshots.py +0 -0
  31. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/cli/commands/sparql.py +0 -0
  32. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/discovery.py +0 -0
  33. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/env_commands.py +0 -0
  34. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/graph/__init__.py +0 -0
  35. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/graph/dataset.py +0 -0
  36. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/graph/terms.py +0 -0
  37. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/identifiers.py +0 -0
  38. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/import_/__init__.py +0 -0
  39. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/import_/fetcher.py +0 -0
  40. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/import_/owner.py +0 -0
  41. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/import_/parser.py +0 -0
  42. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/import_/pipeline.py +0 -0
  43. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/import_/storage.py +0 -0
  44. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/metadata/__init__.py +0 -0
  45. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/metadata/builder.py +0 -0
  46. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/metadata/models.py +0 -0
  47. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/metadata/provenance.py +0 -0
  48. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/metadata/provenance_rdf.py +0 -0
  49. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/publish.py +0 -0
  50. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/query.py +0 -0
  51. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/resolve.py +0 -0
  52. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/url.py +0 -0
  53. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/utils/__init__.py +0 -0
  54. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/utils/checksum.py +0 -0
  55. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/utils/encoding.py +0 -0
  56. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/utils/naming.py +0 -0
  57. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/versioning/__init__.py +0 -0
  58. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_data/versioning/schema_evolution.py +0 -0
  59. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_pipeline/__init__.py +0 -0
  60. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_pipeline/assets/__init__.py +0 -0
  61. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_pipeline/assets/mirrors.py +0 -0
  62. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_pipeline/assets/transforms.py +0 -0
  63. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_pipeline/definitions.py +0 -0
  64. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_pipeline/io_manager.py +0 -0
  65. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_pipeline/resources/__init__.py +0 -0
  66. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_pipeline/resources/iceberg.py +0 -0
  67. {sunstone_data_platform-1.2.0 → sunstone_data_platform-1.3.0}/src/sunstone_pipeline/resources/refs.py +0 -0
@@ -20,3 +20,4 @@ security-prompts/
20
20
  /research-stack
21
21
  /target/
22
22
  CLAUDE.local.md
23
+ /.sunstone
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sunstone-data-platform
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Sunstone Data Platform - Data lakehouse with semantic metadata
5
5
  Author-email: Sunstone Institute <stig@sunstone.institute>
6
6
  License-Expression: MIT
@@ -9,6 +9,7 @@ Requires-Dist: charset-normalizer>=3.4.0
9
9
  Requires-Dist: duckdb>=1.5.3
10
10
  Requires-Dist: fastexcel>=0.20.2
11
11
  Requires-Dist: httpx>=0.27.0
12
+ Requires-Dist: keyring>=25
12
13
  Requires-Dist: publicsuffixlist>=1.0.2.20261003
13
14
  Requires-Dist: pyiceberg[duckdb,gcsfs,s3fs]>=0.10.0
14
15
  Requires-Dist: python-dateutil>=2.9.0.post0
@@ -199,7 +200,7 @@ uv run pytest tests/unit/test_query.py -v
199
200
 
200
201
  | Command | Description |
201
202
  |---------|-------------|
202
- | `sunstone data login`, `logout`, `whoami` | Sign in to graph-server (Keycloak) |
203
+ | `sunstone auth login`, `logout`, `whoami` | Sign in to graph-server (Keycloak) |
203
204
  | `sunstone data ls [namespace]` | List namespaces and tables |
204
205
  | `sunstone data show <table>` | Dataset metadata from the graph (table, Turtle or JSON-LD) |
205
206
  | `sunstone data info [table]` | Iceberg table properties for one or all datasets |
@@ -297,11 +298,13 @@ graph-server publishes the OIDC issuer and public client id at
297
298
 
298
299
  ```bash
299
300
  sunstone env init # cache discovery for configured environments
300
- sunstone data login # browser sign-in (authorization code + PKCE)
301
+ sunstone auth login # browser sign-in (authorization code + PKCE)
301
302
  ```
302
303
 
303
304
  A browser window opens for Keycloak SSO; the refresh token renews silently
304
- until the SSO session expires.
305
+ until the SSO session expires. The refresh token is stored in the OS keyring
306
+ (macOS Keychain, Windows Credential Locker, Linux Secret Service). Without a
307
+ usable keyring it falls back to a mode-0600 file under `~/.cache/sunstone/`.
305
308
 
306
309
  ### Services (CI, automation)
307
310
 
@@ -173,7 +173,7 @@ uv run pytest tests/unit/test_query.py -v
173
173
 
174
174
  | Command | Description |
175
175
  |---------|-------------|
176
- | `sunstone data login`, `logout`, `whoami` | Sign in to graph-server (Keycloak) |
176
+ | `sunstone auth login`, `logout`, `whoami` | Sign in to graph-server (Keycloak) |
177
177
  | `sunstone data ls [namespace]` | List namespaces and tables |
178
178
  | `sunstone data show <table>` | Dataset metadata from the graph (table, Turtle or JSON-LD) |
179
179
  | `sunstone data info [table]` | Iceberg table properties for one or all datasets |
@@ -271,11 +271,13 @@ graph-server publishes the OIDC issuer and public client id at
271
271
 
272
272
  ```bash
273
273
  sunstone env init # cache discovery for configured environments
274
- sunstone data login # browser sign-in (authorization code + PKCE)
274
+ sunstone auth login # browser sign-in (authorization code + PKCE)
275
275
  ```
276
276
 
277
277
  A browser window opens for Keycloak SSO; the refresh token renews silently
278
- until the SSO session expires.
278
+ until the SSO session expires. The refresh token is stored in the OS keyring
279
+ (macOS Keychain, Windows Credential Locker, Linux Secret Service). Without a
280
+ usable keyring it falls back to a mode-0600 file under `~/.cache/sunstone/`.
279
281
 
280
282
  ### Services (CI, automation)
281
283
 
@@ -25,6 +25,7 @@ dependencies = [
25
25
  "python-slugify>=8.0.0",
26
26
  "python-dateutil>=2.9.0.post0",
27
27
  "publicsuffixlist>=1.0.2.20261003",
28
+ "keyring>=25",
28
29
  ]
29
30
 
30
31
  [project.entry-points."sunstone.plugins"]
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.2.0'
22
- __version_tuple__ = version_tuple = (1, 2, 0)
21
+ __version__ = version = '1.3.0'
22
+ __version_tuple__ = version_tuple = (1, 3, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -12,8 +12,9 @@ health, the UI and public views. Two ways to get one:
12
12
  environment credential, so CI is never silently authenticated as whoever
13
13
  last logged in on that machine.
14
14
 
15
- Access tokens live in memory only. The refresh token is the sole thing
16
- written to disk.
15
+ Access tokens live in memory only. The refresh token is the sole credential
16
+ kept between runs: in the OS keyring, or a mode-0600 file when no keyring is
17
+ usable.
17
18
  """
18
19
 
19
20
  from sunstone_data.auth.client_creds import (
@@ -13,7 +13,7 @@ wildcard for ports, so the port cannot be ephemeral. The port is bound
13
13
  *before* the authorization URL is built, because the ``redirect_uri`` must
14
14
  name it.
15
15
 
16
- This is the flow behind ``sunstone data login`` (spec 180 §4). The public
16
+ This is the flow behind ``sunstone auth login`` (spec 180 §4). The public
17
17
  client it signs in as is the one graph-server publishes as ``clientId`` in
18
18
  ``/ui/config.json``, the same one the graph explorer UI uses.
19
19
  """
@@ -86,7 +86,7 @@ def step_up(
86
86
  raise StepUpRequired("writes to main need a passkey sign-in; a service credential cannot step up")
87
87
  session = store.read_session(env_name)
88
88
  if session is None:
89
- raise StepUpRequired(f"writes to main need a passkey sign-in; run `sunstone data login` for {env_name!r}")
89
+ raise StepUpRequired(f"writes to main need a passkey sign-in; run `sunstone auth login` for {env_name!r}")
90
90
  issuer = session.issuer or discovered_issuer(env_name, settings.graph_url)
91
91
  authorization_endpoint = discover_authorization_endpoint(issuer, client)
92
92
  token_endpoint = discover_token_endpoint(issuer, client)
@@ -1,30 +1,40 @@
1
1
  """Per-environment cache for a human login session.
2
2
 
3
- Holds the OAuth2 refresh token from ``sunstone data login`` so a user logs in
3
+ Holds the OAuth2 refresh token from ``sunstone auth login`` so a user logs in
4
4
  once per environment. Access tokens are never stored here — they are minted on
5
5
  demand and kept in memory only.
6
6
 
7
- The file lives next to the discovery cache but one level down, so the two
8
- never collide::
7
+ The refresh token goes in the OS keyring (macOS Keychain, Windows Credential
8
+ Locker, Secret Service on Linux) under service ``sunstone-data-platform`` and
9
+ the environment name. The rest of the session (issuer, client id, time) goes
10
+ in a file next to the discovery cache, one level down so the two never
11
+ collide::
9
12
 
10
13
  $XDG_CACHE_HOME/sunstone/data-platform/auth/{env_name}.json
11
14
 
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.
15
+ When no keyring is usable (e.g. a headless Linux box), the refresh token is
16
+ written into that file instead. The file is always written atomically and
17
+ chmod'ed ``0600``, so no other user on the machine can read it.
14
18
  """
15
19
 
16
20
  from __future__ import annotations
17
21
 
18
22
  import json
23
+ import logging
19
24
  import os
20
25
  import tempfile
21
26
  import time
22
27
  from dataclasses import asdict, dataclass, field, fields
23
28
  from pathlib import Path
24
29
 
30
+ logger = logging.getLogger(__name__)
31
+
25
32
  #: Mode a session file must end up with — owner read/write only.
26
33
  SESSION_FILE_MODE = 0o600
27
34
 
35
+ #: Keyring service name; the keyring username is the environment name.
36
+ KEYRING_SERVICE = "sunstone-data-platform"
37
+
28
38
 
29
39
  @dataclass(frozen=True)
30
40
  class Session:
@@ -53,18 +63,56 @@ def session_path(env_name: str) -> Path:
53
63
  return _auth_dir() / f"{env_name}.json"
54
64
 
55
65
 
66
+ def _keyring_set(env_name: str, token: str) -> bool:
67
+ """Store ``token`` in the OS keyring. Returns False if no keyring is usable."""
68
+ try:
69
+ import keyring
70
+
71
+ keyring.set_password(KEYRING_SERVICE, env_name, token)
72
+ except Exception:
73
+ logger.debug("OS keyring not usable", exc_info=True)
74
+ return False
75
+ return True
76
+
77
+
78
+ def _keyring_get(env_name: str) -> str | None:
79
+ try:
80
+ import keyring
81
+
82
+ return keyring.get_password(KEYRING_SERVICE, env_name)
83
+ except Exception:
84
+ logger.debug("OS keyring not usable", exc_info=True)
85
+ return None
86
+
87
+
88
+ def _keyring_delete(env_name: str) -> None:
89
+ try:
90
+ import keyring
91
+
92
+ keyring.delete_password(KEYRING_SERVICE, env_name)
93
+ except Exception:
94
+ # Missing entry or no usable keyring: nothing to delete either way.
95
+ logger.debug("No keyring entry deleted", exc_info=True)
96
+
97
+
56
98
  def write_session(env_name: str, session: Session) -> Path:
57
99
  """Persist a login session, atomically and mode 0600.
58
100
 
59
- Returns the path written.
101
+ The refresh token goes in the OS keyring when one is usable, else in the
102
+ session file. Returns the path written.
60
103
  """
61
104
  path = session_path(env_name)
105
+ data = asdict(session)
106
+ if _keyring_set(env_name, session.refresh_token):
107
+ del data["refresh_token"]
108
+ else:
109
+ logger.warning("No usable OS keyring; storing the refresh token in %s (mode 0600)", path)
62
110
  path.parent.mkdir(parents=True, exist_ok=True)
63
111
  fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
64
112
  try:
65
113
  os.chmod(tmp_name, SESSION_FILE_MODE)
66
114
  with os.fdopen(fd, "w", encoding="utf-8") as f:
67
- json.dump(asdict(session), f, indent=2)
115
+ json.dump(data, f, indent=2)
68
116
  os.replace(tmp_name, path)
69
117
  except Exception:
70
118
  Path(tmp_name).unlink(missing_ok=True)
@@ -76,7 +124,10 @@ def write_session(env_name: str, session: Session) -> Path:
76
124
 
77
125
 
78
126
  def read_session(env_name: str) -> Session | None:
79
- """Load a cached login session. Returns None if missing or unreadable."""
127
+ """Load a cached login session. Returns None if missing or unreadable.
128
+
129
+ A refresh token in the file (keyring fallback) wins over the keyring.
130
+ """
80
131
  try:
81
132
  with open(session_path(env_name), encoding="utf-8") as f:
82
133
  data = json.load(f)
@@ -86,6 +137,11 @@ def read_session(env_name: str) -> Session | None:
86
137
  return None
87
138
  field_names = {f.name for f in fields(Session)}
88
139
  kwargs = {k: v for k, v in data.items() if k in field_names}
140
+ if not kwargs.get("refresh_token"):
141
+ token = _keyring_get(env_name)
142
+ if token is None:
143
+ return None
144
+ kwargs["refresh_token"] = token
89
145
  try:
90
146
  return Session(**kwargs)
91
147
  except TypeError:
@@ -95,3 +151,4 @@ def read_session(env_name: str) -> Session | None:
95
151
  def clear_session(env_name: str) -> None:
96
152
  """Remove the cached session for an environment, if any."""
97
153
  session_path(env_name).unlink(missing_ok=True)
154
+ _keyring_delete(env_name)
@@ -21,6 +21,9 @@ app = typer.Typer(
21
21
  data_app = typer.Typer(help="Data operations")
22
22
  app.add_typer(data_app, name="data")
23
23
 
24
+ auth_app = typer.Typer(help="Sign in to the data platform")
25
+ app.add_typer(auth_app, name="auth")
26
+
24
27
  console = Console()
25
28
 
26
- __all__ = ["app", "data_app", "console"]
29
+ __all__ = ["app", "auth_app", "data_app", "console"]
@@ -1,16 +1,16 @@
1
- """``sunstone data login`` / ``logout`` / ``whoami`` — human sign-in sessions.
1
+ """``sunstone auth login`` / ``logout`` / ``whoami`` — human sign-in sessions.
2
2
 
3
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.
4
+ caches only the refresh token, per environment, in the OS keyring (see
5
+ :mod:`sunstone_data.auth.store`). Access tokens are minted on demand and never
6
+ stored, so ``whoami`` reports what was cached, not live token claims.
7
7
  """
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
11
  import typer
12
12
 
13
- from sunstone_data.cli._app import console, data_app
13
+ from sunstone_data.cli._app import auth_app, console
14
14
 
15
15
  _ENV_OPTION_HELP = "Environment to act on (defaults to the active sunstone environment)."
16
16
 
@@ -21,7 +21,7 @@ def _resolve_env(env: str | None) -> str:
21
21
  return env or current_environment_name()
22
22
 
23
23
 
24
- @data_app.command("login")
24
+ @auth_app.command("login")
25
25
  def login_cmd(
26
26
  env: str | None = typer.Option(None, "--env", help=_ENV_OPTION_HELP),
27
27
  ) -> None:
@@ -78,7 +78,7 @@ def login_cmd(
78
78
  console.print(f"[green]Logged in to '{env_name}'.[/green]")
79
79
 
80
80
 
81
- @data_app.command("logout")
81
+ @auth_app.command("logout")
82
82
  def logout_cmd(
83
83
  env: str | None = typer.Option(None, "--env", help=_ENV_OPTION_HELP),
84
84
  ) -> None:
@@ -94,7 +94,7 @@ def logout_cmd(
94
94
  console.print(f"Not logged in to '{env_name}'; nothing to do.")
95
95
 
96
96
 
97
- @data_app.command("whoami")
97
+ @auth_app.command("whoami")
98
98
  def whoami_cmd(
99
99
  env: str | None = typer.Option(None, "--env", help=_ENV_OPTION_HELP),
100
100
  ) -> None:
@@ -109,7 +109,7 @@ def whoami_cmd(
109
109
  env_name = _resolve_env(env)
110
110
  session = store.read_session(env_name)
111
111
  if session is None:
112
- console.print(f"Not logged in to '{env_name}'. Run `sunstone data login`.")
112
+ console.print(f"Not logged in to '{env_name}'. Run `sunstone auth login`.")
113
113
  return
114
114
 
115
115
  age_hours = max(0.0, time.time() - session.cached_at) / 3600
@@ -14,16 +14,16 @@ Layout:
14
14
  ``@data_app.command(...)`` and lazy-imports its heavy dependencies
15
15
  inside the function body.
16
16
 
17
- This module re-exports ``app`` (the entry point) and ``data_app`` (used by
18
- :mod:`sunstone_data.plugin`).
17
+ This module re-exports ``app`` (the entry point) and the ``data_app`` and
18
+ ``auth_app`` groups (used by :mod:`sunstone_data.plugin`).
19
19
  """
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
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
24
+ from sunstone_data.cli._app import app, auth_app, console, data_app
25
25
 
26
- __all__ = ["app", "console", "data_app"]
26
+ __all__ = ["app", "auth_app", "console", "data_app"]
27
27
 
28
28
 
29
29
  if __name__ == "__main__":
@@ -109,8 +109,13 @@ def _try_resolve_environment() -> Any | None:
109
109
  """Resolve the active environment via sunstone-py.
110
110
 
111
111
  Returns an ``Environment`` (sunstone-py 1.12+) if available, else None.
112
- Never raises — missing sunstone-py or resolution errors are logged at
113
- debug level and return None so the caller falls back to ``.env`` files.
112
+ Missing sunstone-py and unexpected errors are logged at debug level and
113
+ return None so the caller falls back to ``.env`` files.
114
+
115
+ Raises:
116
+ ValueError: The active environment is misconfigured (undefined name,
117
+ invalid section). Falling back to defaults would silently target
118
+ the wrong service.
114
119
  """
115
120
  try:
116
121
  from sunstone.env import resolve_environment
@@ -119,6 +124,8 @@ def _try_resolve_environment() -> Any | None:
119
124
  except ImportError:
120
125
  logger.debug("sunstone-py not installed, skipping environment resolution")
121
126
  return None
127
+ except ValueError:
128
+ raise
122
129
  except Exception:
123
130
  logger.debug("Failed to resolve environment", exc_info=True)
124
131
  return None
@@ -47,6 +47,13 @@ class DataPlatformSection:
47
47
  :mod:`sunstone_data.discovery`.
48
48
  """
49
49
 
50
+ skolem_base_iri: str | None = None
51
+ """Base for skolem IRIs (``{base_iri}id/`` in deployed environments).
52
+
53
+ Accepted so fleet-distributed configs that set it still load. The client
54
+ does not use it.
55
+ """
56
+
50
57
  graph_url: str | None = None
51
58
  """Deprecated alias for ``base_iri``. Kept so existing config files still load."""
52
59
 
@@ -31,7 +31,7 @@ N_TRIPLES = "application/n-triples"
31
31
  class AuthError(RuntimeError):
32
32
  """Not authenticated: no token, or the token was rejected twice."""
33
33
 
34
- def __init__(self, message: str = "Not authenticated; run `sunstone data login`"):
34
+ def __init__(self, message: str = "Not authenticated; run `sunstone auth login`"):
35
35
  super().__init__(message)
36
36
 
37
37
 
@@ -314,7 +314,7 @@ class GraphClient:
314
314
  status = resp.status_code
315
315
  if status == 401:
316
316
  if parse_challenge(resp.headers.get("WWW-Authenticate")) is not None:
317
- return StepUpRequired(f"{method} {path} needs a fresh passkey sign-in; run `sunstone data login`")
317
+ return StepUpRequired(f"{method} {path} needs a fresh passkey sign-in; run `sunstone auth login`")
318
318
  return AuthError()
319
319
  if status == 403:
320
320
  return PermissionError(_required_role(method, path), self._client_id)
@@ -10,24 +10,24 @@ from sunstone_data.env_section import DataPlatformSection, DataPlatformSectionPr
10
10
  class SunstoneDataPlugin:
11
11
  """Data platform plugin for sunstone-py.
12
12
 
13
- Implements CLIProvider (mounts `data` subcommand group), URLHandler
13
+ Implements CLIProvider (mounts the `data` and `auth` subcommand groups), URLHandler
14
14
  (resolves sunstone: URLs to parquet data), and EnvSectionProvider
15
15
  (owns the ``data-platform`` subtable in env config files).
16
16
  """
17
17
 
18
18
  def cli_groups(self):
19
- """Return the data CLI group for mounting on the sunstone app.
19
+ """Return the data and auth CLI groups for mounting on the sunstone app.
20
20
 
21
21
  Also attaches ``init`` and ``refresh`` to sunstone-py's ``env`` group.
22
22
  sunstone-py calls this only after ``sunstone.cli.env_app`` exists, so
23
23
  importing ``sunstone.cli`` here cannot re-enter plugin discovery.
24
24
  """
25
- from sunstone_data.cli.main import data_app
25
+ from sunstone_data.cli.main import auth_app, data_app
26
26
  from sunstone_data.env_commands import _register
27
27
 
28
28
  _register()
29
29
 
30
- return [("data", data_app)]
30
+ return [("data", data_app), ("auth", auth_app)]
31
31
 
32
32
  def can_handle(self, url: str) -> bool:
33
33
  """Return True for sunstone: URLs.