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,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
|
+
)
|
lorebase_cli/context.py
ADDED
|
@@ -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
|
lorebase_cli/failures.py
ADDED
|
@@ -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
|