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,221 @@
|
|
|
1
|
+
"""`log`, `diff` and `revision show`: history of a Domain."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from typing import Annotated, Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from lorebase_cli.context import AppContext, app_of
|
|
12
|
+
from lorebase_cli.options import AllOpt, DomainOpt, JsonOpt, LimitOpt
|
|
13
|
+
from lorebase_cli.output import Column, short_id
|
|
14
|
+
from lorebase_client import endpoints as ep
|
|
15
|
+
from lorebase_client import models as m
|
|
16
|
+
|
|
17
|
+
revision_app = typer.Typer(help="Inspect Revisions.", no_args_is_help=True)
|
|
18
|
+
|
|
19
|
+
REVISION_COLUMNS = [
|
|
20
|
+
Column("revision", "id", short_id),
|
|
21
|
+
Column("gen", "generation"),
|
|
22
|
+
Column("author", "author"),
|
|
23
|
+
Column("origin", "origin", lambda o: (o or {}).get("type", "")),
|
|
24
|
+
Column("message", "message", lambda v: str(v).splitlines()[0] if v else ""),
|
|
25
|
+
Column("created", "created_at"),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _revision_rows(items: list[m.Revision]) -> list[dict[str, Any]]:
|
|
30
|
+
return [r.model_dump() for r in items]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def log_command(
|
|
34
|
+
ctx: typer.Context,
|
|
35
|
+
branch: Annotated[
|
|
36
|
+
str | None,
|
|
37
|
+
typer.Argument(
|
|
38
|
+
help="Branch, tag or Revision (default: the default branch).", metavar="BRANCH"
|
|
39
|
+
),
|
|
40
|
+
] = None,
|
|
41
|
+
domain: DomainOpt = None,
|
|
42
|
+
author: Annotated[
|
|
43
|
+
str | None, typer.Option("--author", help="Only Revisions by this principal.")
|
|
44
|
+
] = None,
|
|
45
|
+
origin_type: Annotated[
|
|
46
|
+
str | None,
|
|
47
|
+
typer.Option(
|
|
48
|
+
"--origin", help="Only Revisions of this origin (import, operation, merge, ...)."
|
|
49
|
+
),
|
|
50
|
+
] = None,
|
|
51
|
+
all_parents: Annotated[
|
|
52
|
+
bool, typer.Option("--all-parents", help="Follow every parent, not only the first.")
|
|
53
|
+
] = False,
|
|
54
|
+
limit: LimitOpt = None,
|
|
55
|
+
everything: AllOpt = False,
|
|
56
|
+
json_out: JsonOpt = False,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Show the history of a branch, newest Revision first."""
|
|
59
|
+
app = app_of(ctx, json_out)
|
|
60
|
+
client = app.client()
|
|
61
|
+
name = app.domain(domain)
|
|
62
|
+
target = branch or app.branch(None) or client.domains.get(name).default_branch
|
|
63
|
+
items, more = app.take(
|
|
64
|
+
client.revisions.iterate_log(
|
|
65
|
+
name, target, all_parents=all_parents, origin_type=origin_type, author=author
|
|
66
|
+
),
|
|
67
|
+
limit,
|
|
68
|
+
everything,
|
|
69
|
+
)
|
|
70
|
+
app.out.emit(items, lambda: app.out.table(_revision_rows(items), REVISION_COLUMNS))
|
|
71
|
+
if more:
|
|
72
|
+
app.note_truncated(len(items))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
CHANGE_COLUMNS = [
|
|
76
|
+
Column("change", "change"),
|
|
77
|
+
Column("element", "element"),
|
|
78
|
+
Column("where", "where"),
|
|
79
|
+
Column("key", "key"),
|
|
80
|
+
Column("values", "values"),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def change_row(change: m.Change) -> dict[str, Any]:
|
|
85
|
+
"""A change as one table row: Records and Links with the Attributes that changed."""
|
|
86
|
+
where = change.kind or change.relation or change.element_type or ""
|
|
87
|
+
key = change.key or ""
|
|
88
|
+
if change.from_key is not None or change.to_key is not None:
|
|
89
|
+
key = f"{change.from_key} -> {change.to_key}"
|
|
90
|
+
if change.api_name and not key:
|
|
91
|
+
key = change.api_name
|
|
92
|
+
values = "; ".join(_value_change(v) for v in change.changes or [])
|
|
93
|
+
return {
|
|
94
|
+
"change": change.change,
|
|
95
|
+
"element": change.element,
|
|
96
|
+
"where": where,
|
|
97
|
+
"key": key,
|
|
98
|
+
"values": values,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _value_change(value: m.ValueChange) -> str:
|
|
103
|
+
def show(item: object) -> str:
|
|
104
|
+
return "(none)" if item is None else str(item)
|
|
105
|
+
|
|
106
|
+
return f"{value.name}: {show(value.old)} -> {show(value.new)}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def diff_command(
|
|
110
|
+
ctx: typer.Context,
|
|
111
|
+
from_: Annotated[
|
|
112
|
+
str, typer.Argument(metavar="FROM", help="Older side: Revision id, tag or branch.")
|
|
113
|
+
],
|
|
114
|
+
to: Annotated[
|
|
115
|
+
str, typer.Argument(help="Newer side: Revision id, tag or branch.", metavar="TO")
|
|
116
|
+
],
|
|
117
|
+
domain: DomainOpt = None,
|
|
118
|
+
kind: Annotated[
|
|
119
|
+
list[str] | None, typer.Option("--kind", help="Only this Kind. Repeatable.")
|
|
120
|
+
] = None,
|
|
121
|
+
relation: Annotated[
|
|
122
|
+
list[str] | None, typer.Option("--relation", help="Only this Relation. Repeatable.")
|
|
123
|
+
] = None,
|
|
124
|
+
no_schema: Annotated[
|
|
125
|
+
bool, typer.Option("--no-schema", help="Leave out schema changes.")
|
|
126
|
+
] = False,
|
|
127
|
+
limit: LimitOpt = None,
|
|
128
|
+
everything: AllOpt = False,
|
|
129
|
+
json_out: JsonOpt = False,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Show what changed between two Revisions (a branch stands for its head)."""
|
|
132
|
+
app = app_of(ctx, json_out)
|
|
133
|
+
client = app.client()
|
|
134
|
+
pages = client.pages(
|
|
135
|
+
ep.diff(
|
|
136
|
+
app.domain(domain),
|
|
137
|
+
from_,
|
|
138
|
+
to,
|
|
139
|
+
kinds=kind,
|
|
140
|
+
relations=relation,
|
|
141
|
+
schema=False if no_schema else None,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
first = next(pages)
|
|
145
|
+
items, more = app.take(itertools.chain(first.items, _items_of(pages)), limit, everything)
|
|
146
|
+
document = {
|
|
147
|
+
"from_revision": first.from_revision,
|
|
148
|
+
"to_revision": first.to_revision,
|
|
149
|
+
"total": first.total,
|
|
150
|
+
"summary": first.summary,
|
|
151
|
+
"items": items,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
def human() -> None:
|
|
155
|
+
app.out.table([change_row(c) for c in items], CHANGE_COLUMNS)
|
|
156
|
+
summary = ", ".join(
|
|
157
|
+
f"{s.name}: +{s.added} -{s.removed} ~{s.modified}" for s in first.summary
|
|
158
|
+
)
|
|
159
|
+
app.out.info(f"{first.total} changes ({summary})" if first.total else "no changes")
|
|
160
|
+
|
|
161
|
+
app.out.emit(document, human)
|
|
162
|
+
if more:
|
|
163
|
+
app.note_truncated(len(items))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _items_of(pages: Iterator[m.ChangesPage]) -> Iterator[m.Change]:
|
|
167
|
+
for page in pages:
|
|
168
|
+
yield from page.items
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@revision_app.command("show")
|
|
172
|
+
def revision_show(
|
|
173
|
+
ctx: typer.Context,
|
|
174
|
+
revision: Annotated[
|
|
175
|
+
str, typer.Argument(help="Revision id, tag or branch.", metavar="REVISION")
|
|
176
|
+
],
|
|
177
|
+
domain: DomainOpt = None,
|
|
178
|
+
changes: Annotated[
|
|
179
|
+
bool, typer.Option("--changes", help="List what the Revision changed.")
|
|
180
|
+
] = False,
|
|
181
|
+
limit: LimitOpt = None,
|
|
182
|
+
everything: AllOpt = False,
|
|
183
|
+
json_out: JsonOpt = False,
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Show a Revision, and with --changes its changes against the first parent."""
|
|
186
|
+
app = app_of(ctx, json_out)
|
|
187
|
+
client = app.client()
|
|
188
|
+
name = app.domain(domain)
|
|
189
|
+
found = client.revisions.get(name, revision)
|
|
190
|
+
listed: list[m.Change] = []
|
|
191
|
+
more = False
|
|
192
|
+
if changes:
|
|
193
|
+
listed, more = app.take(
|
|
194
|
+
client.revisions.iterate_changeset(name, found.id), limit, everything
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def human() -> None:
|
|
198
|
+
_show_revision(app, found)
|
|
199
|
+
if changes:
|
|
200
|
+
app.out.table([change_row(c) for c in listed], CHANGE_COLUMNS)
|
|
201
|
+
|
|
202
|
+
payload: Any = {**found.model_dump(mode="json"), "changes": listed} if changes else found
|
|
203
|
+
app.out.emit(payload, human)
|
|
204
|
+
if more:
|
|
205
|
+
app.note_truncated(len(listed))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _show_revision(app: AppContext, found: m.Revision) -> None:
|
|
209
|
+
app.out.pairs(
|
|
210
|
+
[
|
|
211
|
+
("revision", found.id),
|
|
212
|
+
("parents", [short_id(p) for p in found.parents]),
|
|
213
|
+
("generation", found.generation),
|
|
214
|
+
("author", found.author),
|
|
215
|
+
("on behalf of", found.on_behalf_of),
|
|
216
|
+
("origin", found.origin),
|
|
217
|
+
("message", found.message),
|
|
218
|
+
("stats", found.stats),
|
|
219
|
+
("created", found.created_at),
|
|
220
|
+
]
|
|
221
|
+
)
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""`proposal create|list|get|review|merge|close|conflicts|resolve|items|decide`: Proposals."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Any, cast, get_args
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from lorebase_cli.commands.data import json_argument
|
|
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
|
+
proposal_app = typer.Typer(
|
|
17
|
+
help="Propose, review and merge changes between branches.", no_args_is_help=True
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
ProposalArg = Annotated[str, typer.Argument(help="Proposal id.", metavar="ID")]
|
|
21
|
+
|
|
22
|
+
PROPOSAL_COLUMNS = [
|
|
23
|
+
Column("id", "id"),
|
|
24
|
+
Column("title", "title"),
|
|
25
|
+
Column("source", "source_ref"),
|
|
26
|
+
Column("target", "target_ref"),
|
|
27
|
+
Column("status", "status"),
|
|
28
|
+
Column("author", "author"),
|
|
29
|
+
Column("created", "created_at"),
|
|
30
|
+
]
|
|
31
|
+
CONFLICT_COLUMNS = [
|
|
32
|
+
Column("id", "id", short_id),
|
|
33
|
+
Column("status", "status"),
|
|
34
|
+
Column("kind", "kind"),
|
|
35
|
+
Column("where", "partition"),
|
|
36
|
+
Column("key", "key"),
|
|
37
|
+
Column("message", "message"),
|
|
38
|
+
]
|
|
39
|
+
ITEM_COLUMNS = [
|
|
40
|
+
Column("id", "id"),
|
|
41
|
+
Column("type", "type"),
|
|
42
|
+
Column("decision", "decision"),
|
|
43
|
+
Column("confidence", "confidence"),
|
|
44
|
+
Column("kind", "target"),
|
|
45
|
+
Column("key", "key"),
|
|
46
|
+
Column("attribute", "attribute"),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _show_proposal(app: AppContext, found: m.ProposalDetail) -> None:
|
|
51
|
+
approval = found.approval
|
|
52
|
+
pairs: list[tuple[str, Any]] = [
|
|
53
|
+
("proposal", found.id),
|
|
54
|
+
("title", found.title),
|
|
55
|
+
("description", found.description),
|
|
56
|
+
("branches", f"{found.source_ref} -> {found.target_ref}"),
|
|
57
|
+
("origin", found.origin),
|
|
58
|
+
("status", found.status),
|
|
59
|
+
("author", found.author),
|
|
60
|
+
("created", found.created_at),
|
|
61
|
+
("updated", found.updated_at),
|
|
62
|
+
("merged revision", found.merged_revision),
|
|
63
|
+
("checks", [f"{c.name}: {c.status}" for c in found.checks]),
|
|
64
|
+
(
|
|
65
|
+
"approvals",
|
|
66
|
+
f"{approval.approvals}/{approval.required}"
|
|
67
|
+
+ (" (satisfied)" if approval.satisfied else ""),
|
|
68
|
+
),
|
|
69
|
+
("reviews", len(found.reviews)),
|
|
70
|
+
]
|
|
71
|
+
if found.attempt is not None:
|
|
72
|
+
pairs.append(
|
|
73
|
+
(
|
|
74
|
+
"conflicts",
|
|
75
|
+
f"{found.attempt.open_conflicts} open, {found.attempt.resolved_conflicts} resolved",
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
app.out.pairs(pairs)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@proposal_app.command("create")
|
|
82
|
+
def proposal_create(
|
|
83
|
+
ctx: typer.Context,
|
|
84
|
+
source: Annotated[str, typer.Argument(help="Branch with the changes.", metavar="SOURCE")],
|
|
85
|
+
target: Annotated[str, typer.Argument(help="Branch to merge into.", metavar="TARGET")],
|
|
86
|
+
title: Annotated[str, typer.Option("--title", "-t", help="Title of the Proposal.")],
|
|
87
|
+
description: Annotated[
|
|
88
|
+
str | None, typer.Option("--description", help="Longer description.")
|
|
89
|
+
] = None,
|
|
90
|
+
domain: DomainOpt = None,
|
|
91
|
+
json_out: JsonOpt = False,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Open a Proposal to merge SOURCE into TARGET."""
|
|
94
|
+
app = app_of(ctx, json_out)
|
|
95
|
+
created = app.client().proposals.create(
|
|
96
|
+
app.domain(domain), source, target, title=title, description=description
|
|
97
|
+
)
|
|
98
|
+
app.out.emit(created, lambda: _show_proposal(app, created))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@proposal_app.command("list")
|
|
102
|
+
def proposal_list(
|
|
103
|
+
ctx: typer.Context,
|
|
104
|
+
status: Annotated[str | None, typer.Option("--status", help="open, merged or closed.")] = None,
|
|
105
|
+
source: Annotated[
|
|
106
|
+
str | None, typer.Option("--source", help="Only Proposals from this branch.")
|
|
107
|
+
] = None,
|
|
108
|
+
target: Annotated[
|
|
109
|
+
str | None, typer.Option("--target", help="Only Proposals into this branch.")
|
|
110
|
+
] = None,
|
|
111
|
+
author: Annotated[
|
|
112
|
+
str | None, typer.Option("--author", help="Only Proposals by this principal.")
|
|
113
|
+
] = None,
|
|
114
|
+
domain: DomainOpt = None,
|
|
115
|
+
limit: LimitOpt = None,
|
|
116
|
+
everything: AllOpt = False,
|
|
117
|
+
json_out: JsonOpt = False,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""List the Proposals of a Domain."""
|
|
120
|
+
app = app_of(ctx, json_out)
|
|
121
|
+
items, more = app.take(
|
|
122
|
+
app.client().proposals.iterate(
|
|
123
|
+
app.domain(domain),
|
|
124
|
+
status=cast("m.ProposalStatus | None", status),
|
|
125
|
+
source=source,
|
|
126
|
+
target=target,
|
|
127
|
+
author=author,
|
|
128
|
+
),
|
|
129
|
+
limit,
|
|
130
|
+
everything,
|
|
131
|
+
)
|
|
132
|
+
app.out.emit(items, lambda: app.out.table([p.model_dump() for p in items], PROPOSAL_COLUMNS))
|
|
133
|
+
if more:
|
|
134
|
+
app.note_truncated(len(items))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@proposal_app.command("get")
|
|
138
|
+
def proposal_get(
|
|
139
|
+
ctx: typer.Context,
|
|
140
|
+
proposal_id: ProposalArg,
|
|
141
|
+
domain: DomainOpt = None,
|
|
142
|
+
json_out: JsonOpt = False,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Show a Proposal with its checks, reviews and approval state."""
|
|
145
|
+
app = app_of(ctx, json_out)
|
|
146
|
+
found = app.client().proposals.get(app.domain(domain), proposal_id)
|
|
147
|
+
app.out.emit(found, lambda: _show_proposal(app, found))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@proposal_app.command("merge")
|
|
151
|
+
def proposal_merge(
|
|
152
|
+
ctx: typer.Context,
|
|
153
|
+
proposal_id: ProposalArg,
|
|
154
|
+
message: Annotated[
|
|
155
|
+
str | None, typer.Option("--message", "-m", help="Message of the merge Revision.")
|
|
156
|
+
] = None,
|
|
157
|
+
domain: DomainOpt = None,
|
|
158
|
+
json_out: JsonOpt = False,
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Merge a Proposal. Open Conflicts or missing approvals refuse it with exit code 2."""
|
|
161
|
+
app = app_of(ctx, json_out)
|
|
162
|
+
client = app.client()
|
|
163
|
+
merged = client.proposals.merge(
|
|
164
|
+
app.domain(domain), proposal_id, message=message, idempotency_key=client.idempotency_key()
|
|
165
|
+
)
|
|
166
|
+
app.out.emit(
|
|
167
|
+
merged,
|
|
168
|
+
lambda: app.out.pairs(
|
|
169
|
+
[
|
|
170
|
+
("proposal", merged.proposal.id),
|
|
171
|
+
("branches", f"{merged.proposal.source_ref} -> {merged.proposal.target_ref}"),
|
|
172
|
+
("result", merged.result),
|
|
173
|
+
("revision", merged.revision),
|
|
174
|
+
("source deleted", merged.source_deleted),
|
|
175
|
+
("replayed", merged.replayed),
|
|
176
|
+
]
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@proposal_app.command("review")
|
|
182
|
+
def proposal_review(
|
|
183
|
+
ctx: typer.Context,
|
|
184
|
+
proposal_id: ProposalArg,
|
|
185
|
+
verdict: Annotated[
|
|
186
|
+
str, typer.Argument(help="approve, request_changes or comment.", metavar="VERDICT")
|
|
187
|
+
],
|
|
188
|
+
comment: Annotated[str | None, typer.Option("--comment", "-c", help="Review comment.")] = None,
|
|
189
|
+
domain: DomainOpt = None,
|
|
190
|
+
json_out: JsonOpt = False,
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Review a Proposal; approvals count towards merging into a protected branch."""
|
|
193
|
+
if verdict not in get_args(m.ReviewVerdict):
|
|
194
|
+
raise CliError(f"unknown verdict {verdict!r}: use approve, request_changes or comment")
|
|
195
|
+
app = app_of(ctx, json_out)
|
|
196
|
+
result = app.client().proposals.review(
|
|
197
|
+
app.domain(domain), proposal_id, cast("m.ReviewVerdict", verdict), comment=comment
|
|
198
|
+
)
|
|
199
|
+
approval = result.approval
|
|
200
|
+
app.out.emit(
|
|
201
|
+
result,
|
|
202
|
+
lambda: app.out.pairs(
|
|
203
|
+
[
|
|
204
|
+
("proposal", proposal_id),
|
|
205
|
+
("verdict", verdict),
|
|
206
|
+
(
|
|
207
|
+
"approvals",
|
|
208
|
+
f"{approval.approvals}/{approval.required}"
|
|
209
|
+
+ (" (satisfied)" if approval.satisfied else ""),
|
|
210
|
+
),
|
|
211
|
+
]
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@proposal_app.command("close")
|
|
217
|
+
def proposal_close(
|
|
218
|
+
ctx: typer.Context,
|
|
219
|
+
proposal_id: ProposalArg,
|
|
220
|
+
domain: DomainOpt = None,
|
|
221
|
+
json_out: JsonOpt = False,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Close a Proposal without merging it."""
|
|
224
|
+
app = app_of(ctx, json_out)
|
|
225
|
+
closed = app.client().proposals.close(app.domain(domain), proposal_id)
|
|
226
|
+
app.out.emit(closed, lambda: _show_proposal(app, closed))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@proposal_app.command("conflicts")
|
|
230
|
+
def proposal_conflicts(
|
|
231
|
+
ctx: typer.Context,
|
|
232
|
+
proposal_id: ProposalArg,
|
|
233
|
+
status: Annotated[
|
|
234
|
+
str | None, typer.Option("--status", help="open or resolved (default: both).")
|
|
235
|
+
] = None,
|
|
236
|
+
domain: DomainOpt = None,
|
|
237
|
+
limit: LimitOpt = None,
|
|
238
|
+
everything: AllOpt = False,
|
|
239
|
+
json_out: JsonOpt = False,
|
|
240
|
+
) -> None:
|
|
241
|
+
"""List the Conflicts of the latest merge attempt of a Proposal."""
|
|
242
|
+
app = app_of(ctx, json_out)
|
|
243
|
+
items, more = app.take(
|
|
244
|
+
app.client().proposals.iterate_conflicts(
|
|
245
|
+
app.domain(domain), proposal_id, status=cast("m.ConflictStatus | None", status)
|
|
246
|
+
),
|
|
247
|
+
limit,
|
|
248
|
+
everything,
|
|
249
|
+
)
|
|
250
|
+
app.out.emit(items, lambda: app.out.table([c.model_dump() for c in items], CONFLICT_COLUMNS))
|
|
251
|
+
if more:
|
|
252
|
+
app.note_truncated(len(items))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@proposal_app.command("resolve")
|
|
256
|
+
def proposal_resolve(
|
|
257
|
+
ctx: typer.Context,
|
|
258
|
+
proposal_id: ProposalArg,
|
|
259
|
+
conflict_id: Annotated[
|
|
260
|
+
str | None,
|
|
261
|
+
typer.Argument(help="Conflict id (see `proposal conflicts`).", metavar="[CONFLICT_ID]"),
|
|
262
|
+
] = None,
|
|
263
|
+
choice: Annotated[
|
|
264
|
+
str | None,
|
|
265
|
+
typer.Option("--choice", help="ours, theirs, base, custom, drop, restore or relax."),
|
|
266
|
+
] = None,
|
|
267
|
+
value: Annotated[
|
|
268
|
+
str | None,
|
|
269
|
+
typer.Option("--value", help="Value of a `custom` choice: inline JSON, @file or -."),
|
|
270
|
+
] = None,
|
|
271
|
+
file: Annotated[
|
|
272
|
+
str | None,
|
|
273
|
+
typer.Option(
|
|
274
|
+
"--file",
|
|
275
|
+
help='Resolve many Conflicts: a JSON array (or {"resolutions": [...]}) of '
|
|
276
|
+
"objects with `fingerprint`, `choice` and optionally `value` or `values`.",
|
|
277
|
+
),
|
|
278
|
+
] = None,
|
|
279
|
+
domain: DomainOpt = None,
|
|
280
|
+
json_out: JsonOpt = False,
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Decide one Conflict (CONFLICT_ID and --choice), or many with --file.
|
|
283
|
+
|
|
284
|
+
Deciding one only stores the decision; --file also re-runs the merge and prints the new
|
|
285
|
+
attempt.
|
|
286
|
+
"""
|
|
287
|
+
app = app_of(ctx, json_out)
|
|
288
|
+
name = app.domain(domain)
|
|
289
|
+
if file is not None:
|
|
290
|
+
if conflict_id or choice or value:
|
|
291
|
+
raise CliError("--file replaces CONFLICT_ID, --choice and --value")
|
|
292
|
+
attempt = app.client().proposals.resolve_many(name, proposal_id, _resolutions(file))
|
|
293
|
+
app.out.emit(
|
|
294
|
+
attempt,
|
|
295
|
+
lambda: app.out.pairs(
|
|
296
|
+
[
|
|
297
|
+
("proposal", attempt.proposal),
|
|
298
|
+
("status", attempt.status),
|
|
299
|
+
("open conflicts", attempt.open_conflicts),
|
|
300
|
+
("resolved conflicts", attempt.resolved_conflicts),
|
|
301
|
+
]
|
|
302
|
+
),
|
|
303
|
+
)
|
|
304
|
+
return
|
|
305
|
+
if not conflict_id or not choice:
|
|
306
|
+
raise CliError("give CONFLICT_ID and --choice, or --file")
|
|
307
|
+
resolution = app.client().proposals.resolve(
|
|
308
|
+
name,
|
|
309
|
+
proposal_id,
|
|
310
|
+
conflict_id,
|
|
311
|
+
choice,
|
|
312
|
+
value=None if value is None else json_argument(value),
|
|
313
|
+
)
|
|
314
|
+
app.out.emit(
|
|
315
|
+
resolution,
|
|
316
|
+
lambda: app.out.pairs(
|
|
317
|
+
[
|
|
318
|
+
("conflict", conflict_id),
|
|
319
|
+
("choice", resolution.choice),
|
|
320
|
+
("value", resolution.value),
|
|
321
|
+
("resolver", resolution.resolver),
|
|
322
|
+
("at", resolution.at),
|
|
323
|
+
]
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _resolutions(file: str) -> list[dict[str, Any]]:
|
|
329
|
+
parsed = json_argument(file if file == "-" else "@" + file)
|
|
330
|
+
if isinstance(parsed, dict):
|
|
331
|
+
parsed = parsed.get("resolutions")
|
|
332
|
+
if not isinstance(parsed, list) or not all(isinstance(item, dict) for item in parsed):
|
|
333
|
+
raise CliError("the resolutions file is a JSON array of objects")
|
|
334
|
+
return parsed
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@proposal_app.command("items")
|
|
338
|
+
def proposal_items(
|
|
339
|
+
ctx: typer.Context,
|
|
340
|
+
proposal_id: ProposalArg,
|
|
341
|
+
status: Annotated[
|
|
342
|
+
list[str] | None,
|
|
343
|
+
typer.Option("--status", help="pending, accepted or rejected (repeatable)."),
|
|
344
|
+
] = None,
|
|
345
|
+
kind: Annotated[
|
|
346
|
+
list[str] | None, typer.Option("--kind", help="Kind or Relation (repeatable).")
|
|
347
|
+
] = None,
|
|
348
|
+
item_type: Annotated[
|
|
349
|
+
list[str] | None,
|
|
350
|
+
typer.Option("--type", help="record, value or link (repeatable)."),
|
|
351
|
+
] = None,
|
|
352
|
+
min_confidence: Annotated[
|
|
353
|
+
float | None, typer.Option("--min-confidence", min=0, max=1, help="Lowest confidence.")
|
|
354
|
+
] = None,
|
|
355
|
+
max_confidence: Annotated[
|
|
356
|
+
float | None,
|
|
357
|
+
typer.Option("--max-confidence", min=0, max=1.01, help="Confidence below this."),
|
|
358
|
+
] = None,
|
|
359
|
+
domain: DomainOpt = None,
|
|
360
|
+
limit: LimitOpt = None,
|
|
361
|
+
everything: AllOpt = False,
|
|
362
|
+
json_out: JsonOpt = False,
|
|
363
|
+
) -> None:
|
|
364
|
+
"""List the items an extraction Proposal proposes, in Proposal order."""
|
|
365
|
+
app = app_of(ctx, json_out)
|
|
366
|
+
items, more = app.take(
|
|
367
|
+
app.client().proposals.iterate_items(
|
|
368
|
+
app.domain(domain),
|
|
369
|
+
proposal_id,
|
|
370
|
+
status=_choices(status, m.ItemDecisionValue, "--status"),
|
|
371
|
+
kind=kind,
|
|
372
|
+
type=_choices(item_type, m.ItemType, "--type"),
|
|
373
|
+
min_confidence=min_confidence,
|
|
374
|
+
max_confidence=max_confidence,
|
|
375
|
+
),
|
|
376
|
+
limit,
|
|
377
|
+
everything,
|
|
378
|
+
)
|
|
379
|
+
app.out.emit(
|
|
380
|
+
items,
|
|
381
|
+
lambda: app.out.table(
|
|
382
|
+
[{**i.model_dump(), "target": i.kind or i.relation} for i in items], ITEM_COLUMNS
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
if more:
|
|
386
|
+
app.note_truncated(len(items))
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@proposal_app.command("decide")
|
|
390
|
+
def proposal_decide(
|
|
391
|
+
ctx: typer.Context,
|
|
392
|
+
proposal_id: ProposalArg,
|
|
393
|
+
accept: Annotated[
|
|
394
|
+
list[str] | None, typer.Option("--accept", help="Item id to accept (repeatable).")
|
|
395
|
+
] = None,
|
|
396
|
+
reject: Annotated[
|
|
397
|
+
list[str] | None, typer.Option("--reject", help="Item id to reject (repeatable).")
|
|
398
|
+
] = None,
|
|
399
|
+
where_status: Annotated[
|
|
400
|
+
list[str] | None,
|
|
401
|
+
typer.Option("--where-status", help="Decide every item in this status (repeatable)."),
|
|
402
|
+
] = None,
|
|
403
|
+
where_kind: Annotated[
|
|
404
|
+
list[str] | None,
|
|
405
|
+
typer.Option("--where-kind", help="Decide every item of this Kind or Relation."),
|
|
406
|
+
] = None,
|
|
407
|
+
where_type: Annotated[
|
|
408
|
+
list[str] | None,
|
|
409
|
+
typer.Option("--where-type", help="Decide every item of this type."),
|
|
410
|
+
] = None,
|
|
411
|
+
where_min_confidence: Annotated[
|
|
412
|
+
float | None,
|
|
413
|
+
typer.Option("--where-min-confidence", min=0, max=1, help="Only items from this."),
|
|
414
|
+
] = None,
|
|
415
|
+
where_max_confidence: Annotated[
|
|
416
|
+
float | None,
|
|
417
|
+
typer.Option("--where-max-confidence", min=0, max=1.01, help="Only items below this."),
|
|
418
|
+
] = None,
|
|
419
|
+
decision: Annotated[
|
|
420
|
+
str | None,
|
|
421
|
+
typer.Option("--decision", help="pending, accepted or rejected, for the --where-* items."),
|
|
422
|
+
] = None,
|
|
423
|
+
domain: DomainOpt = None,
|
|
424
|
+
json_out: JsonOpt = False,
|
|
425
|
+
) -> None:
|
|
426
|
+
"""Accept or reject items, by id and or by filter; the extraction branch is rebuilt."""
|
|
427
|
+
selection = m.ItemSelection(
|
|
428
|
+
status=_choices(where_status, m.ItemDecisionValue, "--where-status"),
|
|
429
|
+
kind=where_kind,
|
|
430
|
+
type=_choices(where_type, m.ItemType, "--where-type"),
|
|
431
|
+
min_confidence=where_min_confidence,
|
|
432
|
+
max_confidence=where_max_confidence,
|
|
433
|
+
)
|
|
434
|
+
filtered = bool(selection.payload())
|
|
435
|
+
chosen = _choices([decision] if decision else None, m.ItemDecisionValue, "--decision")
|
|
436
|
+
if filtered != (chosen is not None):
|
|
437
|
+
raise CliError("--decision and the --where-* filters go together")
|
|
438
|
+
decisions = [
|
|
439
|
+
*(m.ItemDecision(item=item, decision="accepted") for item in accept or []),
|
|
440
|
+
*(m.ItemDecision(item=item, decision="rejected") for item in reject or []),
|
|
441
|
+
]
|
|
442
|
+
if not decisions and not filtered:
|
|
443
|
+
raise CliError("give --accept or --reject items, or --decision with --where-* filters")
|
|
444
|
+
app = app_of(ctx, json_out)
|
|
445
|
+
decided = app.client().proposals.decide_items(
|
|
446
|
+
app.domain(domain),
|
|
447
|
+
proposal_id,
|
|
448
|
+
decisions,
|
|
449
|
+
where=selection if filtered else None,
|
|
450
|
+
decision=chosen[0] if chosen else None,
|
|
451
|
+
)
|
|
452
|
+
done = decided.materialized
|
|
453
|
+
app.out.emit(
|
|
454
|
+
decided,
|
|
455
|
+
lambda: app.out.pairs(
|
|
456
|
+
[
|
|
457
|
+
("proposal", decided.proposal_id),
|
|
458
|
+
("title", decided.title),
|
|
459
|
+
("items decided", len(decided.items)),
|
|
460
|
+
("revision", done.revision),
|
|
461
|
+
("changes", done.changes),
|
|
462
|
+
("applied", done.applied),
|
|
463
|
+
("skipped", [f"{item}: {why}" for item, why in done.skipped.items()]),
|
|
464
|
+
]
|
|
465
|
+
),
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _choices(values: list[str] | None, allowed: Any, option: str) -> list[Any] | None:
|
|
470
|
+
"""`values` checked against the members of a Literal type."""
|
|
471
|
+
if not values:
|
|
472
|
+
return None
|
|
473
|
+
wrong = [v for v in values if v not in get_args(allowed)]
|
|
474
|
+
if wrong:
|
|
475
|
+
raise CliError(f"{option}: unknown {wrong[0]!r}, use {', '.join(get_args(allowed))}")
|
|
476
|
+
return values
|