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,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