opendxp 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.
- opendxp/__init__.py +26 -0
- opendxp/__main__.py +3 -0
- opendxp/answers.py +96 -0
- opendxp/calibration.py +71 -0
- opendxp/cli.py +203 -0
- opendxp/conformance.py +325 -0
- opendxp/data/requests-0.1.jsonl +52 -0
- opendxp/errors.py +23 -0
- opendxp/export/__init__.py +7 -0
- opendxp/export/common.py +88 -0
- opendxp/export/decider.py +169 -0
- opendxp/export/encoder.py +105 -0
- opendxp/export/julia.py +373 -0
- opendxp/export/laya.py +241 -0
- opendxp/export/onnx_graph.py +200 -0
- opendxp/native/__init__.py +120 -0
- opendxp/native/decider.py +348 -0
- opendxp/native/julia.py +317 -0
- opendxp/package.py +148 -0
- opendxp/profiles/__init__.py +0 -0
- opendxp/profiles/causal_letters.py +344 -0
- opendxp/profiles/encoder_markers.py +378 -0
- opendxp/providers.py +143 -0
- opendxp/request.py +114 -0
- opendxp/runtime.py +32 -0
- opendxp/schemas/calibration.schema.json +34 -0
- opendxp/schemas/check-report.schema.json +41 -0
- opendxp/schemas/conformance-case.schema.json +42 -0
- opendxp/schemas/odxp.schema.json +101 -0
- opendxp/schemas/prompt.schema.json +112 -0
- opendxp/schemas/request.schema.json +62 -0
- opendxp/schemas/response.schema.json +56 -0
- opendxp/schemas/template.schema.json +98 -0
- opendxp/schemas.py +44 -0
- opendxp/spec.py +52 -0
- opendxp/text.py +64 -0
- opendxp/tokens.py +39 -0
- opendxp/validate.py +65 -0
- opendxp-0.1.0.dist-info/METADATA +211 -0
- opendxp-0.1.0.dist-info/RECORD +44 -0
- opendxp-0.1.0.dist-info/WHEEL +4 -0
- opendxp-0.1.0.dist-info/entry_points.txt +2 -0
- opendxp-0.1.0.dist-info/licenses/LICENSE +176 -0
- opendxp-0.1.0.dist-info/licenses/NOTICE +21 -0
opendxp/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""OpenDXP: a portable runtime standard for calibrated single-pass decision models.
|
|
2
|
+
|
|
3
|
+
This package is the reference implementation: the runtimes that run any
|
|
4
|
+
conforming package without model-specific code, the converters that make
|
|
5
|
+
packages from the models' own checkpoints, and the conformance tools.
|
|
6
|
+
|
|
7
|
+
import opendxp
|
|
8
|
+
model = opendxp.load("path/to/package") # device chosen from what is available
|
|
9
|
+
model.predict(state, questions) # {"answers": {...}, "usage": {...}}
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load(package_dir: Any, **options: Any) -> Any:
|
|
20
|
+
"""Open a package and start the reference runtime for its profile."""
|
|
21
|
+
from opendxp.runtime import load as _load
|
|
22
|
+
|
|
23
|
+
return _load(package_dir, **options)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = ["__version__", "load"]
|
opendxp/__main__.py
ADDED
opendxp/answers.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""From scores to System One answers: the calibrated softmax, confidence, answer objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from opendxp.errors import OpenDXPError
|
|
12
|
+
from opendxp.request import Question
|
|
13
|
+
from opendxp.spec import DECIMALS
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def softmax(logits: Sequence[float], temperature: float = 1.0) -> list[float]:
|
|
17
|
+
"""softmax(z / T) in float64, over the logits given (the valid options only)."""
|
|
18
|
+
z = np.asarray(logits, dtype=np.float64) / float(temperature)
|
|
19
|
+
if not np.all(np.isfinite(z)):
|
|
20
|
+
raise OpenDXPError("the model returned non-finite scores")
|
|
21
|
+
e = np.exp(z - z.max())
|
|
22
|
+
return (e / e.sum()).tolist()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalise(values: Sequence[float]) -> list[float]:
|
|
26
|
+
total = float(sum(values))
|
|
27
|
+
if total <= 0:
|
|
28
|
+
return [1.0 / len(values)] * len(values)
|
|
29
|
+
return [float(v) / total for v in values]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def confidence(rule: str, p: Sequence[float]) -> float | None:
|
|
33
|
+
"""The confidence rules a package can declare (SPEC.md, "Answers")."""
|
|
34
|
+
n = len(p)
|
|
35
|
+
if rule == "none":
|
|
36
|
+
return None
|
|
37
|
+
if rule == "max-probability":
|
|
38
|
+
return float(max(p))
|
|
39
|
+
if rule == "entropy":
|
|
40
|
+
if n < 2:
|
|
41
|
+
return 1.0
|
|
42
|
+
h = -sum(x * math.log(min(max(x, 1e-12), 1.0)) for x in p)
|
|
43
|
+
return min(1.0, max(0.0, 1.0 - h / math.log(n)))
|
|
44
|
+
if rule == "typesafe":
|
|
45
|
+
if n < 2:
|
|
46
|
+
return 1.0
|
|
47
|
+
return min(1.0, max(0.0, (n * max(p) - 1) / (n - 1)))
|
|
48
|
+
if rule == "typesafe-ordinal":
|
|
49
|
+
if n < 2:
|
|
50
|
+
return 1.0
|
|
51
|
+
k = max(range(n), key=p.__getitem__)
|
|
52
|
+
spread = sum(x * abs(i - k) for i, x in enumerate(p))
|
|
53
|
+
uniform = sum(abs(i - (n - 1) / 2) for i in range(n)) / n
|
|
54
|
+
return min(1.0, max(0.0, 1.0 - spread / uniform))
|
|
55
|
+
raise OpenDXPError(f"unknown confidence rule {rule!r}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def argmax(p: Sequence[float]) -> int:
|
|
59
|
+
"""The first index of the largest value."""
|
|
60
|
+
return max(range(len(p)), key=p.__getitem__)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def answer(
|
|
64
|
+
question: Question,
|
|
65
|
+
p: Sequence[float],
|
|
66
|
+
rules: dict[str, str] | None = None,
|
|
67
|
+
decimals: int | None = DECIMALS,
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
"""One answer in the System One format; probabilities rounded to `decimals` if set."""
|
|
70
|
+
|
|
71
|
+
def r(x: float) -> float:
|
|
72
|
+
return round(float(x), decimals) if decimals is not None else float(x)
|
|
73
|
+
|
|
74
|
+
rule = (rules or {}).get(question.type, "none")
|
|
75
|
+
conf = confidence(rule, p)
|
|
76
|
+
keys = question.keys
|
|
77
|
+
best = argmax(p)
|
|
78
|
+
out: dict[str, Any]
|
|
79
|
+
if question.type == "choice":
|
|
80
|
+
out = {
|
|
81
|
+
"type": "choice",
|
|
82
|
+
"choice": keys[best],
|
|
83
|
+
"probabilities": {k: r(x) for k, x in zip(keys, p, strict=True)},
|
|
84
|
+
}
|
|
85
|
+
elif question.type == "score":
|
|
86
|
+
out = {
|
|
87
|
+
"type": "score",
|
|
88
|
+
"score": r(sum(i * x for i, x in enumerate(p))),
|
|
89
|
+
"legend": question.legend(),
|
|
90
|
+
"probabilities": {k: r(x) for k, x in zip(keys, p, strict=True)},
|
|
91
|
+
}
|
|
92
|
+
else:
|
|
93
|
+
out = {"type": "noul", "noul": r(p[1])}
|
|
94
|
+
if conf is not None:
|
|
95
|
+
out["confidence"] = r(conf)
|
|
96
|
+
return out
|
opendxp/calibration.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""calibration.json: the temperature each answer is divided by before the softmax.
|
|
2
|
+
|
|
3
|
+
A temperature is looked up by question type and option count: the first entry
|
|
4
|
+
of `by_option_count` whose type matches and whose [min, max] range holds the
|
|
5
|
+
count wins; otherwise the per-type temperature; otherwise 1.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from opendxp.errors import PackageError
|
|
16
|
+
from opendxp.spec import QUESTION_TYPES
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Bucket:
|
|
21
|
+
type: str
|
|
22
|
+
min: int
|
|
23
|
+
max: int | None
|
|
24
|
+
temperature: float
|
|
25
|
+
|
|
26
|
+
def holds(self, qtype: str, count: int) -> bool:
|
|
27
|
+
return qtype == self.type and count >= self.min and (self.max is None or count <= self.max)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Calibration:
|
|
31
|
+
def __init__(self, data: dict[str, Any] | None = None) -> None:
|
|
32
|
+
data = data or {}
|
|
33
|
+
self.data = data
|
|
34
|
+
self.by_type: dict[str, float] = {}
|
|
35
|
+
for qtype, value in (data.get("temperature") or {}).items():
|
|
36
|
+
if qtype not in QUESTION_TYPES:
|
|
37
|
+
raise PackageError(f"calibration: unknown question type {qtype!r}")
|
|
38
|
+
self.by_type[qtype] = _positive(value, f"temperature.{qtype}")
|
|
39
|
+
self.buckets: list[Bucket] = []
|
|
40
|
+
for i, entry in enumerate(data.get("by_option_count") or []):
|
|
41
|
+
qtype = entry.get("type")
|
|
42
|
+
if qtype not in QUESTION_TYPES:
|
|
43
|
+
raise PackageError(f"calibration: by_option_count[{i}] has no valid type")
|
|
44
|
+
low, high = entry.get("min", 2), entry.get("max")
|
|
45
|
+
if not isinstance(low, int) or (high is not None and not isinstance(high, int)):
|
|
46
|
+
raise PackageError(f"calibration: by_option_count[{i}] min/max must be integers")
|
|
47
|
+
if high is not None and high < low:
|
|
48
|
+
raise PackageError(f"calibration: by_option_count[{i}] has max < min")
|
|
49
|
+
self.buckets.append(
|
|
50
|
+
Bucket(qtype, low, high, _positive(entry.get("temperature"), f"bucket {i}"))
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_file(cls, path: Path) -> Calibration:
|
|
55
|
+
return cls(json.loads(Path(path).read_text()))
|
|
56
|
+
|
|
57
|
+
def temperature(self, qtype: str, count: int) -> float:
|
|
58
|
+
for bucket in self.buckets:
|
|
59
|
+
if bucket.holds(qtype, count):
|
|
60
|
+
return bucket.temperature
|
|
61
|
+
return self.by_type.get(qtype, 1.0)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _positive(value: Any, where: str) -> float:
|
|
65
|
+
try:
|
|
66
|
+
number = float(value)
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
raise PackageError(f"calibration: {where} is not a number") from None
|
|
69
|
+
if not number > 0 or number == float("inf"):
|
|
70
|
+
raise PackageError(f"calibration: {where} must be a positive finite number")
|
|
71
|
+
return number
|
opendxp/cli.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""The `opendxp` command.
|
|
2
|
+
|
|
3
|
+
opendxp export laya|julia|decider CHECKPOINT OUT_DIR
|
|
4
|
+
opendxp conformance generate PACKAGE --native CHECKPOINT --runtime laya|julia|decider
|
|
5
|
+
opendxp check PACKAGE [--device cpu|auto|coreml|cuda|openvino|qnn|directml|gpu]
|
|
6
|
+
opendxp validate PACKAGE
|
|
7
|
+
opendxp run PACKAGE --request request.json
|
|
8
|
+
opendxp info
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from opendxp import __version__
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _print_json(data: Any) -> None:
|
|
23
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cmd_export(args: argparse.Namespace) -> int:
|
|
27
|
+
mode = "copy" if args.copy else "link"
|
|
28
|
+
options: dict[str, Any] = {"weights_mode": mode}
|
|
29
|
+
if args.name:
|
|
30
|
+
options["name"] = args.name
|
|
31
|
+
if args.family == "laya":
|
|
32
|
+
from opendxp.export import laya as exporter
|
|
33
|
+
elif args.family == "julia":
|
|
34
|
+
from opendxp.export import julia as exporter
|
|
35
|
+
|
|
36
|
+
if args.max_tokens:
|
|
37
|
+
options["max_tokens"] = args.max_tokens
|
|
38
|
+
if args.official_onnx:
|
|
39
|
+
options["official_onnx"] = Path(args.official_onnx)
|
|
40
|
+
else:
|
|
41
|
+
from opendxp.export import decider as exporter
|
|
42
|
+
manifest = exporter.export(Path(args.checkpoint), Path(args.out_dir), **options)
|
|
43
|
+
print(f"wrote {Path(args.out_dir) / 'odxp.json'} ({manifest['profile']})")
|
|
44
|
+
print(
|
|
45
|
+
"next: opendxp conformance generate",
|
|
46
|
+
args.out_dir,
|
|
47
|
+
"--native",
|
|
48
|
+
args.checkpoint,
|
|
49
|
+
"--runtime",
|
|
50
|
+
args.family,
|
|
51
|
+
)
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_generate(args: argparse.Namespace) -> int:
|
|
56
|
+
from opendxp.conformance import DEFAULT_REQUESTS, generate, read_jsonl
|
|
57
|
+
from opendxp.native import load_native
|
|
58
|
+
|
|
59
|
+
requests = read_jsonl(Path(args.requests) if args.requests else DEFAULT_REQUESTS)
|
|
60
|
+
if args.limit:
|
|
61
|
+
requests = requests[: args.limit]
|
|
62
|
+
print(f"loading the model's own runtime ({args.runtime}) from {args.native}")
|
|
63
|
+
native = load_native(args.runtime, Path(args.native), threads=args.threads)
|
|
64
|
+
try:
|
|
65
|
+
summary = generate(Path(args.package), native, requests)
|
|
66
|
+
finally:
|
|
67
|
+
native.close()
|
|
68
|
+
_print_json(summary)
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
73
|
+
from opendxp.conformance import check
|
|
74
|
+
|
|
75
|
+
options: dict[str, Any] = {}
|
|
76
|
+
if args.optimization:
|
|
77
|
+
options["optimization"] = args.optimization
|
|
78
|
+
report = check(
|
|
79
|
+
Path(args.package),
|
|
80
|
+
device=args.device,
|
|
81
|
+
threads=args.threads,
|
|
82
|
+
hashes=not args.no_hashes,
|
|
83
|
+
**options,
|
|
84
|
+
)
|
|
85
|
+
path = Path(args.report) if args.report else Path(args.package) / f"check-{args.device}.json"
|
|
86
|
+
path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
87
|
+
lat = report["latency_ms"]
|
|
88
|
+
agree = report["argmax_agreement"]
|
|
89
|
+
print(f"{report['package']['name']} [{report['package']['profile']}] on {args.device}")
|
|
90
|
+
print(
|
|
91
|
+
f" cases {report['cases_passed']}/{report['cases']} passed, "
|
|
92
|
+
f"questions {report['questions']}, max |dp| {report['max_abs_dp']:.2e}, "
|
|
93
|
+
f"argmax {agree['agree']}/{agree['total']}, "
|
|
94
|
+
f"errors matched {report['errors']['matched']}/{report['errors']['expected']}"
|
|
95
|
+
)
|
|
96
|
+
print(
|
|
97
|
+
f" latency: load {lat['load']} ms, request median {lat['request_median']} ms, "
|
|
98
|
+
f"p90 {lat['request_p90']} ms, per question {lat['question_median']} ms"
|
|
99
|
+
)
|
|
100
|
+
for problem in report["package_problems"]:
|
|
101
|
+
print(f" package: {problem}")
|
|
102
|
+
for failure in report["failures"][:10]:
|
|
103
|
+
print(f" FAIL {failure['id']}: {failure.get('reason') or failure.get('questions')}")
|
|
104
|
+
verdict = "PASS" if report["passed"] else "FAIL"
|
|
105
|
+
if report["passed"] and not report["compatible"]:
|
|
106
|
+
verdict += " (coverage below the minimum: not badge-eligible)"
|
|
107
|
+
print(f" {verdict}; report: {path}")
|
|
108
|
+
return 0 if report["passed"] else 1
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
112
|
+
from opendxp.validate import validate_package
|
|
113
|
+
|
|
114
|
+
problems = validate_package(Path(args.package), hashes=not args.no_hashes)
|
|
115
|
+
for problem in problems:
|
|
116
|
+
print(problem)
|
|
117
|
+
print("valid" if not problems else f"{len(problems)} problem(s)")
|
|
118
|
+
return 0 if not problems else 1
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
122
|
+
from opendxp.runtime import load
|
|
123
|
+
|
|
124
|
+
text = sys.stdin.read() if args.request == "-" else Path(args.request).read_text()
|
|
125
|
+
request = json.loads(text)
|
|
126
|
+
model = load(Path(args.package), device=args.device, threads=args.threads)
|
|
127
|
+
try:
|
|
128
|
+
_print_json(model.predict(request.get("state", ""), request["questions"]))
|
|
129
|
+
finally:
|
|
130
|
+
model.close()
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cmd_info(args: argparse.Namespace) -> int:
|
|
135
|
+
from opendxp.providers import describe_machine
|
|
136
|
+
|
|
137
|
+
_print_json({"opendxp": __version__, **describe_machine()})
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main(argv: list[str] | None = None) -> int:
|
|
142
|
+
parser = argparse.ArgumentParser(prog="opendxp", description="OpenDXP reference tools")
|
|
143
|
+
parser.add_argument("--version", action="version", version=f"opendxp {__version__}")
|
|
144
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
145
|
+
|
|
146
|
+
p = sub.add_parser("export", help="convert a model's checkpoint into an OpenDXP package")
|
|
147
|
+
p.add_argument("family", choices=["laya", "julia", "decider"])
|
|
148
|
+
p.add_argument("checkpoint")
|
|
149
|
+
p.add_argument("out_dir")
|
|
150
|
+
p.add_argument("--name", help="the package name (default: the model's registry name)")
|
|
151
|
+
p.add_argument("--copy", action="store_true", help="copy weights instead of hard-linking")
|
|
152
|
+
p.add_argument("--max-tokens", type=int, help="julia: total token budget (default 8192)")
|
|
153
|
+
p.add_argument(
|
|
154
|
+
"--official-onnx",
|
|
155
|
+
help="julia: map SupersonicLabs/Julia-1-ONNX's model.onnx instead of exporting a graph",
|
|
156
|
+
)
|
|
157
|
+
p.set_defaults(func=cmd_export)
|
|
158
|
+
|
|
159
|
+
p = sub.add_parser("conformance", help="conformance files")
|
|
160
|
+
csub = p.add_subparsers(dest="action", required=True)
|
|
161
|
+
g = csub.add_parser("generate", help="record the model's own answers into conformance.jsonl")
|
|
162
|
+
g.add_argument("package")
|
|
163
|
+
g.add_argument(
|
|
164
|
+
"--native", required=True, help="the checkpoint directory the model's code reads"
|
|
165
|
+
)
|
|
166
|
+
g.add_argument("--runtime", required=True, choices=["laya", "julia", "decider"])
|
|
167
|
+
g.add_argument("--requests", help="a requests JSONL (default: the OpenDXP 0.1 set)")
|
|
168
|
+
g.add_argument("--threads", type=int, default=4)
|
|
169
|
+
g.add_argument("--limit", type=int, help="only the first N requests (for a quick try)")
|
|
170
|
+
g.set_defaults(func=cmd_generate)
|
|
171
|
+
|
|
172
|
+
p = sub.add_parser("check", help="run the conformance file through the reference runtime")
|
|
173
|
+
p.add_argument("package")
|
|
174
|
+
p.add_argument(
|
|
175
|
+
"--device", default="cpu", help="cpu (the reference), auto, coreml, cuda, gpu, ..."
|
|
176
|
+
)
|
|
177
|
+
p.add_argument("--threads", type=int)
|
|
178
|
+
p.add_argument("--report", help="where to write the JSON report")
|
|
179
|
+
p.add_argument("--no-hashes", action="store_true", help="skip the sha256 checks")
|
|
180
|
+
p.add_argument("--optimization", choices=["disable", "basic", "extended", "all"])
|
|
181
|
+
p.set_defaults(func=cmd_check)
|
|
182
|
+
|
|
183
|
+
p = sub.add_parser("validate", help="check a package's files against the schemas and hashes")
|
|
184
|
+
p.add_argument("package")
|
|
185
|
+
p.add_argument("--no-hashes", action="store_true")
|
|
186
|
+
p.set_defaults(func=cmd_validate)
|
|
187
|
+
|
|
188
|
+
p = sub.add_parser("run", help="answer one request with a package")
|
|
189
|
+
p.add_argument("package")
|
|
190
|
+
p.add_argument("--request", required=True, help="a request JSON file, or - for stdin")
|
|
191
|
+
p.add_argument("--device", default="auto")
|
|
192
|
+
p.add_argument("--threads", type=int)
|
|
193
|
+
p.set_defaults(func=cmd_run)
|
|
194
|
+
|
|
195
|
+
p = sub.add_parser("info", help="this machine's backends and devices")
|
|
196
|
+
p.set_defaults(func=cmd_info)
|
|
197
|
+
|
|
198
|
+
args = parser.parse_args(argv)
|
|
199
|
+
return int(args.func(args))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
raise SystemExit(main())
|