lorebase-cli 0.2.3__tar.gz → 0.3.0__tar.gz

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.
Files changed (38) hide show
  1. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/PKG-INFO +2 -2
  2. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/pyproject.toml +2 -2
  3. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/__init__.py +1 -1
  4. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/commands/agents.py +16 -0
  5. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/commands/data.py +118 -4
  6. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/commands/documents.py +112 -7
  7. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/commands/proposals.py +5 -1
  8. lorebase_cli-0.3.0/src/lorebase_cli/commands/reconcile.py +233 -0
  9. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/main.py +3 -0
  10. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/options.py +3 -0
  11. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/tests/test_commands.py +274 -3
  12. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/.gitignore +0 -0
  13. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/__main__.py +0 -0
  14. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/batch_import.py +0 -0
  15. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/coercion.py +0 -0
  16. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/commands/__init__.py +0 -0
  17. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/commands/admin.py +0 -0
  18. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/commands/audit.py +0 -0
  19. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/commands/domains.py +0 -0
  20. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/commands/history.py +0 -0
  21. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/commands/schema.py +0 -0
  22. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/commands/sources.py +0 -0
  23. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/commands/trace.py +0 -0
  24. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/context.py +0 -0
  25. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/failures.py +0 -0
  26. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/importing.py +0 -0
  27. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/linkml.py +0 -0
  28. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/output.py +0 -0
  29. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/py.typed +0 -0
  30. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/settings.py +0 -0
  31. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/src/lorebase_cli/sources.py +0 -0
  32. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/tests/conftest.py +0 -0
  33. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/tests/fixtures/personinfo.linkml.yaml +0 -0
  34. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/tests/integration/test_quickstart.py +0 -0
  35. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/tests/test_coercion_and_sources.py +0 -0
  36. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/tests/test_import_batch.py +0 -0
  37. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/tests/test_linkml.py +0 -0
  38. {lorebase_cli-0.2.3 → lorebase_cli-0.3.0}/tests/test_settings.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lorebase-cli
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: Lorebase CLI
5
5
  Requires-Python: >=3.12
6
- Requires-Dist: lorebase-client<0.3,>=0.2
6
+ Requires-Dist: lorebase-client<0.4,>=0.3
7
7
  Requires-Dist: typer>=0.27
8
8
  Provides-Extra: linkml
9
9
  Requires-Dist: pyyaml>=6.0; extra == 'linkml'
@@ -1,10 +1,10 @@
1
1
  [project]
2
2
  name = "lorebase-cli"
3
- version = "0.2.3"
3
+ version = "0.3.0"
4
4
  description = "Lorebase CLI"
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
7
- "lorebase-client>=0.2,<0.3",
7
+ "lorebase-client>=0.3,<0.4",
8
8
  # Command line framework with shell completion.
9
9
  "typer>=0.27",
10
10
  ]
@@ -1,3 +1,3 @@
1
1
  """lorebase CLI: command line client of a Lorebase Keeper."""
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.3.0"
@@ -57,6 +57,20 @@ def context_command(
57
57
  list[str] | None,
58
58
  typer.Option("--relation", help="Follow only this Relation. Repeatable."),
59
59
  ] = None,
60
+ trait: Annotated[
61
+ list[str] | None,
62
+ typer.Option(
63
+ "--trait",
64
+ help="Limit --search and --query seeds and neighbors to the Kinds implementing "
65
+ "this Trait. Repeatable.",
66
+ ),
67
+ ] = None,
68
+ lens: Annotated[
69
+ str | None,
70
+ typer.Option(
71
+ "--lens", help="Apply this lens: its Relations, depth, budget and Kinds are defaults."
72
+ ),
73
+ ] = None,
60
74
  domain: DomainOpt = None,
61
75
  branch: BranchOpt = None,
62
76
  at: AtOpt = None,
@@ -80,6 +94,8 @@ def context_command(
80
94
  budget_tokens=budget,
81
95
  depth=depth,
82
96
  relations=relation or None,
97
+ traits=trait or None,
98
+ lens=lens,
83
99
  )
84
100
  agents = app.client().agents
85
101
  name = app.domain(domain)
