duckhaven-cli 0.1.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.
dh/commands/dbt.py ADDED
@@ -0,0 +1,272 @@
1
+ """`dh lineage` and `dh semantic` — publishing dbt artifacts.
2
+
3
+ This is the pair of commands the CLI most clearly earns. Both import routes take a
4
+ **local build artifact** and post it, which a browser cannot do at all, and which
5
+ the docs currently tell people to do with hand-written `curl`. Each of those
6
+ invocations re-implements base-URL joining, bearer auth, content-type selection
7
+ and error parsing, and gets at least one of them wrong.
8
+
9
+ The end-to-end flow these replace:
10
+
11
+ dbt parse --target prod
12
+ dh lineage import dbt target/manifest.json --catalog-json target/catalog.json
13
+ dh semantic import dbt target/manifest.json
14
+ dh semantic validate analytics
15
+ dh semantic publish analytics
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from pathlib import Path
22
+
23
+ import typer
24
+
25
+ from dh import context
26
+ from dh.errors import ConflictError
27
+
28
+ lineage_app = typer.Typer(name="lineage", help="Publish and retire lineage from other producers.")
29
+ semantic_app = typer.Typer(name="semantic", help="Publish and manage semantic models.")
30
+
31
+ _RECONCILE = typer.Option(
32
+ None,
33
+ "--reconcile",
34
+ help="provider_run (default) retires what the artifact no longer declares; none does not.",
35
+ )
36
+
37
+
38
+ def _load_json(path: Path) -> dict:
39
+ try:
40
+ return json.loads(path.read_text(encoding="utf-8"))
41
+ except json.JSONDecodeError as exc:
42
+ raise ConflictError("bad_artifact", f"{path} is not valid JSON: {exc}") from exc
43
+
44
+
45
+ # --- Lineage ---------------------------------------------------------------
46
+
47
+
48
+ @lineage_app.command("import")
49
+ def import_lineage(
50
+ ctx: typer.Context,
51
+ provider: str = typer.Argument(..., help="Producer name, e.g. dbt."),
52
+ artifact: Path = typer.Argument(..., exists=True, help="The producer's artifact."),
53
+ catalog_json: Path = typer.Option(
54
+ None,
55
+ "--catalog-json",
56
+ exists=True,
57
+ help="dbt's catalog.json, which unlocks column-level lineage.",
58
+ ),
59
+ reconcile: str = _RECONCILE,
60
+ ) -> None:
61
+ """Publish a producer's own artifact, translated by that producer's adapter.
62
+
63
+ For dbt this is `target/manifest.json`. Passing `--catalog-json` as well sends
64
+ both under one body, which is what makes **column-level** lineage possible --
65
+ assembling that envelope by hand is the part people get wrong.
66
+
67
+ Publish when the project *changes*, not when it runs: the manifest describes
68
+ the dependencies your code declares, so re-posting an unchanged one achieves
69
+ nothing.
70
+ """
71
+ cli = context.of(ctx)
72
+ settings = cli.settings()
73
+ body = _load_json(artifact)
74
+ if catalog_json:
75
+ body = {"manifest": body, "catalog": _load_json(catalog_json)}
76
+ path = f"workspaces/{settings.require('workspace')}/lineage/imports/{provider}"
77
+ with cli.client(settings) as client:
78
+ result = client.post(path, json=body, params={"reconcile": reconcile})
79
+ cli.note(
80
+ f"created {result.get('created', 0)}, updated {result.get('updated', 0)}, "
81
+ f"removed {result.get('removed', 0)}, skipped {len(result.get('skipped') or [])}"
82
+ )
83
+ cli.emit(result)
84
+
85
+
86
+ @lineage_app.command("import-edges")
87
+ def import_edges(
88
+ ctx: typer.Context,
89
+ file: Path = typer.Argument(..., exists=True, help="A canonical edge list."),
90
+ provider: str = typer.Option(..., "--provider", help="Producer asserting these edges."),
91
+ run_id: str = typer.Option(None, "--run-id", help="Required with --reconcile provider_run."),
92
+ reconcile: str = _RECONCILE,
93
+ ) -> None:
94
+ """Publish already-canonical edges from a producer with no adapter.
95
+
96
+ The edge list may be a bare array or the full request body. At most 5000 edges
97
+ per request; split a larger set across calls sharing one `--run-id`.
98
+ """
99
+ cli = context.of(ctx)
100
+ settings = cli.settings()
101
+ loaded = _load_json(file)
102
+ body = loaded if isinstance(loaded, dict) else {"edges": loaded}
103
+ body.setdefault("provider", provider)
104
+ if run_id:
105
+ body["run_id"] = run_id
106
+ if reconcile:
107
+ body["reconcile"] = reconcile
108
+ path = f"workspaces/{settings.require('workspace')}/lineage/imports"
109
+ with cli.client(settings) as client:
110
+ cli.emit(client.post(path, json=body))
111
+
112
+
113
+ @lineage_app.command("purge")
114
+ def purge_lineage(
115
+ ctx: typer.Context,
116
+ provider: str = typer.Option(..., "--provider"),
117
+ yes: bool = context.YES,
118
+ ) -> None:
119
+ """Remove every edge a retired producer asserted. Requires workspace owner."""
120
+ cli = context.of(ctx)
121
+ cli.confirm("Purge all lineage published by", provider, yes=yes)
122
+ settings = cli.settings()
123
+ path = f"workspaces/{settings.require('workspace')}/lineage/imports"
124
+ with cli.client(settings) as client:
125
+ cli.emit(client.delete(path, params={"provider": provider}))
126
+
127
+
128
+ # --- Semantic --------------------------------------------------------------
129
+
130
+
131
+ @semantic_app.command("import")
132
+ def import_semantics(
133
+ ctx: typer.Context,
134
+ provider: str = typer.Argument(..., help="Producer name, e.g. dbt."),
135
+ artifact: Path = typer.Argument(..., exists=True, help="Manifest or YAML document."),
136
+ reconcile: str = _RECONCILE,
137
+ ) -> None:
138
+ """Publish semantic definitions from an external producer.
139
+
140
+ The file is sent **byte for byte**. One route serves hand-written YAML and
141
+ machine-written JSON, and it reads the body raw so a YAML parse error points
142
+ at the line the author wrote rather than one the CLI re-encoded.
143
+
144
+ Imported models arrive as **drafts**: an import is a pipeline publishing, not
145
+ a person deciding. Promote them with `dh semantic validate` then
146
+ `dh semantic publish`.
147
+ """
148
+ cli = context.of(ctx)
149
+ settings = cli.settings()
150
+ path = f"workspaces/{settings.require('workspace')}/semantic/imports/{provider}"
151
+ with cli.client(settings) as client:
152
+ result = client.post(
153
+ path,
154
+ content=artifact.read_bytes(),
155
+ headers={"Content-Type": "text/plain; charset=utf-8"},
156
+ params={"reconcile": reconcile},
157
+ )
158
+ cli.note("Imported as drafts. Promote with `dh semantic validate` then `publish`.")
159
+ cli.emit(result)
160
+
161
+
162
+ @semantic_app.command("purge")
163
+ def purge_semantics(
164
+ ctx: typer.Context,
165
+ provider: str = typer.Option(..., "--provider"),
166
+ yes: bool = context.YES,
167
+ ) -> None:
168
+ """Remove everything one provider published. Requires workspace owner."""
169
+ cli = context.of(ctx)
170
+ cli.confirm("Purge all semantics published by", provider, yes=yes)
171
+ settings = cli.settings()
172
+ path = f"workspaces/{settings.require('workspace')}/semantic/imports"
173
+ with cli.client(settings) as client:
174
+ client.delete(path, params={"provider": provider})
175
+ cli.note(f"Purged everything published by {provider!r}.")
176
+
177
+
178
+ model_app = typer.Typer(name="model", help="Semantic models.")
179
+ semantic_app.add_typer(model_app)
180
+
181
+
182
+ @model_app.command("list")
183
+ def list_models(ctx: typer.Context) -> None:
184
+ """The workspace's semantic models, published and draft."""
185
+ cli = context.of(ctx)
186
+ settings = cli.settings()
187
+ with cli.client(settings) as client:
188
+ cli.emit(client.get(f"workspaces/{settings.require('workspace')}/semantic/models"))
189
+
190
+
191
+ @model_app.command("get")
192
+ def get_model(ctx: typer.Context, model: str) -> None:
193
+ """One model in full: datasets, dimensions, metrics and relationships."""
194
+ cli = context.of(ctx)
195
+ settings = cli.settings()
196
+ with cli.client(settings) as client:
197
+ cli.emit(client.get(f"workspaces/{settings.require('workspace')}/semantic/models/{model}"))
198
+
199
+
200
+ def _transition(action: str, doc: str):
201
+ """The three model state changes, which differ only in their path segment."""
202
+
203
+ def command(ctx: typer.Context, model: str) -> None:
204
+ cli = context.of(ctx)
205
+ settings = cli.settings()
206
+ base = f"workspaces/{settings.require('workspace')}/semantic/models/{model}"
207
+ with cli.client(settings) as client:
208
+ cli.emit(client.post(f"{base}/{action}"))
209
+
210
+ command.__doc__ = doc
211
+ return command
212
+
213
+
214
+ semantic_app.command("validate")(
215
+ _transition("validate", "Check a model without publishing it. Safe to run in CI.")
216
+ )
217
+ semantic_app.command("publish")(
218
+ _transition("publish", "Make a model authoritative to the assistant. Validates first.")
219
+ )
220
+ semantic_app.command("deprecate")(
221
+ _transition("deprecate", "Retire a published model without deleting it.")
222
+ )
223
+
224
+
225
+ relationship_app = typer.Typer(name="relationship", help="Joins between a model's datasets.")
226
+ semantic_app.add_typer(relationship_app)
227
+
228
+
229
+ @relationship_app.command("add")
230
+ def add_relationship(
231
+ ctx: typer.Context,
232
+ model: str = typer.Argument(..., help="Model slug."),
233
+ name: str = typer.Option(..., "--name"),
234
+ left: str = typer.Option(..., "--left", help="The many side; traversal starts here."),
235
+ right: str = typer.Option(..., "--right", help="The unique side; needs a primary key."),
236
+ join: list[str] = typer.Option(..., "--join", help="Repeatable: left_column=right_column."),
237
+ cardinality: str = typer.Option(None, "--cardinality", help="Defaults to many_to_one."),
238
+ ) -> None:
239
+ """Declare a join between two of a model's datasets."""
240
+ cli = context.of(ctx)
241
+ settings = cli.settings()
242
+ body = {
243
+ "name": name,
244
+ "left_dataset": left,
245
+ "right_dataset": right,
246
+ "join_columns": [_join(pair) for pair in join],
247
+ }
248
+ if cardinality:
249
+ body["cardinality"] = cardinality
250
+ base = f"workspaces/{settings.require('workspace')}/semantic/models/{model}"
251
+ with cli.client(settings) as client:
252
+ cli.emit(client.post(f"{base}/relationships", json=body))
253
+
254
+
255
+ def _join(pair: str) -> dict[str, str]:
256
+ left, sep, right = pair.partition("=")
257
+ if not sep or not left or not right:
258
+ raise ConflictError(
259
+ "bad_join", f"Could not read join {pair!r}. Use left_column=right_column."
260
+ )
261
+ return {"left": left, "right": right}
262
+
263
+
264
+ @relationship_app.command("remove")
265
+ def remove_relationship(ctx: typer.Context, model: str, name: str) -> None:
266
+ """Remove a relationship from a model."""
267
+ cli = context.of(ctx)
268
+ settings = cli.settings()
269
+ base = f"workspaces/{settings.require('workspace')}/semantic/models/{model}"
270
+ with cli.client(settings) as client:
271
+ client.delete(f"{base}/relationships/{name}")
272
+ cli.note(f"Removed relationship {name!r} from {model!r}.")
dh/commands/grant.py ADDED
@@ -0,0 +1,129 @@
1
+ """`dh grant` — catalog access control.
2
+
3
+ Access control is the kind of change people want reviewable and applied from a
4
+ script rather than clicked into a browser, which is what puts it in v1 despite
5
+ the blast radius.
6
+
7
+ `GET .../grants` returns the current grants *and* the principals they could name,
8
+ so the one request that shows the state also resolves an email to a user id. The
9
+ workspace members list cannot: `MemberOut` carries no email.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import typer
15
+
16
+ from dh import context
17
+ from dh.commands.catalog import resolve_catalog
18
+ from dh.errors import ConflictError
19
+
20
+ app = typer.Typer(name="grant", help="Catalog access control.")
21
+
22
+ _CATALOG = typer.Option(None, "--catalog", help="Catalog to act on.")
23
+
24
+
25
+ def _grants_path(cli, client, settings, catalog: str | None) -> str:
26
+ target = resolve_catalog(cli, client, settings, catalog)
27
+ return f"workspaces/{settings.require('workspace')}/catalogs/{target}"
28
+
29
+
30
+ def _resolve_principal(payload: dict, who: str) -> str:
31
+ """A user id from an id, an email, or a name.
32
+
33
+ Requiring a pasted UUID would throw away the one advantage this command has
34
+ over `curl`, so an ambiguous or unknown value lists the candidates instead.
35
+ """
36
+ principals = payload.get("principals") or []
37
+ for field in ("user_id", "email", "name"):
38
+ matches = [p for p in principals if str(p.get(field, "")).lower() == who.lower()]
39
+ if len(matches) == 1:
40
+ return str(matches[0]["user_id"])
41
+ if len(matches) > 1:
42
+ raise ConflictError(
43
+ "ambiguous_principal",
44
+ f"{who!r} matches more than one principal by {field}; use the user id.",
45
+ )
46
+ known = ", ".join(sorted(str(p.get("email") or p.get("name")) for p in principals)) or "none"
47
+ raise ConflictError("no_such_principal", f"No principal matching {who!r}. Known: {known}")
48
+
49
+
50
+ @app.command("list")
51
+ def list_grants(
52
+ ctx: typer.Context,
53
+ catalog: str = _CATALOG,
54
+ principals: bool = typer.Option(False, "--principals", help="List candidates instead."),
55
+ ) -> None:
56
+ """Who has what on the catalog, and in which access mode."""
57
+ cli = context.of(ctx)
58
+ settings = cli.settings()
59
+ with cli.client(settings) as client:
60
+ payload = client.get(f"{_grants_path(cli, client, settings, catalog)}/grants")
61
+ if principals:
62
+ cli.emit(payload.get("principals") or [])
63
+ return
64
+ cli.note(f"access mode: {payload.get('access_mode')}")
65
+ cli.emit(payload.get("grants") or [])
66
+
67
+
68
+ @app.command("set")
69
+ def set_grant(
70
+ ctx: typer.Context,
71
+ user: str = typer.Option(..., "--user", help="User id, email or name."),
72
+ tier: str = typer.Option(..., "--tier", help="metadata, reader or writer."),
73
+ schema: str = typer.Option(None, "--schema", help="Narrow the grant to one schema."),
74
+ table: str = typer.Option(None, "--table", help="Narrow it further to one table."),
75
+ catalog: str = _CATALOG,
76
+ ) -> None:
77
+ """Grant a principal access to the catalog, a schema, or one table.
78
+
79
+ Idempotent: setting a grant that already exists updates its tier. The target
80
+ is keyed in the body rather than the path because the key is composite and
81
+ not URL-safe, which is why this is a PUT with no id in the URL.
82
+ """
83
+ if table and not schema:
84
+ # The server enforces this too; refusing here saves a round trip and
85
+ # names the missing flag rather than the missing field.
86
+ raise ConflictError("schema_required", "--table also needs --schema.")
87
+ cli = context.of(ctx)
88
+ settings = cli.settings()
89
+ with cli.client(settings) as client:
90
+ base = _grants_path(cli, client, settings, catalog)
91
+ current = client.get(f"{base}/grants")
92
+ body = {"user_id": _resolve_principal(current, user), "tier": tier}
93
+ if schema:
94
+ body["schema_name"] = schema
95
+ if table:
96
+ body["table_name"] = table
97
+ cli.emit(client.put(f"{base}/grants", json=body))
98
+
99
+
100
+ @app.command("remove")
101
+ def remove_grant(ctx: typer.Context, grant_id: str, catalog: str = _CATALOG) -> None:
102
+ """Revoke one grant by its id, as shown by `dh grant list`."""
103
+ cli = context.of(ctx)
104
+ settings = cli.settings()
105
+ with cli.client(settings) as client:
106
+ base = _grants_path(cli, client, settings, catalog)
107
+ client.delete(f"{base}/grants/{grant_id}")
108
+ cli.note(f"Removed grant {grant_id}.")
109
+
110
+
111
+ @app.command("access-mode")
112
+ def set_access_mode(
113
+ ctx: typer.Context,
114
+ mode: str = typer.Argument(..., help="open or scoped."),
115
+ catalog: str = _CATALOG,
116
+ ) -> None:
117
+ """Switch the catalog between open and scoped access.
118
+
119
+ `open` means every workspace member can read it; `scoped` means only what
120
+ grants allow. Switching to `scoped` takes effect immediately, so a catalog
121
+ with no grants becomes unreadable to everyone but its admins.
122
+ """
123
+ if mode not in ("open", "scoped"):
124
+ raise ConflictError("bad_access_mode", f"Expected open or scoped, got {mode!r}.")
125
+ cli = context.of(ctx)
126
+ settings = cli.settings()
127
+ with cli.client(settings) as client:
128
+ base = _grants_path(cli, client, settings, catalog)
129
+ cli.emit(client.patch(f"{base}/access-mode", json={"access_mode": mode}))
dh/commands/health.py ADDED
@@ -0,0 +1,64 @@
1
+ """`dh health` and `dh version` — is this deployment up, and what is it running."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from dh import __version__, context
8
+ from dh.errors import DhError, NotFoundError
9
+ from dh.rest import RestClient
10
+
11
+
12
+ def health(ctx: typer.Context) -> None:
13
+ """Liveness, readiness, and the deployment's own health report.
14
+
15
+ Each check is reported rather than raised, so one failing dependency still
16
+ shows the state of the others -- which is the whole reason to run this.
17
+ """
18
+ cli = context.of(ctx)
19
+ settings = cli.settings()
20
+ with cli.client(settings) as client:
21
+ rows = [
22
+ _probe(client, "healthz", "liveness"),
23
+ _probe(client, "readyz", "readiness"),
24
+ _probe(client, "maintenance/health", "deployment"),
25
+ ]
26
+ workspace = settings.get("workspace")
27
+ if workspace:
28
+ rows.append(_probe(client, f"workspaces/{workspace}/health", "workspace"))
29
+ cli.emit(rows)
30
+
31
+
32
+ def _probe(client: RestClient, path: str, name: str) -> dict[str, object]:
33
+ try:
34
+ body = client.get(path)
35
+ except DhError as exc:
36
+ return {"check": name, "ok": False, "detail": exc.message}
37
+ detail = body.get("status") if isinstance(body, dict) else None
38
+ return {"check": name, "ok": True, "detail": detail or "ok"}
39
+
40
+
41
+ def version(ctx: typer.Context) -> None:
42
+ """The CLI's version, and the server's when one is reachable.
43
+
44
+ `api_version` is the server's wire-contract integer; a pipeline can assert on
45
+ it. A server old enough to lack `GET /api/version` reports null rather than
46
+ failing, matching how the connector treats the same 404.
47
+ """
48
+ cli = context.of(ctx)
49
+ settings = cli.settings()
50
+ payload: dict[str, object] = {"cli": __version__, "server": None, "api_version": None}
51
+ host, token = settings.get("host"), settings.get("token")
52
+ if host and token:
53
+ try:
54
+ with RestClient(host, token) as client:
55
+ body = client.get("version")
56
+ payload["server"] = body.get("version")
57
+ payload["api_version"] = body.get("api_version")
58
+ except NotFoundError:
59
+ payload["server"] = "unknown (server predates /api/version)"
60
+ except DhError as exc:
61
+ # `dh version` must work offline: it is what people run first when
62
+ # something is wrong, and failing here would hide the CLI's own version.
63
+ cli.note(f"Could not reach the server: {exc.message}")
64
+ cli.emit(payload)
dh/commands/profile.py ADDED
@@ -0,0 +1,80 @@
1
+ """`dh profile` — inspect and edit the local profile file. No HTTP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from dh import config as config_mod
8
+ from dh import context
9
+ from dh.errors import ConfigError
10
+ from dh.resolve import SECRET
11
+
12
+ app = typer.Typer(name="profile", help="Inspect and edit local connection profiles.")
13
+
14
+
15
+ @app.command("list")
16
+ def list_profiles(ctx: typer.Context) -> None:
17
+ """List the configured profiles and which one is the default."""
18
+ cli = context.of(ctx)
19
+ cfg = config_mod.load()
20
+ if not cfg.profiles:
21
+ cli.note(f"No profiles in {cfg.path}. Run `dh auth login` to create one.")
22
+ cli.emit([])
23
+ return
24
+ default = cfg.resolved_default()
25
+ cli.emit(
26
+ [
27
+ {
28
+ "name": name,
29
+ "default": name == default,
30
+ "host": cfg.profiles[name].host,
31
+ "workspace": cfg.profiles[name].workspace,
32
+ }
33
+ for name in sorted(cfg.profiles)
34
+ ]
35
+ )
36
+
37
+
38
+ @app.command("show")
39
+ def show_profile(
40
+ ctx: typer.Context,
41
+ name: str = typer.Argument(None, help="Defaults to the default profile."),
42
+ ) -> None:
43
+ """Show one profile. The token is reported as present, never printed."""
44
+ cli = context.of(ctx)
45
+ cfg = config_mod.load()
46
+ target = name or cfg.resolved_default()
47
+ if not target:
48
+ raise ConfigError("no_profile", f"No profiles configured in {cfg.path}.")
49
+ profile = cfg.get(target)
50
+ if profile is None:
51
+ raise ConfigError("no_such_profile", f"No profile named {target!r} in {cfg.path}")
52
+ cli.emit(
53
+ {
54
+ "name": profile.name,
55
+ **{
56
+ field: ("set" if getattr(profile, field) else None)
57
+ if field in SECRET
58
+ else getattr(profile, field)
59
+ for field in config_mod.FIELDS
60
+ },
61
+ }
62
+ )
63
+
64
+
65
+ @app.command("use")
66
+ def use_profile(ctx: typer.Context, name: str) -> None:
67
+ """Make a profile the default for subsequent commands."""
68
+ cli = context.of(ctx)
69
+ cfg = config_mod.load()
70
+ config_mod.save(config_mod.set_default(cfg, name))
71
+ cli.note(f"Default profile is now {name!r}.")
72
+
73
+
74
+ @app.command("remove")
75
+ def remove_profile(ctx: typer.Context, name: str) -> None:
76
+ """Delete a profile and the token stored with it."""
77
+ cli = context.of(ctx)
78
+ cfg = config_mod.load()
79
+ config_mod.save(config_mod.remove(cfg, name))
80
+ cli.note(f"Removed profile {name!r}.")