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,140 @@
1
+ """`trace list`, `trace show`, `trace note`: agent trace sessions of a Keeper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Any
6
+
7
+ import typer
8
+
9
+ from lorebase_cli.context import app_of
10
+ from lorebase_cli.options import DomainOpt, JsonOpt, LimitOpt
11
+ from lorebase_cli.output import Column
12
+ from lorebase_client import models as m
13
+
14
+ trace_app = typer.Typer(help="Inspect what agents did in a session.", no_args_is_help=True)
15
+
16
+ SessionArg = Annotated[str, typer.Argument(help="Session id.", metavar="SESSION_ID")]
17
+ ActorOpt = Annotated[str | None, typer.Option("--actor", help="Only the calls of this principal.")]
18
+
19
+ SESSION_COLUMNS = [
20
+ Column("session", "session_id"),
21
+ Column("actor", "actor"),
22
+ Column("last activity", "last_at"),
23
+ Column("calls", "calls"),
24
+ Column("notes", "notes"),
25
+ Column("domains", "domains"),
26
+ Column("tools", "tools"),
27
+ Column("proposals", "proposals"),
28
+ Column("inferred", "inferred"),
29
+ ]
30
+ STEP_COLUMNS = [
31
+ Column("step", "step"),
32
+ Column("time", "at"),
33
+ Column("type", "type"),
34
+ Column("tool", "tool"),
35
+ Column("endpoint", "endpoint"),
36
+ Column("status", "status"),
37
+ Column("ms", "latency_ms"),
38
+ Column("cache", "cache"),
39
+ Column("counts", "counts"),
40
+ Column("refs", "refs"),
41
+ Column("note", "note"),
42
+ ]
43
+
44
+
45
+ def plain(text: str | None) -> str | None:
46
+ """Opaque text for a terminal: control characters (escape sequences) are replaced."""
47
+ if text is None:
48
+ return None
49
+ return "".join(c if c.isprintable() else "?" for c in text)
50
+
51
+
52
+ def counts_of(result: dict[str, Any] | None) -> str:
53
+ """`items=3 edges=2` from the result counts of a call."""
54
+ counts = (result or {}).get("counts")
55
+ if not isinstance(counts, dict):
56
+ return ""
57
+ return " ".join(f"{name}={value}" for name, value in counts.items())
58
+
59
+
60
+ def step_row(step: m.TraceStep) -> dict[str, Any]:
61
+ """A timeline step as table cells."""
62
+ row = step.model_dump()
63
+ row["endpoint"] = " ".join(part for part in (step.method, step.route) if part)
64
+ row["counts"] = counts_of(step.result)
65
+ refs = step.refs
66
+ row["refs"] = (
67
+ ""
68
+ if refs is None
69
+ else f"{len(refs.records)}r {len(refs.links)}l" + ("+" if refs.truncated else "")
70
+ )
71
+ row["latency_ms"] = None if step.latency_ms is None else f"{step.latency_ms:.0f}"
72
+ row["note"] = plain(step.note)
73
+ return row
74
+
75
+
76
+ @trace_app.command("list")
77
+ def trace_list(
78
+ ctx: typer.Context,
79
+ actor: ActorOpt = None,
80
+ domain: DomainOpt = None,
81
+ limit: LimitOpt = None,
82
+ json_out: JsonOpt = False,
83
+ ) -> None:
84
+ """List the sessions you may see, most recent activity first."""
85
+ app = app_of(ctx, json_out)
86
+ page = app.client().trace.sessions(
87
+ actor=actor, domain_id=domain, limit=limit or app.settings.list_limit
88
+ )
89
+
90
+ def human() -> None:
91
+ if not page.enabled:
92
+ app.out.info("tracing is off on this server; showing recorded sessions only")
93
+ app.out.table([s.model_dump() for s in page.items], SESSION_COLUMNS)
94
+
95
+ app.out.emit(page, human)
96
+ if page.next_cursor is not None:
97
+ app.note_truncated(len(page.items))
98
+
99
+
100
+ @trace_app.command("show")
101
+ def trace_show(
102
+ ctx: typer.Context,
103
+ session_id: SessionArg,
104
+ actor: ActorOpt = None,
105
+ json_out: JsonOpt = False,
106
+ ) -> None:
107
+ """Print the timeline of a session: calls with their outcome, and notes."""
108
+ app = app_of(ctx, json_out)
109
+ timeline = app.client().trace.session(session_id, actor=actor)
110
+
111
+ def human() -> None:
112
+ if any(s.inferred for s in timeline.steps):
113
+ app.out.info("inferred session: calls without a session id, grouped heuristically")
114
+ app.out.table([step_row(s) for s in timeline.steps], STEP_COLUMNS)
115
+ if timeline.truncated:
116
+ app.out.info("the session has more steps than shown")
117
+
118
+ app.out.emit(timeline, human)
119
+
120
+
121
+ @trace_app.command("note")
122
+ def trace_note(
123
+ ctx: typer.Context,
124
+ session_id: SessionArg,
125
+ text: Annotated[str, typer.Argument(help="Text of the note.", metavar="TEXT")],
126
+ domain: DomainOpt = None,
127
+ step: Annotated[
128
+ int | None, typer.Option("--step", min=0, help="Step counter of the agent.")
129
+ ] = None,
130
+ json_out: JsonOpt = False,
131
+ ) -> None:
132
+ """Attach a note to a session (written off the request path)."""
133
+ app = app_of(ctx, json_out)
134
+ result = app.client().trace.add_note(session_id, text, domain_id=domain, step=step)
135
+ app.out.emit(
136
+ result,
137
+ lambda: app.out.info(
138
+ "note recorded" if result.recorded else "note dropped: tracing is off"
139
+ ),
140
+ )
@@ -0,0 +1,93 @@
1
+ """State shared by the commands of one invocation: settings, output and the client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import itertools
6
+ from collections.abc import Callable, Iterable
7
+ from dataclasses import dataclass, field
8
+
9
+ import typer
10
+
11
+ from lorebase_cli.failures import CliError
12
+ from lorebase_cli.output import Output
13
+ from lorebase_cli.settings import ENV_DOMAIN, CliSettings
14
+ from lorebase_client import LorebaseClient
15
+
16
+ ClientFactory = Callable[[CliSettings], LorebaseClient]
17
+
18
+
19
+ def default_client(settings: CliSettings) -> LorebaseClient:
20
+ """A client for the configured server. (Imports send idempotency keys themselves; creating
21
+ things does not, so that creating what exists is reported as a conflict.)"""
22
+ return LorebaseClient(
23
+ settings.url,
24
+ api_prefix=settings.api_prefix,
25
+ api_key=settings.api_key,
26
+ token=settings.token,
27
+ timeout=settings.timeout,
28
+ env={},
29
+ )
30
+
31
+
32
+ @dataclass
33
+ class AppContext:
34
+ """Per-invocation state, stored in the Typer context."""
35
+
36
+ settings: CliSettings
37
+ client_factory: ClientFactory = default_client
38
+ json_mode: bool = False
39
+ quiet: bool = False
40
+ _client: LorebaseClient | None = field(default=None, repr=False)
41
+
42
+ @property
43
+ def out(self) -> Output:
44
+ return Output(json_mode=self.json_mode, max_cell_width=self.settings.max_cell_width)
45
+
46
+ def client(self) -> LorebaseClient:
47
+ if self._client is None:
48
+ self._client = self.client_factory(self.settings)
49
+ return self._client
50
+
51
+ def close(self) -> None:
52
+ if self._client is not None:
53
+ self._client.close()
54
+
55
+ def domain(self, given: str | None) -> str:
56
+ """The Domain to work in: `--domain`, `LOREBASE_DOMAIN`, then the config file."""
57
+ chosen = given or self.settings.domain
58
+ if not chosen:
59
+ raise CliError(
60
+ f"no Domain: pass --domain, set {ENV_DOMAIN} or `domain` in the config file"
61
+ )
62
+ return chosen
63
+
64
+ def branch(self, given: str | None) -> str | None:
65
+ """The branch named by `--branch`, `LOREBASE_BRANCH` or the config file; none means
66
+ the Domain's default branch."""
67
+ return given or self.settings.branch
68
+
69
+ def take[T](
70
+ self, items: Iterable[T], limit: int | None, everything: bool
71
+ ) -> tuple[list[T], bool]:
72
+ """The first items of a listing and whether more exist: `--all` takes all, `--limit`
73
+ that many, else the `list_limit` setting."""
74
+ if everything:
75
+ return list(items), False
76
+ cap = limit or self.settings.list_limit
77
+ taken = list(itertools.islice(items, cap + 1))
78
+ return taken[:cap], len(taken) > cap
79
+
80
+ def note_truncated(self, shown: int) -> None:
81
+ """Tell the person a listing was cut (standard error, human output only)."""
82
+ if not self.json_mode and not self.quiet:
83
+ self.out.info(f"showing the first {shown} items; use --all or --limit for more")
84
+
85
+
86
+ def app_of(ctx: typer.Context, json_flag: bool = False) -> AppContext:
87
+ """The invocation state; a `--json` flag given after the command turns JSON output on."""
88
+ app = ctx.find_object(AppContext)
89
+ if app is None: # pragma: no cover - the root callback always sets it
90
+ raise CliError("the command ran without its settings")
91
+ if json_flag:
92
+ app.json_mode = True
93
+ return app
@@ -0,0 +1,195 @@
1
+ """Exit codes and the text of failures.
2
+
3
+ | Code | Meaning |
4
+ |------|---------|
5
+ | 0 | success |
6
+ | 1 | usage or configuration error, a thing that does not exist (404), any other refused request |
7
+ | 2 | validation failure (422) or conflict (409): the request is well formed but cannot apply |
8
+ | 3 | authentication or permission failure (401, 403) |
9
+ | 4 | server error (5xx) or server unreachable |
10
+
11
+ Failures go to standard error; with `--json` they are a JSON document there, so that standard
12
+ output only ever holds results.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from collections.abc import Callable, Sequence
19
+ from typing import Any
20
+
21
+ from lorebase_client import (
22
+ AuthenticationError,
23
+ ConflictError,
24
+ LorebaseConnectionError,
25
+ LorebaseError,
26
+ PermissionDeniedError,
27
+ ServerError,
28
+ ValidationFailedError,
29
+ )
30
+
31
+ EXIT_OK = 0
32
+ EXIT_USAGE = 1
33
+ EXIT_INVALID = 2
34
+ EXIT_AUTH = 3
35
+ EXIT_SERVER = 4
36
+
37
+
38
+ class CliError(Exception):
39
+ """A failure with a message for the user and an exit code (default: usage error)."""
40
+
41
+ def __init__(self, message: str, *, exit_code: int = EXIT_USAGE) -> None:
42
+ super().__init__(message)
43
+ self.message = message
44
+ self.exit_code = exit_code
45
+
46
+
47
+ Locator = Callable[[Sequence[Any]], str | None]
48
+
49
+
50
+ class ImportAbortedError(CliError):
51
+ """An import stopped at a rejected batch; earlier batches stay committed.
52
+
53
+ `locate` turns the path of an issue into a place in the input files, for imports that read
54
+ several (the default numbers the rows of the one file); `scope` words which rows were rejected.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ cause: LorebaseError,
60
+ *,
61
+ first_row: int,
62
+ last_row: int,
63
+ committed_rows: int,
64
+ committed_revisions: list[str],
65
+ scope: str | None = None,
66
+ locate: Locator | None = None,
67
+ ) -> None:
68
+ super().__init__(
69
+ f"{scope or f'rows {first_row}-{last_row} were rejected'}; {committed_rows} earlier "
70
+ "rows are committed",
71
+ exit_code=exit_code_for(cause),
72
+ )
73
+ self.cause = cause
74
+ self.locate = locate
75
+ self.first_row = first_row
76
+ self.last_row = last_row
77
+ self.committed_rows = committed_rows
78
+ self.committed_revisions = committed_revisions
79
+
80
+
81
+ def exit_code_for(error: LorebaseError) -> int:
82
+ """The exit code of a client error."""
83
+ if isinstance(error, ValidationFailedError | ConflictError):
84
+ return EXIT_INVALID
85
+ if isinstance(error, AuthenticationError | PermissionDeniedError):
86
+ return EXIT_AUTH
87
+ if isinstance(error, ServerError | LorebaseConnectionError):
88
+ return EXIT_SERVER
89
+ return EXIT_USAGE
90
+
91
+
92
+ def _location(path: list[Any], row_offset: int) -> str:
93
+ """`rows[12].supplier_code`-style text for an issue path; row indexes are shifted by
94
+ `row_offset` (the number of the first row sent, for imports)."""
95
+ text = ""
96
+ previous: Any = None
97
+ for part in path:
98
+ if isinstance(part, int):
99
+ number = part + row_offset if previous == "rows" else part
100
+ text += f"[{number}]"
101
+ else:
102
+ text += f".{part}" if text else str(part)
103
+ previous = part
104
+ return text
105
+
106
+
107
+ def issue_lines(
108
+ error: LorebaseError,
109
+ *,
110
+ row_offset: int = 0,
111
+ limit: int = 20,
112
+ locate: Locator | None = None,
113
+ ) -> list[str]:
114
+ """One line per validation finding or invalid request member."""
115
+ lines: list[str] = []
116
+ for issue in error.issues[:limit]:
117
+ path = list(issue.get("path") or [])
118
+ where = (locate(path) if locate else None) or _location(path, row_offset)
119
+ subject = " ".join(
120
+ str(issue[name]) for name in ("kind", "relation", "key", "attribute") if issue.get(name)
121
+ )
122
+ prefix = f"{where}: " if where else ""
123
+ suffix = f" [{issue['code']}]" if issue.get("code") else ""
124
+ lines.append(
125
+ f" {prefix}{issue.get('message', '')}{suffix}" + (f" ({subject})" if subject else "")
126
+ )
127
+ for member in error.errors[:limit]:
128
+ lines.append(f" {member.get('pointer', '')}: {member.get('message', '')}")
129
+ total = len(error.issues) + len(error.errors)
130
+ if total > limit:
131
+ lines.append(f" ... and {total - limit} more")
132
+ return lines
133
+
134
+
135
+ def describe(
136
+ error: LorebaseError, *, row_offset: int = 0, locate: Locator | None = None
137
+ ) -> list[str]:
138
+ """Lines to print for an error: the problem detail, what it carries and a hint."""
139
+ lines = [f"error: {error.message}"]
140
+ lines.extend(issue_lines(error, row_offset=row_offset, locate=locate))
141
+ if error.current_head:
142
+ lines.append(f" the branch is now at {error.current_head}; read it again and retry")
143
+ if error.extra.get("proposals"):
144
+ lines.append(f" open a Proposal at {error.extra['proposals']} instead")
145
+ if error.extra.get("permission"):
146
+ lines.append(f" missing permission: {error.extra['permission']}")
147
+ if error.retry_after:
148
+ lines.append(f" retry after {error.retry_after:g} s")
149
+ if error.request_id:
150
+ lines.append(f" request id: {error.request_id}")
151
+ return lines
152
+
153
+
154
+ def render_failure(error: Exception, *, json_mode: bool) -> tuple[str, int]:
155
+ """The text for standard error and the exit code of a failure."""
156
+ row_offset = 0
157
+ locate: Locator | None = None
158
+ committed: dict[str, Any] = {}
159
+ api_error: LorebaseError | None = None
160
+ notes: list[str] = []
161
+ if isinstance(error, ImportAbortedError):
162
+ api_error = error.cause
163
+ row_offset = error.first_row # 1-based row numbers of the input file
164
+ locate = error.locate
165
+ committed = {
166
+ "committed_rows": error.committed_rows,
167
+ "committed_revisions": error.committed_revisions,
168
+ "first_row": error.first_row,
169
+ "last_row": error.last_row,
170
+ }
171
+ notes = [error.message]
172
+ code = error.exit_code
173
+ elif isinstance(error, LorebaseError):
174
+ api_error = error
175
+ code = exit_code_for(error)
176
+ elif isinstance(error, CliError):
177
+ code = error.exit_code
178
+ else:
179
+ raise error
180
+ if json_mode:
181
+ body: dict[str, Any] = {"message": str(error), **committed}
182
+ if api_error is not None:
183
+ body.update(
184
+ status=api_error.status,
185
+ code=api_error.code,
186
+ type=api_error.type,
187
+ detail=api_error.detail,
188
+ request_id=api_error.request_id,
189
+ extra=api_error.extra,
190
+ )
191
+ return json.dumps({"error": body}, indent=2), code
192
+ if api_error is None:
193
+ return f"error: {error}", code
194
+ lines = describe(api_error, row_offset=row_offset, locate=locate)
195
+ return "\n".join([*lines, *(f" {note}" for note in notes)]), code
@@ -0,0 +1,277 @@
1
+ """Importing rows from a file through the `import_records` and `import_links` Operations
2
+ (`import_batch` is in `batch_import`).
3
+
4
+ Rows are coerced against the schema of the target branch, grouped in batches (one request and
5
+ one Revision each; the server commits very large requests in batches of its own) and sent in
6
+ order. Progress goes to standard error. A rejected batch stops the import: earlier batches stay
7
+ committed, the error says which rows were rejected, and re-running the same import is safe
8
+ because imports upsert.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import itertools
14
+ from collections.abc import Callable, Iterable, Iterator, Mapping
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, Literal
17
+
18
+ from lorebase_cli.coercion import (
19
+ KEY_COLUMN,
20
+ LINK_COLUMNS,
21
+ LINK_ENDS,
22
+ CoercionError,
23
+ attribute_types,
24
+ coerce_row,
25
+ is_missing,
26
+ )
27
+ from lorebase_cli.failures import CliError, ImportAbortedError
28
+ from lorebase_client import LorebaseClient, LorebaseError
29
+
30
+ Target = Literal["records", "links", "batch"]
31
+ LINKS_MEMBER = "links" # the member of a Record row that holds its Links
32
+ Progress = Callable[["BatchResult"], None]
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class BatchResult:
37
+ """The outcome of one request."""
38
+
39
+ number: int
40
+ first_row: int
41
+ rows: int
42
+ edits: int
43
+ revisions: list[str]
44
+ status: str
45
+
46
+
47
+ @dataclass
48
+ class ImportReport:
49
+ """What an import did."""
50
+
51
+ target: Target
52
+ name: str
53
+ domain: str
54
+ branch: str | None
55
+ dry_run: bool
56
+ batches: list[BatchResult] = field(default_factory=list)
57
+
58
+ @property
59
+ def rows(self) -> int:
60
+ return sum(b.rows for b in self.batches)
61
+
62
+ @property
63
+ def edits(self) -> int:
64
+ return sum(b.edits for b in self.batches)
65
+
66
+ @property
67
+ def revisions(self) -> list[str]:
68
+ return [r for b in self.batches for r in b.revisions]
69
+
70
+ def summary(self) -> dict[str, Any]:
71
+ return {
72
+ "target": self.target,
73
+ "name": self.name,
74
+ "domain": self.domain,
75
+ "branch": self.branch,
76
+ "dry_run": self.dry_run,
77
+ "batches": len(self.batches),
78
+ "rows": self.rows,
79
+ "edits": self.edits,
80
+ "revisions": self.revisions,
81
+ }
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class Prepared:
86
+ """How rows of one target are coerced."""
87
+
88
+ types: Mapping[str, str]
89
+ extra_columns: tuple[str, ...]
90
+ renames: Mapping[str, str]
91
+ link_columns: Mapping[str, str] = field(default_factory=dict) # Relation -> file column
92
+
93
+
94
+ def prepare(
95
+ schema: Mapping[str, Any],
96
+ target: Target,
97
+ name: str,
98
+ *,
99
+ renames: Mapping[str, str] | None = None,
100
+ link_columns: Mapping[str, str] | None = None,
101
+ ) -> Prepared:
102
+ """Look the Kind or Relation up in the schema document and fix its column rules.
103
+
104
+ `link_columns` (Records only) names the file column that holds the key at the other end of
105
+ each Relation; those cells are sent as the `links` of the row.
106
+ """
107
+ if target == "records":
108
+ types = attribute_types(schema, "kinds", name)
109
+ kind_id, kind = next((i, k) for i, k in schema["kinds"].items() if k["api_name"] == name)
110
+ extra = () if kind.get("primary_key") else (KEY_COLUMN,)
111
+ if link_columns:
112
+ _check_link_relations(schema, kind_id, name, types, link_columns)
113
+ return Prepared(types, extra, dict(renames or {}), dict(link_columns or {}))
114
+ types = attribute_types(schema, "relations", name)
115
+ return Prepared(types, LINK_COLUMNS, {**LINK_ENDS, **(renames or {})})
116
+
117
+
118
+ def _check_link_relations(
119
+ schema: Mapping[str, Any],
120
+ kind_id: str,
121
+ kind_name: str,
122
+ types: Mapping[str, str],
123
+ link_columns: Mapping[str, str],
124
+ ) -> None:
125
+ """Every `--link` Relation must be a stored Relation that starts at the Kind."""
126
+ if LINKS_MEMBER in types:
127
+ raise CliError(
128
+ f"{kind_name} has an Attribute named {LINKS_MEMBER!r}; --link cannot be used"
129
+ )
130
+ usable: set[str] = set()
131
+ for relation in schema.get("relations", {}).values():
132
+ if relation.get("backing", "explicit") != "explicit":
133
+ continue
134
+ if relation.get("from_kind") == kind_id:
135
+ usable.add(relation["api_name"])
136
+ if relation.get("to_kind") == kind_id and relation.get("inverse_api_name"):
137
+ usable.add(relation["inverse_api_name"])
138
+ for relation_name in link_columns:
139
+ if relation_name not in usable:
140
+ known = ", ".join(sorted(usable)) or "none"
141
+ raise CliError(
142
+ f"{relation_name!r} is not a stored Relation of {kind_name} (--link); "
143
+ f"it has: {known}"
144
+ )
145
+
146
+
147
+ def _parse_pairs(pairs: Iterable[str], option: str, shape: str) -> dict[str, str]:
148
+ out: dict[str, str] = {}
149
+ for pair in pairs:
150
+ left, separator, right = pair.partition("=")
151
+ if not separator or not left or not right:
152
+ raise CliError(f"{option} expects {shape}, got {pair!r}")
153
+ out[left] = right
154
+ return out
155
+
156
+
157
+ def parse_renames(pairs: Iterable[str]) -> dict[str, str]:
158
+ """`--map file_column=attribute` options as a dict."""
159
+ return _parse_pairs(pairs, "--map", "column=attribute")
160
+
161
+
162
+ def parse_link_columns(pairs: Iterable[str]) -> dict[str, str]:
163
+ """`--link relation=file_column` options as a dict."""
164
+ return _parse_pairs(pairs, "--link", "relation=column")
165
+
166
+
167
+ def _link_targets(value: Any) -> Any:
168
+ """What a link cell holds: a key, a list of keys or an object with `key` and Attributes."""
169
+ if isinstance(value, list):
170
+ return [
171
+ item if isinstance(item, dict) else str(item) for item in value if not is_missing(item)
172
+ ]
173
+ return value if isinstance(value, dict) else str(value)
174
+
175
+
176
+ def _split_links(
177
+ row: Mapping[str, Any], link_columns: Mapping[str, str]
178
+ ) -> tuple[Mapping[str, Any], dict[str, Any]]:
179
+ """The row without its link columns, and the `links` they make."""
180
+ if not link_columns:
181
+ return row, {}
182
+ rest = dict(row)
183
+ links: dict[str, Any] = {}
184
+ for relation, column in link_columns.items():
185
+ value = rest.pop(column, None)
186
+ if is_missing(value) or value == []:
187
+ continue
188
+ targets = _link_targets(value)
189
+ if targets != []:
190
+ links[relation] = targets
191
+ return rest, links
192
+
193
+
194
+ def coerced_rows(
195
+ rows: Iterable[Mapping[str, Any]],
196
+ prepared: Prepared,
197
+ *,
198
+ ignore_unknown: bool,
199
+ source: str | None = None,
200
+ ) -> Iterator[dict[str, Any]]:
201
+ """Rows coerced one by one; a bad value names its row (1-based, header excluded) and, when
202
+ several files are read, `source`."""
203
+ where = f"{source}: " if source else ""
204
+ for number, row in enumerate(rows, start=1):
205
+ row, links = _split_links(row, prepared.link_columns)
206
+ try:
207
+ coerced = coerce_row(
208
+ prepared.types,
209
+ row,
210
+ renames=prepared.renames,
211
+ ignore_unknown=ignore_unknown,
212
+ extra_columns=prepared.extra_columns,
213
+ )
214
+ except CoercionError as error:
215
+ raise CliError(f"{where}row {number}: {error}") from None
216
+ if links:
217
+ coerced[LINKS_MEMBER] = links
218
+ yield coerced
219
+
220
+
221
+ def run_import(
222
+ client: LorebaseClient,
223
+ *,
224
+ domain: str,
225
+ branch: str | None,
226
+ target: Target,
227
+ name: str,
228
+ rows: Iterable[dict[str, Any]],
229
+ batch_size: int,
230
+ dry_run: bool = False,
231
+ message: str | None = None,
232
+ progress: Progress | None = None,
233
+ ) -> ImportReport:
234
+ """Send `rows` in batches of `batch_size`; returns what was done."""
235
+ report = ImportReport(target, name, domain, branch, dry_run)
236
+ first_row = 1
237
+ for number, batch in enumerate(itertools.batched(rows, batch_size), start=1):
238
+ last_row = first_row + len(batch) - 1
239
+ note = f"{message} (rows {first_row}-{last_row})" if message else None
240
+ key = client.idempotency_key("import") # a retry of this request cannot apply it twice
241
+ try:
242
+ if target == "records":
243
+ result = client.operations.import_records(
244
+ domain,
245
+ name,
246
+ batch,
247
+ branch=branch,
248
+ dry_run=dry_run,
249
+ message=note,
250
+ idempotency_key=key,
251
+ )
252
+ else:
253
+ result = client.operations.import_links(
254
+ domain,
255
+ name,
256
+ batch,
257
+ branch=branch,
258
+ dry_run=dry_run,
259
+ message=note,
260
+ idempotency_key=key,
261
+ )
262
+ except LorebaseError as error:
263
+ raise ImportAbortedError(
264
+ error,
265
+ first_row=first_row,
266
+ last_row=last_row,
267
+ committed_rows=report.rows,
268
+ committed_revisions=report.revisions,
269
+ ) from error
270
+ outcome = BatchResult(
271
+ number, first_row, len(batch), result.edit_count, result.revisions, result.status
272
+ )
273
+ report.batches.append(outcome)
274
+ if progress is not None:
275
+ progress(outcome)
276
+ first_row = last_row + 1
277
+ return report