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
lorebase_cli/main.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""The `lorebase` command line application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated, Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from typer.core import TyperGroup
|
|
11
|
+
|
|
12
|
+
import lorebase_client
|
|
13
|
+
from lorebase_cli import __version__
|
|
14
|
+
from lorebase_cli.commands import (
|
|
15
|
+
admin,
|
|
16
|
+
agents,
|
|
17
|
+
audit,
|
|
18
|
+
data,
|
|
19
|
+
documents,
|
|
20
|
+
domains,
|
|
21
|
+
history,
|
|
22
|
+
proposals,
|
|
23
|
+
schema,
|
|
24
|
+
sources,
|
|
25
|
+
trace,
|
|
26
|
+
)
|
|
27
|
+
from lorebase_cli.context import AppContext
|
|
28
|
+
from lorebase_cli.failures import EXIT_USAGE, CliError, render_failure
|
|
29
|
+
from lorebase_cli.settings import ENV_CONFIG, ENV_PROFILE, load_settings
|
|
30
|
+
from lorebase_client import LorebaseError
|
|
31
|
+
|
|
32
|
+
CLICK_USAGE_EXIT = 2 # exit code Typer gives to usage errors; ours is EXIT_USAGE
|
|
33
|
+
|
|
34
|
+
EPILOG = (
|
|
35
|
+
"Exit codes: 0 success, 1 usage or configuration error (also not found), "
|
|
36
|
+
"2 validation failure or conflict, 3 authentication or permission failure, "
|
|
37
|
+
"4 server error or server unreachable."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_invalid_request(error: Exception) -> bool:
|
|
42
|
+
"""A request model of the client rejected the arguments (a pydantic validation error)."""
|
|
43
|
+
return type(error).__module__.startswith("pydantic") and isinstance(error, ValueError)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LorebaseGroup(TyperGroup):
|
|
47
|
+
"""Turns failures into messages on standard error and the documented exit codes."""
|
|
48
|
+
|
|
49
|
+
def invoke(self, ctx: Any) -> Any: # Typer's vendored Context class is not public
|
|
50
|
+
try:
|
|
51
|
+
return super().invoke(ctx)
|
|
52
|
+
except typer.TyperException as error: # usage errors: bad option, missing argument
|
|
53
|
+
show = getattr(error, "show", None)
|
|
54
|
+
if callable(show):
|
|
55
|
+
show()
|
|
56
|
+
else: # pragma: no cover - every TyperException of typer 0.27 can show itself
|
|
57
|
+
typer.echo(f"error: {error}", err=True)
|
|
58
|
+
code = getattr(error, "exit_code", EXIT_USAGE)
|
|
59
|
+
raise typer.Exit(EXIT_USAGE if code == CLICK_USAGE_EXIT else code) from None
|
|
60
|
+
except (LorebaseError, CliError) as error:
|
|
61
|
+
app = ctx.find_object(AppContext)
|
|
62
|
+
text, code = render_failure(error, json_mode=bool(app and app.json_mode))
|
|
63
|
+
typer.echo(text, err=True)
|
|
64
|
+
raise typer.Exit(code) from None
|
|
65
|
+
except ValueError as error:
|
|
66
|
+
if not _is_invalid_request(error):
|
|
67
|
+
raise
|
|
68
|
+
typer.echo(f"error: invalid arguments\n{error}", err=True)
|
|
69
|
+
raise typer.Exit(EXIT_USAGE) from None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
app = typer.Typer(
|
|
73
|
+
cls=LorebaseGroup,
|
|
74
|
+
name="lorebase",
|
|
75
|
+
help="Command line client of a Lorebase Keeper.\n\n"
|
|
76
|
+
"Point it at a server with LOREBASE_URL and LOREBASE_API_KEY (or LOREBASE_TOKEN), or a "
|
|
77
|
+
"profile in ~/.config/lorebase/config.toml. Every command takes --json for machine output. "
|
|
78
|
+
"Run `lorebase --install-completion` for shell completion.",
|
|
79
|
+
epilog=EPILOG,
|
|
80
|
+
no_args_is_help=True,
|
|
81
|
+
add_completion=True,
|
|
82
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _version(show: bool) -> None:
|
|
87
|
+
if show:
|
|
88
|
+
typer.echo(f"lorebase {__version__} (client {lorebase_client.__version__})")
|
|
89
|
+
raise typer.Exit()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.callback()
|
|
93
|
+
def root(
|
|
94
|
+
ctx: typer.Context,
|
|
95
|
+
config: Annotated[
|
|
96
|
+
Path | None,
|
|
97
|
+
typer.Option(
|
|
98
|
+
"--config", help=f"Config file (default: ~/.config/lorebase/config.toml; {ENV_CONFIG})."
|
|
99
|
+
),
|
|
100
|
+
] = None,
|
|
101
|
+
profile: Annotated[
|
|
102
|
+
str | None, typer.Option("--profile", help=f"Profile of the config file ({ENV_PROFILE}).")
|
|
103
|
+
] = None,
|
|
104
|
+
url: Annotated[str | None, typer.Option("--url", help="Server URL (LOREBASE_URL).")] = None,
|
|
105
|
+
json_out: Annotated[
|
|
106
|
+
bool, typer.Option("--json", help="Print JSON instead of tables, for every command.")
|
|
107
|
+
] = False,
|
|
108
|
+
quiet: Annotated[
|
|
109
|
+
bool, typer.Option("--quiet", "-q", help="Print no progress or notes.")
|
|
110
|
+
] = False,
|
|
111
|
+
version: Annotated[
|
|
112
|
+
bool,
|
|
113
|
+
typer.Option("--version", callback=_version, is_eager=True, help="Show the version."),
|
|
114
|
+
] = False,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Load the settings every command uses."""
|
|
117
|
+
del version
|
|
118
|
+
settings = load_settings(
|
|
119
|
+
env=os.environ,
|
|
120
|
+
config_path=config,
|
|
121
|
+
profile=profile,
|
|
122
|
+
url=url,
|
|
123
|
+
warn=lambda message: typer.echo(f"warning: {message}", err=True),
|
|
124
|
+
)
|
|
125
|
+
state = AppContext(settings=settings, json_mode=json_out, quiet=quiet)
|
|
126
|
+
ctx.obj = state
|
|
127
|
+
ctx.call_on_close(state.close)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
app.add_typer(domains.domain_app, name="domain")
|
|
131
|
+
app.add_typer(domains.branch_app, name="branch")
|
|
132
|
+
app.add_typer(domains.tag_app, name="tag")
|
|
133
|
+
app.add_typer(schema.schema_app, name="schema")
|
|
134
|
+
app.add_typer(data.import_app, name="import")
|
|
135
|
+
app.add_typer(data.record_app, name="record")
|
|
136
|
+
app.add_typer(history.revision_app, name="revision")
|
|
137
|
+
app.add_typer(admin.apikey_app, name="apikey")
|
|
138
|
+
app.add_typer(admin.admin_app, name="admin")
|
|
139
|
+
app.add_typer(audit.audit_app, name="audit")
|
|
140
|
+
app.add_typer(trace.trace_app, name="trace")
|
|
141
|
+
app.add_typer(proposals.proposal_app, name="proposal")
|
|
142
|
+
app.add_typer(sources.sources_app, name="sources")
|
|
143
|
+
app.add_typer(documents.documents_app, name="documents")
|
|
144
|
+
app.command("whoami")(domains.whoami)
|
|
145
|
+
app.command("query")(data.query_command)
|
|
146
|
+
app.command("search")(data.search_command)
|
|
147
|
+
app.command("log")(history.log_command)
|
|
148
|
+
app.command("diff")(history.diff_command)
|
|
149
|
+
app.command("context")(agents.context_command)
|
|
150
|
+
app.command("explain")(agents.explain_command)
|
|
151
|
+
app.command("health")(agents.health_command)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main() -> None:
|
|
155
|
+
"""Entry point of the `lorebase` script."""
|
|
156
|
+
app(prog_name="lorebase")
|
lorebase_cli/options.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Options shared by many commands, and the shell completion of Domain ids."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from lorebase_cli.context import default_client
|
|
11
|
+
from lorebase_cli.settings import load_settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def complete_domain(incomplete: str) -> list[str]:
|
|
15
|
+
"""Domain ids starting with `incomplete`, from the configured server (none on any failure,
|
|
16
|
+
since completion must never print errors)."""
|
|
17
|
+
try:
|
|
18
|
+
settings = load_settings(env=os.environ)
|
|
19
|
+
if not settings.url:
|
|
20
|
+
return []
|
|
21
|
+
client = default_client(settings)
|
|
22
|
+
try:
|
|
23
|
+
return [d.id for d in client.domains.iterate() if d.id.startswith(incomplete)]
|
|
24
|
+
finally:
|
|
25
|
+
client.close()
|
|
26
|
+
except (Exception, SystemExit):
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
JsonOpt = Annotated[bool, typer.Option("--json", help="Print JSON instead of a table.")]
|
|
31
|
+
DomainOpt = Annotated[
|
|
32
|
+
str | None,
|
|
33
|
+
typer.Option(
|
|
34
|
+
"--domain",
|
|
35
|
+
"-d",
|
|
36
|
+
help="Domain id (default: LOREBASE_DOMAIN or `domain` in the config file).",
|
|
37
|
+
autocompletion=complete_domain,
|
|
38
|
+
),
|
|
39
|
+
]
|
|
40
|
+
BranchOpt = Annotated[
|
|
41
|
+
str | None,
|
|
42
|
+
typer.Option(
|
|
43
|
+
"--branch",
|
|
44
|
+
"-b",
|
|
45
|
+
help="Branch (default: LOREBASE_BRANCH, `branch` in the config file, else the Domain's "
|
|
46
|
+
"default branch).",
|
|
47
|
+
),
|
|
48
|
+
]
|
|
49
|
+
AtOpt = Annotated[
|
|
50
|
+
str | None, typer.Option("--at", help="Revision id or tag to read at (time travel).")
|
|
51
|
+
]
|
|
52
|
+
LimitOpt = Annotated[
|
|
53
|
+
int | None,
|
|
54
|
+
typer.Option("--limit", "-n", min=1, help="Most items to print (default: list_limit setting)."),
|
|
55
|
+
]
|
|
56
|
+
AllOpt = Annotated[bool, typer.Option("--all", help="Print every item, however many.")]
|
lorebase_cli/output.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Rendering of results: JSON for machines, tables for people.
|
|
2
|
+
|
|
3
|
+
Tables are plain aligned text by default (stable in pipes and tests); on a terminal they use
|
|
4
|
+
`rich` when it is installed (it comes with Typer). Long cells are cut at
|
|
5
|
+
`settings.max_cell_width`. Informational messages go to standard error so that standard output
|
|
6
|
+
only holds the result.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import date, datetime
|
|
16
|
+
from decimal import Decimal
|
|
17
|
+
from typing import IO, Any
|
|
18
|
+
|
|
19
|
+
ELLIPSIS = "…"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Column:
|
|
24
|
+
"""A table column: header, key of the value and optional formatter."""
|
|
25
|
+
|
|
26
|
+
header: str
|
|
27
|
+
key: str
|
|
28
|
+
format: Callable[[Any], str] | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def to_jsonable(value: Any) -> Any:
|
|
32
|
+
"""Plain JSON data of models, dates and decimals."""
|
|
33
|
+
dump = getattr(value, "model_dump", None) # a pydantic model of the client
|
|
34
|
+
if callable(dump):
|
|
35
|
+
return dump(mode="json", by_alias=True)
|
|
36
|
+
if isinstance(value, Mapping):
|
|
37
|
+
return {str(k): to_jsonable(v) for k, v in value.items()}
|
|
38
|
+
if isinstance(value, list | tuple):
|
|
39
|
+
return [to_jsonable(v) for v in value]
|
|
40
|
+
if isinstance(value, datetime | date):
|
|
41
|
+
return value.isoformat()
|
|
42
|
+
if isinstance(value, Decimal):
|
|
43
|
+
return str(value)
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cell(value: Any) -> str:
|
|
48
|
+
"""Text of a table cell."""
|
|
49
|
+
if value is None:
|
|
50
|
+
return ""
|
|
51
|
+
if isinstance(value, bool):
|
|
52
|
+
return "true" if value else "false"
|
|
53
|
+
if isinstance(value, datetime):
|
|
54
|
+
return value.isoformat(timespec="seconds")
|
|
55
|
+
if isinstance(value, list | tuple):
|
|
56
|
+
return ", ".join(cell(v) for v in value)
|
|
57
|
+
if isinstance(value, Mapping):
|
|
58
|
+
return json.dumps(to_jsonable(value), separators=(",", ":"), ensure_ascii=False)
|
|
59
|
+
return str(value).replace("\n", " ")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def shorten(text: str, width: int) -> str:
|
|
63
|
+
return text if len(text) <= width else text[: width - 1] + ELLIPSIS
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def short_id(value: str | None, length: int = 12) -> str:
|
|
67
|
+
"""The first characters of a Revision id."""
|
|
68
|
+
return "" if value is None else value[:length]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Output:
|
|
72
|
+
"""Where a command writes its result."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
*,
|
|
77
|
+
json_mode: bool = False,
|
|
78
|
+
max_cell_width: int = 60,
|
|
79
|
+
stdout: IO[str] | None = None,
|
|
80
|
+
stderr: IO[str] | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
self.json_mode = json_mode
|
|
83
|
+
self.max_cell_width = max_cell_width
|
|
84
|
+
self._stdout = stdout
|
|
85
|
+
self._stderr = stderr
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def stdout(self) -> IO[str]:
|
|
89
|
+
return self._stdout or sys.stdout
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def stderr(self) -> IO[str]:
|
|
93
|
+
return self._stderr or sys.stderr
|
|
94
|
+
|
|
95
|
+
def info(self, message: str) -> None:
|
|
96
|
+
"""A note for the person running the command (standard error)."""
|
|
97
|
+
print(message, file=self.stderr)
|
|
98
|
+
|
|
99
|
+
def text(self, text: str) -> None:
|
|
100
|
+
"""Raw text on standard output (a schema document)."""
|
|
101
|
+
self.stdout.write(text if text.endswith("\n") else text + "\n")
|
|
102
|
+
|
|
103
|
+
def data(self, payload: Any) -> None:
|
|
104
|
+
"""The JSON document of a result."""
|
|
105
|
+
print(json.dumps(to_jsonable(payload), indent=2, ensure_ascii=False), file=self.stdout)
|
|
106
|
+
|
|
107
|
+
def emit(self, payload: Any, human: Callable[[], None]) -> None:
|
|
108
|
+
"""JSON in JSON mode, otherwise whatever `human` prints."""
|
|
109
|
+
if self.json_mode:
|
|
110
|
+
self.data(payload)
|
|
111
|
+
else:
|
|
112
|
+
human()
|
|
113
|
+
|
|
114
|
+
def table(
|
|
115
|
+
self,
|
|
116
|
+
rows: Sequence[Mapping[str, Any]],
|
|
117
|
+
columns: Sequence[Column],
|
|
118
|
+
*,
|
|
119
|
+
truncate: bool = True,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Rows with the given columns (long cells cut unless `truncate` is off); nothing but
|
|
122
|
+
the header for no rows."""
|
|
123
|
+
grid = [
|
|
124
|
+
[
|
|
125
|
+
shorten(
|
|
126
|
+
column.format(row.get(column.key))
|
|
127
|
+
if column.format
|
|
128
|
+
else cell(row.get(column.key)),
|
|
129
|
+
self.max_cell_width if truncate else sys.maxsize,
|
|
130
|
+
)
|
|
131
|
+
for column in columns
|
|
132
|
+
]
|
|
133
|
+
for row in rows
|
|
134
|
+
]
|
|
135
|
+
headers = [column.header for column in columns]
|
|
136
|
+
if self._use_rich():
|
|
137
|
+
self._rich_table(headers, grid)
|
|
138
|
+
return
|
|
139
|
+
widths = [max(len(h), *(len(line[i]) for line in grid), 0) for i, h in enumerate(headers)]
|
|
140
|
+
for line in (headers, *grid):
|
|
141
|
+
print(
|
|
142
|
+
" ".join(c.ljust(w) for c, w in zip(line, widths, strict=True)).rstrip(),
|
|
143
|
+
file=self.stdout,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def pairs(self, items: Sequence[tuple[str, Any]]) -> None:
|
|
147
|
+
"""Name and value per line."""
|
|
148
|
+
rows = [{"name": name, "value": value} for name, value in items]
|
|
149
|
+
self.table(rows, [Column("field", "name"), Column("value", "value")], truncate=False)
|
|
150
|
+
|
|
151
|
+
def _use_rich(self) -> bool:
|
|
152
|
+
if self._stdout is not None or not sys.stdout.isatty():
|
|
153
|
+
return False
|
|
154
|
+
try:
|
|
155
|
+
import rich # noqa: F401
|
|
156
|
+
except ImportError: # pragma: no cover - rich ships with typer
|
|
157
|
+
return False
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
def _rich_table(self, headers: list[str], grid: list[list[str]]) -> None: # pragma: no cover
|
|
161
|
+
from rich.console import Console
|
|
162
|
+
from rich.table import Table
|
|
163
|
+
|
|
164
|
+
table = Table(show_header=True, header_style="bold")
|
|
165
|
+
for header in headers:
|
|
166
|
+
table.add_column(header, overflow="fold")
|
|
167
|
+
for line in grid:
|
|
168
|
+
table.add_row(*line)
|
|
169
|
+
Console().print(table)
|
lorebase_cli/py.typed
ADDED
|
File without changes
|
lorebase_cli/settings.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""CLI settings: command line, then environment, then the config file, then defaults.
|
|
2
|
+
|
|
3
|
+
The config file is TOML, by default `$XDG_CONFIG_HOME/lorebase/config.toml` (falling back to
|
|
4
|
+
`~/.config/lorebase/config.toml`); `--config` or `LOREBASE_CONFIG` names another one:
|
|
5
|
+
|
|
6
|
+
profile = "local" # profile used when none is asked for
|
|
7
|
+
|
|
8
|
+
[profiles.local]
|
|
9
|
+
url = "http://localhost:8080"
|
|
10
|
+
api_key = "lbk_..." # or token = "<OIDC access token>"
|
|
11
|
+
domain = "supply" # default for --domain
|
|
12
|
+
branch = "work" # default for --branch
|
|
13
|
+
|
|
14
|
+
[import]
|
|
15
|
+
batch_size = 1000 # rows per request
|
|
16
|
+
|
|
17
|
+
[output]
|
|
18
|
+
list_limit = 50 # items printed by list commands without --all
|
|
19
|
+
max_cell_width = 60 # longer table cells are cut
|
|
20
|
+
|
|
21
|
+
Keys outside `[profiles.*]` apply to every profile. Credentials are not accepted as command
|
|
22
|
+
line options (they would end up in shell history): use the environment or the config file,
|
|
23
|
+
which should be readable by its owner only.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import stat
|
|
30
|
+
import tomllib
|
|
31
|
+
from collections.abc import Mapping
|
|
32
|
+
from dataclasses import dataclass, replace
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from lorebase_cli.failures import CliError
|
|
37
|
+
|
|
38
|
+
ENV_CONFIG = "LOREBASE_CONFIG"
|
|
39
|
+
ENV_PROFILE = "LOREBASE_PROFILE"
|
|
40
|
+
ENV_DOMAIN = "LOREBASE_DOMAIN"
|
|
41
|
+
ENV_BRANCH = "LOREBASE_BRANCH"
|
|
42
|
+
ENV_BATCH_SIZE = "LOREBASE_BATCH_SIZE"
|
|
43
|
+
ENV_LIST_LIMIT = "LOREBASE_LIST_LIMIT"
|
|
44
|
+
|
|
45
|
+
DEFAULT_BATCH_SIZE = 1000
|
|
46
|
+
DEFAULT_LIST_LIMIT = 50
|
|
47
|
+
DEFAULT_MAX_CELL_WIDTH = 60
|
|
48
|
+
CONFIG_DIR = "lorebase"
|
|
49
|
+
CONFIG_FILE = "config.toml"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class CliSettings:
|
|
54
|
+
"""Everything the commands need from configuration."""
|
|
55
|
+
|
|
56
|
+
url: str | None = None
|
|
57
|
+
api_prefix: str | None = None
|
|
58
|
+
api_key: str | None = None
|
|
59
|
+
token: str | None = None
|
|
60
|
+
timeout: float | None = None
|
|
61
|
+
domain: str | None = None
|
|
62
|
+
branch: str | None = None
|
|
63
|
+
batch_size: int = DEFAULT_BATCH_SIZE
|
|
64
|
+
list_limit: int = DEFAULT_LIST_LIMIT
|
|
65
|
+
max_cell_width: int = DEFAULT_MAX_CELL_WIDTH
|
|
66
|
+
profile: str | None = None
|
|
67
|
+
config_path: Path | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def default_config_path(env: Mapping[str, str]) -> Path:
|
|
71
|
+
"""Where the config file is looked for when none is named."""
|
|
72
|
+
base = env.get("XDG_CONFIG_HOME")
|
|
73
|
+
root = Path(base) if base else Path.home() / ".config"
|
|
74
|
+
return root / CONFIG_DIR / CONFIG_FILE
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read(path: Path, *, explicit: bool) -> dict[str, Any]:
|
|
78
|
+
if not path.exists():
|
|
79
|
+
if explicit:
|
|
80
|
+
raise CliError(f"config file not found: {path}")
|
|
81
|
+
return {}
|
|
82
|
+
try:
|
|
83
|
+
return tomllib.loads(path.read_text())
|
|
84
|
+
except (OSError, tomllib.TOMLDecodeError) as error:
|
|
85
|
+
raise CliError(f"cannot read config file {path}: {error}") from error
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _positive(value: object, name: str) -> int:
|
|
89
|
+
try:
|
|
90
|
+
number = int(str(value))
|
|
91
|
+
except ValueError:
|
|
92
|
+
number = 0
|
|
93
|
+
if number < 1:
|
|
94
|
+
raise CliError(f"{name} must be a positive integer, got {value!r}")
|
|
95
|
+
return number
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _number(value: object, name: str) -> float:
|
|
99
|
+
try:
|
|
100
|
+
return float(str(value))
|
|
101
|
+
except ValueError:
|
|
102
|
+
raise CliError(f"{name} must be a number, got {value!r}") from None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _text(table: Mapping[str, Any], key: str) -> str | None:
|
|
106
|
+
value = table.get(key)
|
|
107
|
+
return None if value is None else str(value)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load_settings(
|
|
111
|
+
*,
|
|
112
|
+
env: Mapping[str, str] | None = None,
|
|
113
|
+
config_path: Path | None = None,
|
|
114
|
+
profile: str | None = None,
|
|
115
|
+
url: str | None = None,
|
|
116
|
+
warn: Any = None,
|
|
117
|
+
) -> CliSettings:
|
|
118
|
+
"""Merge the command line values given, the environment and the config file.
|
|
119
|
+
|
|
120
|
+
`warn` is called with a message when the config file holds credentials and other users
|
|
121
|
+
can read it.
|
|
122
|
+
"""
|
|
123
|
+
environment = os.environ if env is None else env
|
|
124
|
+
named = config_path or (Path(environment[ENV_CONFIG]) if environment.get(ENV_CONFIG) else None)
|
|
125
|
+
path = named or default_config_path(environment)
|
|
126
|
+
document = _read(path, explicit=named is not None)
|
|
127
|
+
|
|
128
|
+
chosen = profile or environment.get(ENV_PROFILE) or document.get("profile")
|
|
129
|
+
profiles = document.get("profiles", {})
|
|
130
|
+
if chosen is not None and chosen not in profiles:
|
|
131
|
+
known = ", ".join(sorted(profiles)) or "none"
|
|
132
|
+
raise CliError(f"profile {chosen!r} is not in {path} (profiles: {known})")
|
|
133
|
+
layer: dict[str, Any] = {**document, **(profiles.get(chosen, {}) if chosen else {})}
|
|
134
|
+
importing = {**document.get("import", {}), **layer.get("import", {})}
|
|
135
|
+
output = {**document.get("output", {}), **layer.get("output", {})}
|
|
136
|
+
|
|
137
|
+
def pick(env_name: str, key: str) -> str | None:
|
|
138
|
+
return environment.get(env_name) or _text(layer, key)
|
|
139
|
+
|
|
140
|
+
from_file = (layer.get("api_key") and not environment.get("LOREBASE_API_KEY")) or (
|
|
141
|
+
layer.get("token") and not environment.get("LOREBASE_TOKEN")
|
|
142
|
+
)
|
|
143
|
+
shared = stat.S_IRWXG | stat.S_IRWXO
|
|
144
|
+
if warn and from_file and path.exists() and path.stat().st_mode & shared:
|
|
145
|
+
warn(f"{path} holds credentials and other users can read it; run: chmod 600 {path}")
|
|
146
|
+
|
|
147
|
+
settings = CliSettings(
|
|
148
|
+
url=url or pick("LOREBASE_URL", "url"),
|
|
149
|
+
api_prefix=pick("LOREBASE_API_PREFIX", "api_prefix"),
|
|
150
|
+
api_key=pick("LOREBASE_API_KEY", "api_key"),
|
|
151
|
+
token=pick("LOREBASE_TOKEN", "token"),
|
|
152
|
+
domain=pick(ENV_DOMAIN, "domain"),
|
|
153
|
+
branch=pick(ENV_BRANCH, "branch"),
|
|
154
|
+
profile=chosen,
|
|
155
|
+
config_path=path if path.exists() else None,
|
|
156
|
+
)
|
|
157
|
+
timeout = pick("LOREBASE_TIMEOUT", "timeout")
|
|
158
|
+
if timeout is not None:
|
|
159
|
+
settings = replace(settings, timeout=_number(timeout, "timeout"))
|
|
160
|
+
size = environment.get(ENV_BATCH_SIZE) or importing.get("batch_size")
|
|
161
|
+
if size is not None:
|
|
162
|
+
settings = replace(settings, batch_size=_positive(size, "batch_size"))
|
|
163
|
+
limit = environment.get(ENV_LIST_LIMIT) or output.get("list_limit")
|
|
164
|
+
if limit is not None:
|
|
165
|
+
settings = replace(settings, list_limit=_positive(limit, "list_limit"))
|
|
166
|
+
width = output.get("max_cell_width")
|
|
167
|
+
if width is not None:
|
|
168
|
+
settings = replace(settings, max_cell_width=_positive(width, "max_cell_width"))
|
|
169
|
+
return settings
|
lorebase_cli/sources.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Readers of import files: CSV, TSV, JSON Lines, a JSON array and Parquet.
|
|
2
|
+
|
|
3
|
+
Each reader yields one dict per row, lazily, so that a large file is never held in memory
|
|
4
|
+
whole (a JSON array is the exception). The format comes from `--format` or the file suffix.
|
|
5
|
+
Parquet needs `pyarrow`, an optional dependency (`pip install "lorebase-cli[parquet]"`).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from lorebase_cli.failures import CliError
|
|
18
|
+
|
|
19
|
+
STDIN = "-"
|
|
20
|
+
FORMATS = ("csv", "tsv", "jsonl", "json", "parquet")
|
|
21
|
+
SUFFIXES = {
|
|
22
|
+
".csv": "csv",
|
|
23
|
+
".tsv": "tsv",
|
|
24
|
+
".jsonl": "jsonl",
|
|
25
|
+
".ndjson": "jsonl",
|
|
26
|
+
".json": "json",
|
|
27
|
+
".parquet": "parquet",
|
|
28
|
+
}
|
|
29
|
+
PARQUET_BATCH_ROWS = 10_000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def detect_format(path: str, given: str | None) -> str:
|
|
33
|
+
"""The format named, or the one the suffix of `path` implies."""
|
|
34
|
+
if given:
|
|
35
|
+
if given not in FORMATS:
|
|
36
|
+
raise CliError(f"unknown format {given!r}; use one of {', '.join(FORMATS)}")
|
|
37
|
+
return given
|
|
38
|
+
suffix = Path(path).suffix.lower()
|
|
39
|
+
if suffix in SUFFIXES:
|
|
40
|
+
return SUFFIXES[suffix]
|
|
41
|
+
raise CliError(
|
|
42
|
+
f"cannot tell the format of {path!r} from its suffix; pass --format ({', '.join(FORMATS)})"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_rows(path: str, fmt: str, *, delimiter: str | None = None) -> Iterator[dict[str, Any]]:
|
|
47
|
+
"""Rows of the file (`-` is standard input, for text formats)."""
|
|
48
|
+
if fmt == "parquet":
|
|
49
|
+
if path == STDIN:
|
|
50
|
+
raise CliError("Parquet cannot be read from standard input")
|
|
51
|
+
return _parquet(Path(path))
|
|
52
|
+
if fmt in ("csv", "tsv"):
|
|
53
|
+
return _csv(path, delimiter or ("\t" if fmt == "tsv" else ","))
|
|
54
|
+
if fmt == "jsonl":
|
|
55
|
+
return _jsonl(path)
|
|
56
|
+
return _json_array(path)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _lines(path: str) -> Iterator[str]:
|
|
60
|
+
if path == STDIN:
|
|
61
|
+
yield from sys.stdin
|
|
62
|
+
return
|
|
63
|
+
try:
|
|
64
|
+
handle = Path(path).open(encoding="utf-8-sig", newline="") # noqa: SIM115 - closed below
|
|
65
|
+
except OSError as error:
|
|
66
|
+
raise CliError(f"cannot read {path}: {error.strerror or error}") from error
|
|
67
|
+
with handle:
|
|
68
|
+
yield from handle
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _csv(path: str, delimiter: str) -> Iterator[dict[str, Any]]:
|
|
72
|
+
reader = csv.DictReader(_lines(path), delimiter=delimiter)
|
|
73
|
+
if reader.fieldnames is None:
|
|
74
|
+
raise CliError(f"{path}: no header row")
|
|
75
|
+
for number, row in enumerate(reader, start=2):
|
|
76
|
+
if None in row:
|
|
77
|
+
raise CliError(f"{path}: line {number} has more cells than the header")
|
|
78
|
+
yield row
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _jsonl(path: str) -> Iterator[dict[str, Any]]:
|
|
82
|
+
for number, line in enumerate(_lines(path), start=1):
|
|
83
|
+
if not line.strip():
|
|
84
|
+
continue
|
|
85
|
+
try:
|
|
86
|
+
row = json.loads(line)
|
|
87
|
+
except ValueError:
|
|
88
|
+
raise CliError(f"{path}: line {number} is not valid JSON") from None
|
|
89
|
+
if not isinstance(row, dict):
|
|
90
|
+
raise CliError(f"{path}: line {number} is not a JSON object")
|
|
91
|
+
yield row
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _json_array(path: str) -> Iterator[dict[str, Any]]:
|
|
95
|
+
text = "".join(_lines(path))
|
|
96
|
+
try:
|
|
97
|
+
rows = json.loads(text)
|
|
98
|
+
except ValueError:
|
|
99
|
+
raise CliError(f"{path}: not valid JSON") from None
|
|
100
|
+
if not isinstance(rows, list) or not all(isinstance(r, dict) for r in rows):
|
|
101
|
+
raise CliError(f"{path}: expected a JSON array of objects")
|
|
102
|
+
yield from rows
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parquet(path: Path) -> Iterator[dict[str, Any]]:
|
|
106
|
+
try:
|
|
107
|
+
import pyarrow.parquet as parquet # type: ignore[import-untyped]
|
|
108
|
+
except ImportError:
|
|
109
|
+
raise CliError(
|
|
110
|
+
"reading Parquet needs pyarrow: pip install 'lorebase-cli[parquet]'"
|
|
111
|
+
) from None
|
|
112
|
+
try:
|
|
113
|
+
file = parquet.ParquetFile(path)
|
|
114
|
+
except (OSError, ValueError) as error: # missing, unreadable or not a Parquet file
|
|
115
|
+
raise CliError(f"cannot read {path}: {error}") from error
|
|
116
|
+
with file:
|
|
117
|
+
for batch in file.iter_batches(batch_size=PARQUET_BATCH_ROWS):
|
|
118
|
+
yield from batch.to_pylist()
|
|
@@ -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'
|