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 +9 -0
- nace_cli/_cli.py +151 -0
- nace_cli/_decide.py +147 -0
- nace_cli/_docops.py +414 -0
- nace_cli/_entrypoint.py +24 -0
- nace_cli/_inputs.py +94 -0
- nace_cli/_jobs.py +292 -0
- nace_cli/_login.py +77 -0
- nace_cli/_output.py +169 -0
- nace_cli/_schemas.py +135 -0
- nace_cli/py.typed +0 -0
- nace_cli-0.1.0.dist-info/METADATA +124 -0
- nace_cli-0.1.0.dist-info/RECORD +16 -0
- nace_cli-0.1.0.dist-info/WHEEL +4 -0
- nace_cli-0.1.0.dist-info/entry_points.txt +3 -0
- nace_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|