@@ -1,9 +1,10 @@
1
- """`import`, `record`, `query` and `search`: loading and reading Records and Links."""
1
+ """`import`, `record`, `query`, `search` and `resolve`: loading and reading Records and Links."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import sys
7
+ from collections.abc import Iterator
7
8
  from pathlib import Path
8
9
  from typing import Annotated, Any
9
10
 
@@ -638,15 +639,39 @@ def query_command(
638
639
  body["at"] = at
639
640
  client = app.client()
640
641
  domain_id = app.domain(domain)
642
+ first = client.records.query(domain_id, body)
641
643
  if body.get("aggregate"):
642
- result = client.records.query(domain_id, body)
643
- app.out.emit(result, lambda: _print_aggregate(app, result))
644
+ app.out.emit(first, lambda: _print_aggregate(app, first))
645
+ _print_coverage(app, first.coverage)
644
646
  return
645
- items, more = app.take(client.records.iterate_query(domain_id, body), limit, everything)
647
+
648
+ def pages() -> Iterator[m.RecordItem]:
649
+ page = first
650
+ while True:
651
+ yield from page.items
652
+ if page.next_cursor is None:
653
+ return
654
+ # Only the request's own members: a Keeper older than the coverage block refuses
655
+ # unknown ones, so later pages repeat the first one's body with the cursor.
656
+ following = {**body, "cursor": page.next_cursor}
657
+ page = client.records.query(domain_id, following)
658
+
659
+ items, more = app.take(pages(), limit, everything)
646
660
  selected = [str(s) for s in body.get("select") or [] if s not in ("key", "title")]
647
661
  _print_records(app, items, selected)
648
662
  if more:
649
663
  app.note_truncated(len(items))
664
+ _print_coverage(app, first.coverage)
665
+
666
+
667
+ def _print_coverage(app: AppContext, coverage: m.Coverage | None) -> None:
668
+ """The coverage notes (design 8.11), one line each, besides the results."""
669
+ if coverage is None:
670
+ return
671
+ for note in coverage.notes:
672
+ app.out.info(note)
673
+ if coverage.omitted:
674
+ app.out.info(f"coverage: {coverage.omitted}")
650
675
 
651
676
 
652
677
  def _query_body(text: str) -> dict[str, Any]:
@@ -691,6 +716,18 @@ def search_command(
691
716
  kind: Annotated[
692
717
  list[str] | None, typer.Option("--kind", help="Only this Kind. Repeatable.")
693
718
  ] = None,
719
+ trait: Annotated[
720
+ list[str] | None,
721
+ typer.Option(
722
+ "--trait", help="Only the Kinds that implement this Trait. Repeatable; adds to --kind."
723
+ ),
724
+ ] = None,
725
+ lens: Annotated[
726
+ str | None,
727
+ typer.Option(
728
+ "--lens", help="Search the Kinds and Traits of this lens unless --kind or --trait."
729
+ ),
730
+ ] = None,
694
731
  mode: Annotated[
695
732
  str | None,
696
733
  typer.Option(
@@ -719,6 +756,8 @@ def search_command(
719
756
  {
720
757
  "q": text,
721
758
  "kinds": kind,
759
+ "traits": trait,
760
+ "lens": lens,
722
761
  "mode": mode,
723
762
  "query_vector": vector,
724
763
  "branch": app.branch(branch),
@@ -740,3 +779,78 @@ def search_command(
740
779
  ],
741
780
  ),
742
781
  )
782
+ if result.semantic_truncated:
783
+ app.out.info("the vector list is short: the index scan stopped at its bound; narrow --kind")
784
+
785
+
786
+ def resolve_command(
787
+ ctx: typer.Context,
788
+ text: Annotated[str, typer.Argument(help="Words to ground, e.g. a question.", metavar="TEXT")],
789
+ kind: Annotated[
790
+ list[str] | None, typer.Option("--kind", help="Only look in this Kind. Repeatable.")
791
+ ] = None,
792
+ level: Annotated[
793
+ list[str] | None,
794
+ typer.Option(
795
+ "--level",
796
+ help="exact, synonym, trigram or embedding (default: those the server enables). "
797
+ "Repeatable.",
798
+ ),
799
+ ] = None,
800
+ vector_file: Annotated[
801
+ str | None,
802
+ typer.Option(
803
+ "--vector-file",
804
+ help="Embedding of TEXT, enabling the embedding level when nothing else matched: a "
805
+ "JSON array, or the output of `lorebase-inference embed`; `-` reads standard input.",
806
+ ),
807
+ ] = None,
808
+ domain: DomainOpt = None,
809
+ branch: BranchOpt = None,
810
+ at: AtOpt = None,
811
+ limit: LimitOpt = None,
812
+ json_out: JsonOpt = False,
813
+ ) -> None:
814
+ """Ground the words of a text in Kinds, Attributes and stored values (with `where` fragments
815
+ in --json); it never runs a query."""
816
+ app = app_of(ctx, json_out)
817
+ vector = None if vector_file is None else query_vector(vector_file)
818
+ request = m.ResolveRequest.model_validate(
819
+ {
820
+ "text": text,
821
+ "kinds": kind,
822
+ "levels": level,
823
+ "query_vector": vector,
824
+ "branch": app.branch(branch),
825
+ "at": at,
826
+ "limit": limit or app.settings.list_limit,
827
+ }
828
+ )
829
+ result = app.client().records.resolve(app.domain(domain), request)
830
+
831
+ def rows() -> list[dict[str, Any]]:
832
+ return [
833
+ {**c.model_dump(), "ref": ".".join(filter(None, [c.kind, c.attribute]))}
834
+ for c in result.candidates
835
+ ]
836
+
837
+ app.out.emit(
838
+ result,
839
+ lambda: app.out.table(
840
+ rows(),
841
+ [
842
+ Column("target", "target"),
843
+ Column("kind.attribute", "ref"),
844
+ Column("values", "values"),
845
+ Column("level", "level"),
846
+ Column("score", "score", lambda v: f"{v:.3f}"),
847
+ Column("records", "records"),
848
+ Column("matched", "matched"),
849
+ ],
850
+ ),
851
+ )
852
+ for skipped in result.skipped:
853
+ app.out.info(f"{skipped.level} skipped: {skipped.reason}")
854
+ if result.unmatched:
855
+ app.out.info("unmatched: " + ", ".join(result.unmatched))
856
+ _print_coverage(app, result.coverage)
@@ -1,4 +1,6 @@
1
- """`documents upload|list|get|text`: documents stored for extraction by Scribe."""
1
+ """`documents upload|list|versions|get|text|retire|purge`: documents stored for extraction by
2
+ Scribe. Uploads with `--key` are versions of one source; retiring stops further extraction and
3
+ purging irreversibly erases the content."""
2
4
 
3
5
  from __future__ import annotations
4
6
 
@@ -10,7 +12,7 @@ import typer
10
12
  from lorebase_cli.context import AppContext, app_of
11
13
  from lorebase_cli.failures import CliError
12
14
  from lorebase_cli.options import AllOpt, DomainOpt, JsonOpt, LimitOpt
13
- from lorebase_cli.output import Column, short_id
15
+ from lorebase_cli.output import Column
14
16
  from lorebase_client import models as m
15
17
 
16
18
  documents_app = typer.Typer(
@@ -22,11 +24,20 @@ DOCUMENT_COLUMNS = [
22
24
  Column("filename", "filename"),
23
25
  Column("type", "media_type"),
24
26
  Column("size", "size"),
25
- Column("sha256", "sha256", short_id),
27
+ Column("key", "source_key"),
28
+ Column("v", "version"),
29
+ Column("status", "status"),
26
30
  Column("created", "created_at"),
27
31
  ]
28
32
 
29
33
 
34
+ KeyOpt = Annotated[
35
+ str | None,
36
+ typer.Option("--key", help="Source key (path, URL or external id): the document's identity."),
37
+ ]
38
+ ReasonOpt = Annotated[str, typer.Option("--reason", help="Why, recorded in the audit.")]
39
+
40
+
30
41
  def _tiers(text: str | None) -> list[int] | None:
31
42
  """Extraction tiers from a comma separated list such as `0,1`."""
32
43
  if text is None:
@@ -44,9 +55,23 @@ def _document_pairs(found: m.Document) -> list[tuple[str, Any]]:
44
55
  ("media type", found.media_type),
45
56
  ("size", found.size),
46
57
  ("sha256", found.sha256),
58
+ ("source key", found.source_key),
59
+ ("version", found.version),
60
+ ("replaces", found.replaces),
61
+ ("status", found.status),
47
62
  ("text parsed", found.text_sha256 is not None),
48
63
  ("created", found.created_at),
49
64
  ("created by", found.created_by),
65
+ *(
66
+ [("retired", f"{found.retired_at} by {found.retired_by}")]
67
+ if found.retired_at is not None
68
+ else []
69
+ ),
70
+ *(
71
+ [("purged", f"{found.purged_at} by {found.purged_by}")]
72
+ if found.purged_at is not None
73
+ else []
74
+ ),
50
75
  ]
51
76
 
52
77
 
@@ -67,10 +92,14 @@ def documents_upload(
67
92
  tiers: Annotated[
68
93
  str | None, typer.Option("--tiers", help="Extraction tiers, comma separated (e.g. 0,1).")
69
94
  ] = None,
95
+ key: KeyOpt = None,
70
96
  domain: DomainOpt = None,
71
97
  json_out: JsonOpt = False,
72
98
  ) -> None:
73
- """Upload a document (PATH, or --url) and queue its extraction."""
99
+ """Upload a document (PATH, or --url) and queue its extraction.
100
+
101
+ With --key, new bytes become a new version of the key's document.
102
+ """
74
103
  app = app_of(ctx, json_out)
75
104
  name = app.domain(domain)
76
105
  extract = False if no_extract else None
@@ -78,11 +107,11 @@ def documents_upload(
78
107
  documents = app.client().documents
79
108
  if path is not None and url is None:
80
109
  try:
81
- uploaded = documents.upload(name, path, extract=extract, tiers=wanted)
110
+ uploaded = documents.upload(name, path, extract=extract, tiers=wanted, source_key=key)
82
111
  except OSError as error:
83
112
  raise CliError(f"cannot read {path}: {error.strerror or error}") from error
84
113
  elif url is not None and path is None:
85
- uploaded = documents.upload_url(name, url, extract=extract, tiers=wanted)
114
+ uploaded = documents.upload_url(name, url, extract=extract, tiers=wanted, source_key=key)
86
115
  else:
87
116
  raise CliError("give PATH or --url")
88
117
  app.out.emit(
@@ -92,6 +121,7 @@ def documents_upload(
92
121
  *_document_pairs(uploaded.document),
93
122
  ("duplicate", uploaded.duplicate),
94
123
  ("job", uploaded.job_id),
124
+ ("check job", uploaded.check_job_id),
95
125
  ]
96
126
  ),
97
127
  )
@@ -100,6 +130,9 @@ def documents_upload(
100
130
  @documents_app.command("list")
101
131
  def documents_list(
102
132
  ctx: typer.Context,
133
+ key: Annotated[
134
+ str | None, typer.Option("--key", help="Only the versions of this source key.")
135
+ ] = None,
103
136
  domain: DomainOpt = None,
104
137
  limit: LimitOpt = None,
105
138
  everything: AllOpt = False,
@@ -107,7 +140,26 @@ def documents_list(
107
140
  ) -> None:
108
141
  """List the documents of a Domain, newest first."""
109
142
  app = app_of(ctx, json_out)
110
- items, more = app.take(app.client().documents.iterate(app.domain(domain)), limit, everything)
143
+ stream = app.client().documents.iterate(app.domain(domain), source_key=key)
144
+ items, more = app.take(stream, limit, everything)
145
+ app.out.emit(items, lambda: app.out.table([d.model_dump() for d in items], DOCUMENT_COLUMNS))
146
+ if more:
147
+ app.note_truncated(len(items))
148
+
149
+
150
+ @documents_app.command("versions")
151
+ def documents_versions(
152
+ ctx: typer.Context,
153
+ document_id: Annotated[str, typer.Argument(help="Document id.", metavar="ID")],
154
+ domain: DomainOpt = None,
155
+ limit: LimitOpt = None,
156
+ everything: AllOpt = False,
157
+ json_out: JsonOpt = False,
158
+ ) -> None:
159
+ """List the versions of a document's source key, newest first."""
160
+ app = app_of(ctx, json_out)
161
+ stream = app.client().documents.iterate_versions(app.domain(domain), document_id)
162
+ items, more = app.take(stream, limit, everything)
111
163
  app.out.emit(items, lambda: app.out.table([d.model_dump() for d in items], DOCUMENT_COLUMNS))
