lorebase-cli 0.2.3__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.
@@ -0,0 +1,254 @@
1
+ """`apikey create|list|revoke`: API keys of a Domain; `admin erase`: erasure of personal data;
2
+ `admin vault status|rotate`: vault key rotation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Annotated
10
+
11
+ import typer
12
+ from pydantic import JsonValue
13
+
14
+ from lorebase_cli.context import app_of
15
+ from lorebase_cli.failures import CliError
16
+ from lorebase_cli.options import AllOpt, DomainOpt, JsonOpt, LimitOpt
17
+ from lorebase_cli.output import Column
18
+
19
+ apikey_app = typer.Typer(help="Manage the API keys of a Domain.", no_args_is_help=True)
20
+ admin_app = typer.Typer(help="Administer the personal data of a Domain.", no_args_is_help=True)
21
+ vault_app = typer.Typer(
22
+ help="Rotate the vault keys (installation administrators).", no_args_is_help=True
23
+ )
24
+ admin_app.add_typer(vault_app, name="vault")
25
+
26
+ KEY_COLUMNS = [
27
+ Column("id", "id"),
28
+ Column("prefix", "prefix"),
29
+ Column("principal", "principal_id"),
30
+ Column("expires", "expires_at"),
31
+ Column("last used", "last_used_at"),
32
+ Column("revoked", "revoked_at"),
33
+ Column("networks", "allowed_networks"),
34
+ ]
35
+
36
+
37
+ @apikey_app.command("create")
38
+ def apikey_create(
39
+ ctx: typer.Context,
40
+ name: Annotated[
41
+ str,
42
+ typer.Argument(help="Name of the service principal the key belongs to.", metavar="NAME"),
43
+ ],
44
+ domain: DomainOpt = None,
45
+ role: Annotated[
46
+ str | None,
47
+ typer.Option("--role", help="Role the principal gets in the Domain (server default)."),
48
+ ] = None,
49
+ ttl_days: Annotated[
50
+ int | None, typer.Option("--ttl-days", min=1, help="Lifetime in days (server default).")
51
+ ] = None,
52
+ principal_id: Annotated[
53
+ str | None,
54
+ typer.Option("--principal-id", help="Add a key to an existing service principal."),
55
+ ] = None,
56
+ allow_network: Annotated[
57
+ list[str] | None,
58
+ typer.Option(
59
+ "--allow-network",
60
+ metavar="CIDR",
61
+ help="Client network the key may be used from, e.g. 203.0.113.0/24; repeatable. "
62
+ "Without it the key works from any address.",
63
+ ),
64
+ ] = None,
65
+ json_out: JsonOpt = False,
66
+ ) -> None:
67
+ """Create an API key. The secret is shown once; store it right away."""
68
+ app = app_of(ctx, json_out)
69
+ created = app.client().api_keys.create(
70
+ app.domain(domain),
71
+ name,
72
+ role=role,
73
+ ttl_days=ttl_days,
74
+ principal_id=principal_id,
75
+ allowed_networks=allow_network,
76
+ )
77
+
78
+ def human() -> None:
79
+ app.out.pairs(
80
+ [
81
+ ("key", created.key),
82
+ ("id", created.id),
83
+ ("principal", created.principal_id),
84
+ ("role", created.role),
85
+ ("expires", created.expires_at),
86
+ ("networks", ", ".join(created.allowed_networks) or "any"),
87
+ ]
88
+ )
89
+ app.out.info("This is the only time the key is shown: store it now.")
90
+
91
+ app.out.emit(created, human)
92
+
93
+
94
+ @apikey_app.command("list")
95
+ def apikey_list(
96
+ ctx: typer.Context,
97
+ domain: DomainOpt = None,
98
+ limit: LimitOpt = None,
99
+ everything: AllOpt = False,
100
+ json_out: JsonOpt = False,
101
+ ) -> None:
102
+ """List the API keys of a Domain, including revoked and expired ones."""
103
+ app = app_of(ctx, json_out)
104
+ items, more = app.take(app.client().api_keys.iterate(app.domain(domain)), limit, everything)
105
+ app.out.emit(items, lambda: app.out.table([k.model_dump() for k in items], KEY_COLUMNS))
106
+ if more:
107
+ app.note_truncated(len(items))
108
+
109
+
110
+ @apikey_app.command("revoke")
111
+ def apikey_revoke(
112
+ ctx: typer.Context,
113
+ key_id: Annotated[
114
+ str, typer.Argument(help="Id of the key (see `apikey list`).", metavar="KEY_ID")
115
+ ],
116
+ domain: DomainOpt = None,
117
+ json_out: JsonOpt = False,
118
+ ) -> None:
119
+ """Revoke an API key; it stops working at once."""
120
+ app = app_of(ctx, json_out)
121
+ app.client().api_keys.revoke(app.domain(domain), key_id)
122
+ app.out.emit({"revoked": key_id}, lambda: app.out.info(f"revoked key {key_id}"))
123
+
124
+
125
+ @admin_app.command("erase")
126
+ def admin_erase(
127
+ ctx: typer.Context,
128
+ kind: Annotated[str, typer.Argument(help="Kind api name.", metavar="KIND")],
129
+ key: Annotated[
130
+ str | None, typer.Option("--key", help="Erase the values of the Record with this key.")
131
+ ] = None,
132
+ attribute: Annotated[
133
+ list[str] | None,
134
+ typer.Option(
135
+ "--attribute",
136
+ help="Vault Attribute (api name). With --key: limit to it (repeatable); with "
137
+ "--value: the Attribute whose value is erased in every Record.",
138
+ ),
139
+ ] = None,
140
+ value: Annotated[
141
+ str | None,
142
+ typer.Option(
143
+ "--value",
144
+ metavar="- | @FILE",
145
+ help="Where the value to erase is read from (JSON, or plain text when it is not "
146
+ "valid JSON): - for standard input (asked without echo on a terminal), @FILE for a "
147
+ "file. A value given on the command line is refused: it would stay in the shell "
148
+ "history and the process list.",
149
+ ),
150
+ ] = None,
151
+ branch: Annotated[
152
+ str | None, typer.Option("--branch", help="Branch whose schema names the Kind.")
153
+ ] = None,
154
+ domain: DomainOpt = None,
155
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Do not ask for confirmation.")] = False,
156
+ json_out: JsonOpt = False,
157
+ ) -> None:
158
+ """Erase personal data from the vault, on every branch and Revision. This cannot be undone.
159
+
160
+ `--key K` erases what the Record ever held in its vault Attributes; `--attribute A --value
161
+ V` erases V of A in every Record of the Kind. History keeps its shape: reads show the
162
+ values as erased.
163
+ """
164
+ app = app_of(ctx, json_out)
165
+ if (key is None) == (value is None):
166
+ raise CliError("give --key, or --attribute and --value")
167
+ attributes = attribute or []
168
+ if value is not None and len(attributes) != 1:
169
+ raise CliError("erasure by --value takes exactly one --attribute")
170
+ text = None if value is None else _value_text(value)
171
+ target = f"{kind}/{key}" if key is not None else f"a value of {kind}.{attributes[0]}"
172
+ if not yes:
173
+ if not sys.stdin.isatty():
174
+ raise CliError("erasure is irreversible: pass --yes to confirm")
175
+ typer.confirm(f"Erase {target} from the vault for good?", abort=True)
176
+ vault = app.client().vault
177
+ if key is not None:
178
+ result = vault.erase_record(
179
+ app.domain(domain), kind, key, attributes=attributes or None, branch=branch
180
+ )
181
+ else:
182
+ result = vault.erase_value(
183
+ app.domain(domain),
184
+ kind,
185
+ attributes[0],
186
+ _parsed(text or ""),
187
+ branch=branch,
188
+ )
189
+ app.out.emit(result, lambda: app.out.info(f"erased {result.erased} vault value(s)"))
190
+
191
+
192
+ def _value_text(source: str) -> str:
193
+ """The value read from standard input (`-`) or a file (`@FILE`)."""
194
+ if source == "-":
195
+ if sys.stdin.isatty():
196
+ return str(typer.prompt("Value to erase", hide_input=True))
197
+ return sys.stdin.read().rstrip("\n")
198
+ if source.startswith("@") and len(source) > 1:
199
+ try:
200
+ return Path(source[1:]).read_text(encoding="utf-8").rstrip("\n")
201
+ except OSError as error:
202
+ raise CliError(f"cannot read {source[1:]}: {error.strerror}") from None
203
+ raise CliError(
204
+ "give the value as --value - (standard input) or --value @FILE: a value on the "
205
+ "command line stays in the shell history and the process list"
206
+ )
207
+
208
+
209
+ def _parsed(text: str) -> JsonValue:
210
+ try:
211
+ parsed: JsonValue = json.loads(text)
212
+ except ValueError:
213
+ return text
214
+ return parsed
215
+
216
+
217
+ @vault_app.command("status")
218
+ def vault_status(ctx: typer.Context, json_out: JsonOpt = False) -> None:
219
+ """Show which keys the vault rows use and which earlier keys can be removed."""
220
+ app = app_of(ctx, json_out)
221
+ keys = app.client().vault.keys()
222
+
223
+ def human() -> None:
224
+ app.out.pairs(
225
+ [
226
+ ("encryption key", keys.key_id),
227
+ ("token key", keys.token_key_id),
228
+ ("rows by encryption key", _counts(keys.encryption_keys)),
229
+ ("rows by fingerprint key", _counts(keys.fingerprint_keys)),
230
+ ("tombstones by fingerprint key", _counts(keys.tombstone_keys)),
231
+ ("rows to rotate", keys.outdated),
232
+ ("removable previous_keys", ", ".join(keys.removable_keys) or "none"),
233
+ (
234
+ "removable previous_token_keys",
235
+ ", ".join(keys.removable_token_keys) or "none",
236
+ ),
237
+ ("digest key", keys.digests),
238
+ ]
239
+ )
240
+
241
+ app.out.emit(keys, human)
242
+
243
+
244
+ @vault_app.command("rotate")
245
+ def vault_rotate(ctx: typer.Context, json_out: JsonOpt = False) -> None:
246
+ """Queue the job that seals and fingerprints the vault rows of every Domain again under the
247
+ current keys, in batches. Follow it with `admin vault status`."""
248
+ app = app_of(ctx, json_out)
249
+ queued = app.client().vault.rotate()
250
+ app.out.emit(queued, lambda: app.out.info(f"queued vault rotation job {queued.job_id}"))
251
+
252
+
253
+ def _counts(counts: dict[str, int]) -> str:
254
+ return ", ".join(f"{key}: {count}" for key, count in counts.items()) or "none"
@@ -0,0 +1,244 @@
1
+ """`context`, `explain` and `health`: what an agent reads about Records, where their values came
2
+ from, and how sound the data is."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Annotated, Any
7
+
8
+ import typer
9
+
10
+ from lorebase_cli.commands.data import json_argument
11
+ from lorebase_cli.context import app_of
12
+ from lorebase_cli.failures import CliError
13
+ from lorebase_cli.options import AtOpt, BranchOpt, DomainOpt, JsonOpt
14
+ from lorebase_cli.output import Column, short_id
15
+ from lorebase_client import models as m
16
+
17
+ MarkdownOpt = Annotated[
18
+ bool, typer.Option("--markdown", help="Print the Markdown rendering instead of a summary.")
19
+ ]
20
+
21
+ SEED_COLUMNS = [Column("record", "ref"), Column("title", "title"), Column("values", "values")]
22
+ NEIGHBOR_COLUMNS = [
23
+ Column("record", "ref"),
24
+ Column("title", "title"),
25
+ Column("depth", "depth"),
26
+ ]
27
+ ATTRIBUTE_COLUMNS = [
28
+ Column("attribute", "name"),
29
+ Column("value", "value"),
30
+ Column("actor", "actor"),
31
+ Column("origin", "origin_type"),
32
+ Column("revision", "revision", short_id),
33
+ Column("at", "at"),
34
+ ]
35
+
36
+
37
+ def context_command(
38
+ ctx: typer.Context,
39
+ ref: Annotated[
40
+ list[str] | None,
41
+ typer.Option("--ref", help="Seed Record as KIND/KEY. Repeatable."),
42
+ ] = None,
43
+ search: Annotated[
44
+ str | None, typer.Option("--search", help="Seed Records found by this text.")
45
+ ] = None,
46
+ query: Annotated[
47
+ str | None,
48
+ typer.Option("--query", help="Seed Records of a DSL query: inline JSON, @file or -."),
49
+ ] = None,
50
+ budget: Annotated[
51
+ int | None, typer.Option("--budget", min=1, help="Token budget of the pack.")
52
+ ] = None,
53
+ depth: Annotated[
54
+ int | None, typer.Option("--depth", min=0, help="Hops to expand around the seeds.")
55
+ ] = None,
56
+ relation: Annotated[
57
+ list[str] | None,
58
+ typer.Option("--relation", help="Follow only this Relation. Repeatable."),
59
+ ] = None,
60
+ domain: DomainOpt = None,
61
+ branch: BranchOpt = None,
62
+ at: AtOpt = None,
63
+ markdown: MarkdownOpt = False,
64
+ json_out: JsonOpt = False,
65
+ ) -> None:
66
+ """Build a context pack: seed Records, their neighbors, schema and recent changes.
67
+
68
+ Name the seeds with exactly one of --ref, --search or --query.
69
+ """
70
+ app = app_of(ctx, json_out)
71
+ parsed = None if query is None else json_argument(query)
72
+ if parsed is not None and not isinstance(parsed, dict):
73
+ raise CliError("a query is a JSON object")
74
+ request = m.ContextRequest(
75
+ branch=app.branch(branch),
76
+ at=at,
77
+ refs=ref or None,
78
+ query=parsed,
79
+ search=search,
80
+ budget_tokens=budget,
81
+ depth=depth,
82
+ relations=relation or None,
83
+ )
84
+ agents = app.client().agents
85
+ name = app.domain(domain)
86
+ if markdown:
87
+ app.out.text(agents.context_markdown(name, request))
88
+ return
89
+ pack = agents.context(name, request)
90
+
91
+ def human() -> None:
92
+ used = pack.budget.get("used_tokens")
93
+ limit = pack.budget.get("limit_tokens")
94
+ app.out.pairs(
95
+ [
96
+ ("domain", pack.domain),
97
+ ("branch", pack.branch),
98
+ ("revision", short_id(pack.revision)),
99
+ ("seeds", len(pack.seeds)),
100
+ ("neighbors", len(pack.neighbors)),
101
+ ("tokens", f"{used}/{limit}"),
102
+ ]
103
+ )
104
+ app.out.table(pack.seeds, SEED_COLUMNS)
105
+ if pack.neighbors:
106
+ app.out.table(pack.neighbors, NEIGHBOR_COLUMNS)
107
+
108
+ app.out.emit(pack, human)
109
+
110
+
111
+ def explain_command(
112
+ ctx: typer.Context,
113
+ kind: Annotated[str, typer.Argument(help="Kind api name.", metavar="KIND")],
114
+ key: Annotated[str, typer.Argument(help="Record key.", metavar="KEY")],
115
+ domain: DomainOpt = None,
116
+ branch: BranchOpt = None,
117
+ at: AtOpt = None,
118
+ markdown: MarkdownOpt = False,
119
+ json_out: JsonOpt = False,
120
+ ) -> None:
121
+ """Explain a Record: where each value and Link came from, and the Proposals behind it."""
122
+ app = app_of(ctx, json_out)
123
+ agents = app.client().agents
124
+ name = app.domain(domain)
125
+ chosen = app.branch(branch)
126
+ if markdown:
127
+ app.out.text(agents.explain_markdown(name, kind, key, branch=chosen, at=at))
128
+ return
129
+ found = agents.explain(name, kind, key, branch=chosen, at=at)
130
+
131
+ def human() -> None:
132
+ record = found.record
133
+ app.out.pairs(
134
+ [
135
+ ("record", record.get("ref")),
136
+ ("title", record.get("title")),
137
+ ("branch", found.branch),
138
+ ("revision", short_id(found.revision)),
139
+ ("created by", (found.created or {}).get("actor")),
140
+ ("links", len(found.links)),
141
+ ("changes", found.history.get("changes")),
142
+ ("proposals", [f"{p.get('id')} ({p.get('status')})" for p in found.proposals]),
143
+ ]
144
+ )
145
+ app.out.table(found.attributes, ATTRIBUTE_COLUMNS)
146
+
147
+ app.out.emit(found, human)
148
+
149
+
150
+ KIND_HEALTH_COLUMNS = [
151
+ Column("kind", "kind"),
152
+ Column("records", "records"),
153
+ Column("invalid", "invalid"),
154
+ Column("duplicate groups", "duplicates"),
155
+ Column("derived", "derived"),
156
+ Column("last change", "last_change", short_id),
157
+ ]
158
+ RELATION_HEALTH_COLUMNS = [
159
+ Column("relation", "relation"),
160
+ Column("from", "from_kind"),
161
+ Column("to", "to_kind"),
162
+ Column("links", "links"),
163
+ Column("orphans", "orphans"),
164
+ Column("gaps", "gaps"),
165
+ ]
166
+
167
+
168
+ def _kind_rows(kinds: list[dict[str, Any]]) -> list[dict[str, Any]]:
169
+ return [
170
+ {
171
+ "kind": k["kind"],
172
+ "records": k["records"],
173
+ "invalid": k["violations"]["invalid_records"],
174
+ "duplicates": k["duplicates"]["total_groups"],
175
+ "derived": "stale" if (k.get("derived") or {}).get("stale") else "",
176
+ "last_change": (k.get("last_change") or {}).get("revision"),
177
+ }
178
+ for k in kinds
179
+ ]
180
+
181
+
182
+ def _relation_rows(relations: list[dict[str, Any]]) -> list[dict[str, Any]]:
183
+ return [
184
+ {
185
+ **r,
186
+ "orphans": r["orphan_links"]["count"],
187
+ "gaps": None if r.get("required_gaps") is None else r["required_gaps"]["missing"],
188
+ }
189
+ for r in relations
190
+ ]
191
+
192
+
193
+ def health_command(
194
+ ctx: typer.Context,
195
+ kind: Annotated[
196
+ str | None,
197
+ typer.Argument(help="Report on this Kind only.", metavar="[KIND]"),
198
+ ] = None,
199
+ domain: DomainOpt = None,
200
+ branch: BranchOpt = None,
201
+ at: AtOpt = None,
202
+ markdown: MarkdownOpt = False,
203
+ json_out: JsonOpt = False,
204
+ ) -> None:
205
+ """Check the data: constraint violations, orphan Links, required-Link gaps, duplicates."""
206
+ app = app_of(ctx, json_out)
207
+ agents = app.client().agents
208
+ name = app.domain(domain)
209
+ chosen = app.branch(branch)
210
+ if markdown:
211
+ app.out.text(
212
+ agents.kind_health_markdown(name, kind, branch=chosen, at=at)
213
+ if kind
214
+ else agents.health_markdown(name, branch=chosen, at=at)
215
+ )
216
+ return
217
+ found = (
218
+ agents.kind_health(name, kind, branch=chosen, at=at)
219
+ if kind
220
+ else agents.health(name, branch=chosen, at=at)
221
+ )
222
+ kinds = [found.health] if isinstance(found, m.KindHealthReport) else found.kinds
223
+
224
+ def human() -> None:
225
+ summary = found.summary
226
+ app.out.pairs(
227
+ [
228
+ ("domain", found.domain),
229
+ ("branch", found.branch),
230
+ ("revision", short_id(found.revision)),
231
+ ("status", "ok" if summary.ok else "needs attention"),
232
+ ("records", summary.records),
233
+ ("invalid records", summary.invalid_records),
234
+ ("orphan links", summary.orphan_links),
235
+ ("required-link gaps", summary.required_relation_gaps),
236
+ ("duplicate groups", summary.duplicate_groups),
237
+ ("kinds with stale derived values", summary.stale_kinds),
238
+ ]
239
+ )
240
+ app.out.table(_kind_rows(kinds), KIND_HEALTH_COLUMNS)
241
+ if found.relations:
242
+ app.out.table(_relation_rows(found.relations), RELATION_HEALTH_COLUMNS)
243
+
244
+ app.out.emit(found, human)
@@ -0,0 +1,51 @@
1
+ """`audit verify`: check the hash chain of a Domain's audit log."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from lorebase_cli.context import app_of
10
+ from lorebase_cli.failures import EXIT_INVALID
11
+ from lorebase_cli.options import DomainOpt, JsonOpt
12
+
13
+ audit_app = typer.Typer(help="Check the audit log of a Domain.", no_args_is_help=True)
14
+
15
+
16
+ @audit_app.command("verify")
17
+ def audit_verify(
18
+ ctx: typer.Context,
19
+ domain: DomainOpt = None,
20
+ after_seq: Annotated[
21
+ int | None,
22
+ typer.Option(
23
+ "--after-seq",
24
+ min=0,
25
+ help="Start after this entry, trusting it (the last seq of an earlier run).",
26
+ ),
27
+ ] = None,
28
+ json_out: JsonOpt = False,
29
+ ) -> None:
30
+ """Recompute the audit hash chain of a Domain and report the first broken entry.
31
+
32
+ Needs the audit.read permission (the admin role). Exits with 2 when an entry does not
33
+ verify.
34
+ """
35
+ app = app_of(ctx, json_out)
36
+ result = app.client().audit.verify(app.domain(domain), after_seq=after_seq)
37
+
38
+ def human() -> None:
39
+ if result.first_broken is None:
40
+ app.out.text(f"intact: {result.checked} entries verified")
41
+ else:
42
+ broken = result.first_broken
43
+ app.out.text(
44
+ f"broken at seq {broken.seq}: {broken.reason}; "
45
+ f"{result.checked} entries verified before it"
46
+ )
47
+ app.out.pairs([("domain", result.domain_id), ("last seq", result.last_seq)])
48
+
49
+ app.out.emit(result, human)
50
+ if not result.intact:
51
+ raise typer.Exit(EXIT_INVALID)