bindable 0.1.2__tar.gz → 0.1.4__tar.gz
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.
- {bindable-0.1.2 → bindable-0.1.4}/PKG-INFO +1 -1
- bindable-0.1.4/bindable/__init__.py +33 -0
- bindable-0.1.4/bindable/_version.py +1 -0
- {bindable-0.1.2 → bindable-0.1.4}/bindable/client.py +43 -5
- bindable-0.1.4/bindable/decision.py +75 -0
- {bindable-0.1.2 → bindable-0.1.4}/pyproject.toml +1 -1
- bindable-0.1.4/tests/test_decision_from.py +104 -0
- bindable-0.1.4/tests/test_stage.py +152 -0
- {bindable-0.1.2 → bindable-0.1.4}/tests/test_version.py +1 -1
- bindable-0.1.2/bindable/__init__.py +0 -22
- bindable-0.1.2/bindable/_version.py +0 -1
- {bindable-0.1.2 → bindable-0.1.4}/.gitignore +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/LICENSE +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/NOTICE +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/README.md +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/bindable/_canonical_record.py +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/bindable/crypto/__init__.py +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/bindable/crypto/canonical.py +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/bindable/crypto/hash.py +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/bindable/crypto/keys.py +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/bindable/crypto/sign.py +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/bindable/errors.py +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/bindable/py.typed +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/bindable/verify.py +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/tests/__init__.py +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/tests/crypto/sign_vectors.json +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/tests/crypto/vectors.json +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/tests/fixtures/KEY_PROVENANCE.md +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/tests/fixtures/bindable-test-rc4-2026-08.pem +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/tests/fixtures/receipt_public_blocked_rc4.json +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/tests/fixtures/receipt_public_released_rc4.json +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/tests/test_canonical_vectors.py +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/tests/test_client.py +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/tests/test_signature_verify.py +0 -0
- {bindable-0.1.2 → bindable-0.1.4}/tests/test_verify_receipt.py +0 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Bindable — Python client for the hosted Bindable Action Gate.
|
|
2
|
+
|
|
3
|
+
Submit a Proposal to the Bindable Action Gate via ``ActionGate.decide()``,
|
|
4
|
+
receive an Outcome, and verify the returned Receipt offline with
|
|
5
|
+
``verify_receipt``. Derive the Decision — released or blocked — from the
|
|
6
|
+
signed Receipt with ``decision_from``. The client is pure transport; all
|
|
7
|
+
proof-rule evaluation happens on the hosted gate, and receipt verification
|
|
8
|
+
is fully offline.
|
|
9
|
+
|
|
10
|
+
Review resolves the governing Contract by stage. ``decide()`` takes an optional
|
|
11
|
+
``stage`` — one of ``STAGES`` — and omits the field entirely when it is not
|
|
12
|
+
given, leaving the platform's ``development`` default in force.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
from .client import STAGES, ActionGate
|
|
17
|
+
from .crypto import load_public_key
|
|
18
|
+
from .decision import BLOCKED, RELEASED, decision_from
|
|
19
|
+
from .errors import BindableAPIError
|
|
20
|
+
from .verify import ReceiptVerification, verify_receipt
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"BLOCKED",
|
|
24
|
+
"RELEASED",
|
|
25
|
+
"STAGES",
|
|
26
|
+
"ActionGate",
|
|
27
|
+
"BindableAPIError",
|
|
28
|
+
"ReceiptVerification",
|
|
29
|
+
"__version__",
|
|
30
|
+
"decision_from",
|
|
31
|
+
"load_public_key",
|
|
32
|
+
"verify_receipt",
|
|
33
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.4"
|
|
@@ -14,12 +14,38 @@ from __future__ import annotations
|
|
|
14
14
|
import secrets
|
|
15
15
|
from collections.abc import Mapping
|
|
16
16
|
from types import TracebackType
|
|
17
|
-
from typing import Any
|
|
17
|
+
from typing import Any, Final
|
|
18
18
|
|
|
19
19
|
import httpx
|
|
20
20
|
|
|
21
21
|
from bindable.errors import BindableAPIError
|
|
22
22
|
|
|
23
|
+
#: The stages a Contract can be resolved at, as the platform names them.
|
|
24
|
+
#:
|
|
25
|
+
#: Review resolves the governing Contract BY STAGE. Omitting the field lets the
|
|
26
|
+
#: platform apply its own default, which is ``development`` — correct for
|
|
27
|
+
#: Sandbox work and wrong for a promoted Contract, which exists only at
|
|
28
|
+
#: ``production``. Callers targeting a promoted Contract must say so.
|
|
29
|
+
#:
|
|
30
|
+
#: Single-sourced here so that callers validating their own configuration —
|
|
31
|
+
#: an environment variable, a config file — check against the same tuple
|
|
32
|
+
#: rather than keeping a second copy that can drift.
|
|
33
|
+
STAGES: Final[tuple[str, ...]] = ("development", "test", "production")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _validated_stage(stage: str) -> str:
|
|
37
|
+
"""Return ``stage`` if the platform accepts it, else raise locally.
|
|
38
|
+
|
|
39
|
+
Checked here rather than sent hopefully: a bad stage is a caller's
|
|
40
|
+
configuration mistake, and a named ValueError at the call site is a better
|
|
41
|
+
answer than a 422 that has already cost a round trip.
|
|
42
|
+
"""
|
|
43
|
+
if stage not in STAGES:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"stage must be one of {', '.join(STAGES)}; got {stage!r}"
|
|
46
|
+
)
|
|
47
|
+
return stage
|
|
48
|
+
|
|
23
49
|
|
|
24
50
|
class ActionGate:
|
|
25
51
|
"""Thin, token-authenticated httpx client for the hosted Bindable Action Gate.
|
|
@@ -54,13 +80,15 @@ class ActionGate:
|
|
|
54
80
|
payload: Mapping[str, Any],
|
|
55
81
|
proposed_action_id: str,
|
|
56
82
|
nonce: str | None = None,
|
|
83
|
+
stage: str | None = None,
|
|
57
84
|
) -> dict[str, Any]:
|
|
58
85
|
"""Submit a Proposal to ``/v1/review`` and return the hosted Outcome dict.
|
|
59
86
|
|
|
60
|
-
Sends
|
|
61
|
-
payload, proposed_action_id, nonce) with bearer-token auth
|
|
62
|
-
|
|
63
|
-
|
|
87
|
+
Sends the five frozen wire fields (persistent_agent_id, action_type,
|
|
88
|
+
payload, proposed_action_id, nonce) with bearer-token auth, plus
|
|
89
|
+
``stage`` when — and only when — one is given. The Outcome is returned
|
|
90
|
+
verbatim; its ``receipt`` field (when present) can be passed to
|
|
91
|
+
``verify_receipt`` for offline verification.
|
|
64
92
|
|
|
65
93
|
The endpoint moved from ``/v1/decide`` to ``/v1/review`` in 0.1.2. The
|
|
66
94
|
platform keeps BOTH paths live for a deprecation window, so an older
|
|
@@ -74,11 +102,19 @@ class ActionGate:
|
|
|
74
102
|
proposed_action_id: The caller's id for this proposed action.
|
|
75
103
|
nonce: Replay-protection nonce; a fresh ``secrets.token_hex(16)``
|
|
76
104
|
is generated when omitted.
|
|
105
|
+
stage: Which stage to resolve the governing Contract at — one of
|
|
106
|
+
:data:`STAGES`. Omitted from the request entirely when None,
|
|
107
|
+
which leaves the platform's own default (``development``) in
|
|
108
|
+
force and keeps the body byte-identical to a pre-0.1.4 call.
|
|
109
|
+
A promoted Contract exists only at ``production``, so a caller
|
|
110
|
+
targeting one must pass it.
|
|
77
111
|
|
|
78
112
|
Returns:
|
|
79
113
|
The parsed Outcome JSON object as a dict.
|
|
80
114
|
|
|
81
115
|
Raises:
|
|
116
|
+
ValueError: If ``stage`` is given and is not one of :data:`STAGES`.
|
|
117
|
+
Raised before any request is sent.
|
|
82
118
|
BindableAPIError: If the gate returns a non-2xx response. The
|
|
83
119
|
error carries ``.status`` and the ``detail`` from the JSON body.
|
|
84
120
|
"""
|
|
@@ -91,6 +127,8 @@ class ActionGate:
|
|
|
91
127
|
"proposed_action_id": proposed_action_id,
|
|
92
128
|
"nonce": nonce,
|
|
93
129
|
}
|
|
130
|
+
if stage is not None:
|
|
131
|
+
body["stage"] = _validated_stage(stage)
|
|
94
132
|
response = self._client.post(
|
|
95
133
|
f"{self._base_url}/v1/review",
|
|
96
134
|
json=body,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The published derivation of a Decision from a signed Receipt.
|
|
2
|
+
|
|
3
|
+
The Action Review does not return released/blocked. Callers derive it from
|
|
4
|
+
the Receipt, by this rule and only this rule:
|
|
5
|
+
|
|
6
|
+
released iff verdict == "passed"
|
|
7
|
+
blocked for every other verdict, and for any Receipt that cannot be
|
|
8
|
+
obtained or read.
|
|
9
|
+
|
|
10
|
+
This is the CANONICAL, PUBLIC derivation. It is deliberately small enough
|
|
11
|
+
to reimplement in any language — TypeScript, Go, Rust, Java — in a few
|
|
12
|
+
lines, and any such reimplementation MUST behave identically:
|
|
13
|
+
|
|
14
|
+
// TypeScript, equivalent by construction
|
|
15
|
+
const decisionFrom = (r: any): "released" | "blocked" =>
|
|
16
|
+
r?.verdict === "passed" ? "released" : "blocked";
|
|
17
|
+
|
|
18
|
+
Two properties are load-bearing and must be preserved in every port:
|
|
19
|
+
|
|
20
|
+
1. The comparison is EXACT. ``"passed"`` releases; ``"Passed"``,
|
|
21
|
+
``"PASSED"``, ``" passed"``, and ``True`` do not. Leniency here would
|
|
22
|
+
let two implementations disagree about the same Receipt.
|
|
23
|
+
2. Blocked is the SAFE DEFAULT. A missing verdict, an unreadable Receipt,
|
|
24
|
+
a null, a non-mapping, or anything unexpected yields "blocked". This
|
|
25
|
+
function never raises and never returns "released" on ambiguity.
|
|
26
|
+
|
|
27
|
+
Reading the verdict is all this does. It performs no I/O, no
|
|
28
|
+
canonicalization, and no cryptography, and it does not establish that the
|
|
29
|
+
Receipt is authentic — pair it with ``verify_receipt`` when the Receipt's
|
|
30
|
+
integrity matters.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from collections.abc import Mapping
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
#: The Decision vocabulary. These exact strings are the wire words.
|
|
39
|
+
RELEASED = "released"
|
|
40
|
+
BLOCKED = "blocked"
|
|
41
|
+
|
|
42
|
+
#: The one verdict value that releases an action.
|
|
43
|
+
PASSED = "passed"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def decision_from(receipt: Mapping[str, Any] | None) -> str:
|
|
47
|
+
"""Derive the Decision from a Receipt: ``"released"`` or ``"blocked"``.
|
|
48
|
+
|
|
49
|
+
Returns ``"released"`` if and only if the Receipt's ``verdict`` field
|
|
50
|
+
is exactly ``"passed"``. Everything else — any other verdict, a
|
|
51
|
+
missing field, an unreadable or absent Receipt — is ``"blocked"``.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
receipt: A Receipt as a plain mapping, the same shape
|
|
55
|
+
``verify_receipt`` takes (for example ``outcome["receipt"]``
|
|
56
|
+
parsed from JSON). ``None`` and malformed values are accepted
|
|
57
|
+
and yield ``"blocked"``.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
``"released"`` or ``"blocked"``. Never raises.
|
|
61
|
+
|
|
62
|
+
Example:
|
|
63
|
+
>>> from bindable import decision_from, verify_receipt
|
|
64
|
+
>>> receipt = outcome["receipt"]
|
|
65
|
+
>>> if verify_receipt(receipt, public_key=pem).valid:
|
|
66
|
+
... if decision_from(receipt) == "released":
|
|
67
|
+
... run_the_action()
|
|
68
|
+
'released'
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
if not isinstance(receipt, Mapping):
|
|
72
|
+
return BLOCKED
|
|
73
|
+
return RELEASED if receipt.get("verdict") == PASSED else BLOCKED
|
|
74
|
+
except Exception: # noqa: BLE001 - fail-closed: never raise, never release
|
|
75
|
+
return BLOCKED
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""decision_from — the published released/blocked derivation.
|
|
2
|
+
|
|
3
|
+
This rule is reimplemented by counterparties in other languages, so these
|
|
4
|
+
tests pin it precisely: exactly one verdict releases, everything else
|
|
5
|
+
blocks, and nothing raises.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from bindable import BLOCKED, RELEASED, decision_from
|
|
17
|
+
|
|
18
|
+
_FIX = Path(__file__).parent / "fixtures"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ─── The rule ──────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_passed_verdict_releases() -> None:
|
|
25
|
+
assert decision_from({"verdict": "passed"}) == "released"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.mark.parametrize("verdict", ["failed", "error", "exception"])
|
|
29
|
+
def test_every_other_verdict_blocks(verdict: str) -> None:
|
|
30
|
+
assert decision_from({"verdict": verdict}) == "blocked"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_missing_verdict_key_blocks() -> None:
|
|
34
|
+
"""Fail-closed: an absent verdict is never a release."""
|
|
35
|
+
assert decision_from({}) == "blocked"
|
|
36
|
+
assert decision_from({"receipt_id": "r1", "chain_hash": "ab" * 32}) == "blocked"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.parametrize("receipt", [
|
|
40
|
+
None,
|
|
41
|
+
"not a receipt",
|
|
42
|
+
["not", "a", "receipt"],
|
|
43
|
+
42,
|
|
44
|
+
object(),
|
|
45
|
+
])
|
|
46
|
+
def test_unreadable_receipt_blocks_without_raising(receipt: Any) -> None:
|
|
47
|
+
"""'Any Receipt that cannot be obtained or read' -> blocked."""
|
|
48
|
+
assert decision_from(receipt) == "blocked"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.parametrize("verdict", ["Passed", "PASSED", " passed", "passed ", True, 1, None])
|
|
52
|
+
def test_comparison_is_exact(verdict: Any) -> None:
|
|
53
|
+
"""Near-misses must NOT release — leniency would let ports disagree."""
|
|
54
|
+
assert decision_from({"verdict": verdict}) == "blocked"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_only_ever_returns_the_two_wire_words() -> None:
|
|
58
|
+
for receipt in ({"verdict": "passed"}, {"verdict": "failed"}, {}, None):
|
|
59
|
+
assert decision_from(receipt) in {RELEASED, BLOCKED}
|
|
60
|
+
assert (RELEASED, BLOCKED) == ("released", "blocked")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_a_verdict_whose_equality_explodes_still_blocks() -> None:
|
|
64
|
+
"""The function never raises, whatever it is handed."""
|
|
65
|
+
|
|
66
|
+
class Hostile:
|
|
67
|
+
def __eq__(self, other: object) -> bool:
|
|
68
|
+
raise RuntimeError("boom")
|
|
69
|
+
|
|
70
|
+
assert decision_from({"verdict": Hostile()}) == "blocked"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ─── Against real minted Receipts ──────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.parametrize("filename,expected", [
|
|
77
|
+
("receipt_public_released_rc4.json", "released"),
|
|
78
|
+
("receipt_public_blocked_rc4.json", "blocked"),
|
|
79
|
+
])
|
|
80
|
+
def test_real_minted_receipts_derive_the_right_decision(
|
|
81
|
+
filename: str, expected: str
|
|
82
|
+
) -> None:
|
|
83
|
+
receipt = json.loads((_FIX / filename).read_text(encoding="utf-8"))
|
|
84
|
+
assert decision_from(receipt) == expected
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_the_derivation_tracks_the_verdict_field_specifically() -> None:
|
|
88
|
+
"""A released Receipt whose verdict is altered derives blocked.
|
|
89
|
+
|
|
90
|
+
(Altering it also breaks the chain hash — that is verify_receipt's job,
|
|
91
|
+
not this function's. decision_from reads the verdict, nothing more.)
|
|
92
|
+
"""
|
|
93
|
+
receipt = json.loads(
|
|
94
|
+
(_FIX / "receipt_public_released_rc4.json").read_text(encoding="utf-8")
|
|
95
|
+
)
|
|
96
|
+
assert decision_from(receipt) == "released"
|
|
97
|
+
receipt["verdict"] = "failed"
|
|
98
|
+
assert decision_from(receipt) == "blocked"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_derivation_does_not_depend_on_status_or_failed_rule() -> None:
|
|
102
|
+
"""Only the verdict decides — no other field participates."""
|
|
103
|
+
assert decision_from({"verdict": "passed", "failed_rule": "r-2"}) == "released"
|
|
104
|
+
assert decision_from({"verdict": "failed", "status": "released"}) == "blocked"
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""The stage parameter: sent when given, absent when not, refused when wrong.
|
|
2
|
+
|
|
3
|
+
Review resolves the governing Contract BY STAGE, and a promoted Contract exists
|
|
4
|
+
only at ``production``. A client that cannot say which stage it means can only
|
|
5
|
+
ever reach the platform's default, so these tests pin three things: that a
|
|
6
|
+
given stage reaches the wire, that an omitted one leaves the request
|
|
7
|
+
byte-identical to a pre-0.1.4 call, and that a wrong one is refused here rather
|
|
8
|
+
than spent on a round trip.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
from bindable import STAGES, ActionGate
|
|
20
|
+
|
|
21
|
+
_BASE_URL = "https://gate.example.com"
|
|
22
|
+
_TOKEN = "test-token"
|
|
23
|
+
|
|
24
|
+
_OUTCOME: dict[str, Any] = {
|
|
25
|
+
"status": "released",
|
|
26
|
+
"verdict": "passed",
|
|
27
|
+
"reason": None,
|
|
28
|
+
"reason_code": None,
|
|
29
|
+
"receipt": None,
|
|
30
|
+
"action_result": None,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_FROZEN_FIVE = {
|
|
34
|
+
"persistent_agent_id",
|
|
35
|
+
"action_type",
|
|
36
|
+
"payload",
|
|
37
|
+
"proposed_action_id",
|
|
38
|
+
"nonce",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _capture(captured: dict[str, Any]) -> Any:
|
|
43
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
44
|
+
captured["body"] = json.loads(request.content)
|
|
45
|
+
captured["raw"] = request.content
|
|
46
|
+
return httpx.Response(200, json=_OUTCOME)
|
|
47
|
+
|
|
48
|
+
return handler
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _gate_with(handler: Any) -> ActionGate:
|
|
52
|
+
gate = ActionGate(base_url=_BASE_URL, token=_TOKEN)
|
|
53
|
+
gate._client.close()
|
|
54
|
+
gate._client = httpx.Client(transport=httpx.MockTransport(handler), timeout=5.0)
|
|
55
|
+
return gate
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _decide(gate: ActionGate, **extra: Any) -> dict[str, Any]:
|
|
59
|
+
return gate.decide(
|
|
60
|
+
persistent_agent_id="agent_1",
|
|
61
|
+
action_type="paid_data_call",
|
|
62
|
+
payload={"matter_id": "MER-2026-014", "call_cost_usd": 12},
|
|
63
|
+
proposed_action_id="claim_1",
|
|
64
|
+
nonce="fixednonce",
|
|
65
|
+
**extra,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ─── the vocabulary ────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_the_stages_are_the_three_the_platform_resolves_against() -> None:
|
|
73
|
+
assert STAGES == ("development", "test", "production")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ─── sent when given ───────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.parametrize("stage", STAGES)
|
|
80
|
+
def test_a_given_stage_reaches_the_wire(stage: str) -> None:
|
|
81
|
+
captured: dict[str, Any] = {}
|
|
82
|
+
with _gate_with(_capture(captured)) as gate:
|
|
83
|
+
_decide(gate, stage=stage)
|
|
84
|
+
|
|
85
|
+
assert captured["body"]["stage"] == stage
|
|
86
|
+
assert set(captured["body"]) == _FROZEN_FIVE | {"stage"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_production_is_reachable_because_a_promoted_contract_lives_only_there() -> None:
|
|
90
|
+
"""The reason this parameter exists, stated as a test."""
|
|
91
|
+
captured: dict[str, Any] = {}
|
|
92
|
+
with _gate_with(_capture(captured)) as gate:
|
|
93
|
+
_decide(gate, stage="production")
|
|
94
|
+
|
|
95
|
+
assert captured["body"]["stage"] == "production"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ─── omitted when None ─────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_an_omitted_stage_is_absent_from_the_wire_entirely() -> None:
|
|
102
|
+
"""Not null, not empty — absent. The platform's default stays in force."""
|
|
103
|
+
captured: dict[str, Any] = {}
|
|
104
|
+
with _gate_with(_capture(captured)) as gate:
|
|
105
|
+
_decide(gate)
|
|
106
|
+
|
|
107
|
+
assert "stage" not in captured["body"]
|
|
108
|
+
assert set(captured["body"]) == _FROZEN_FIVE
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_omitting_the_stage_is_byte_identical_to_passing_none() -> None:
|
|
112
|
+
"""Every pre-0.1.4 caller is untouched: same bytes, not merely same fields."""
|
|
113
|
+
without: dict[str, Any] = {}
|
|
114
|
+
explicit_none: dict[str, Any] = {}
|
|
115
|
+
|
|
116
|
+
with _gate_with(_capture(without)) as gate:
|
|
117
|
+
_decide(gate)
|
|
118
|
+
with _gate_with(_capture(explicit_none)) as gate:
|
|
119
|
+
_decide(gate, stage=None)
|
|
120
|
+
|
|
121
|
+
assert without["raw"] == explicit_none["raw"]
|
|
122
|
+
assert b"stage" not in without["raw"]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ─── refused when invalid ──────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@pytest.mark.parametrize(
|
|
129
|
+
"bad", ["prod", "Production", "PRODUCTION", "staging", "dev", "", " development"]
|
|
130
|
+
)
|
|
131
|
+
def test_an_invalid_stage_is_refused_locally(bad: str) -> None:
|
|
132
|
+
"""A configuration mistake, answered here — not spent on a 422."""
|
|
133
|
+
sent: list[Any] = []
|
|
134
|
+
|
|
135
|
+
def handler(request: httpx.Request) -> httpx.Response: # pragma: no cover
|
|
136
|
+
sent.append(request)
|
|
137
|
+
return httpx.Response(200, json=_OUTCOME)
|
|
138
|
+
|
|
139
|
+
with _gate_with(handler) as gate, pytest.raises(ValueError) as excinfo:
|
|
140
|
+
_decide(gate, stage=bad)
|
|
141
|
+
|
|
142
|
+
assert sent == [] # nothing left the process
|
|
143
|
+
message = str(excinfo.value)
|
|
144
|
+
assert repr(bad) in message
|
|
145
|
+
for allowed in STAGES:
|
|
146
|
+
assert allowed in message # the error names what IS allowed
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_the_refusal_happens_before_the_request_is_built() -> None:
|
|
150
|
+
"""No partial send, no retry ambiguity: the call never starts."""
|
|
151
|
+
with _gate_with(_capture({})) as gate, pytest.raises(ValueError, match="stage must be one of"):
|
|
152
|
+
_decide(gate, stage="nope")
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
"""Bindable — Python client for the hosted Bindable Action Gate.
|
|
2
|
-
|
|
3
|
-
Submit a Proposal to the Bindable Action Gate via ``ActionGate.decide()``,
|
|
4
|
-
receive a Decision (the Outcome), and verify the returned Receipt offline with
|
|
5
|
-
``verify_receipt``. The client is pure transport; all proof-rule evaluation
|
|
6
|
-
happens on the hosted gate, and receipt verification is fully offline.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from ._version import __version__
|
|
10
|
-
from .client import ActionGate
|
|
11
|
-
from .crypto import load_public_key
|
|
12
|
-
from .errors import BindableAPIError
|
|
13
|
-
from .verify import ReceiptVerification, verify_receipt
|
|
14
|
-
|
|
15
|
-
__all__ = [
|
|
16
|
-
"ActionGate",
|
|
17
|
-
"BindableAPIError",
|
|
18
|
-
"ReceiptVerification",
|
|
19
|
-
"__version__",
|
|
20
|
-
"load_public_key",
|
|
21
|
-
"verify_receipt",
|
|
22
|
-
]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|