pyjev 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.
- pyjev/__init__.py +47 -0
- pyjev/__main__.py +3 -0
- pyjev/_version.py +24 -0
- pyjev/cli.py +614 -0
- pyjev/client.py +401 -0
- pyjev/compile.py +151 -0
- pyjev/credentials.py +164 -0
- pyjev/decisions.py +312 -0
- pyjev/py.typed +0 -0
- pyjev/results.py +80 -0
- pyjev-0.1.0.dist-info/METADATA +113 -0
- pyjev-0.1.0.dist-info/RECORD +16 -0
- pyjev-0.1.0.dist-info/WHEEL +5 -0
- pyjev-0.1.0.dist-info/entry_points.txt +2 -0
- pyjev-0.1.0.dist-info/licenses/LICENSE +201 -0
- pyjev-0.1.0.dist-info/top_level.txt +1 -0
pyjev/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""pyjev public API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError
|
|
6
|
+
from importlib.metadata import version as package_version
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .client import AsyncJev, Jev
|
|
11
|
+
from .compile import CompiledDecision, compile_decision
|
|
12
|
+
from .results import BundleResult, ChoiceResult, NoulResult, ScoreResult
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
except ImportError: # source checkout without generated setuptools-scm output
|
|
17
|
+
try:
|
|
18
|
+
__version__ = package_version("pyjev")
|
|
19
|
+
except PackageNotFoundError:
|
|
20
|
+
__version__ = "0.0.0"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def decide(
|
|
24
|
+
name: str,
|
|
25
|
+
state: Any,
|
|
26
|
+
*,
|
|
27
|
+
config: str | Path | None = None,
|
|
28
|
+
model: str | None = None,
|
|
29
|
+
api_key: str | None = None,
|
|
30
|
+
) -> NoulResult | ChoiceResult | ScoreResult | BundleResult:
|
|
31
|
+
"""Evaluate a named decision using a short-lived convenience client."""
|
|
32
|
+
with Jev(api_key=api_key, model=model) as jev:
|
|
33
|
+
return jev.decide(name, state=state, config=config, model=model)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"BundleResult",
|
|
38
|
+
"CompiledDecision",
|
|
39
|
+
"AsyncJev",
|
|
40
|
+
"ChoiceResult",
|
|
41
|
+
"Jev",
|
|
42
|
+
"NoulResult",
|
|
43
|
+
"ScoreResult",
|
|
44
|
+
"compile_decision",
|
|
45
|
+
"__version__",
|
|
46
|
+
"decide",
|
|
47
|
+
]
|
pyjev/__main__.py
ADDED
pyjev/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = 'gf0a03f3a5'
|
pyjev/cli.py
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
"""Command-line interface for pyjev."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, TypeVar
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from typesafe_sdk import (
|
|
15
|
+
TypeSafeAPIConnectionError,
|
|
16
|
+
TypeSafeAPIError,
|
|
17
|
+
TypeSafeAPITimeoutError,
|
|
18
|
+
TypeSafeAuthenticationError,
|
|
19
|
+
TypeSafeError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from . import __version__
|
|
23
|
+
from .client import Jev
|
|
24
|
+
from .compile import compile_decision
|
|
25
|
+
from .credentials import (
|
|
26
|
+
ENV_NAME,
|
|
27
|
+
CredentialError,
|
|
28
|
+
credential_file_path,
|
|
29
|
+
credential_source,
|
|
30
|
+
delete_api_key,
|
|
31
|
+
set_api_key,
|
|
32
|
+
set_file_api_key,
|
|
33
|
+
)
|
|
34
|
+
from .decisions import (
|
|
35
|
+
BundleDecision,
|
|
36
|
+
DecisionConfigError,
|
|
37
|
+
NoulDecision,
|
|
38
|
+
decision_to_dict,
|
|
39
|
+
load_config,
|
|
40
|
+
load_decision,
|
|
41
|
+
load_decisions,
|
|
42
|
+
)
|
|
43
|
+
from .results import BundleResult, ChoiceResult, NoulResult, ScoreResult
|
|
44
|
+
|
|
45
|
+
EXIT_OK = 0
|
|
46
|
+
EXIT_RUNTIME_ERROR = 1
|
|
47
|
+
EXIT_USAGE = 2
|
|
48
|
+
EXIT_CONFIDENCE = 3
|
|
49
|
+
|
|
50
|
+
T = TypeVar("T")
|
|
51
|
+
Result = BundleResult | NoulResult | ChoiceResult | ScoreResult
|
|
52
|
+
|
|
53
|
+
app = typer.Typer(
|
|
54
|
+
no_args_is_help=True,
|
|
55
|
+
help="Reusable, confidence-aware Jev decisions from the shell.",
|
|
56
|
+
)
|
|
57
|
+
auth_app = typer.Typer(no_args_is_help=True, help="Manage the TypeSafe API key.")
|
|
58
|
+
decision_app = typer.Typer(no_args_is_help=True, help="Inspect read-only named decisions.")
|
|
59
|
+
app.add_typer(auth_app, name="auth")
|
|
60
|
+
app.add_typer(decision_app, name="decision")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _version_callback(value: bool) -> None:
|
|
64
|
+
if value:
|
|
65
|
+
typer.echo(__version__)
|
|
66
|
+
raise typer.Exit()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.callback()
|
|
70
|
+
def main(
|
|
71
|
+
version: bool = typer.Option(
|
|
72
|
+
False,
|
|
73
|
+
"--version",
|
|
74
|
+
callback=_version_callback,
|
|
75
|
+
is_eager=True,
|
|
76
|
+
help="Show the pyjev version and exit.",
|
|
77
|
+
),
|
|
78
|
+
) -> None:
|
|
79
|
+
del version
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _read_text(path: Path) -> str:
|
|
83
|
+
try:
|
|
84
|
+
return path.read_text(encoding="utf-8")
|
|
85
|
+
except OSError as exc:
|
|
86
|
+
raise typer.BadParameter(f"Could not read {path}: {exc}") from exc
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _state_value(state: str | None, state_file: Path | None, state_json: bool) -> Any:
|
|
90
|
+
if state is not None and state_file is not None:
|
|
91
|
+
raise typer.BadParameter("Use either --state or --state-file, not both.")
|
|
92
|
+
|
|
93
|
+
if state_file is not None:
|
|
94
|
+
text = _read_text(state_file)
|
|
95
|
+
elif state is not None:
|
|
96
|
+
text = state
|
|
97
|
+
elif not sys.stdin.isatty():
|
|
98
|
+
text = sys.stdin.read()
|
|
99
|
+
else:
|
|
100
|
+
raise typer.BadParameter("Provide --state, --state-file, or pipe state on stdin.")
|
|
101
|
+
|
|
102
|
+
if not state_json:
|
|
103
|
+
return text
|
|
104
|
+
try:
|
|
105
|
+
return json.loads(text)
|
|
106
|
+
except json.JSONDecodeError as exc:
|
|
107
|
+
raise typer.BadParameter(f"State is not valid JSON: {exc}") from exc
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _validate_output_options(*, json_output: bool, value_only: bool) -> None:
|
|
111
|
+
if json_output and value_only:
|
|
112
|
+
typer.echo("Error: Use either --json or --value, not both.", err=True)
|
|
113
|
+
raise typer.Exit(code=EXIT_USAGE)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _validate_min_confidence(value: float | None) -> None:
|
|
117
|
+
if value is not None and not 0 <= value <= 1:
|
|
118
|
+
raise typer.BadParameter("--min-confidence must be between 0 and 1.")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _print_json(data: Any) -> None:
|
|
122
|
+
typer.echo(json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _emit_result(result: Result, *, json_output: bool, value_only: bool) -> None:
|
|
126
|
+
_validate_output_options(json_output=json_output, value_only=value_only)
|
|
127
|
+
if json_output:
|
|
128
|
+
_print_json(result.to_dict())
|
|
129
|
+
return
|
|
130
|
+
if isinstance(result, BundleResult):
|
|
131
|
+
if value_only:
|
|
132
|
+
raise typer.BadParameter("--value is not valid for bundle decisions.")
|
|
133
|
+
for name, answer in result.answers.items():
|
|
134
|
+
typer.echo(f"[{name}]")
|
|
135
|
+
_emit_result(answer, json_output=False, value_only=False)
|
|
136
|
+
return
|
|
137
|
+
if value_only:
|
|
138
|
+
typer.echo(result.value)
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
if isinstance(result, NoulResult):
|
|
142
|
+
typer.echo(f"noul={result.value:.6f}")
|
|
143
|
+
elif isinstance(result, ChoiceResult):
|
|
144
|
+
typer.echo(f"choice={result.value} confidence={result.confidence:.6f}")
|
|
145
|
+
else:
|
|
146
|
+
typer.echo(f"score={result.value:.6f} confidence={result.confidence:.6f}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _gate_failed(confidence: float, minimum: float | None) -> bool:
|
|
150
|
+
return minimum is not None and confidence < minimum
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _emit_gate_failure(
|
|
154
|
+
result: ChoiceResult | ScoreResult,
|
|
155
|
+
*,
|
|
156
|
+
minimum: float,
|
|
157
|
+
json_output: bool,
|
|
158
|
+
) -> None:
|
|
159
|
+
if json_output:
|
|
160
|
+
_print_json(
|
|
161
|
+
{
|
|
162
|
+
"gate": {
|
|
163
|
+
"passed": False,
|
|
164
|
+
"minimum_confidence": minimum,
|
|
165
|
+
"confidence": result.confidence,
|
|
166
|
+
},
|
|
167
|
+
"result": result.to_dict(),
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
typer.echo(
|
|
172
|
+
f"Confidence {result.confidence:.2f} is below required {minimum:.2f}.",
|
|
173
|
+
err=True,
|
|
174
|
+
)
|
|
175
|
+
raise typer.Exit(code=EXIT_CONFIDENCE)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _emit_gated_result(
|
|
179
|
+
result: Result,
|
|
180
|
+
*,
|
|
181
|
+
json_output: bool,
|
|
182
|
+
value_only: bool,
|
|
183
|
+
minimum: float | None,
|
|
184
|
+
) -> None:
|
|
185
|
+
if isinstance(result, NoulResult):
|
|
186
|
+
_emit_result(result, json_output=json_output, value_only=value_only)
|
|
187
|
+
return
|
|
188
|
+
if isinstance(result, BundleResult):
|
|
189
|
+
_emit_result(result, json_output=json_output, value_only=value_only)
|
|
190
|
+
return
|
|
191
|
+
if minimum is not None and _gate_failed(result.confidence, minimum):
|
|
192
|
+
_emit_gate_failure(result, minimum=minimum, json_output=json_output)
|
|
193
|
+
_emit_result(result, json_output=json_output, value_only=value_only)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _parse_options(options: list[str]) -> dict[str, str | None]:
|
|
197
|
+
parsed: dict[str, str | None] = {}
|
|
198
|
+
for item in options:
|
|
199
|
+
label, separator, description = item.partition("=")
|
|
200
|
+
label = label.strip()
|
|
201
|
+
if not label:
|
|
202
|
+
raise typer.BadParameter("Choice labels cannot be empty.")
|
|
203
|
+
if label in parsed:
|
|
204
|
+
raise typer.BadParameter(f"Duplicate choice label: {label}")
|
|
205
|
+
parsed[label] = description if separator else None
|
|
206
|
+
if len(parsed) < 2:
|
|
207
|
+
raise typer.BadParameter("Provide at least two --option values.")
|
|
208
|
+
if len(parsed) > 255:
|
|
209
|
+
raise typer.BadParameter("Provide no more than 255 --option values.")
|
|
210
|
+
return parsed
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _parse_decision_error(exc: DecisionConfigError) -> typer.BadParameter:
|
|
214
|
+
return typer.BadParameter(str(exc))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _run_api(action: Callable[[Jev], T], *, model: str | None = None) -> T:
|
|
218
|
+
try:
|
|
219
|
+
with Jev(model=model) as jev:
|
|
220
|
+
return action(jev)
|
|
221
|
+
except TypeSafeAuthenticationError as exc:
|
|
222
|
+
typer.echo(
|
|
223
|
+
"Error: TypeSafe authentication failed.\nSet TYPESAFE_API_KEY or run `pyjev auth set`.",
|
|
224
|
+
err=True,
|
|
225
|
+
)
|
|
226
|
+
raise typer.Exit(code=EXIT_RUNTIME_ERROR) from exc
|
|
227
|
+
except (TypeSafeAPITimeoutError, TypeSafeAPIConnectionError) as exc:
|
|
228
|
+
typer.echo(f"Error: Could not reach TypeSafe: {exc}", err=True)
|
|
229
|
+
raise typer.Exit(code=EXIT_RUNTIME_ERROR) from exc
|
|
230
|
+
except TypeSafeAPIError as exc:
|
|
231
|
+
typer.echo(f"Error: TypeSafe API request failed: {exc}", err=True)
|
|
232
|
+
raise typer.Exit(code=EXIT_RUNTIME_ERROR) from exc
|
|
233
|
+
except TypeSafeError as exc:
|
|
234
|
+
message = str(exc)
|
|
235
|
+
if "No API key" in message:
|
|
236
|
+
message = "TypeSafe authentication failed. Set TYPESAFE_API_KEY or run `pyjev auth set`."
|
|
237
|
+
typer.echo(f"Error: {message}", err=True)
|
|
238
|
+
raise typer.Exit(code=EXIT_RUNTIME_ERROR) from exc
|
|
239
|
+
except CredentialError as exc:
|
|
240
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
241
|
+
raise typer.Exit(code=EXIT_RUNTIME_ERROR) from exc
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _run_noul(
|
|
245
|
+
question: str,
|
|
246
|
+
state: str | None,
|
|
247
|
+
state_file: Path | None,
|
|
248
|
+
state_json: bool,
|
|
249
|
+
true: str | None,
|
|
250
|
+
false: str | None,
|
|
251
|
+
model: str | None,
|
|
252
|
+
json_output: bool,
|
|
253
|
+
value_only: bool,
|
|
254
|
+
) -> None:
|
|
255
|
+
_validate_output_options(json_output=json_output, value_only=value_only)
|
|
256
|
+
value = _state_value(state, state_file, state_json)
|
|
257
|
+
result = _run_api(
|
|
258
|
+
lambda jev: jev.noul(question, state=value, true=true, false=false),
|
|
259
|
+
model=model,
|
|
260
|
+
)
|
|
261
|
+
_emit_result(result, json_output=json_output, value_only=value_only)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@app.command("ask")
|
|
265
|
+
def ask(
|
|
266
|
+
question: str = typer.Argument(..., help="Yes/no question or statement."),
|
|
267
|
+
state: str | None = typer.Option(None, "--state", "-s", help="State text."),
|
|
268
|
+
state_file: Path | None = typer.Option(None, "--state-file", help="Read state from a file."),
|
|
269
|
+
state_json: bool = typer.Option(False, "--state-json", help="Decode the state as JSON."),
|
|
270
|
+
true: str | None = typer.Option(None, "--true", help="Description of the yes/true outcome."),
|
|
271
|
+
false: str | None = typer.Option(None, "--false", help="Description of the no/false outcome."),
|
|
272
|
+
model: str | None = typer.Option(None, "--model", help="Override the TypeSafe model."),
|
|
273
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON."),
|
|
274
|
+
value_only: bool = typer.Option(False, "--value", help="Emit only the numeric Noul value."),
|
|
275
|
+
) -> None:
|
|
276
|
+
_run_noul(question, state, state_file, state_json, true, false, model, json_output, value_only)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@app.command("noul")
|
|
280
|
+
def noul(
|
|
281
|
+
question: str = typer.Argument(..., help="Yes/no question or statement."),
|
|
282
|
+
state: str | None = typer.Option(None, "--state", "-s", help="State text."),
|
|
283
|
+
state_file: Path | None = typer.Option(None, "--state-file", help="Read state from a file."),
|
|
284
|
+
state_json: bool = typer.Option(False, "--state-json", help="Decode the state as JSON."),
|
|
285
|
+
true: str | None = typer.Option(None, "--true", help="Description of the yes/true outcome."),
|
|
286
|
+
false: str | None = typer.Option(None, "--false", help="Description of the no/false outcome."),
|
|
287
|
+
model: str | None = typer.Option(None, "--model", help="Override the TypeSafe model."),
|
|
288
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON."),
|
|
289
|
+
value_only: bool = typer.Option(False, "--value", help="Emit only the numeric Noul value."),
|
|
290
|
+
) -> None:
|
|
291
|
+
_run_noul(question, state, state_file, state_json, true, false, model, json_output, value_only)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@app.command("choice")
|
|
295
|
+
def choice(
|
|
296
|
+
question: str = typer.Argument(..., help="Question to decide."),
|
|
297
|
+
option: list[str] = typer.Option(
|
|
298
|
+
...,
|
|
299
|
+
"--option",
|
|
300
|
+
"-o",
|
|
301
|
+
help="Choice as LABEL or LABEL=DESCRIPTION. Repeat for each option.",
|
|
302
|
+
),
|
|
303
|
+
state: str | None = typer.Option(None, "--state", "-s", help="State text."),
|
|
304
|
+
state_file: Path | None = typer.Option(None, "--state-file", help="Read state from a file."),
|
|
305
|
+
state_json: bool = typer.Option(False, "--state-json", help="Decode the state as JSON."),
|
|
306
|
+
model: str | None = typer.Option(None, "--model", help="Override the TypeSafe model."),
|
|
307
|
+
min_confidence: float | None = typer.Option(None, "--min-confidence", help="Exit 3 below this confidence."),
|
|
308
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON."),
|
|
309
|
+
value_only: bool = typer.Option(False, "--value", help="Emit only the selected label."),
|
|
310
|
+
) -> None:
|
|
311
|
+
_validate_output_options(json_output=json_output, value_only=value_only)
|
|
312
|
+
_validate_min_confidence(min_confidence)
|
|
313
|
+
value = _state_value(state, state_file, state_json)
|
|
314
|
+
choices = _parse_options(option)
|
|
315
|
+
result = _run_api(lambda jev: jev.choice(question, state=value, choices=choices), model=model)
|
|
316
|
+
_emit_gated_result(
|
|
317
|
+
result,
|
|
318
|
+
json_output=json_output,
|
|
319
|
+
value_only=value_only,
|
|
320
|
+
minimum=min_confidence,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@app.command("score")
|
|
325
|
+
def score(
|
|
326
|
+
question: str = typer.Argument(..., help="Question to score."),
|
|
327
|
+
level: list[str] = typer.Option(
|
|
328
|
+
...,
|
|
329
|
+
"--level",
|
|
330
|
+
"-l",
|
|
331
|
+
help="Ordered score-level description. Repeat from score 0 upward.",
|
|
332
|
+
),
|
|
333
|
+
state: str | None = typer.Option(None, "--state", "-s", help="State text."),
|
|
334
|
+
state_file: Path | None = typer.Option(None, "--state-file", help="Read state from a file."),
|
|
335
|
+
state_json: bool = typer.Option(False, "--state-json", help="Decode the state as JSON."),
|
|
336
|
+
model: str | None = typer.Option(None, "--model", help="Override the TypeSafe model."),
|
|
337
|
+
min_confidence: float | None = typer.Option(None, "--min-confidence", help="Exit 3 below this confidence."),
|
|
338
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON."),
|
|
339
|
+
value_only: bool = typer.Option(False, "--value", help="Emit only the expected score."),
|
|
340
|
+
) -> None:
|
|
341
|
+
_validate_output_options(json_output=json_output, value_only=value_only)
|
|
342
|
+
_validate_min_confidence(min_confidence)
|
|
343
|
+
if not 2 <= len(level) <= 10:
|
|
344
|
+
raise typer.BadParameter("Provide between 2 and 10 --level values.")
|
|
345
|
+
value = _state_value(state, state_file, state_json)
|
|
346
|
+
result = _run_api(lambda jev: jev.score(question, state=value, levels=level), model=model)
|
|
347
|
+
_emit_gated_result(
|
|
348
|
+
result,
|
|
349
|
+
json_output=json_output,
|
|
350
|
+
value_only=value_only,
|
|
351
|
+
minimum=min_confidence,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _named_decision_result(
|
|
356
|
+
name: str,
|
|
357
|
+
*,
|
|
358
|
+
state: Any,
|
|
359
|
+
config: str | None,
|
|
360
|
+
model: str | None,
|
|
361
|
+
) -> Result:
|
|
362
|
+
return _run_api(lambda jev: jev.decide(name, state=state, config=config, model=model), model=model)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@app.command("decide")
|
|
366
|
+
def decide(
|
|
367
|
+
name: str = typer.Argument(..., help="Name declared in .pyjev.toml."),
|
|
368
|
+
state: str | None = typer.Option(None, "--state", "-s", help="State text."),
|
|
369
|
+
state_file: Path | None = typer.Option(None, "--state-file", help="Read state from a file."),
|
|
370
|
+
state_json: bool = typer.Option(False, "--state-json", help="Decode the state as JSON."),
|
|
371
|
+
model: str | None = typer.Option(None, "--model", help="Override the decision's model."),
|
|
372
|
+
config: Path | None = typer.Option(None, "--config", help="Path to a .pyjev.toml file."),
|
|
373
|
+
min_confidence: float | None = typer.Option(None, "--min-confidence", help="Exit 3 below this confidence."),
|
|
374
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON."),
|
|
375
|
+
value_only: bool = typer.Option(False, "--value", help="Emit only the selected value."),
|
|
376
|
+
) -> None:
|
|
377
|
+
_validate_output_options(json_output=json_output, value_only=value_only)
|
|
378
|
+
_validate_min_confidence(min_confidence)
|
|
379
|
+
try:
|
|
380
|
+
declaration = load_decision(name, config)
|
|
381
|
+
except DecisionConfigError as exc:
|
|
382
|
+
raise _parse_decision_error(exc) from exc
|
|
383
|
+
if isinstance(declaration, (NoulDecision, BundleDecision)) and min_confidence is not None:
|
|
384
|
+
if isinstance(declaration, BundleDecision):
|
|
385
|
+
raise typer.BadParameter("--min-confidence is not valid for bundle decisions.")
|
|
386
|
+
raise typer.BadParameter("--min-confidence is only valid for named Choice and Score decisions.")
|
|
387
|
+
if isinstance(declaration, BundleDecision) and value_only:
|
|
388
|
+
raise typer.BadParameter("--value is not valid for bundle decisions.")
|
|
389
|
+
value = _state_value(state, state_file, state_json)
|
|
390
|
+
result = _named_decision_result(name, state=value, config=str(config) if config else None, model=model)
|
|
391
|
+
_emit_gated_result(
|
|
392
|
+
result,
|
|
393
|
+
json_output=json_output,
|
|
394
|
+
value_only=value_only,
|
|
395
|
+
minimum=min_confidence,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class CredentialStorage(str, Enum):
|
|
400
|
+
auto = "auto"
|
|
401
|
+
keyring = "keyring"
|
|
402
|
+
file = "file"
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _file_warning(path: Path) -> None:
|
|
406
|
+
typer.echo(
|
|
407
|
+
f"Warning: {path} stores the API key as plaintext readable by your user account.",
|
|
408
|
+
err=True,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@auth_app.command("set")
|
|
413
|
+
def auth_set(
|
|
414
|
+
api_key: str | None = typer.Option(
|
|
415
|
+
None,
|
|
416
|
+
"--api-key",
|
|
417
|
+
help="API key. Omit to enter it interactively.",
|
|
418
|
+
),
|
|
419
|
+
storage: CredentialStorage = typer.Option(
|
|
420
|
+
CredentialStorage.auto,
|
|
421
|
+
"--storage",
|
|
422
|
+
help="Credential storage: auto, keyring, or file.",
|
|
423
|
+
),
|
|
424
|
+
) -> None:
|
|
425
|
+
interactive_key = api_key is None
|
|
426
|
+
if api_key is None:
|
|
427
|
+
api_key = typer.prompt("TypeSafe API key", hide_input=True)
|
|
428
|
+
|
|
429
|
+
if storage == CredentialStorage.file:
|
|
430
|
+
path = credential_file_path()
|
|
431
|
+
_file_warning(path)
|
|
432
|
+
try:
|
|
433
|
+
set_file_api_key(api_key)
|
|
434
|
+
except CredentialError as exc:
|
|
435
|
+
typer.echo(str(exc), err=True)
|
|
436
|
+
raise typer.Exit(code=EXIT_RUNTIME_ERROR) from exc
|
|
437
|
+
typer.echo(f"Stored TypeSafe API key in {path}.")
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
set_api_key(api_key)
|
|
442
|
+
except CredentialError as exc:
|
|
443
|
+
typer.echo(str(exc), err=True)
|
|
444
|
+
if storage == CredentialStorage.keyring:
|
|
445
|
+
raise typer.Exit(code=EXIT_RUNTIME_ERROR) from exc
|
|
446
|
+
if not interactive_key:
|
|
447
|
+
typer.echo(
|
|
448
|
+
f"Set {ENV_NAME} or use --storage file to store the key in a user-level plaintext file.",
|
|
449
|
+
err=True,
|
|
450
|
+
)
|
|
451
|
+
raise typer.Exit(code=EXIT_RUNTIME_ERROR) from exc
|
|
452
|
+
|
|
453
|
+
path = credential_file_path()
|
|
454
|
+
_file_warning(path)
|
|
455
|
+
if not typer.confirm("Store the API key there?", default=False):
|
|
456
|
+
typer.echo(
|
|
457
|
+
f"API key was not stored. Set {ENV_NAME} or configure an OS keyring.",
|
|
458
|
+
err=True,
|
|
459
|
+
)
|
|
460
|
+
raise typer.Exit(code=EXIT_RUNTIME_ERROR) from exc
|
|
461
|
+
try:
|
|
462
|
+
set_file_api_key(api_key)
|
|
463
|
+
except CredentialError as file_exc:
|
|
464
|
+
typer.echo(str(file_exc), err=True)
|
|
465
|
+
raise typer.Exit(code=EXIT_RUNTIME_ERROR) from file_exc
|
|
466
|
+
typer.echo(f"Stored TypeSafe API key in {path}.")
|
|
467
|
+
return
|
|
468
|
+
typer.echo("Stored TypeSafe API key in the OS keyring.")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@auth_app.command("status")
|
|
472
|
+
def auth_status() -> None:
|
|
473
|
+
try:
|
|
474
|
+
source = credential_source()
|
|
475
|
+
except CredentialError as exc:
|
|
476
|
+
typer.echo(str(exc), err=True)
|
|
477
|
+
raise typer.Exit(code=EXIT_RUNTIME_ERROR) from exc
|
|
478
|
+
if source == "environment":
|
|
479
|
+
typer.echo(f"API key available from {ENV_NAME}.")
|
|
480
|
+
elif source == "keyring":
|
|
481
|
+
typer.echo("API key stored in the OS keyring.")
|
|
482
|
+
elif source == "file":
|
|
483
|
+
typer.echo(f"API key stored in {credential_file_path()} (plaintext file).")
|
|
484
|
+
else:
|
|
485
|
+
typer.echo("No API key found.")
|
|
486
|
+
raise typer.Exit(code=EXIT_RUNTIME_ERROR)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@auth_app.command("delete")
|
|
490
|
+
def auth_delete() -> None:
|
|
491
|
+
try:
|
|
492
|
+
removed = delete_api_key()
|
|
493
|
+
except CredentialError as exc:
|
|
494
|
+
typer.echo(str(exc), err=True)
|
|
495
|
+
raise typer.Exit(code=EXIT_RUNTIME_ERROR) from exc
|
|
496
|
+
typer.echo("Deleted stored API key." if removed else "No stored API key found.")
|
|
497
|
+
if os.getenv(ENV_NAME) is not None:
|
|
498
|
+
typer.echo(f"{ENV_NAME} is still set and remains the active credential.")
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
@decision_app.command("list")
|
|
502
|
+
def decision_list(
|
|
503
|
+
config: Path | None = typer.Option(None, "--config", help="Path to a .pyjev.toml file."),
|
|
504
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON."),
|
|
505
|
+
) -> None:
|
|
506
|
+
try:
|
|
507
|
+
decisions = load_decisions(config)
|
|
508
|
+
except DecisionConfigError as exc:
|
|
509
|
+
raise _parse_decision_error(exc) from exc
|
|
510
|
+
if json_output:
|
|
511
|
+
_print_json([decision_to_dict(decision) for decision in decisions.values()])
|
|
512
|
+
return
|
|
513
|
+
for decision in decisions.values():
|
|
514
|
+
typer.echo(f"{decision.name}\t{type(decision).__name__.removesuffix('Decision').lower()}")
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@decision_app.command("show")
|
|
518
|
+
def decision_show(
|
|
519
|
+
name: str = typer.Argument(..., help="Decision name."),
|
|
520
|
+
config: Path | None = typer.Option(None, "--config", help="Path to a .pyjev.toml file."),
|
|
521
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON."),
|
|
522
|
+
) -> None:
|
|
523
|
+
try:
|
|
524
|
+
decision = load_decision(name, config)
|
|
525
|
+
except DecisionConfigError as exc:
|
|
526
|
+
raise _parse_decision_error(exc) from exc
|
|
527
|
+
if json_output:
|
|
528
|
+
_print_json(decision_to_dict(decision))
|
|
529
|
+
else:
|
|
530
|
+
typer.echo(f"name={decision.name}")
|
|
531
|
+
typer.echo(f"type={type(decision).__name__.removesuffix('Decision').lower()}")
|
|
532
|
+
if isinstance(decision, BundleDecision):
|
|
533
|
+
typer.echo(f"questions={','.join(decision.questions)}")
|
|
534
|
+
else:
|
|
535
|
+
typer.echo(f"question={decision.question}")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
@decision_app.command("validate")
|
|
539
|
+
def decision_validate(
|
|
540
|
+
config: Path | None = typer.Option(None, "--config", help="Path to a .pyjev.toml file."),
|
|
541
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON."),
|
|
542
|
+
) -> None:
|
|
543
|
+
try:
|
|
544
|
+
loaded = load_config(config)
|
|
545
|
+
except DecisionConfigError as exc:
|
|
546
|
+
raise _parse_decision_error(exc) from exc
|
|
547
|
+
if json_output:
|
|
548
|
+
_print_json(
|
|
549
|
+
{
|
|
550
|
+
"config": str(loaded.path),
|
|
551
|
+
"schema": loaded.schema,
|
|
552
|
+
"decisions": [decision_to_dict(decision) for decision in loaded.decisions.values()],
|
|
553
|
+
}
|
|
554
|
+
)
|
|
555
|
+
return
|
|
556
|
+
typer.echo(f"Valid {loaded.path}: schema {loaded.schema}; {len(loaded.decisions)} decisions")
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@decision_app.command("compile")
|
|
560
|
+
def decision_compile(
|
|
561
|
+
name: str = typer.Argument(..., help="Name declared in .pyjev.toml."),
|
|
562
|
+
state: str | None = typer.Option(None, "--state", "-s", help="State text."),
|
|
563
|
+
state_file: Path | None = typer.Option(None, "--state-file", help="Read state from a file."),
|
|
564
|
+
state_json: bool = typer.Option(False, "--state-json", help="Decode the state as JSON."),
|
|
565
|
+
model: str | None = typer.Option(None, "--model", help="Override the decision's model."),
|
|
566
|
+
config: Path | None = typer.Option(None, "--config", help="Path to a .pyjev.toml file."),
|
|
567
|
+
) -> None:
|
|
568
|
+
value = _state_value(state, state_file, state_json)
|
|
569
|
+
try:
|
|
570
|
+
compiled = compile_decision(name, state=value, config=config, model=model)
|
|
571
|
+
except DecisionConfigError as exc:
|
|
572
|
+
raise _parse_decision_error(exc) from exc
|
|
573
|
+
_print_json(compiled.to_dict())
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
@app.command("run")
|
|
577
|
+
def run(
|
|
578
|
+
request: str = typer.Argument("-", help="JSON request file, or '-' for stdin."),
|
|
579
|
+
) -> None:
|
|
580
|
+
text = sys.stdin.read() if request == "-" else _read_text(Path(request))
|
|
581
|
+
try:
|
|
582
|
+
payload = json.loads(text)
|
|
583
|
+
except json.JSONDecodeError as exc:
|
|
584
|
+
raise typer.BadParameter(f"Request is not valid JSON: {exc}") from exc
|
|
585
|
+
|
|
586
|
+
if not isinstance(payload, dict) or "state" not in payload or "questions" not in payload:
|
|
587
|
+
raise typer.BadParameter("Request must be an object with 'state' and 'questions'.")
|
|
588
|
+
questions = payload["questions"]
|
|
589
|
+
if not isinstance(questions, dict) or not questions:
|
|
590
|
+
raise typer.BadParameter("'questions' must be a non-empty object.")
|
|
591
|
+
|
|
592
|
+
result = _run_api(
|
|
593
|
+
lambda jev: jev.run(state=payload["state"], questions=questions),
|
|
594
|
+
model=payload.get("model"),
|
|
595
|
+
)
|
|
596
|
+
_print_json(result)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
@app.command("models")
|
|
600
|
+
def models(
|
|
601
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON."),
|
|
602
|
+
) -> None:
|
|
603
|
+
available = _run_api(lambda jev: jev.models())
|
|
604
|
+
if json_output:
|
|
605
|
+
_print_json(available)
|
|
606
|
+
return
|
|
607
|
+
for model in available:
|
|
608
|
+
name = model.get("name", "")
|
|
609
|
+
description = model.get("description", "")
|
|
610
|
+
typer.echo(f"{name}\t{description}")
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
if __name__ == "__main__":
|
|
614
|
+
app()
|