ainra 0.4.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.
ainra/__init__.py ADDED
@@ -0,0 +1,60 @@
1
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
2
+ """ainra — an independent Python implementation of the AINRA passport verifier.
3
+
4
+ Verify-only, offline, fail-closed, zero-telemetry. Written from the AINRA
5
+ specification and conformance vectors (not transliterated from the Rust core or
6
+ the TypeScript SDK); the differential harness proves it agrees with them
7
+ byte-for-byte on every vector's verdict and reason. See the package README and
8
+ decision D-041 for the precise independence claim and the shared cryptographic
9
+ primitives.
10
+
11
+ Quickstart::
12
+
13
+ from ainra import Verifier
14
+ v = Verifier(anchors) # anchors: {registrar_id: {...keys...}}
15
+ verdict = v.verify(bundle, now) # caller supplies `now`; no I/O
16
+ if verdict.valid:
17
+ ...
18
+ else:
19
+ print(verdict.reason) # one of the 20 frozen reasons
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from . import reasons
25
+ from .delta import verify_delta, verify_head
26
+ from .directory import verify_directory
27
+ from .instance import (
28
+ INSTANCE_CRED_DEFAULT_SECS,
29
+ instance_signing_bytes,
30
+ mint_instance_credential,
31
+ instance_cred_digest,
32
+ pop_signing_bytes,
33
+ prove_instance_possession,
34
+ )
35
+ from .middleware import AinraGate, ainra_gate
36
+ from .verdict import Verdict
37
+ from .verifier import Verifier
38
+ from .verify import verify
39
+
40
+ __all__ = [
41
+ # ADR-019 — the instance rung. Verification is automatic (step 10 of verify()); these are the PRODUCING side,
42
+ # and they live on opposite sides of the container boundary on purpose.
43
+ "mint_instance_credential",
44
+ "prove_instance_possession",
45
+ "instance_signing_bytes",
46
+ "instance_cred_digest",
47
+ "pop_signing_bytes",
48
+ "INSTANCE_CRED_DEFAULT_SECS",
49
+ "Verifier",
50
+ "Verdict",
51
+ "verify",
52
+ "verify_delta",
53
+ "verify_head",
54
+ "verify_directory",
55
+ "AinraGate",
56
+ "ainra_gate",
57
+ "reasons",
58
+ ]
59
+
60
+ __version__ = "0.3.0"
ainra/_b64.py ADDED
@@ -0,0 +1,57 @@
1
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
2
+ """The strict canonical base64url gateway (decision D-029).
3
+
4
+ Every external base64url ingestion in the verifier routes through :func:`decode`.
5
+ Python's :func:`base64.urlsafe_b64decode` is *lenient* — it silently accepts
6
+ non-zero trailing bits, standard-alphabet ("+"/"/") swaps, whitespace, and
7
+ padding. That leniency is exactly the fail-open class D-029 closes. We require a
8
+ canonical round-trip (``encode(decode(s)) == s``) plus an explicit alphabet
9
+ check, mirroring the Rust core's ``base64ct`` ``Base64UrlUnpadded`` byte-for-byte:
10
+ a value is accepted iff it is unpadded base64url whose final character's unused
11
+ low bits are all zero. Anything else is refused (returns ``None``); the caller
12
+ maps that refusal to the field-appropriate frozen reason.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+
19
+ _ALPHABET = frozenset(
20
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
21
+ )
22
+
23
+
24
+ def decode(s: object) -> bytes | None:
25
+ """Strictly decode a canonical unpadded base64url string.
26
+
27
+ Returns the raw bytes, or ``None`` if ``s`` is not a canonical encoding.
28
+ Never raises — a verifier must fail closed, not crash.
29
+ """
30
+ if not isinstance(s, str):
31
+ return None
32
+ # Reject padding, whitespace, and any non-alphabet character up front.
33
+ for ch in s:
34
+ if ch not in _ALPHABET:
35
+ return None
36
+ try:
37
+ raw = base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
38
+ except Exception:
39
+ return None
40
+ # Canonical round-trip: catches non-zero trailing bits (a lenient decoder
41
+ # would drop them). Identical acceptance set to base64ct Base64UrlUnpadded.
42
+ if base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") != s:
43
+ return None
44
+ return raw
45
+
46
+
47
+ def encode(raw: bytes) -> str:
48
+ """Canonical unpadded base64url encoding of ``raw``."""
49
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
50
+
51
+
52
+ def decode_fixed(s: object, length: int) -> bytes | None:
53
+ """Strictly decode and require exactly ``length`` bytes, else ``None``."""
54
+ raw = decode(s)
55
+ if raw is None or len(raw) != length:
56
+ return None
57
+ return raw
ainra/_canon.py ADDED
@@ -0,0 +1,98 @@
1
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
2
+ """Deterministic canonical JSON (decisions D-003 and D-010).
3
+
4
+ The canonical form is the sorted-key, no-whitespace scheme the whole AINRA
5
+ corpus is signed and hashed over: object keys sorted, ``"key":value`` with no
6
+ spaces, scalars serialized exactly as a minimal JSON encoder would. Three input
7
+ classes are *rejected* (:class:`CanonError`) because they would diverge across a
8
+ byte-for-byte cross-language differential (D-010): floating-point numbers,
9
+ non-ASCII object keys (UTF-16 vs UTF-8 sort order disagree), and integers
10
+ outside ``[-(2**53 - 1), 2**53 - 1]`` (not exactly representable as a JS
11
+ ``Number``). No conformant AINRA credential uses any of them.
12
+
13
+ This is an independent implementation written from the specification, not a port
14
+ of the Rust ``canon`` or the TypeScript ``canonicalize``; the differential
15
+ harness is what proves the three agree.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ _MAX_SAFE_INT = (2**53) - 1
21
+
22
+
23
+ class CanonError(ValueError):
24
+ """Raised when a value cannot be canonicalized deterministically."""
25
+
26
+
27
+ def _string(s: str) -> str:
28
+ # Minimal JSON string escaping, matching a standard JSON encoder (the same
29
+ # set produced by JSON.stringify / serde_json): quote, backslash, the five
30
+ # short control escapes, and \u00xx for the remaining C0 controls. Forward
31
+ # slash is NOT escaped. Non-ASCII characters are emitted verbatim (UTF-8).
32
+ out = ['"']
33
+ for ch in s:
34
+ o = ord(ch)
35
+ if ch == '"':
36
+ out.append('\\"')
37
+ elif ch == "\\":
38
+ out.append("\\\\")
39
+ elif ch == "\b":
40
+ out.append("\\b")
41
+ elif ch == "\f":
42
+ out.append("\\f")
43
+ elif ch == "\n":
44
+ out.append("\\n")
45
+ elif ch == "\r":
46
+ out.append("\\r")
47
+ elif ch == "\t":
48
+ out.append("\\t")
49
+ elif o < 0x20:
50
+ out.append("\\u%04x" % o)
51
+ else:
52
+ out.append(ch)
53
+ out.append('"')
54
+ return "".join(out)
55
+
56
+
57
+ def _is_ascii(s: str) -> bool:
58
+ return all(ord(c) < 0x80 for c in s)
59
+
60
+
61
+ def canonicalize(value: object) -> str:
62
+ """Return the canonical JSON string for ``value`` (raises on divergent input)."""
63
+
64
+ def enc(v: object) -> str:
65
+ if v is None:
66
+ return "null"
67
+ if v is True:
68
+ return "true"
69
+ if v is False:
70
+ return "false"
71
+ if isinstance(v, float):
72
+ # A float in the input is a divergence hazard (D-010): reject.
73
+ raise CanonError("floats are not canonicalizable")
74
+ if isinstance(v, int):
75
+ if v > _MAX_SAFE_INT or v < -_MAX_SAFE_INT:
76
+ raise CanonError("integer outside the JS-safe range")
77
+ return str(v)
78
+ if isinstance(v, str):
79
+ return _string(v)
80
+ if isinstance(v, (list, tuple)):
81
+ return "[" + ",".join(enc(x) for x in v) + "]"
82
+ if isinstance(v, dict):
83
+ items = []
84
+ for k in v:
85
+ if not isinstance(k, str):
86
+ raise CanonError("object keys must be strings")
87
+ if not _is_ascii(k):
88
+ raise CanonError("non-ASCII object key")
89
+ for k in sorted(v.keys()):
90
+ items.append(_string(k) + ":" + enc(v[k]))
91
+ return "{" + ",".join(items) + "}"
92
+ raise CanonError(f"uncanonicalizable value of type {type(v).__name__}")
93
+
94
+ return enc(value)
95
+
96
+
97
+ def canon_bytes(value: object) -> bytes:
98
+ return canonicalize(value).encode("utf-8")
ainra/_crypto.py ADDED
@@ -0,0 +1,152 @@
1
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
2
+ """Cryptographic primitives — shared audited libraries, fail-closed wrappers.
3
+
4
+ Independence note (see the package README and decision D-041): the verification
5
+ *logic* in this package is written independently from the specification and the
6
+ conformance vectors. The primitives themselves are deliberately NOT reimplemented
7
+ — reimplementing a signature scheme would be less safe, not more independent:
8
+
9
+ * **Ed25519** (32-byte key, 64-byte signature) and **ML-DSA-65 / FIPS 204**
10
+ (1952-byte key, 3309-byte signature) come from ``cryptography`` (pyca), which
11
+ wraps OpenSSL 3.5+.
12
+ * **SLH-DSA-SHA2-128s / FIPS 205** (32-byte key, 7856-byte signature) is not yet
13
+ surfaced by ``cryptography``, so it is bound directly from the same OpenSSL
14
+ ``libcrypto`` via ``ctypes`` (EVP raw-public-key verify) — an established C
15
+ implementation used for that primitive only.
16
+ * **SHA-256** is the Python standard library ``hashlib``.
17
+
18
+ The differential (the fourth column of ``make diff``) exercises the *logic*, not
19
+ the primitives; agreement with the Rust core, the TS SDK, and the JS CLI is the
20
+ independent confirmation. Every wrapper returns ``bool`` and never raises: a
21
+ verifier fails closed, so any error, wrong size, or exception is ``False``.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import ctypes
27
+ import ctypes.util
28
+ import hashlib
29
+
30
+ from cryptography.hazmat.primitives.asymmetric import ed25519, mldsa
31
+
32
+ # Exact FIPS / RFC 8032 sizes — asserted as a conformance gate, matching the
33
+ # Rust core's size asserts (a deviation means a broken dependency, not a code
34
+ # change).
35
+ ED25519_PUB_LEN = 32
36
+ ED25519_SIG_LEN = 64
37
+ MLDSA65_PUB_LEN = 1952
38
+ MLDSA65_SIG_LEN = 3309
39
+ SLH_DSA_SHA2_128S_PUB_LEN = 32
40
+ SLH_DSA_SHA2_128S_SIG_LEN = 7856
41
+
42
+
43
+ def sha256(data: bytes) -> bytes:
44
+ return hashlib.sha256(data).digest()
45
+
46
+
47
+ def ed25519_verify(pub: bytes, sig: bytes, msg: bytes) -> bool:
48
+ """Verify an Ed25519 signature; fail closed on any anomaly."""
49
+ if len(pub) != ED25519_PUB_LEN or len(sig) != ED25519_SIG_LEN:
50
+ return False
51
+ try:
52
+ ed25519.Ed25519PublicKey.from_public_bytes(pub).verify(sig, msg)
53
+ return True
54
+ except Exception:
55
+ return False
56
+
57
+
58
+ def mldsa65_verify(pub: bytes, sig: bytes, msg: bytes) -> bool:
59
+ """Verify an ML-DSA-65 (FIPS 204) signature; fail closed on any anomaly."""
60
+ if len(pub) != MLDSA65_PUB_LEN or len(sig) != MLDSA65_SIG_LEN:
61
+ return False
62
+ try:
63
+ mldsa.MLDSA65PublicKey.from_public_bytes(pub).verify(sig, msg)
64
+ return True
65
+ except Exception:
66
+ return False
67
+
68
+
69
+ # ── SLH-DSA-SHA2-128s via OpenSSL libcrypto (ctypes) ────────────────────────
70
+ class _SlhBackend:
71
+ """Lazy, process-wide binding to OpenSSL's SLH-DSA-SHA2-128s verify."""
72
+
73
+ _ALG = b"SLH-DSA-SHA2-128s"
74
+
75
+ def __init__(self) -> None:
76
+ self._lib = None
77
+
78
+ def _load(self):
79
+ if self._lib is not None:
80
+ return self._lib
81
+ name = ctypes.util.find_library("crypto") or "libcrypto.so.3"
82
+ lib = ctypes.CDLL(name)
83
+
84
+ lib.EVP_PKEY_new_raw_public_key_ex.restype = ctypes.c_void_p
85
+ lib.EVP_PKEY_new_raw_public_key_ex.argtypes = [
86
+ ctypes.c_void_p, ctypes.c_char_p, ctypes.c_void_p,
87
+ ctypes.c_char_p, ctypes.c_size_t,
88
+ ]
89
+ lib.EVP_PKEY_free.restype = None
90
+ lib.EVP_PKEY_free.argtypes = [ctypes.c_void_p]
91
+ lib.EVP_MD_CTX_new.restype = ctypes.c_void_p
92
+ lib.EVP_MD_CTX_new.argtypes = []
93
+ lib.EVP_MD_CTX_free.restype = None
94
+ lib.EVP_MD_CTX_free.argtypes = [ctypes.c_void_p]
95
+ lib.EVP_DigestVerifyInit_ex.restype = ctypes.c_int
96
+ lib.EVP_DigestVerifyInit_ex.argtypes = [
97
+ ctypes.c_void_p, ctypes.c_void_p, ctypes.c_char_p, ctypes.c_void_p,
98
+ ctypes.c_char_p, ctypes.c_void_p, ctypes.c_void_p,
99
+ ]
100
+ lib.EVP_DigestVerify.restype = ctypes.c_int
101
+ lib.EVP_DigestVerify.argtypes = [
102
+ ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t,
103
+ ctypes.c_char_p, ctypes.c_size_t,
104
+ ]
105
+ self._lib = lib
106
+ return lib
107
+
108
+ def verify(self, pub: bytes, sig: bytes, msg: bytes) -> bool:
109
+ if len(pub) != SLH_DSA_SHA2_128S_PUB_LEN or len(sig) != SLH_DSA_SHA2_128S_SIG_LEN:
110
+ return False
111
+ try:
112
+ lib = self._load()
113
+ except Exception:
114
+ return False
115
+ pkey = None
116
+ ctx = None
117
+ try:
118
+ pkey = lib.EVP_PKEY_new_raw_public_key_ex(
119
+ None, self._ALG, None, pub, len(pub)
120
+ )
121
+ if not pkey:
122
+ return False
123
+ ctx = lib.EVP_MD_CTX_new()
124
+ if not ctx:
125
+ return False
126
+ if lib.EVP_DigestVerifyInit_ex(ctx, None, None, None, None, pkey, None) != 1:
127
+ return False
128
+ return lib.EVP_DigestVerify(ctx, sig, len(sig), msg, len(msg)) == 1
129
+ except Exception:
130
+ return False
131
+ finally:
132
+ if ctx:
133
+ lib.EVP_MD_CTX_free(ctx)
134
+ if pkey:
135
+ lib.EVP_PKEY_free(pkey)
136
+
137
+
138
+ _slh = _SlhBackend()
139
+
140
+
141
+ def slh_dsa_sha2_128s_verify(pub: bytes, sig: bytes, msg: bytes) -> bool:
142
+ """Verify an SLH-DSA-SHA2-128s (FIPS 205) signature; fail closed."""
143
+ return _slh.verify(pub, sig, msg)
144
+
145
+
146
+ def slh_available() -> bool:
147
+ """True iff the OpenSSL SLH-DSA verify path is usable in this environment."""
148
+ try:
149
+ _slh._load()
150
+ return True
151
+ except Exception:
152
+ return False
ainra/_merkle.py ADDED
@@ -0,0 +1,76 @@
1
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
2
+ """RFC 6962 Merkle hashing (decision D-008): SHA-256 with domain separation.
3
+
4
+ Leaf hash is ``SHA-256(0x00 || data)``; interior node is
5
+ ``SHA-256(0x01 || left || right)``. Inclusion is verified by the standard
6
+ RFC 6962 §2.1.1 audit-path walk. Written independently from RFC 6962.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+
13
+
14
+ def leaf_hash(data: bytes) -> bytes:
15
+ return hashlib.sha256(b"\x00" + data).digest()
16
+
17
+
18
+ def _node_hash(left: bytes, right: bytes) -> bytes:
19
+ return hashlib.sha256(b"\x01" + left + right).digest()
20
+
21
+
22
+ def root_from_audit_path(leaf: bytes, index: object, proof: list[bytes]) -> bytes | None:
23
+ """Reconstruct the tree root from an audit path using only the leaf index.
24
+
25
+ At each level the proof sibling is combined left/right by the current index
26
+ bit (``index & 1``), then the index is shifted down — the method AINRA's
27
+ logs use to emit inclusion proofs. Returns ``None`` on a structurally invalid
28
+ input (fail closed). The caller compares the result to the credential's
29
+ committed ``log.root``.
30
+ """
31
+ if not isinstance(index, int) or index < 0:
32
+ return None
33
+ h = leaf
34
+ i = index
35
+ for sibling in proof:
36
+ if len(sibling) != 32:
37
+ return None
38
+ if i & 1:
39
+ h = _node_hash(sibling, h)
40
+ else:
41
+ h = _node_hash(h, sibling)
42
+ i >>= 1
43
+ return h
44
+
45
+
46
+ def root_from_inclusion(
47
+ leaf: bytes, index: int, tree_size: int, proof: list[bytes]
48
+ ) -> bytes | None:
49
+ """Reconstruct the tree root from an RFC 6962 inclusion proof.
50
+
51
+ Returns the computed root hash, or ``None`` if the (index, size, proof) are
52
+ structurally inconsistent. The caller compares the result to the signed
53
+ checkpoint root — a mismatch (or ``None``) is fail-closed ``not_logged``.
54
+ """
55
+ if index < 0 or tree_size <= 0 or index >= tree_size:
56
+ return None
57
+ fn = index
58
+ sn = tree_size - 1
59
+ h = leaf
60
+ for sibling in proof:
61
+ if len(sibling) != 32:
62
+ return None
63
+ if fn == sn or (fn & 1) == 1:
64
+ h = _node_hash(sibling, h)
65
+ # Ascend past every left-child ancestor.
66
+ while fn != 0 and (fn & 1) == 0:
67
+ fn >>= 1
68
+ sn >>= 1
69
+ else:
70
+ h = _node_hash(h, sibling)
71
+ fn >>= 1
72
+ sn >>= 1
73
+ # A well-formed proof consumes exactly enough siblings to reach the root.
74
+ if sn != 0:
75
+ return None
76
+ return h
ainra/_name.py ADDED
@@ -0,0 +1,65 @@
1
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
2
+ """AINRA name grammar (The Standard §2).
3
+
4
+ ``ainra:{registrar}:{operator}:{lineage}@{version}`` where each label is
5
+ lowercase alphanumerics and hyphens, and ``version`` is a 1–3 field numeric
6
+ semver. The issuer is a version-less DID: ``did:ainra:{registrar}:{operator}:{lineage}``.
7
+ Written from the grammar; the ``name-malformed`` vectors are the ground truth.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass
14
+
15
+ # A label is one or more lowercase alphanumerics / hyphens.
16
+ _LABEL = re.compile(r"^[a-z0-9-]+$")
17
+ # semver: 1 to 3 dot-separated numeric fields.
18
+ _VERSION = re.compile(r"^[0-9]+(\.[0-9]+){0,2}$")
19
+
20
+
21
+ def _label_ok(s: str) -> bool:
22
+ return bool(_LABEL.match(s))
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SubjectName:
27
+ registrar: str
28
+ operator: str
29
+ lineage: str
30
+ version: str
31
+
32
+ @property
33
+ def number(self) -> str:
34
+ """The permanent version-less AINRA Number (a ``did:ainra`` DID)."""
35
+ return f"did:ainra:{self.registrar}:{self.operator}:{self.lineage}"
36
+
37
+
38
+ def parse_subject(name: object) -> SubjectName | None:
39
+ """Parse ``ainra:reg:op:lin@ver``; ``None`` if it violates the grammar."""
40
+ if not isinstance(name, str) or "@" not in name:
41
+ return None
42
+ body, _, version = name.partition("@")
43
+ if not _VERSION.match(version):
44
+ return None
45
+ parts = body.split(":")
46
+ if len(parts) != 4 or parts[0] != "ainra":
47
+ return None
48
+ reg, op, lin = parts[1], parts[2], parts[3]
49
+ if not (_label_ok(reg) and _label_ok(op) and _label_ok(lin)):
50
+ return None
51
+ return SubjectName(reg, op, lin, version)
52
+
53
+
54
+ def parse_issuer(iss: object) -> str | None:
55
+ """Parse ``did:ainra:reg:op:lin`` and return the registrar id, else ``None``."""
56
+ if not isinstance(iss, str):
57
+ return None
58
+ parts = iss.split(":")
59
+ # ["did", "ainra", reg, op, lin]
60
+ if len(parts) != 5 or parts[0] != "did" or parts[1] != "ainra":
61
+ return None
62
+ reg, op, lin = parts[2], parts[3], parts[4]
63
+ if not (_label_ok(reg) and _label_ok(op) and _label_ok(lin)):
64
+ return None
65
+ return reg
@@ -0,0 +1,54 @@
1
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
2
+ """Differential runner: the fourth column of ``make diff``.
3
+
4
+ Reads a directory of conformance vectors and prints, one line per vector,
5
+ ``<name>\\t<canonical-result-json>`` — the verdict+reason (passport), or the
6
+ accept+reason/registrars (delta / directory). ``tools/diff-harness/run.mjs``
7
+ spawns this once per corpus and asserts every line agrees with the vector's
8
+ recorded ``expect`` (which is the Rust core's verdict), so the Python verifier
9
+ joins the core / TS-SDK / JS-CLI differential as an independent fourth brain.
10
+
11
+ Usage: python -m ainra._vector_runner <passport|delta|directory> <dir>
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import sys
19
+
20
+ from .delta import verify_delta_vector
21
+ from .directory import verify_directory
22
+ from .verify import verify as verify_passport
23
+
24
+
25
+ def _stable(obj) -> str:
26
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"))
27
+
28
+
29
+ def run(kind: str, directory: str) -> int:
30
+ files = sorted(f for f in os.listdir(directory) if f.endswith(".json") and f != "manifest.json")
31
+ out = []
32
+ for f in files:
33
+ with open(os.path.join(directory, f), encoding="utf-8") as fh:
34
+ v = json.load(fh)
35
+ if kind == "passport":
36
+ pres = v["presentation"]
37
+ result = verify_passport(v["anchors"], pres, pres.get("now")).as_result()
38
+ elif kind == "delta":
39
+ result = verify_delta_vector(v)
40
+ elif kind == "directory":
41
+ result = verify_directory(v)
42
+ else:
43
+ print(f"unknown kind: {kind}", file=sys.stderr)
44
+ return 2
45
+ out.append(f"{v['name']}\t{_stable(result)}")
46
+ sys.stdout.write("\n".join(out) + ("\n" if out else ""))
47
+ return 0
48
+
49
+
50
+ if __name__ == "__main__":
51
+ if len(sys.argv) != 3:
52
+ print(__doc__, file=sys.stderr)
53
+ sys.exit(2)
54
+ sys.exit(run(sys.argv[1], sys.argv[2]))