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/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")
@@ -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
@@ -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
@@ -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'