112
164
  if more:
113
165
  app.note_truncated(len(items))
@@ -159,3 +211,56 @@ def documents_text(
159
211
  app.out.info(f"text cut at {len(found.text)} of {found.length} characters")
160
212
 
161
213
  app.out.emit(found, human)
214
+
215
+
216
+ @documents_app.command("retire")
217
+ def documents_retire(
218
+ ctx: typer.Context,
219
+ document_id: Annotated[str, typer.Argument(help="Document id.", metavar="ID")],
220
+ reason: ReasonOpt,
221
+ domain: DomainOpt = None,
222
+ json_out: JsonOpt = False,
223
+ ) -> None:
224
+ """Retire a document: it is never extracted again and its evidence is re-checked."""
225
+ app = app_of(ctx, json_out)
226
+ done = app.client().documents.retire(app.domain(domain), document_id, reason=reason)
227
+ app.out.emit(
228
+ done,
229
+ lambda: app.out.pairs([*_document_pairs(done.document), ("check job", done.job_id)]),
230
+ )
231
+
232
+
233
+ @documents_app.command("purge")
234
+ def documents_purge(
235
+ ctx: typer.Context,
236
+ document_id: Annotated[str, typer.Argument(help="Document id.", metavar="ID")],
237
+ reason: ReasonOpt,
238
+ confirm: Annotated[
239
+ str, typer.Option("--confirm", help="The document id again: the purge is irreversible.")
240
+ ],
241
+ domain: DomainOpt = None,
242
+ json_out: JsonOpt = False,
243
+ ) -> None:
244
+ """Irreversibly erase a document's bytes, text, chunks and evidence quotes."""
245
+ if confirm != document_id:
246
+ raise CliError(f"--confirm must repeat the document id {document_id!r}")
247
+ app = app_of(ctx, json_out)
248
+ done = app.client().documents.purge(
249
+ app.domain(domain), document_id, reason=reason, confirm=confirm
250
+ )
251
+ app.out.emit(
252
+ done,
253
+ lambda: app.out.pairs(
254
+ [
255
+ *_document_pairs(done.document),
256
+ ("check job", done.job_id),
257
+ ("blob deleted", done.blob_deleted),
258
+ ("text deleted", done.text_deleted),
259
+ ("chunks", done.chunks),
260
+ ("cache entries", done.cache_entries),
261
+ ("items erased", done.items),
262
+ ("candidates erased", done.candidates),
263
+ ("suggestions erased", done.suggestions),
264
+ ]
265
+ ),
266
+ )
@@ -9,7 +9,7 @@ import typer
9
9
  from lorebase_cli.commands.data import json_argument
10
10
  from lorebase_cli.context import AppContext, app_of
11
11
  from lorebase_cli.failures import CliError
12
- from lorebase_cli.options import AllOpt, DomainOpt, JsonOpt, LimitOpt
12
+ from lorebase_cli.options import AllOpt, DomainOpt, JsonOpt, LimitOpt, MineOpt
13
13
  from lorebase_cli.output import Column, short_id
14
14
  from lorebase_client import models as m
15
15
 
@@ -26,6 +26,8 @@ PROPOSAL_COLUMNS = [
26
26
  Column("target", "target_ref"),
27
27
  Column("status", "status"),
28
28
  Column("author", "author"),
29
+ Column("stewards", "steward_roles"),
30
+ Column("assignees", "assignees"),
29
31
  Column("created", "created_at"),
30
32
  ]
31
33
  CONFLICT_COLUMNS = [
@@ -111,6 +113,7 @@ def proposal_list(
111
113
  author: Annotated[
112
114
  str | None, typer.Option("--author", help="Only Proposals by this principal.")
113
115
  ] = None,
116
+ mine: MineOpt = False,
114
117
  domain: DomainOpt = None,
115
118
  limit: LimitOpt = None,
116
119
  everything: AllOpt = False,
@@ -125,6 +128,7 @@ def proposal_list(
125
128
  source=source,
126
129
  target=target,
127
130
  author=author,
131
+ assigned="me" if mine else None,
128
132
  ),
129
133
  limit,
130
134
  everything,
@@ -0,0 +1,233 @@
1
+ """`reconcile list|show|resolve|dismiss|reopen`: the reconciliation queue."""
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, BranchOpt, DomainOpt, JsonOpt, LimitOpt, MineOpt
13
+ from lorebase_cli.output import Column
14
+ from lorebase_client import models as m
15
+
16
+ reconcile_app = typer.Typer(
17
+ help="Review contradictions and duplicates between Sources.", no_args_is_help=True
18
+ )
19
+
20
+ CandidateArg = Annotated[str, typer.Argument(help="Candidate id.", metavar="ID")]
21
+ ReasonOpt = Annotated[str, typer.Option("--reason", "-r", help="Why; kept in the audit log.")]
22
+
23
+ CANDIDATE_COLUMNS = [
24
+ Column("id", "id"),
25
+ Column("type", "type"),
26
+ Column("status", "status"),
27
+ Column("kind", "kind"),
28
+ Column("keys", "keys", lambda keys: ", ".join(keys)),
29
+ Column("attribute", "attribute"),
30
+ Column("claims", "claim_count"),
31
+ Column("stewards", "steward_roles"),
32
+ Column("assignees", "assignees"),
33
+ Column("created", "created_at"),
34
+ ]
35
+
36
+
37
+ def _show_candidate(app: AppContext, found: m.ReconciliationCandidate) -> None:
38
+ pairs: list[tuple[str, Any]] = [
39
+ ("candidate", found.id),
40
+ ("branch", found.branch),
41
+ ("type", found.type),
42
+ ("status", found.status),
43
+ ("kind", found.kind),
44
+ ("keys", found.keys),
45
+ ("attribute", found.attribute),
46
+ (
47
+ "claims",
48
+ [
49
+ f"{c.claim_id}: {c.value!r}" + (f" ({c.source})" if c.source else "")
50
+ for c in found.claims
51
+ ],
52
+ ),
53
+ ("claim count", found.claim_count),
54
+ ("proposal", found.proposal_id),
55
+ ("decided by", found.decided_by),
56
+ ("decided at", found.decided_at),
57
+ ("created", found.created_at),
58
+ ("updated", found.updated_at),
59
+ ]
60
+ app.out.pairs(pairs)
61
+
62
+
63
+ def _show_decision(app: AppContext, decided: m.ReconciliationDecision) -> None:
64
+ app.out.pairs(
65
+ [
66
+ ("candidate", decided.candidate.id),
67
+ ("status", decided.status),
68
+ ("claim", decided.claim_id),
69
+ ("branch", decided.branch),
70
+ ("revision", decided.revision),
71
+ ("proposal", decided.proposal),
72
+ ("detail", decided.detail),
73
+ ]
74
+ )
75
+
76
+
77
+ def _checked(value: str | None, allowed: Any, option: str) -> str | None:
78
+ """`value` checked against the members of a Literal type."""
79
+ if value is not None and value not in get_args(allowed):
80
+ raise CliError(f"{option}: unknown {value!r}, use {', '.join(get_args(allowed))}")
81
+ return value
82
+
83
+
84
+ @reconcile_app.command("list")
85
+ def reconcile_list(
86
+ ctx: typer.Context,
87
+ candidate_type: Annotated[
88
+ str | None,
89
+ typer.Option("--type", help="duplicate, contradiction, superseded or stale."),
90
+ ] = None,
91
+ status: Annotated[
92
+ str | None,
93
+ typer.Option("--status", help="open, resolved, dismissed or obsolete (default: all)."),
94
+ ] = None,
95
+ kind: Annotated[str | None, typer.Option("--kind", help="Only this Kind (api name).")] = None,
96
+ attribute: Annotated[
97
+ str | None,
98
+ typer.Option("--attribute", help="Only this Attribute (api name; needs --kind)."),
99
+ ] = None,
100
+ source: Annotated[
101
+ str | None,
102
+ typer.Option("--source", help="Only candidates with a claim of this Source identity."),
103
+ ] = None,
104
+ mine: MineOpt = False,
105
+ domain: DomainOpt = None,
106
+ branch: BranchOpt = None,
107
+ limit: LimitOpt = None,
108
+ everything: AllOpt = False,
109
+ json_out: JsonOpt = False,
110
+ ) -> None:
111
+ """List the candidates of a branch, newest first."""
112
+ chosen_type = _checked(candidate_type, m.CandidateType, "--type")
113
+ chosen_status = _checked(status, m.CandidateStatus, "--status")
114
+ app = app_of(ctx, json_out)
115
+ items, more = app.take(
116
+ app.client().reconciliation.iterate(
117
+ app.domain(domain),
118
+ branch=app.branch(branch),
119
+ type=cast("m.CandidateType | None", chosen_type),
120
+ status=cast("m.CandidateStatus | None", chosen_status),
121
+ kind=kind,
122
+ attribute=attribute,
123
+ source=source,
124
+ assigned="me" if mine else None,
125
+ ),
126
+ limit,
127
+ everything,
128
+ )
129
+ app.out.emit(items, lambda: app.out.table([c.model_dump() for c in items], CANDIDATE_COLUMNS))
130
+ if more:
131
+ app.note_truncated(len(items))
132
+
133
+
134
+ @reconcile_app.command("show")
135
+ def reconcile_show(
136
+ ctx: typer.Context,
137
+ candidate_id: CandidateArg,
138
+ domain: DomainOpt = None,
139
+ json_out: JsonOpt = False,
140
+ ) -> None:
141
+ """Show a candidate with its claims and last decision."""
142
+ app = app_of(ctx, json_out)
143
+ found = app.client().reconciliation.get(app.domain(domain), candidate_id)
144
+ app.out.emit(found, lambda: _show_candidate(app, found))
145
+
146
+
147
+ @reconcile_app.command("resolve")
148
+ def reconcile_resolve(
149
+ ctx: typer.Context,
150
+ candidate_id: CandidateArg,
151
+ choice: Annotated[
152
+ str, typer.Option("--choice", help="claim, keep_current, custom, policy or merge.")
153
+ ],
154
+ reason: ReasonOpt,
155
+ claim: Annotated[
156
+ str | None, typer.Option("--claim", help="Claim id to take (choice `claim`).")
157
+ ] = None,
158
+ value: Annotated[
159
+ str | None,
160
+ typer.Option("--value", help="Value of a `custom` choice: inline JSON, @file or -."),
161
+ ] = None,
162
+ policy: Annotated[
163
+ str | None,
164
+ typer.Option(
165
+ "--policy",
166
+ help="authority, newest_wins, prefer_current or prefer_incoming (choice `policy`).",
167
+ ),
168
+ ] = None,
169
+ survivor: Annotated[
170
+ str | None, typer.Option("--survivor", help="Key of the Record that stays (`merge`).")
171
+ ] = None,
172
+ values: Annotated[
173
+ str | None,
174
+ typer.Option(
175
+ "--values",
176
+ help="Values to set on the survivor, by Attribute api name (`merge`): "
177
+ "a JSON object, inline, @file or -.",
178
+ ),
179
+ ] = None,
180
+ dry_run: Annotated[
181
+ bool, typer.Option("--dry-run", help="Report the outcome without writing.")
182
+ ] = False,
183
+ domain: DomainOpt = None,
184
+ json_out: JsonOpt = False,
185
+ ) -> None:
186
+ """Decide a candidate; a write that needs review opens a Proposal."""
187
+ chosen = _checked(choice, m.ReconcileChoice, "--choice")
188
+ chosen_policy = _checked(policy, m.ReconcilePolicy, "--policy")
189
+ parsed = None if values is None else json_argument(values)
190
+ if parsed is not None and not isinstance(parsed, dict):
191
+ raise CliError("--values is a JSON object")
192
+ app = app_of(ctx, json_out)
193
+ decided = app.client().reconciliation.resolve(
194
+ app.domain(domain),
195
+ candidate_id,
196
+ cast("m.ReconcileChoice", chosen),
197
+ reason=reason,
198
+ claim_id=claim,
199
+ value=None if value is None else json_argument(value),
200
+ policy=cast("m.ReconcilePolicy | None", chosen_policy),
201
+ survivor=survivor,
202
+ values=parsed,
203
+ dry_run=dry_run,
204
+ )
205
+ app.out.emit(decided, lambda: _show_decision(app, decided))
206
+
207
+
208
+ @reconcile_app.command("dismiss")
209
+ def reconcile_dismiss(
210
+ ctx: typer.Context,
211
+ candidate_id: CandidateArg,
212
+ reason: ReasonOpt,
213
+ domain: DomainOpt = None,
214
+ json_out: JsonOpt = False,
215
+ ) -> None:
216
+ """Dismiss a candidate: the difference is accepted as it is."""
217
+ app = app_of(ctx, json_out)
218
+ decided = app.client().reconciliation.dismiss(app.domain(domain), candidate_id, reason=reason)
219
+ app.out.emit(decided, lambda: _show_decision(app, decided))
220
+
221
+
222
+ @reconcile_app.command("reopen")
223
+ def reconcile_reopen(
224
+ ctx: typer.Context,
225
+ candidate_id: CandidateArg,
226
+ reason: ReasonOpt,
227
+ domain: DomainOpt = None,
228
+ json_out: JsonOpt = False,
229
+ ) -> None:
230
+ """Reopen a dismissed candidate."""
231
+ app = app_of(ctx, json_out)
232
+ decided = app.client().reconciliation.reopen(app.domain(domain), candidate_id, reason=reason)
233
+ app.out.emit(decided, lambda: _show_decision(app, decided))
@@ -20,6 +20,7 @@ from lorebase_cli.commands import (
20
20
  domains,
21
21
  history,
22
22
  proposals,
23
+ reconcile,
23
24
  schema,
24
25
  sources,
25
26
  trace,
@@ -139,11 +140,13 @@ app.add_typer(admin.admin_app, name="admin")
139
140
  app.add_typer(audit.audit_app, name="audit")
140
141
  app.add_typer(trace.trace_app, name="trace")
141
142
  app.add_typer(proposals.proposal_app, name="proposal")
143
+ app.add_typer(reconcile.reconcile_app, name="reconcile")
142
144
  app.add_typer(sources.sources_app, name="sources")
143
145
  app.add_typer(documents.documents_app, name="documents")
144
146
  app.command("whoami")(domains.whoami)
145
147
  app.command("query")(data.query_command)
146
148
  app.command("search")(data.search_command)
149
+ app.command("resolve")(data.resolve_command)
147
150
  app.command("log")(history.log_command)
148
151
  app.command("diff")(history.diff_command)
149
152
  app.command("context")(agents.context_command)
@@ -54,3 +54,6 @@ LimitOpt = Annotated[
54
54
  typer.Option("--limit", "-n", min=1, help="Most items to print (default: list_limit setting)."),
55
55
  ]
56
56
  AllOpt = Annotated[bool, typer.Option("--all", help="Print every item, however many.")]
57
+ MineOpt = Annotated[
58
+ bool, typer.Option("--mine", help="Only items assigned to you through a steward role.")
59
+ ]
@@ -160,7 +160,7 @@ def test_no_arguments_prints_help() -> None:
160
160
  def test_version() -> None:
161
161
  result = run("--version")
162
162
  assert result.exit_code == 0
163
- assert result.stdout.startswith("lorebase 0.2.3")
163
+ assert result.stdout.startswith("lorebase 0.3.0")
164
164
 
165
165
 
166
166
  @pytest.mark.parametrize(
@@ -1046,6 +1046,66 @@ def test_search_with_a_query_vector(http: respx.MockRouter, tmp_path: Path) -> N
1046
1046
  assert "JSON array of numbers" in result.output
1047
1047
 
1048
1048
 
1049
+ def test_resolve(http: respx.MockRouter, tmp_path: Path) -> None:
1050
+ route = http.post("/domains/supply/resolve").respond(
1051
+ 200,
1052
+ json={
1053
+ "revision": REV,
1054
+ "served_from": "materialized",
1055
+ "candidates": [
1056
+ {
1057
+ "target": "value",
1058
+ "kind": "Part",
1059
+ "attribute": "category",
1060
+ "values": ["bearing", "cuscinetto"],
1061
+ "predicate": {"attr": "category", "op": "in", "value": ["bearing"]},
1062
+ "score": 0.875,
1063
+ "level": "synonym",
1064
+ "matched": "bearings",
1065
+ "records": 12,
1066
+ },
1067
+ {"target": "kind", "kind": "Part", "score": 1, "level": "exact", "matched": "part"},
1068
+ ],
1069
+ "unmatched": ["the"],
1070
+ "skipped": [{"level": "embedding", "reason": "no query vector"}],
1071
+ },
1072
+ )
1073
+ result = run("resolve", "the bearings", "--kind", "Part", "--level", "exact", "-d", "supply")
1074
+ assert result.exit_code == 0
1075
+ assert result.stdout.splitlines()[0].split() == [
1076
+ "target",
1077
+ "kind.attribute",
1078
+ "values",
1079
+ "level",
1080
+ "score",
1081
+ "records",
1082
+ "matched",
1083
+ ]
1084
+ assert "Part.category" in result.stdout
1085
+ assert "bearing, cuscinetto" in result.stdout
1086
+ assert "0.875" in result.stdout
1087
+ assert "embedding skipped: no query vector" in result.output
1088
+ assert "unmatched: the" in result.output
1089
+ plain = tmp_path / "plain.json"
1090
+ plain.write_text("[1, 2]")
1091
+ as_json = json.loads(
1092
+ run("resolve", "bearings", "--vector-file", str(plain), "-d", "supply", "--json").stdout
1093
+ )
1094
+ assert as_json["candidates"][0]["predicate"] == {
1095
+ "attr": "category",
1096
+ "op": "in",
1097
+ "value": ["bearing"],
1098
+ }
1099
+ bodies = request_bodies(route)
1100
+ assert bodies[0] == {
1101
+ "text": "the bearings",
1102
+ "kinds": ["Part"],
1103
+ "levels": ["exact"],
1104
+ "limit": 50,
1105
+ }
1106
+ assert bodies[1]["query_vector"] == [1.0, 2.0]
1107
+
1108
+
1049
1109
  # History ------------------------------------------------------------------------------------
1050
1110
 
1051
1111
 
@@ -1484,6 +1544,14 @@ DOCUMENT_ROW = {
1484
1544
  "metadata": {},
1485
1545
  "created_at": NOW,
1486
1546
  "created_by": "usr_1",
1547
+ "source_key": None,
1548
+ "version": 1,
1549
+ "replaces": None,
1550
+ "status": "active",
1551
+ "retired_at": None,
1552
+ "retired_by": None,
1553
+ "purged_at": None,
1554
+ "purged_by": None,
1487
1555
  }
1488
1556
  ITEM_ROW = {
1489
1557
  "id": "itm_1",
@@ -1565,7 +1633,7 @@ def test_documents_list_and_get(http: respx.MockRouter) -> None:
1565
1633
  "error": None,
1566
1634
  }
1567
1635
  http.get("/domains/supply/documents/doc_1").respond(
1568
- 200, json={**DOCUMENT_ROW, "extraction_jobs": [job]}
1636
+ 200, json={**DOCUMENT_ROW, "extraction_jobs": [job], "open_stale_candidates": 0}
1569
1637
  )
1570
1638
  listed = run("documents", "list", "-d", "supply")
1571
1639
  assert listed.exit_code == 0, listed.output
@@ -1578,6 +1646,74 @@ def test_documents_list_and_get(http: respx.MockRouter) -> None:
1578
1646
  assert as_json["extraction_jobs"][0]["id"] == "ext_1"
1579
1647
 
1580
1648
 
1649
+ def test_documents_keys_versions_retire_and_purge(http: respx.MockRouter, tmp_path: Path) -> None:
1650
+ v2 = {**DOCUMENT_ROW, "id": "doc_2", "source_key": "k", "version": 2, "replaces": "doc_1"}
1651
+ upload = http.post("/domains/supply/documents").respond(
1652
+ 201, json={"document": v2, "duplicate": False, "job_id": None, "check_job_id": 9}
1653
+ )
1654
+ listing = http.get("/domains/supply/documents").respond(
1655
+ 200, json={"items": [v2], "next_cursor": None}
1656
+ )
1657
+ http.get("/domains/supply/documents/doc_2/versions").respond(
1658
+ 200, json={"items": [v2, DOCUMENT_ROW], "next_cursor": None}
1659
+ )
1660
+ retired = {**v2, "status": "retired", "retired_at": NOW, "retired_by": "usr_1"}
1661
+ retire = http.post("/domains/supply/documents/doc_2/retirement").respond(
1662
+ 200, json={"document": retired, "job_id": 11}
1663
+ )
1664
+ purge = http.post("/domains/supply/documents/doc_2/purge").respond(
1665
+ 200,
1666
+ json={
1667
+ "document": {**retired, "status": "purged"},
1668
+ "job_id": None,
1669
+ "blob_deleted": True,
1670
+ "text_deleted": True,
1671
+ "chunks": 3,
1672
+ "cache_entries": 2,
1673
+ "items": 1,
1674
+ "candidates": 0,
1675
+ "suggestions": 0,
1676
+ },
1677
+ )
1678
+ spec = tmp_path / "spec.txt"
1679
+ spec.write_bytes(b"hello")
1680
+ sent = run("documents", "upload", str(spec), "--key", "k", "-d", "supply")
1681
+ assert sent.exit_code == 0, sent.output
1682
+ assert b'name="source_key"\r\n\r\nk' in upload.calls.last.request.content
1683
+ assert "check job" in sent.stdout
1684
+ by_url = run(
1685
+ "documents", "upload", "--url", "https://example.test/a", "--key", "k", "-d", "supply"
1686
+ )
1687
+ assert by_url.exit_code == 0, by_url.output
1688
+ assert json.loads(upload.calls.last.request.content)["source_key"] == "k"
1689
+ listed = run("documents", "list", "--key", "k", "-d", "supply")
1690
+ assert listed.exit_code == 0, listed.output
1691
+ assert listing.calls.last.request.url.params["source_key"] == "k"
1692
+ assert "retired" not in listed.stdout
1693
+ versions = run("documents", "versions", "doc_2", "-d", "supply")
1694
+ assert versions.exit_code == 0, versions.output
1695
+ assert "doc_1" in versions.stdout
1696
+ done = run("documents", "retire", "doc_2", "--reason", "obsolete", "-d", "supply")
1697
+ assert done.exit_code == 0, done.output
1698
+ assert "retired" in done.stdout
1699
+ assert request_bodies(retire) == [{"reason": "obsolete"}]
1700
+ erased = run(
1701
+ "documents", "purge", "doc_2", "--reason", "gdpr", "--confirm", "doc_2", "-d", "supply"
1702
+ )
1703
+ assert erased.exit_code == 0, erased.output
1704
+ assert request_bodies(purge) == [{"reason": "gdpr", "confirm": "doc_2"}]
1705
+ assert "chunks" in erased.stdout
1706
+
1707
+
1708
+ def test_documents_purge_refuses_a_wrong_confirmation(http: respx.MockRouter) -> None:
1709
+ route = http.post("/domains/supply/documents/doc_2/purge")
1710
+ result = run(
1711
+ "documents", "purge", "doc_2", "--reason", "x", "--confirm", "doc_3", "-d", "supply"
1712
+ )
1713
+ assert result.exit_code == 1
1714
+ assert not route.called
1715
+
1716
+
1581
1717
  def test_documents_text_prints_the_parsed_text(http: respx.MockRouter) -> None:
1582
1718
  body = {
1583
1719
  "document_id": "doc_1",
@@ -1730,7 +1866,7 @@ def test_module_entry_point_runs() -> None:
1730
1866
  check=False,
1731
1867
  )
1732
1868
  assert done.returncode == 0
1733
- assert done.stdout.startswith("lorebase 0.2.3")
1869
+ assert done.stdout.startswith("lorebase 0.3.0")
1734
1870
 
1735
1871
 
1736
1872
  # Proposals, context and explain --------------------------------------------------------------
@@ -1868,6 +2004,8 @@ def test_proposal_list_filters_and_renders(http: respx.MockRouter) -> None:
1868
2004
  "target",
1869
2005
  "status",
1870
2006
  "author",
2007
+ "stewards",
2008
+ "assignees",
1871
2009
  "created",
1872
2010
  ]
1873
2011
  assert "prp_2" in result.stdout
@@ -1880,6 +2018,10 @@ def test_proposal_list_filters_and_renders(http: respx.MockRouter) -> None:
1880
2018
  )
1881
2019
  as_json = json.loads(run("proposal", "list", "-d", "supply", "-n", "1", "--json").stdout)
1882
2020
  assert [p["id"] for p in as_json] == ["prp_1"]
2021
+ assert "assigned" not in params
2022
+ mine = run("proposal", "list", "--mine", "-d", "supply")
2023
+ assert mine.exit_code == 0, mine.output
2024
+ assert route.calls.last.request.url.params["assigned"] == "me"
1883
2025
 
1884
2026
 
1885
2027
  def test_proposal_get_shows_review_state(http: respx.MockRouter) -> None:
@@ -2381,3 +2523,132 @@ def test_schema_suggestions_merge_reports_counts(http: respx.MockRouter) -> None
2381
2523
  assert route.called
2382
2524
  as_json = run("schema", "suggestions", "merge", "-d", "supply", "--json")
2383
2525
  assert json.loads(as_json.stdout) == {"merged": 3, "kept": 2}
2526
+
2527
+
2528
+ # Reconciliation -----------------------------------------------------------------------------
2529
+
2530
+
2531
+ def candidate_row(**extra: Any) -> dict[str, Any]:
2532
+ return {
2533
+ "id": "rcc_1",
2534
+ "branch": "main",
2535
+ "type": "contradiction",
2536
+ "status": "open",
2537
+ "kind": "Supplier",
2538
+ "kind_id": "knd_1",
2539
+ "keys": ["S-1"],
2540
+ "attribute": "city",
2541
+ "attribute_id": "att_1",
2542
+ "claims": [
2543
+ {"claim_id": "c1", "value": "Turin", "source": "source:src_1"},
2544
+ {"claim_id": "c2", "value": "Milan"},
2545
+ ],
2546
+ "claim_count": 2,
2547
+ "signals": {},
2548
+ "proposal_id": None,
2549
+ "decision": None,
2550
+ "decided_by": None,
2551
+ "decided_at": None,
2552
+ "created_at": NOW,
2553
+ "updated_at": NOW,
2554
+ **extra,
2555
+ }
2556
+
2557
+
2558
+ def test_reconcile_list_and_show(http: respx.MockRouter) -> None:
2559
+ listing = http.get("/domains/supply/reconciliation").respond(
2560
+ 200, json={"items": [candidate_row()], "next_cursor": None}
2561
+ )
2562
+ http.get("/domains/supply/reconciliation/rcc_1").respond(200, json=candidate_row())
2563
+ listed = run(
2564
+ "reconcile",
2565
+ "list",
2566
+ "--type",
2567
+ "contradiction",
2568
+ "--status",
2569
+ "open",
2570
+ "--kind",
2571
+ "Supplier",
2572
+ "--attribute",
2573
+ "city",
2574
+ "--source",
2575
+ "source:src_1",
2576
+ "-b",
2577
+ "work",
2578
+ "-d",
2579
+ "supply",
2580
+ )
2581
+ assert listed.exit_code == 0, listed.output
2582
+ assert "rcc_1" in listed.stdout
2583
+ assert "S-1" in listed.stdout
2584
+ params = listing.calls.last.request.url.params
2585
+ assert {k: params[k] for k in ("branch", "type", "status", "kind", "attribute", "source")} == {
2586
+ "branch": "work",
2587
+ "type": "contradiction",
2588
+ "status": "open",
2589
+ "kind": "Supplier",
2590
+ "attribute": "city",
2591
+ "source": "source:src_1",
2592
+ }
2593
+ assert "assigned" not in params
2594
+ mine = run("reconcile", "list", "--mine", "-d", "supply")
2595
+ assert mine.exit_code == 0, mine.output
2596
+ assert listing.calls.last.request.url.params["assigned"] == "me"
2597
+ shown = run("reconcile", "show", "rcc_1", "-d", "supply")
2598
+ assert shown.exit_code == 0, shown.output
2599
+ assert "Turin" in shown.stdout
2600
+ as_json = json.loads(run("reconcile", "show", "rcc_1", "-d", "supply", "--json").stdout)
2601
+ assert as_json["claim_count"] == 2
2602
+ bad = run("reconcile", "list", "--type", "other", "-d", "supply")
2603
+ assert bad.exit_code == 1
2604
+
2605
+
2606
+ def test_reconcile_resolve_dismiss_and_reopen(http: respx.MockRouter) -> None:
2607
+ decision = {"status": "dry_run", "candidate": candidate_row(), "claim_id": "c1"}
2608
+ resolution = http.post("/domains/supply/reconciliation/rcc_1/resolution").respond(
2609
+ 200, json=decision
2610
+ )
2611
+ dismissal = http.post("/domains/supply/reconciliation/rcc_1/dismissal").respond(
2612
+ 200, json={**decision, "status": "dismissed"}
2613
+ )
2614
+ reopen = http.post("/domains/supply/reconciliation/rcc_1/reopen").respond(
2615
+ 200, json={**decision, "status": "reopened"}
2616
+ )
2617
+ resolved = run(
2618
+ "reconcile",
2619
+ "resolve",
2620
+ "rcc_1",
2621
+ "--choice",
2622
+ "merge",
2623
+ "--survivor",
2624
+ "S-1",
2625
+ "--values",
2626
+ '{"city": "Turin"}',
2627
+ "--value",
2628
+ '"Turin"',
2629
+ "--reason",
2630
+ "checked",
2631
+ "--dry-run",
2632
+ "-d",
2633
+ "supply",
2634
+ )
2635
+ assert resolved.exit_code == 0, resolved.output
2636
+ assert "dry_run" in resolved.stdout
2637
+ assert request_bodies(resolution) == [
2638
+ {
2639
+ "choice": "merge",
2640
+ "value": "Turin",
2641
+ "survivor": "S-1",
2642
+ "values": {"city": "Turin"},
2643
+ "reason": "checked",
2644
+ "dry_run": True,
2645
+ }
2646
+ ]
2647
+ assert run(
2648
+ "reconcile", "resolve", "rcc_1", "--choice", "x", "-r", "r", "-d", "supply"
2649
+ ).exit_code
2650
+ assert run("reconcile", "resolve", "rcc_1", "--choice", "claim", "-d", "supply").exit_code == 1
2651
+ assert run("reconcile", "dismiss", "rcc_1", "-r", "same city", "-d", "supply").exit_code == 0
2652
+ assert request_bodies(dismissal) == [{"reason": "same city"}]
2653
+ assert run("reconcile", "reopen", "rcc_1", "-r", "wrong", "-d", "supply").exit_code == 0
2654
+ assert request_bodies(reopen) == [{"reason": "wrong"}]
File without changes