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,161 @@
1
+ """`documents upload|list|get|text`: documents stored for extraction by Scribe."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated, Any
7
+
8
+ import typer
9
+
10
+ from lorebase_cli.context import AppContext, app_of
11
+ from lorebase_cli.failures import CliError
12
+ from lorebase_cli.options import AllOpt, DomainOpt, JsonOpt, LimitOpt
13
+ from lorebase_cli.output import Column, short_id
14
+ from lorebase_client import models as m
15
+
16
+ documents_app = typer.Typer(
17
+ help="Upload documents and follow their extraction.", no_args_is_help=True
18
+ )
19
+
20
+ DOCUMENT_COLUMNS = [
21
+ Column("id", "id"),
22
+ Column("filename", "filename"),
23
+ Column("type", "media_type"),
24
+ Column("size", "size"),
25
+ Column("sha256", "sha256", short_id),
26
+ Column("created", "created_at"),
27
+ ]
28
+
29
+
30
+ def _tiers(text: str | None) -> list[int] | None:
31
+ """Extraction tiers from a comma separated list such as `0,1`."""
32
+ if text is None:
33
+ return None
34
+ try:
35
+ return [int(part) for part in text.split(",")]
36
+ except ValueError:
37
+ raise CliError(f"--tiers is a comma separated list of integers, got {text!r}") from None
38
+
39
+
40
+ def _document_pairs(found: m.Document) -> list[tuple[str, Any]]:
41
+ return [
42
+ ("document", found.id),
43
+ ("filename", found.filename),
44
+ ("media type", found.media_type),
45
+ ("size", found.size),
46
+ ("sha256", found.sha256),
47
+ ("text parsed", found.text_sha256 is not None),
48
+ ("created", found.created_at),
49
+ ("created by", found.created_by),
50
+ ]
51
+
52
+
53
+ @documents_app.command("upload")
54
+ def documents_upload(
55
+ ctx: typer.Context,
56
+ path: Annotated[
57
+ Path | None,
58
+ typer.Argument(help="File to upload.", metavar="[PATH]", show_default=False),
59
+ ] = None,
60
+ url: Annotated[
61
+ str | None,
62
+ typer.Option("--url", help="Have the server fetch the document from this URL instead."),
63
+ ] = None,
64
+ no_extract: Annotated[
65
+ bool, typer.Option("--no-extract", help="Store the document without queueing extraction.")
66
+ ] = False,
67
+ tiers: Annotated[
68
+ str | None, typer.Option("--tiers", help="Extraction tiers, comma separated (e.g. 0,1).")
69
+ ] = None,
70
+ domain: DomainOpt = None,
71
+ json_out: JsonOpt = False,
72
+ ) -> None:
73
+ """Upload a document (PATH, or --url) and queue its extraction."""
74
+ app = app_of(ctx, json_out)
75
+ name = app.domain(domain)
76
+ extract = False if no_extract else None
77
+ wanted = _tiers(tiers)
78
+ documents = app.client().documents
79
+ if path is not None and url is None:
80
+ try:
81
+ uploaded = documents.upload(name, path, extract=extract, tiers=wanted)
82
+ except OSError as error:
83
+ raise CliError(f"cannot read {path}: {error.strerror or error}") from error
84
+ elif url is not None and path is None:
85
+ uploaded = documents.upload_url(name, url, extract=extract, tiers=wanted)
86
+ else:
87
+ raise CliError("give PATH or --url")
88
+ app.out.emit(
89
+ uploaded,
90
+ lambda: app.out.pairs(
91
+ [
92
+ *_document_pairs(uploaded.document),
93
+ ("duplicate", uploaded.duplicate),
94
+ ("job", uploaded.job_id),
95
+ ]
96
+ ),
97
+ )
98
+
99
+
100
+ @documents_app.command("list")
101
+ def documents_list(
102
+ ctx: typer.Context,
103
+ domain: DomainOpt = None,
104
+ limit: LimitOpt = None,
105
+ everything: AllOpt = False,
106
+ json_out: JsonOpt = False,
107
+ ) -> None:
108
+ """List the documents of a Domain, newest first."""
109
+ app = app_of(ctx, json_out)
110
+ items, more = app.take(app.client().documents.iterate(app.domain(domain)), limit, everything)
111
+ app.out.emit(items, lambda: app.out.table([d.model_dump() for d in items], DOCUMENT_COLUMNS))
112
+ if more:
113
+ app.note_truncated(len(items))
114
+
115
+
116
+ def _show_document(app: AppContext, found: m.DocumentDetail) -> None:
117
+ app.out.pairs(
118
+ [
119
+ *_document_pairs(found),
120
+ *(
121
+ (
122
+ f"job {job.id}",
123
+ f"{job.status} ({job.model}, prompt {job.prompt_version})"
124
+ + (f", proposal {job.proposal_id}" if job.proposal_id else ""),
125
+ )
126
+ for job in found.extraction_jobs
127
+ ),
128
+ ]
129
+ )
130
+
131
+
132
+ @documents_app.command("get")
133
+ def documents_get(
134
+ ctx: typer.Context,
135
+ document_id: Annotated[str, typer.Argument(help="Document id.", metavar="ID")],
136
+ domain: DomainOpt = None,
137
+ json_out: JsonOpt = False,
138
+ ) -> None:
139
+ """Show a document with its extraction jobs."""
140
+ app = app_of(ctx, json_out)
141
+ found = app.client().documents.get(app.domain(domain), document_id)
142
+ app.out.emit(found, lambda: _show_document(app, found))
143
+
144
+
145
+ @documents_app.command("text")
146
+ def documents_text(
147
+ ctx: typer.Context,
148
+ document_id: Annotated[str, typer.Argument(help="Document id.", metavar="ID")],
149
+ domain: DomainOpt = None,
150
+ json_out: JsonOpt = False,
151
+ ) -> None:
152
+ """Print the parsed text of a document (the text extraction evidence offsets index)."""
153
+ app = app_of(ctx, json_out)
154
+ found = app.client().documents.text(app.domain(domain), document_id)
155
+
156
+ def human() -> None:
157
+ app.out.text(found.text)
158
+ if found.truncated:
159
+ app.out.info(f"text cut at {len(found.text)} of {found.length} characters")
160
+
161
+ app.out.emit(found, human)
@@ -0,0 +1,404 @@
1
+ """`whoami`, `domain`, `branch` and `tag` commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import time
7
+ from typing import Annotated, Any, cast, get_args
8
+
9
+ import typer
10
+
11
+ from lorebase_cli.context import AppContext, app_of
12
+ from lorebase_cli.failures import CliError
13
+ from lorebase_cli.options import AllOpt, DomainOpt, JsonOpt, LimitOpt, complete_domain
14
+ from lorebase_cli.output import Column, short_id
15
+ from lorebase_client.models import Branch, BranchMode, Domain
16
+
17
+ domain_app = typer.Typer(help="Create, inspect and delete Domains.", no_args_is_help=True)
18
+ branch_app = typer.Typer(help="List, create, protect and delete branches.", no_args_is_help=True)
19
+ tag_app = typer.Typer(
20
+ help="List and create tags (immutable names for Revisions).", no_args_is_help=True
21
+ )
22
+
23
+ DomainId = Annotated[
24
+ str, typer.Argument(help="Domain id.", metavar="DOMAIN", autocompletion=complete_domain)
25
+ ]
26
+
27
+ DOMAIN_COLUMNS = [
28
+ Column("id", "id"),
29
+ Column("name", "display_name"),
30
+ Column("default branch", "default_branch"),
31
+ Column("created", "created_at"),
32
+ ]
33
+
34
+
35
+ def whoami(ctx: typer.Context, json_out: JsonOpt = False) -> None:
36
+ """Show who the credentials authenticate as."""
37
+ app = app_of(ctx, json_out)
38
+ me = app.client().whoami()
39
+
40
+ def human() -> None:
41
+ rows: list[tuple[str, Any]] = [
42
+ ("id", me.id),
43
+ ("name", me.display_name),
44
+ ("type", me.type),
45
+ ("method", me.method),
46
+ ("installation admin", me.installation_admin),
47
+ ("subjects", me.subjects),
48
+ ]
49
+ if me.quota is not None:
50
+ rows.append(("quota resets", me.quota.resets_at))
51
+ for name, use in (
52
+ ("requests", me.quota.requests),
53
+ ("context tokens", me.quota.context_tokens),
54
+ ):
55
+ limit = "unlimited" if use.limit is None else use.limit
56
+ rows.append((f"{name} today", f"{use.used} of {limit}"))
57
+ app.out.pairs(rows)
58
+
59
+ app.out.emit(me, human)
60
+
61
+
62
+ # Domains ----------------------------------------------------------------------------------
63
+
64
+
65
+ @domain_app.command("create")
66
+ def domain_create(
67
+ ctx: typer.Context,
68
+ domain_id: Annotated[
69
+ str, typer.Argument(help="Domain id: lowercase slug, immutable.", metavar="DOMAIN")
70
+ ],
71
+ name: Annotated[str, typer.Option("--name", help="Display name.")] = "",
72
+ description: Annotated[str, typer.Option("--description", help="Description.")] = "",
73
+ default_branch: Annotated[
74
+ str | None, typer.Option(help="Name of the default branch (server default: main).")
75
+ ] = None,
76
+ json_out: JsonOpt = False,
77
+ ) -> None:
78
+ """Create a Domain with an empty first Revision and a protected default branch."""
79
+ app = app_of(ctx, json_out)
80
+ created = app.client().domains.create(
81
+ domain_id, display_name=name, description=description, default_branch=default_branch
82
+ )
83
+ app.out.emit(created, lambda: _show_domain(app, created))
84
+
85
+
86
+ @domain_app.command("list")
87
+ def domain_list(
88
+ ctx: typer.Context,
89
+ limit: LimitOpt = None,
90
+ everything: AllOpt = False,
91
+ json_out: JsonOpt = False,
92
+ ) -> None:
93
+ """List the Domains you can read."""
94
+ app = app_of(ctx, json_out)
95
+ items, more = app.take(app.client().domains.iterate(), limit, everything)
96
+ app.out.emit(items, lambda: app.out.table([d.model_dump() for d in items], DOMAIN_COLUMNS))
97
+ if more:
98
+ app.note_truncated(len(items))
99
+
100
+
101
+ @domain_app.command("get")
102
+ def domain_get(ctx: typer.Context, domain_id: DomainId, json_out: JsonOpt = False) -> None:
103
+ """Show a Domain and the head of its default branch."""
104
+ app = app_of(ctx, json_out)
105
+ found = app.client().domains.get(domain_id)
106
+ app.out.emit(found, lambda: _show_domain(app, found))
107
+
108
+
109
+ @domain_app.command("delete")
110
+ def domain_delete(
111
+ ctx: typer.Context,
112
+ domain_id: DomainId,
113
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Do not ask for confirmation.")] = False,
114
+ json_out: JsonOpt = False,
115
+ ) -> None:
116
+ """Delete a Domain with all its Revisions, branches and grants. This cannot be undone."""
117
+ app = app_of(ctx, json_out)
118
+ if not yes:
119
+ if not _interactive():
120
+ raise CliError("deleting a Domain is irreversible: pass --yes to confirm")
121
+ typed = typer.prompt(f"This deletes Domain {domain_id!r} for good. Type its id to confirm")
122
+ if typed != domain_id:
123
+ raise CliError("the id typed does not match; nothing was deleted")
124
+ app.client().domains.delete(domain_id, confirm=domain_id)
125
+ app.out.emit({"deleted": domain_id}, lambda: app.out.info(f"deleted Domain {domain_id}"))
126
+
127
+
128
+ def _interactive() -> bool:
129
+ return sys.stdin.isatty()
130
+
131
+
132
+ def _show_domain(app: AppContext, domain: Domain) -> None:
133
+ app.out.pairs(
134
+ [
135
+ ("id", domain.id),
136
+ ("name", domain.display_name),
137
+ ("description", domain.description),
138
+ ("default branch", domain.default_branch),
139
+ ("head", domain.head),
140
+ ("created", domain.created_at),
141
+ ("created by", domain.created_by),
142
+ ]
143
+ )
144
+
145
+
146
+ # Branches ---------------------------------------------------------------------------------
147
+
148
+ BRANCH_COLUMNS = [
149
+ Column("branch", "name"),
150
+ Column("head", "head", short_id),
151
+ Column("protected", "protected"),
152
+ Column("default", "default"),
153
+ Column("materialization", "materialization"),
154
+ Column("copy", "materialization_status"),
155
+ ]
156
+
157
+ _sleep = time.sleep
158
+ _clock = time.monotonic
159
+
160
+
161
+ def _copy_note(branch: Branch) -> str | None:
162
+ """The background copy of a branch: status, phase and rows read."""
163
+ if branch.materialization_status is None:
164
+ return None
165
+ progress = branch.materialization_progress
166
+ if progress is None:
167
+ return branch.materialization_status
168
+ total = "?" if progress.rows_total is None else progress.rows_total
169
+ return f"{branch.materialization_status}: {progress.phase} {progress.rows_done}/{total}"
170
+
171
+
172
+ @branch_app.command("list")
173
+ def branch_list(
174
+ ctx: typer.Context,
175
+ domain: DomainOpt = None,
176
+ limit: LimitOpt = None,
177
+ everything: AllOpt = False,
178
+ json_out: JsonOpt = False,
179
+ ) -> None:
180
+ """List the branches of a Domain."""
181
+ app = app_of(ctx, json_out)
182
+ items, more = app.take(app.client().branches.iterate(app.domain(domain)), limit, everything)
183
+ app.out.emit(items, lambda: app.out.table([b.model_dump() for b in items], BRANCH_COLUMNS))
184
+ if more:
185
+ app.note_truncated(len(items))
186
+
187
+
188
+ @branch_app.command("get")
189
+ def branch_get(
190
+ ctx: typer.Context,
191
+ name: Annotated[str, typer.Argument(help="Branch name.", metavar="NAME")],
192
+ domain: DomainOpt = None,
193
+ json_out: JsonOpt = False,
194
+ ) -> None:
195
+ """Show a branch and how far it is from the default branch."""
196
+ app = app_of(ctx, json_out)
197
+ found = app.client().branches.get(app.domain(domain), name)
198
+ app.out.emit(
199
+ found,
200
+ lambda: app.out.pairs(
201
+ [
202
+ ("branch", found.name),
203
+ ("head", found.head),
204
+ ("protected", found.protected),
205
+ ("default", found.default),
206
+ ("ahead", found.ahead),
207
+ ("behind", found.behind),
208
+ ("materialization", found.materialization),
209
+ ("copy", _copy_note(found)),
210
+ ("created", found.created_at),
211
+ ]
212
+ ),
213
+ )
214
+
215
+
216
+ @branch_app.command("create")
217
+ def branch_create(
218
+ ctx: typer.Context,
219
+ name: Annotated[
220
+ str, typer.Argument(help="New branch name (may contain slashes).", metavar="NAME")
221
+ ],
222
+ source: Annotated[
223
+ str | None, typer.Option("--source", help="Branch or tag to start from (default branch).")
224
+ ] = None,
225
+ revision: Annotated[
226
+ str | None, typer.Option("--revision", help="Revision id to start at.")
227
+ ] = None,
228
+ mode: Annotated[
229
+ str | None,
230
+ typer.Option("--mode", help="copy (own rows) or overlay (only its changes)."),
231
+ ] = None,
232
+ wait: Annotated[
233
+ bool, typer.Option("--wait", help="Wait until the background copy of the branch ends.")
234
+ ] = False,
235
+ wait_timeout: Annotated[
236
+ float, typer.Option("--wait-timeout", min=0, help="Seconds --wait gives up after.")
237
+ ] = 3600.0,
238
+ poll_interval: Annotated[
239
+ float, typer.Option("--poll-interval", min=0, help="Seconds between --wait checks.")
240
+ ] = 2.0,
241
+ domain: DomainOpt = None,
242
+ json_out: JsonOpt = False,
243
+ ) -> None:
244
+ """Create a branch from a branch, a tag or a Revision. A large copy starts as an overlay
245
+ that a background job copies; --wait blocks until it is a full copy."""
246
+ app = app_of(ctx, json_out)
247
+ allowed = get_args(BranchMode)
248
+ if mode is not None and mode not in allowed:
249
+ raise CliError(f"--mode: unknown {mode!r}, use {', '.join(allowed)}")
250
+ domain_id = app.domain(domain)
251
+ created: Branch = app.client().branches.create(
252
+ domain_id, name, source=source, revision=revision, mode=cast(BranchMode, mode)
253
+ )
254
+ if wait:
255
+ deadline = _clock() + wait_timeout
256
+ last: str | None = None
257
+ while created.materialization_status in ("pending", "running"):
258
+ note = _copy_note(created)
259
+ if note != last:
260
+ app.out.info(f"copying: {note}")
261
+ last = note
262
+ if _clock() >= deadline:
263
+ raise CliError(f"branch {name!r} is still being copied after {wait_timeout:g}s")
264
+ _sleep(poll_interval)
265
+ created = app.client().branches.get(domain_id, name)
266
+ if created.materialization_status == "failed":
267
+ progress = created.materialization_progress
268
+ error = progress.error if progress is not None else None
269
+ raise CliError(f"copy of branch {name!r} failed: {error or 'unknown error'}")
270
+ app.out.emit(created, lambda: app.out.table([created.model_dump()], BRANCH_COLUMNS))
271
+
272
+
273
+ @branch_app.command("delete")
274
+ def branch_delete(
275
+ ctx: typer.Context,
276
+ name: Annotated[str, typer.Argument(help="Branch name.", metavar="NAME")],
277
+ domain: DomainOpt = None,
278
+ json_out: JsonOpt = False,
279
+ ) -> None:
280
+ """Delete a branch (its Revisions stay reachable by id and tag). The default and
281
+ protected branches cannot be deleted."""
282
+ app = app_of(ctx, json_out)
283
+ app.client().branches.delete(app.domain(domain), name)
284
+ app.out.emit({"deleted": name}, lambda: app.out.info(f"deleted branch {name}"))
285
+
286
+
287
+ @branch_app.command("protect")
288
+ def branch_protect(
289
+ ctx: typer.Context,
290
+ name: Annotated[str, typer.Argument(help="Branch name.", metavar="NAME")],
291
+ off: Annotated[bool, typer.Option("--off", help="Remove the protection instead.")] = False,
292
+ domain: DomainOpt = None,
293
+ json_out: JsonOpt = False,
294
+ ) -> None:
295
+ """Protect a branch: changes reach it through Proposals only."""
296
+ app = app_of(ctx, json_out)
297
+ updated = app.client().branches.set_protected(app.domain(domain), name, not off)
298
+ app.out.emit(updated, lambda: app.out.table([updated.model_dump()], BRANCH_COLUMNS))
299
+
300
+
301
+ TAG_CHANGE_COLUMNS = [
302
+ Column("section", "section"),
303
+ Column("owner", "owner"),
304
+ Column("attribute", "attribute"),
305
+ Column("tags", "tags", lambda tags: ", ".join(tags)),
306
+ ]
307
+
308
+
309
+ @branch_app.command("rebase-tags")
310
+ def branch_rebase_tags(
311
+ ctx: typer.Context,
312
+ name: Annotated[str, typer.Argument(help="Branch name.", metavar="BRANCH")],
313
+ message: Annotated[
314
+ str | None, typer.Option("--message", "-m", help="Revision message.")
315
+ ] = None,
316
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Plan only; write nothing.")] = False,
317
+ rows_only: Annotated[
318
+ bool,
319
+ typer.Option(
320
+ "--rows-only",
321
+ help="Leave the schema alone: queue the rewrite of the stale titles and search "
322
+ "vectors only.",
323
+ ),
324
+ ] = False,
325
+ expected_head: Annotated[
326
+ str | None,
327
+ typer.Option("--expected-head", help="Fail unless the branch is at this Revision."),
328
+ ] = None,
329
+ domain: DomainOpt = None,
330
+ json_out: JsonOpt = False,
331
+ ) -> None:
332
+ """Give a branch forked before the default branch tagged an Attribute the tags it lacks.
333
+
334
+ One schema change adds the default branch's sensitivity tags (never removes one) and
335
+ recomputes the titles and search vectors the branch stored before them. Protected
336
+ branches refuse it; --rows-only only queues the rewrite of those rows.
337
+ """
338
+ app = app_of(ctx, json_out)
339
+ result = app.client().branches.rebase_tags(
340
+ app.domain(domain),
341
+ name,
342
+ message=message,
343
+ expected_head=expected_head,
344
+ dry_run=dry_run,
345
+ rows_only=rows_only,
346
+ )
347
+
348
+ def report() -> None:
349
+ app.out.pairs(
350
+ [
351
+ ("status", result.status),
352
+ ("branch", result.branch),
353
+ ("revision", result.revision),
354
+ ("element changes", result.change_count),
355
+ ("job", result.job),
356
+ ]
357
+ )
358
+ if result.tags:
359
+ app.out.table([t.model_dump() for t in result.tags], TAG_CHANGE_COLUMNS)
360
+
361
+ app.out.emit(result, report)
362
+
363
+
364
+ # Tags -------------------------------------------------------------------------------------
365
+
366
+ TAG_COLUMNS = [
367
+ Column("tag", "name"),
368
+ Column("revision", "revision", short_id),
369
+ Column("created", "created_at"),
370
+ Column("by", "created_by"),
371
+ ]
372
+
373
+
374
+ @tag_app.command("list")
375
+ def tag_list(
376
+ ctx: typer.Context,
377
+ domain: DomainOpt = None,
378
+ limit: LimitOpt = None,
379
+ everything: AllOpt = False,
380
+ json_out: JsonOpt = False,
381
+ ) -> None:
382
+ """List the tags of a Domain."""
383
+ app = app_of(ctx, json_out)
384
+ items, more = app.take(app.client().tags.iterate(app.domain(domain)), limit, everything)
385
+ app.out.emit(items, lambda: app.out.table([t.model_dump() for t in items], TAG_COLUMNS))
386
+ if more:
387
+ app.note_truncated(len(items))
388
+
389
+
390
+ @tag_app.command("create")
391
+ def tag_create(
392
+ ctx: typer.Context,
393
+ name: Annotated[str, typer.Argument(help="Tag name.", metavar="NAME")],
394
+ source: Annotated[
395
+ str | None, typer.Option("--source", help="Branch whose head to tag.")
396
+ ] = None,
397
+ revision: Annotated[str | None, typer.Option("--revision", help="Revision id to tag.")] = None,
398
+ domain: DomainOpt = None,
399
+ json_out: JsonOpt = False,
400
+ ) -> None:
401
+ """Tag a Revision (default: the head of the default branch). Tags never move."""
402
+ app = app_of(ctx, json_out)
403
+ created = app.client().tags.create(app.domain(domain), name, source=source, revision=revision)
404
+ app.out.emit(created, lambda: app.out.table([created.model_dump()], TAG_COLUMNS))