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 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
@@ -0,0 +1,3 @@
1
+ from .cli import app
2
+
3
+ app()
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()