nace-cli 0.1.0__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.
nace_cli/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """``nace`` — the Nace CLI.
2
+
3
+ Decisions (``POST /v1/systemone``), document jobs and uploads
4
+ (``/v1/documents/*``) and saved extraction schemas (``/v1/extraction-schemas``)
5
+ over ``nace-sdk``. Credentials come from
6
+ ``$NACE_API_KEY`` / ``$NACE_BASE_URL`` or ``~/.nace/config.toml``.
7
+ """
8
+
9
+ __version__ = "0.1.0"
nace_cli/_cli.py ADDED
@@ -0,0 +1,151 @@
1
+ """``nace`` entry point: parser, client construction, and error reporting.
2
+
3
+ Exit codes: ``0`` success, ``1`` an API or job failure, ``2`` a usage or
4
+ configuration error. Stdout carries the payload; stderr carries status, so
5
+ ``nace parse doc.pdf > doc.md`` captures only the document.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import logging
12
+ import sys
13
+ from typing import Any
14
+
15
+ from nace_sdk import NaceAPIConnectionError, NaceAPIError, NaceClient, NaceError, NaceJobFailedError, NaceJobTimeoutError
16
+ from nace_sdk.constants import LOG_LEVEL_ENV, read_env
17
+ from nace_sdk.credentials import CredentialsError, resolve_credentials
18
+
19
+ from nace_cli import _decide, _docops, _jobs, _login, _schemas
20
+ from nace_cli._output import CommandFailed
21
+
22
+ #: Long enough for a decision (Drex holds one up to 60 s) and for a document
23
+ #: create that waits server-side; job waits have their own budget (--wait-timeout).
24
+ DEFAULT_TIMEOUT_SECONDS = 120.0
25
+ _ISSUE_LIMIT = 5
26
+
27
+
28
+ class _StderrHandler(logging.Handler):
29
+ """Writes to whatever ``sys.stderr`` is at the time, so redirection and capture see SDK logs."""
30
+
31
+ def emit(self, record: logging.LogRecord) -> None:
32
+ print(self.format(record), file=sys.stderr)
33
+
34
+
35
+ _SDK_LOG = _StderrHandler()
36
+ _SDK_LOG.setFormatter(logging.Formatter("nace_sdk %(levelname)s %(message)s"))
37
+
38
+
39
+ def main(argv: list[str] | None = None) -> int:
40
+ parser = build_parser()
41
+ args = parser.parse_args(argv)
42
+ if args.show_version:
43
+ return _login.run_version(args)
44
+ if args.command is None:
45
+ parser.print_help()
46
+ return 2
47
+ _log_to_stderr()
48
+ try:
49
+ if args.family == "standalone":
50
+ return args.run(args)
51
+ credentials = resolve_credentials()
52
+ with NaceClient(api_key=credentials.api_key, base_url=credentials.base_url, timeout=args.timeout) as client:
53
+ return args.run(client, args)
54
+ except CredentialsError as exc:
55
+ print(f"error: {exc}", file=sys.stderr)
56
+ return 2
57
+ except NaceAPIError as exc:
58
+ report_status_error(exc)
59
+ return 1
60
+ except (NaceAPIConnectionError, NaceJobFailedError, NaceJobTimeoutError, CommandFailed) as exc:
61
+ print(f"error: {exc}", file=sys.stderr)
62
+ return 1
63
+ except NaceError as exc:
64
+ # Raised before any request: a bad argument the SDK refused locally.
65
+ print(f"error: {exc}", file=sys.stderr)
66
+ return 2
67
+ except (ValueError, OSError) as exc:
68
+ print(f"error: {exc}", file=sys.stderr)
69
+ return 2
70
+
71
+
72
+ def _log_to_stderr() -> None:
73
+ """``$NACE_LOG_LEVEL`` sets the SDK logger's level when it loads; the CLI gives those records a place to go."""
74
+ logger = logging.getLogger("nace_sdk")
75
+ if read_env(LOG_LEVEL_ENV) and _SDK_LOG not in logger.handlers:
76
+ logger.addHandler(_SDK_LOG)
77
+
78
+
79
+ def _validation_lines(exc: NaceAPIError) -> list[dict[str, Any]]:
80
+ """Where a request failed validation: ``error.issues``, else the document routes' ``error.detail.errors``."""
81
+ if exc.issues:
82
+ return exc.issues
83
+ errors = exc.detail.get("errors") if isinstance(exc.detail, dict) else None
84
+ if not isinstance(errors, list):
85
+ return []
86
+ lines = []
87
+ for entry in errors:
88
+ if not isinstance(entry, dict):
89
+ continue
90
+ location = entry.get("loc")
91
+ path = entry.get("path") or (".".join(str(part) for part in location if part != "body") if isinstance(location, list) else "")
92
+ lines.append({"path": path, "message": entry.get("message") or entry.get("msg") or ""})
93
+ return lines
94
+
95
+
96
+ def report_status_error(exc: NaceAPIError) -> None:
97
+ """One line a person can act on, plus where a request failed validation."""
98
+ label = "/".join(str(part) for part in (exc.type, exc.code) if part)
99
+ tag = f" [{label}]" if label else ""
100
+ request = f" ({exc.request_id})" if exc.request_id else ""
101
+ print(f"error: {exc.status}{tag} {exc.message}{request}", file=sys.stderr)
102
+ issues = _validation_lines(exc)
103
+ for issue in issues[:_ISSUE_LIMIT]:
104
+ where = f"{issue.get('path')}: " if issue.get("path") else ""
105
+ print(f" - {where}{issue.get('message', '')}", file=sys.stderr)
106
+ if len(issues) > _ISSUE_LIMIT:
107
+ print(f" (+{len(issues) - _ISSUE_LIMIT} more)", file=sys.stderr)
108
+ if exc.status == 402:
109
+ billing = _login.dashboard_url("/dashboard/billing")
110
+ # payment_required is an unpaid invoice: a top-up doesn't lift it, paying the invoice does.
111
+ if exc.type == "payment_required":
112
+ print(f" Pay the unpaid invoice at {billing}", file=sys.stderr)
113
+ else:
114
+ print(f" Top up or add a card at {billing}", file=sys.stderr)
115
+ if exc.code == "path_conflict":
116
+ print(
117
+ " Upload to another path (--upload-path, or --path on `nace upload`), or pass --on-conflict new_version to add a version of that file.",
118
+ file=sys.stderr,
119
+ )
120
+
121
+
122
+ def build_parser() -> argparse.ArgumentParser:
123
+ parser = argparse.ArgumentParser(
124
+ prog="nace",
125
+ description=(
126
+ "Nace CLI: calibrated decisions and document jobs. Credentials come from "
127
+ "$NACE_API_KEY / $NACE_BASE_URL or ~/.nace/config.toml (`nace login`). "
128
+ "Set $NACE_LOG_LEVEL=debug to log each request to stderr."
129
+ ),
130
+ )
131
+ parser.add_argument("--version", dest="show_version", action="store_true", help="Print CLI and SDK versions (same as `nace version`).")
132
+ common = argparse.ArgumentParser(add_help=False)
133
+ common.add_argument("--json", action="store_true", help="Print the raw response JSON.")
134
+ common.add_argument(
135
+ "--timeout",
136
+ type=float,
137
+ default=DEFAULT_TIMEOUT_SECONDS,
138
+ metavar="SECONDS",
139
+ help=f"Per-request HTTP timeout (default {DEFAULT_TIMEOUT_SECONDS:.0f}s).",
140
+ )
141
+ sub = parser.add_subparsers(dest="command")
142
+ _login.add_parsers(sub)
143
+ _decide.add_parsers(sub, common)
144
+ _docops.add_parsers(sub, common)
145
+ _jobs.add_parsers(sub, common)
146
+ _schemas.add_parsers(sub, common)
147
+ return parser
148
+
149
+
150
+ if __name__ == "__main__":
151
+ raise SystemExit(main())
nace_cli/_decide.py ADDED
@@ -0,0 +1,147 @@
1
+ """``nace decide`` (``POST /v1/systemone``) and ``nace models``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from typing import Any
8
+
9
+ from nace_sdk import Choice, ChoiceAnswer, ListModelsResponse, NaceClient, Noul, NoulAnswer, Score, ScoreAnswer, SystemOneResponse
10
+
11
+ from nace_cli._inputs import read_json, read_text
12
+ from nace_cli._output import to_json
13
+
14
+
15
+ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser) -> None:
16
+ decide = sub.add_parser(
17
+ "decide",
18
+ parents=[common],
19
+ help="Ask calibrated questions about a state.",
20
+ description=(
21
+ "Ask named questions about a state. Shorthand questions: "
22
+ "--noul 'refund=Is the customer asking for a refund?', "
23
+ "--choice 'topic=billing,shipping,other: Which topic?', "
24
+ "--score 'urgency=low,medium,high: How urgent?'. "
25
+ "Anything richer goes in --questions as JSON."
26
+ ),
27
+ )
28
+ decide.add_argument("--state", default=None, help="The state: text, @FILE, or - for stdin. JSON is sent as JSON.")
29
+ decide.add_argument("--noul", action="append", default=[], metavar="NAME=INSTRUCTIONS", help="Yes/no question; repeatable.")
30
+ decide.add_argument("--choice", action="append", default=[], metavar="NAME=A,B,C[: INSTRUCTIONS]", help="Pick-one question.")
31
+ decide.add_argument("--score", action="append", default=[], metavar="NAME=LOW,...,HIGH[: INSTRUCTIONS]", help="Ordered levels.")
32
+ decide.add_argument("--questions", default=None, metavar="JSON", help="Questions object: inline JSON, @FILE or a path.")
33
+ decide.add_argument("--body", default=None, metavar="JSON", help="A whole request body; other flags override its fields.")
34
+ decide.add_argument("-m", "--model", default=None, help="Model id (default drex-latest). See `nace models`.")
35
+ decide.set_defaults(run=run_decide, family="client")
36
+
37
+ models = sub.add_parser("models", parents=[common], help="List the models your key can call.")
38
+ models.set_defaults(run=run_models, family="client")
39
+
40
+
41
+ def build_request(args: argparse.Namespace) -> dict[str, Any]:
42
+ loaded = read_json(args.body, what="--body") if args.body else {}
43
+ if not isinstance(loaded, dict):
44
+ raise ValueError("--body must be a JSON object")
45
+ body: dict[str, Any] = dict(loaded)
46
+ questions: dict[str, Any] = dict(body.get("questions") or {})
47
+ if args.questions:
48
+ loaded = read_json(args.questions, what="--questions")
49
+ if not isinstance(loaded, dict):
50
+ raise ValueError("--questions must be a JSON object of name to question")
51
+ questions.update(loaded)
52
+ for spec in args.noul:
53
+ name, text = _named(spec, "--noul")
54
+ questions[name] = Noul(instructions=text or None)
55
+ for spec in args.choice:
56
+ name, labels, text = _labelled(spec, "--choice")
57
+ questions[name] = Choice(instructions=text, criteria=dict.fromkeys(labels))
58
+ for spec in args.score:
59
+ name, levels, text = _labelled(spec, "--score")
60
+ questions[name] = Score(instructions=text, criteria=levels)
61
+ if not questions:
62
+ raise ValueError("ask at least one question (--noul, --choice, --score or --questions)")
63
+ if args.state is not None:
64
+ body["state"] = _state(read_text(args.state))
65
+ if "state" not in body:
66
+ raise ValueError("--state is required (text, @FILE, or - for stdin)")
67
+ if args.model:
68
+ body["model"] = args.model
69
+ body["questions"] = questions
70
+ return body
71
+
72
+
73
+ def _state(text: str) -> Any:
74
+ """A state that parses as a JSON object or array is sent as JSON; anything else as text."""
75
+ stripped = text.strip()
76
+ if stripped.startswith(("{", "[")):
77
+ try:
78
+ return read_json(stripped, what="--state")
79
+ except ValueError:
80
+ return text
81
+ return text
82
+
83
+
84
+ def _named(spec: str, flag: str) -> tuple[str, str]:
85
+ name, sep, rest = spec.partition("=")
86
+ if not sep or not name.strip():
87
+ raise ValueError(f"{flag} needs NAME=..., got {spec!r}")
88
+ return name.strip(), rest.strip()
89
+
90
+
91
+ def _labelled(spec: str, flag: str) -> tuple[str, list[str], str | None]:
92
+ name, rest = _named(spec, flag)
93
+ labels_text, _, text = rest.partition(":")
94
+ labels = [label.strip() for label in labels_text.split(",") if label.strip()]
95
+ if not labels:
96
+ raise ValueError(f"{flag} needs at least one label: NAME=A,B,C[: INSTRUCTIONS]")
97
+ return name, labels, text.strip() or None
98
+
99
+
100
+ def run_decide(client: NaceClient, args: argparse.Namespace) -> int:
101
+ body = build_request(args)
102
+ extra = {key: value for key, value in body.items() if key not in ("state", "questions", "model")}
103
+ response = client.system_one(body["state"], body["questions"], model=body.get("model"), extra_body=extra or None)
104
+ if args.json:
105
+ print(response_json(response))
106
+ return 0
107
+ print(render_answers(response))
108
+ timing = f" · {response.evaluation_time_ms:.0f} ms" if response.evaluation_time_ms is not None else ""
109
+ print(f"{response.model} · {response.usage.input_tokens} input tokens{timing} · {response.request_id}", file=sys.stderr)
110
+ return 0
111
+
112
+
113
+ def response_json(response: SystemOneResponse | ListModelsResponse) -> str:
114
+ """The body as Drex sent it, ``request_id`` included: the model keeps the id out of its fields."""
115
+ body = response.raw_http_response.json()
116
+ request_id = response.raw_http_response.headers.get("x-request-id")
117
+ if isinstance(body, dict) and "request_id" not in body and request_id:
118
+ body["request_id"] = request_id
119
+ return to_json(body)
120
+
121
+
122
+ def render_answers(response: SystemOneResponse) -> str:
123
+ width = max((len(name) for name in response.answers), default=0)
124
+ lines = []
125
+ for name, answer in response.answers.items():
126
+ if isinstance(answer, NoulAnswer):
127
+ detail = f"{answer.noul:.3f}"
128
+ elif isinstance(answer, ChoiceAnswer):
129
+ detail = f"{answer.choice} (confidence {answer.confidence:.3f})"
130
+ elif isinstance(answer, ScoreAnswer):
131
+ detail = f"{answer.score:.3f} on {' < '.join(answer.legend)} (confidence {answer.confidence:.3f})"
132
+ else: # pragma: no cover - the union is closed today
133
+ detail = str(answer)
134
+ lines.append(f"{name.ljust(width)} {answer.type:<6} {detail}")
135
+ return "\n".join(lines)
136
+
137
+
138
+ def run_models(client: NaceClient, args: argparse.Namespace) -> int:
139
+ response = client.models.list()
140
+ if args.json:
141
+ print(response_json(response))
142
+ return 0
143
+ width = max((len(model.name) for model in response.models), default=0)
144
+ for model in response.models:
145
+ alias = f" -> {model.alias_for}" if model.alias_for else ""
146
+ print(f"{model.name.ljust(width)} {model.release_date}{alias} {model.description}")
147
+ return 0