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,3 @@
1
+ """lorebase CLI: command line client of a Lorebase Keeper."""
2
+
3
+ __version__ = "0.2.3"
@@ -0,0 +1,5 @@
1
+ """`python -m lorebase_cli`."""
2
+
3
+ from lorebase_cli.main import main
4
+
5
+ main()
@@ -0,0 +1,330 @@
1
+ """Importing Records together with their Links through the `import_batch` Operation.
2
+
3
+ Files of Records (one per Kind) and files of Links (one per Relation) are cut into requests
4
+ that keep a Record with its Links: a Link travels with the Record at its `from` end (or, when
5
+ that Record is not in the import, at its `to` end), so a Relation that is `required_from` is
6
+ satisfied in the Revision that creates the Record. Units are sent in this order: Records that
7
+ own no Link (in the order of the files given), then Records with their Links, then Links whose
8
+ ends are not part of the import. A unit is never split; a request is full when the next unit
9
+ would pass `batch_size` rows.
10
+
11
+ Every file is read twice: once to coerce and check all rows before anything is written (this
12
+ also finds the keys of the Records), once to send. The Links are held in memory, since they can
13
+ be owned by Records of any file.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import unicodedata
19
+ from collections.abc import Iterable, Iterator, Mapping, Sequence
20
+ from dataclasses import dataclass, field
21
+ from typing import Any
22
+
23
+ from lorebase_cli import sources
24
+ from lorebase_cli.coercion import KEY_COLUMN
25
+ from lorebase_cli.failures import CliError, ImportAbortedError
26
+ from lorebase_cli.importing import (
27
+ BatchResult,
28
+ ImportReport,
29
+ Prepared,
30
+ Progress,
31
+ coerced_rows,
32
+ prepare,
33
+ )
34
+ from lorebase_client import LorebaseClient, LorebaseError
35
+
36
+ KEY_TYPES = frozenset({"string", "text", "uri", "ref", "enum", "integer"})
37
+ Identity = tuple[str, str] # Kind api name and Record key
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Source:
42
+ """A file of Records of a Kind, or of Links of a Relation."""
43
+
44
+ name: str
45
+ path: str
46
+
47
+
48
+ def parse_sources(pairs: Iterable[str], option: str, shape: str) -> list[Source]:
49
+ """`--records Kind=file` or `--links relation=file` options."""
50
+ out: list[Source] = []
51
+ for pair in pairs:
52
+ name, separator, path = pair.partition("=")
53
+ if not separator or not name or not path:
54
+ raise CliError(f"{option} expects {shape}, got {pair!r}")
55
+ if path == sources.STDIN:
56
+ raise CliError(f"{option}: import batch reads files, not standard input")
57
+ out.append(Source(name, path))
58
+ return out
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Item:
63
+ """A row ready to send and where it came from (1-based, header excluded)."""
64
+
65
+ name: str # Kind or Relation api name
66
+ row: dict[str, Any]
67
+ path: str
68
+ number: int
69
+
70
+
71
+ @dataclass
72
+ class Unit:
73
+ """A Record with the Links it owns, or one Link on its own."""
74
+
75
+ record: Item | None = None
76
+ links: list[Item] = field(default_factory=list)
77
+
78
+ @property
79
+ def size(self) -> int:
80
+ return (self.record is not None) + len(self.links)
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class RequestRows:
85
+ """The rows of one request."""
86
+
87
+ records: list[Item]
88
+ links: list[Item]
89
+
90
+ @property
91
+ def size(self) -> int:
92
+ return len(self.records) + len(self.links)
93
+
94
+ def parameters(self) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
95
+ return (
96
+ [{"kind": i.name, **i.row} for i in self.records],
97
+ [{"relation": i.name, **i.row} for i in self.links],
98
+ )
99
+
100
+ def locate(self, path: Sequence[Any]) -> str | None:
101
+ """`file row N rest` for an issue path under `parameters.records` or `.links`."""
102
+ if len(path) < 3 or path[0] != "parameters" or not isinstance(path[2], int):
103
+ return None
104
+ items = {"records": self.records, "links": self.links}.get(str(path[1]))
105
+ if items is None or not 0 <= path[2] < len(items):
106
+ return None
107
+ item = items[path[2]]
108
+ rest = ".".join(str(part) for part in path[3:])
109
+ return f"{item.path} row {item.number}" + (f" {rest}" if rest else "")
110
+
111
+
112
+ def pack(units: Iterable[Unit], size: int) -> Iterator[RequestRows]:
113
+ """Units in requests of at most `size` rows (a larger unit is a request of its own)."""
114
+ records: list[Item] = []
115
+ links: list[Item] = []
116
+ for unit in units:
117
+ if (records or links) and len(records) + len(links) + unit.size > size:
118
+ yield RequestRows(records, links)
119
+ records, links = [], []
120
+ if unit.record is not None:
121
+ records.append(unit.record)
122
+ links.extend(unit.links)
123
+ if records or links:
124
+ yield RequestRows(records, links)
125
+
126
+
127
+ class BatchImport:
128
+ """The files of one `import batch`, checked and ready to be read again for sending."""
129
+
130
+ def __init__(
131
+ self,
132
+ schema: Mapping[str, Any],
133
+ records: Sequence[Source],
134
+ links: Sequence[Source],
135
+ *,
136
+ fmt: str | None,
137
+ delimiter: str | None,
138
+ renames: Mapping[str, str],
139
+ ignore_unknown: bool,
140
+ ) -> None:
141
+ self.records = list(records)
142
+ self.fmt = fmt
143
+ self.delimiter = delimiter
144
+ self.ignore_unknown = ignore_unknown
145
+ self.prepared = {
146
+ s.name: prepare(schema, "records", s.name, renames=renames) for s in records
147
+ }
148
+ self.key_attributes = {s.name: _key_attribute(schema, s.name) for s in records}
149
+ self.record_count = 0
150
+ self.identities: dict[Identity, None] = {} # in file order
151
+ self.owned: dict[Identity, list[Item]] = {}
152
+ self.loose: list[Item] = []
153
+ self._load_links(schema, links, renames)
154
+ self._scan_records()
155
+ self._assign_links()
156
+
157
+ # Reading --------------------------------------------------------------------------------
158
+
159
+ def _rows(self, source: Source, prepared: Prepared) -> Iterator[Item]:
160
+ fmt = sources.detect_format(source.path, self.fmt)
161
+ rows = coerced_rows(
162
+ sources.read_rows(source.path, fmt, delimiter=self.delimiter),
163
+ prepared,
164
+ ignore_unknown=self.ignore_unknown,
165
+ source=source.path,
166
+ )
167
+ for number, row in enumerate(rows, start=1):
168
+ yield Item(source.name, row, source.path, number)
169
+
170
+ def _load_links(
171
+ self, schema: Mapping[str, Any], links: Sequence[Source], renames: Mapping[str, str]
172
+ ) -> None:
173
+ self._links: list[tuple[Item, Identity, Identity]] = []
174
+ for source in links:
175
+ prepared = prepare(schema, "links", source.name, renames=renames)
176
+ from_kind, to_kind = _relation_kinds(schema, source.name)
177
+ for item in self._rows(source, prepared):
178
+ ends = (item.row.get("from"), item.row.get("to"))
179
+ if not all(isinstance(end, str) for end in ends):
180
+ raise CliError(
181
+ f"{item.path}: row {item.number}: a Link needs `from` and `to` "
182
+ "(columns from_key and to_key)"
183
+ )
184
+ a, b = (_normalized(str(end)) for end in ends)
185
+ self._links.append((item, (from_kind, a), (to_kind, b)))
186
+
187
+ def _scan_records(self) -> None:
188
+ for source in self.records:
189
+ for item in self._rows(source, self.prepared[source.name]):
190
+ self.record_count += 1
191
+ identity = self._identity(item)
192
+ if identity is not None:
193
+ self.identities.setdefault(identity)
194
+
195
+ def _assign_links(self) -> None:
196
+ for item, start, end in self._links:
197
+ owner = start if start in self.identities else end if end in self.identities else None
198
+ if owner is None:
199
+ self.loose.append(item)
200
+ else:
201
+ self.owned.setdefault(owner, []).append(item)
202
+ del self._links
203
+
204
+ def _identity(self, item: Item) -> Identity | None:
205
+ attribute = self.key_attributes[item.name]
206
+ value = item.row.get(attribute or KEY_COLUMN)
207
+ return None if value is None else (item.name, _normalized(str(value)))
208
+
209
+ # Units ----------------------------------------------------------------------------------
210
+
211
+ def units(self) -> Iterator[Unit]:
212
+ """Records without Links, Records with their Links, then the Links left over."""
213
+ owned = {identity: list(links) for identity, links in self.owned.items()}
214
+ with_links: list[Unit] = []
215
+ for source in self.records:
216
+ for item in self._rows(source, self.prepared[source.name]):
217
+ identity = self._identity(item)
218
+ links = owned.pop(identity, []) if identity is not None else []
219
+ if links:
220
+ with_links.append(Unit(item, links))
221
+ else:
222
+ yield Unit(item)
223
+ yield from with_links
224
+ for link in self.loose:
225
+ yield Unit(links=[link])
226
+
227
+ def sizes(self) -> list[int]:
228
+ """The sizes of the units in the order `units()` yields them."""
229
+ with_links = [1 + len(self.owned[i]) for i in self.identities if i in self.owned]
230
+ lone = self.record_count - len(with_links)
231
+ return [1] * lone + with_links + [1] * len(self.loose)
232
+
233
+ @property
234
+ def link_count(self) -> int:
235
+ return sum(len(v) for v in self.owned.values()) + len(self.loose)
236
+
237
+ def expected_requests(self, size: int) -> int:
238
+ """How many requests `pack` will make (exact unless Records repeat)."""
239
+ requests = used = 0
240
+ for unit_size in self.sizes():
241
+ if used and used + unit_size > size:
242
+ requests += 1
243
+ used = 0
244
+ used += unit_size
245
+ return requests + bool(used)
246
+
247
+
248
+ def _normalized(key: str) -> str:
249
+ return unicodedata.normalize("NFC", key)
250
+
251
+
252
+ def _kind_entry(schema: Mapping[str, Any], name: str) -> Mapping[str, Any]:
253
+ return next(k for k in schema["kinds"].values() if k["api_name"] == name)
254
+
255
+
256
+ def _key_attribute(schema: Mapping[str, Any], kind_name: str) -> str | None:
257
+ """The api name of the one Attribute that is the key of a Kind (`None`: a `key` column)."""
258
+ kind = _kind_entry(schema, kind_name)
259
+ key = kind.get("primary_key") or []
260
+ if not key:
261
+ return None
262
+ if len(key) > 1:
263
+ raise CliError(
264
+ f"{kind_name} has a composite key; import batch cannot tell which Links go with "
265
+ "which Record. Use `import records` with --link instead"
266
+ )
267
+ attribute = kind["attributes"][key[0]]
268
+ if attribute["type"]["name"] not in KEY_TYPES:
269
+ raise CliError(
270
+ f"the key of {kind_name} is a {attribute['type']['name']}; import batch needs a "
271
+ "text or integer key. Use `import records` with --link instead"
272
+ )
273
+ return str(attribute["api_name"])
274
+
275
+
276
+ def _relation_kinds(schema: Mapping[str, Any], name: str) -> tuple[str, str]:
277
+ """The api names of the Kinds at the `from` and `to` ends of a Relation."""
278
+ relation = next(r for r in schema["relations"].values() if r["api_name"] == name)
279
+ kinds = schema["kinds"]
280
+ return kinds[relation["from_kind"]]["api_name"], kinds[relation["to_kind"]]["api_name"]
281
+
282
+
283
+ def run_batch_import(
284
+ client: LorebaseClient,
285
+ plan: BatchImport,
286
+ *,
287
+ domain: str,
288
+ branch: str | None,
289
+ name: str,
290
+ batch_size: int,
291
+ dry_run: bool = False,
292
+ message: str | None = None,
293
+ progress: Progress | None = None,
294
+ ) -> ImportReport:
295
+ """Send the units of `plan` in requests of at most `batch_size` rows."""
296
+ report = ImportReport("batch", name, domain, branch, dry_run)
297
+ first_row = 1
298
+ for number, request in enumerate(pack(plan.units(), batch_size), start=1):
299
+ last_row = first_row + request.size - 1
300
+ note = f"{message} (rows {first_row}-{last_row})" if message else None
301
+ records, links = request.parameters()
302
+ key = client.idempotency_key("import") # a retry of this request cannot apply it twice
303
+ try:
304
+ result = client.operations.import_batch(
305
+ domain,
306
+ records,
307
+ links,
308
+ branch=branch,
309
+ dry_run=dry_run,
310
+ message=note,
311
+ idempotency_key=key,
312
+ )
313
+ except LorebaseError as error:
314
+ raise ImportAbortedError(
315
+ error,
316
+ first_row=first_row,
317
+ last_row=last_row,
318
+ committed_rows=report.rows,
319
+ committed_revisions=report.revisions,
320
+ scope=f"request {number} ({len(records)} Records, {len(links)} Links) was rejected",
321
+ locate=request.locate,
322
+ ) from error
323
+ outcome = BatchResult(
324
+ number, first_row, request.size, result.edit_count, result.revisions, result.status
325
+ )
326
+ report.batches.append(outcome)
327
+ if progress is not None:
328
+ progress(outcome)
329
+ first_row = last_row + 1
330
+ return report
@@ -0,0 +1,176 @@
1
+ """Coercion of file values to what the import Operations take, driven by the schema.
2
+
3
+ CSV gives text; JSON Lines and Parquet give typed values. Keeper takes canonical forms:
4
+ booleans are JSON booleans, integers and decimals are strings (never floats, so no digit is
5
+ lost), floats are numbers, timestamps and dates are text, `json` values are JSON. Empty cells,
6
+ nulls and NaN are left out of the row (the Attribute is unset).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import math
13
+ import re
14
+ from collections.abc import Iterable, Mapping
15
+ from datetime import date, datetime
16
+ from decimal import Decimal, InvalidOperation
17
+ from typing import Any
18
+
19
+ from lorebase_cli.failures import CliError
20
+
21
+ TRUE_WORDS = frozenset({"true", "t", "yes", "y", "1"})
22
+ FALSE_WORDS = frozenset({"false", "f", "no", "n", "0"})
23
+ INTEGER = re.compile(r"[+-]?[0-9]+")
24
+ KEY_COLUMN = "key"
25
+ LINK_ENDS = {"from_key": "from", "to_key": "to"}
26
+ LINK_COLUMNS = ("from", "to")
27
+
28
+
29
+ class CoercionError(ValueError):
30
+ """A value that cannot take the type of its Attribute."""
31
+
32
+ def __init__(self, column: str, message: str) -> None:
33
+ super().__init__(f"column {column!r}: {message}")
34
+ self.column = column
35
+
36
+
37
+ def is_missing(value: Any) -> bool:
38
+ """Empty text, null and NaN all mean `unset`."""
39
+ if value is None:
40
+ return True
41
+ if isinstance(value, str):
42
+ return value.strip() == ""
43
+ return isinstance(value, float) and math.isnan(value)
44
+
45
+
46
+ def _boolean(column: str, value: Any) -> bool:
47
+ if isinstance(value, bool):
48
+ return value
49
+ if isinstance(value, int) and value in (0, 1):
50
+ return bool(value)
51
+ word = str(value).strip().lower()
52
+ if word in TRUE_WORDS:
53
+ return True
54
+ if word in FALSE_WORDS:
55
+ return False
56
+ raise CoercionError(column, f"{value!r} is not a boolean (true or false)")
57
+
58
+
59
+ def _integer(column: str, value: Any) -> str:
60
+ if isinstance(value, bool):
61
+ raise CoercionError(column, "a boolean is not an integer")
62
+ if isinstance(value, int):
63
+ return str(value)
64
+ if isinstance(value, float | Decimal) and value == int(value):
65
+ return str(int(value))
66
+ text = str(value).strip()
67
+ if not INTEGER.fullmatch(text):
68
+ raise CoercionError(column, f"{value!r} is not an integer")
69
+ return text
70
+
71
+
72
+ def _decimal(column: str, value: Any) -> str:
73
+ if isinstance(value, bool):
74
+ raise CoercionError(column, "a boolean is not a decimal")
75
+ try:
76
+ number = value if isinstance(value, Decimal) else Decimal(str(value).strip())
77
+ except InvalidOperation:
78
+ raise CoercionError(column, f"{value!r} is not a decimal number") from None
79
+ if not number.is_finite():
80
+ raise CoercionError(column, f"{value!r} is not a finite number")
81
+ return format(number, "f")
82
+
83
+
84
+ def _float(column: str, value: Any) -> float:
85
+ if isinstance(value, bool):
86
+ raise CoercionError(column, "a boolean is not a float")
87
+ try:
88
+ number = float(value if not isinstance(value, str) else value.strip())
89
+ except ValueError:
90
+ raise CoercionError(column, f"{value!r} is not a number") from None
91
+ if not math.isfinite(number):
92
+ raise CoercionError(column, f"{value!r} is not a finite number")
93
+ return number
94
+
95
+
96
+ def _json(column: str, value: Any, *, expect: type | None = None) -> Any:
97
+ parsed = value
98
+ if isinstance(value, str):
99
+ try:
100
+ parsed = json.loads(value)
101
+ except ValueError:
102
+ raise CoercionError(column, "is not valid JSON") from None
103
+ if expect is not None and not isinstance(parsed, expect):
104
+ raise CoercionError(column, f"must be a JSON {expect.__name__}")
105
+ return parsed
106
+
107
+
108
+ def _timestamp(value: Any) -> str:
109
+ if isinstance(value, datetime):
110
+ return value.isoformat()
111
+ return str(value).strip()
112
+
113
+
114
+ def coerce_value(type_name: str, column: str, value: Any) -> Any:
115
+ """`value` in the form Keeper takes for an Attribute of `type_name`."""
116
+ if type_name == "boolean":
117
+ return _boolean(column, value)
118
+ if type_name == "integer":
119
+ return _integer(column, value)
120
+ if type_name == "decimal":
121
+ return _decimal(column, value)
122
+ if type_name == "float":
123
+ return _float(column, value)
124
+ if type_name == "json":
125
+ return _json(column, value)
126
+ if type_name == "geo_point":
127
+ return _json(column, value, expect=dict)
128
+ if type_name == "array":
129
+ return _json(column, value, expect=list)
130
+ if type_name in ("timestamp", "date"):
131
+ if isinstance(value, date) and not isinstance(value, datetime):
132
+ return value.isoformat()
133
+ return _timestamp(value)
134
+ return value if isinstance(value, str) else str(value)
135
+
136
+
137
+ def attribute_types(schema: Mapping[str, Any], section: str, name: str) -> dict[str, str]:
138
+ """Attribute api name to type name for a Kind (`kinds`) or Relation (`relations`)."""
139
+ elements: Mapping[str, Any] = schema.get(section, {})
140
+ for element in elements.values():
141
+ if element.get("api_name") == name:
142
+ return {
143
+ a["api_name"]: str(a["type"]["name"])
144
+ for a in element.get("attributes", {}).values()
145
+ }
146
+ known = ", ".join(sorted(e.get("api_name", "?") for e in elements.values())) or "none"
147
+ what = "Kind" if section == "kinds" else "Relation"
148
+ raise CliError(f"unknown {what} {name!r}; the schema has: {known}")
149
+
150
+
151
+ def coerce_row(
152
+ types: Mapping[str, str],
153
+ row: Mapping[str, Any],
154
+ *,
155
+ renames: Mapping[str, str] | None = None,
156
+ ignore_unknown: bool = False,
157
+ extra_columns: Iterable[str] = (),
158
+ ) -> dict[str, Any]:
159
+ """One import row: columns renamed, unknown ones refused (or dropped), values coerced.
160
+
161
+ `extra_columns` are accepted besides Attributes (`key` for Kinds without a natural key,
162
+ `from` and `to` for Links); their values pass as text.
163
+ """
164
+ allowed = set(extra_columns)
165
+ out: dict[str, Any] = {}
166
+ for original, value in row.items():
167
+ column = (renames or {}).get(original, original)
168
+ if is_missing(value):
169
+ continue
170
+ if column in types:
171
+ out[column] = coerce_value(types[column], column, value)
172
+ elif column in allowed:
173
+ out[column] = value if isinstance(value, str) else str(value)
174
+ elif not ignore_unknown:
175
+ raise CoercionError(column, "is not an Attribute (use --ignore-unknown to skip it)")
176
+ return out
@@ -0,0 +1 @@
1
+ """Command groups of the lorebase CLI."""