lorebase-cli 0.2.3__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.
- lorebase_cli-0.2.3/.gitignore +79 -0
- lorebase_cli-0.2.3/PKG-INFO +11 -0
- lorebase_cli-0.2.3/pyproject.toml +26 -0
- lorebase_cli-0.2.3/src/lorebase_cli/__init__.py +3 -0
- lorebase_cli-0.2.3/src/lorebase_cli/__main__.py +5 -0
- lorebase_cli-0.2.3/src/lorebase_cli/batch_import.py +330 -0
- lorebase_cli-0.2.3/src/lorebase_cli/coercion.py +176 -0
- lorebase_cli-0.2.3/src/lorebase_cli/commands/__init__.py +1 -0
- lorebase_cli-0.2.3/src/lorebase_cli/commands/admin.py +254 -0
- lorebase_cli-0.2.3/src/lorebase_cli/commands/agents.py +244 -0
- lorebase_cli-0.2.3/src/lorebase_cli/commands/audit.py +51 -0
- lorebase_cli-0.2.3/src/lorebase_cli/commands/data.py +742 -0
- lorebase_cli-0.2.3/src/lorebase_cli/commands/documents.py +161 -0
- lorebase_cli-0.2.3/src/lorebase_cli/commands/domains.py +404 -0
- lorebase_cli-0.2.3/src/lorebase_cli/commands/history.py +221 -0
- lorebase_cli-0.2.3/src/lorebase_cli/commands/proposals.py +476 -0
- lorebase_cli-0.2.3/src/lorebase_cli/commands/schema.py +523 -0
- lorebase_cli-0.2.3/src/lorebase_cli/commands/sources.py +69 -0
- lorebase_cli-0.2.3/src/lorebase_cli/commands/trace.py +140 -0
- lorebase_cli-0.2.3/src/lorebase_cli/context.py +93 -0
- lorebase_cli-0.2.3/src/lorebase_cli/failures.py +195 -0
- lorebase_cli-0.2.3/src/lorebase_cli/importing.py +277 -0
- lorebase_cli-0.2.3/src/lorebase_cli/linkml.py +1607 -0
- lorebase_cli-0.2.3/src/lorebase_cli/main.py +156 -0
- lorebase_cli-0.2.3/src/lorebase_cli/options.py +56 -0
- lorebase_cli-0.2.3/src/lorebase_cli/output.py +169 -0
- lorebase_cli-0.2.3/src/lorebase_cli/py.typed +0 -0
- lorebase_cli-0.2.3/src/lorebase_cli/settings.py +169 -0
- lorebase_cli-0.2.3/src/lorebase_cli/sources.py +118 -0
- lorebase_cli-0.2.3/tests/conftest.py +21 -0
- lorebase_cli-0.2.3/tests/fixtures/personinfo.linkml.yaml +200 -0
- lorebase_cli-0.2.3/tests/integration/test_quickstart.py +200 -0
- lorebase_cli-0.2.3/tests/test_coercion_and_sources.py +249 -0
- lorebase_cli-0.2.3/tests/test_commands.py +2383 -0
- lorebase_cli-0.2.3/tests/test_import_batch.py +445 -0
- lorebase_cli-0.2.3/tests/test_linkml.py +816 -0
- lorebase_cli-0.2.3/tests/test_settings.py +148 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
.eggs/
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
.python-eggs/
|
|
14
|
+
pip-wheel-metadata/
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
.pyre/
|
|
19
|
+
.pytype/
|
|
20
|
+
.hypothesis/
|
|
21
|
+
.coverage
|
|
22
|
+
.coverage.*
|
|
23
|
+
coverage.xml
|
|
24
|
+
htmlcov/
|
|
25
|
+
.tox/
|
|
26
|
+
.nox/
|
|
27
|
+
*.prof
|
|
28
|
+
|
|
29
|
+
# Node
|
|
30
|
+
node_modules/
|
|
31
|
+
.pnpm-store/
|
|
32
|
+
.npm/
|
|
33
|
+
.yarn/
|
|
34
|
+
!.yarn/releases
|
|
35
|
+
.pnp.*
|
|
36
|
+
npm-debug.log*
|
|
37
|
+
yarn-debug.log*
|
|
38
|
+
yarn-error.log*
|
|
39
|
+
pnpm-debug.log*
|
|
40
|
+
.vite/
|
|
41
|
+
*.tsbuildinfo
|
|
42
|
+
apps/web/dist/
|
|
43
|
+
apps/web/.vitest/
|
|
44
|
+
|
|
45
|
+
# Environment and secrets
|
|
46
|
+
.env
|
|
47
|
+
.env.*
|
|
48
|
+
!.env.example
|
|
49
|
+
*.pem
|
|
50
|
+
*.key
|
|
51
|
+
secrets/
|
|
52
|
+
|
|
53
|
+
# Docker
|
|
54
|
+
docker-compose.override.yaml
|
|
55
|
+
docker-compose.override.yml
|
|
56
|
+
.docker/
|
|
57
|
+
|
|
58
|
+
# Data and local artifacts
|
|
59
|
+
*.sqlite
|
|
60
|
+
*.db
|
|
61
|
+
/tmp/
|
|
62
|
+
/data/
|
|
63
|
+
tests/perf/results/
|
|
64
|
+
|
|
65
|
+
# Editors and OS
|
|
66
|
+
.idea/
|
|
67
|
+
.vscode/*
|
|
68
|
+
!.vscode/extensions.json
|
|
69
|
+
!.vscode/settings.example.json
|
|
70
|
+
*.swp
|
|
71
|
+
*.swo
|
|
72
|
+
*~
|
|
73
|
+
.DS_Store
|
|
74
|
+
Thumbs.db
|
|
75
|
+
*.code-workspace
|
|
76
|
+
.history/
|
|
77
|
+
|
|
78
|
+
# Session worktrees
|
|
79
|
+
.claude/
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lorebase-cli
|
|
3
|
+
Version: 0.2.3
|
|
4
|
+
Summary: Lorebase CLI
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: lorebase-client<0.3,>=0.2
|
|
7
|
+
Requires-Dist: typer>=0.27
|
|
8
|
+
Provides-Extra: linkml
|
|
9
|
+
Requires-Dist: pyyaml>=6.0; extra == 'linkml'
|
|
10
|
+
Provides-Extra: parquet
|
|
11
|
+
Requires-Dist: pyarrow>=15; extra == 'parquet'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "lorebase-cli"
|
|
3
|
+
version = "0.2.3"
|
|
4
|
+
description = "Lorebase CLI"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"lorebase-client>=0.2,<0.3",
|
|
8
|
+
# Command line framework with shell completion.
|
|
9
|
+
"typer>=0.27",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
# Parquet imports (`lorebase import records data.parquet`).
|
|
14
|
+
parquet = ["pyarrow>=15"]
|
|
15
|
+
# LinkML schema interchange (`lorebase schema export --format linkml`, `schema import --from linkml`).
|
|
16
|
+
linkml = ["pyyaml>=6.0"]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
lorebase = "lorebase_cli.main:main"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/lorebase_cli"]
|
|
@@ -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."""
|