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.
- lorebase_cli/__init__.py +3 -0
- lorebase_cli/__main__.py +5 -0
- lorebase_cli/batch_import.py +330 -0
- lorebase_cli/coercion.py +176 -0
- lorebase_cli/commands/__init__.py +1 -0
- lorebase_cli/commands/admin.py +254 -0
- lorebase_cli/commands/agents.py +244 -0
- lorebase_cli/commands/audit.py +51 -0
- lorebase_cli/commands/data.py +742 -0
- lorebase_cli/commands/documents.py +161 -0
- lorebase_cli/commands/domains.py +404 -0
- lorebase_cli/commands/history.py +221 -0
- lorebase_cli/commands/proposals.py +476 -0
- lorebase_cli/commands/schema.py +523 -0
- lorebase_cli/commands/sources.py +69 -0
- lorebase_cli/commands/trace.py +140 -0
- lorebase_cli/context.py +93 -0
- lorebase_cli/failures.py +195 -0
- lorebase_cli/importing.py +277 -0
- lorebase_cli/linkml.py +1607 -0
- lorebase_cli/main.py +156 -0
- lorebase_cli/options.py +56 -0
- lorebase_cli/output.py +169 -0
- lorebase_cli/py.typed +0 -0
- lorebase_cli/settings.py +169 -0
- lorebase_cli/sources.py +118 -0
- lorebase_cli-0.2.3.dist-info/METADATA +11 -0
- lorebase_cli-0.2.3.dist-info/RECORD +30 -0
- lorebase_cli-0.2.3.dist-info/WHEEL +4 -0
- lorebase_cli-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -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))
|