colvo 0.1.0__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.
- colvo-0.1.0/PKG-INFO +35 -0
- colvo-0.1.0/README.md +22 -0
- colvo-0.1.0/colvo/__init__.py +8 -0
- colvo-0.1.0/colvo/client.py +180 -0
- colvo-0.1.0/colvo.egg-info/PKG-INFO +35 -0
- colvo-0.1.0/colvo.egg-info/SOURCES.txt +9 -0
- colvo-0.1.0/colvo.egg-info/dependency_links.txt +1 -0
- colvo-0.1.0/colvo.egg-info/top_level.txt +1 -0
- colvo-0.1.0/pyproject.toml +19 -0
- colvo-0.1.0/setup.cfg +4 -0
- colvo-0.1.0/tests/test_client.py +74 -0
colvo-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: colvo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: COLVO SDK — propose agent actions through Guard, report observations, read decisions. Standard library only.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://colvo.app/docs/move-to-guard
|
|
7
|
+
Keywords: colvo,ai agents,guardrails,stripe,refunds,human in the loop
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# colvo (Python)
|
|
15
|
+
|
|
16
|
+
The agent proposes, COLVO decides (ALLOW / REVIEW / HOLD / DENY), executes exactly once and verifies against the provider.
|
|
17
|
+
Standard library only, Python 3.9+. Guide: https://colvo.app/docs/move-to-guard
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import os
|
|
21
|
+
from colvo import Colvo, ColvoError, message_for
|
|
22
|
+
|
|
23
|
+
colvo = Colvo(os.environ["COLVO_AGENT_KEY"])
|
|
24
|
+
op = colvo.operations.propose({
|
|
25
|
+
"mandate_id": mandate_id, "source_request_id": ticket_id, "action": "refund.create",
|
|
26
|
+
"parameters": {"payment_id": "pi_…", "amount_minor": 5000, "currency": "eur"},
|
|
27
|
+
})
|
|
28
|
+
reply(message_for(op))
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- `operations.propose(proposal, idempotency_key=None)` — safe retries with a deterministic idempotency key.
|
|
32
|
+
- `operations.get(id)`, `operations.wait_for(id, until="verified")`, `observations.report(obs)`, `mandates.create(m)` (backend key).
|
|
33
|
+
- Errors raise `ColvoError(status, code, message, details)`.
|
|
34
|
+
|
|
35
|
+
Tests: `python3 -m unittest discover -s sdks/python/tests`.
|
colvo-0.1.0/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# colvo (Python)
|
|
2
|
+
|
|
3
|
+
The agent proposes, COLVO decides (ALLOW / REVIEW / HOLD / DENY), executes exactly once and verifies against the provider.
|
|
4
|
+
Standard library only, Python 3.9+. Guide: https://colvo.app/docs/move-to-guard
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
import os
|
|
8
|
+
from colvo import Colvo, ColvoError, message_for
|
|
9
|
+
|
|
10
|
+
colvo = Colvo(os.environ["COLVO_AGENT_KEY"])
|
|
11
|
+
op = colvo.operations.propose({
|
|
12
|
+
"mandate_id": mandate_id, "source_request_id": ticket_id, "action": "refund.create",
|
|
13
|
+
"parameters": {"payment_id": "pi_…", "amount_minor": 5000, "currency": "eur"},
|
|
14
|
+
})
|
|
15
|
+
reply(message_for(op))
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- `operations.propose(proposal, idempotency_key=None)` — safe retries with a deterministic idempotency key.
|
|
19
|
+
- `operations.get(id)`, `operations.wait_for(id, until="verified")`, `observations.report(obs)`, `mandates.create(m)` (backend key).
|
|
20
|
+
- Errors raise `ColvoError(status, code, message, details)`.
|
|
21
|
+
|
|
22
|
+
Tests: `python3 -m unittest discover -s sdks/python/tests`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""COLVO SDK — the agent proposes, COLVO decides, executes exactly once and verifies.
|
|
2
|
+
|
|
3
|
+
Standard library only (Python 3.9+). Docs: https://colvo.app/docs/move-to-guard
|
|
4
|
+
"""
|
|
5
|
+
from .client import Colvo, ColvoError, idempotency_key_for, message_for
|
|
6
|
+
|
|
7
|
+
__all__ = ["Colvo", "ColvoError", "idempotency_key_for", "message_for"]
|
|
8
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any, Callable, Dict, Optional
|
|
9
|
+
|
|
10
|
+
Json = Dict[str, Any]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ColvoError(Exception):
|
|
14
|
+
"""An API error: ``status`` (0 = network), ``code`` (e.g. ``business_key_conflict``), ``details``."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, status: int, code: str, message: str, details: Optional[Json] = None):
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.status = status
|
|
19
|
+
self.code = code
|
|
20
|
+
self.message = message
|
|
21
|
+
self.details = details or {}
|
|
22
|
+
|
|
23
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
24
|
+
return f"ColvoError(status={self.status}, code={self.code!r}, message={self.message!r})"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def idempotency_key_for(proposal: Json) -> str:
|
|
28
|
+
"""Same effect -> same key: a retry returns the original operation instead of doubling it."""
|
|
29
|
+
p = proposal.get("parameters", {})
|
|
30
|
+
target = p.get("payment_id") or p.get("subscription_id") or "target"
|
|
31
|
+
if proposal["action"] == "refund.create":
|
|
32
|
+
detail = f"{p.get('amount_minor')}{p.get('currency', '')}"
|
|
33
|
+
else:
|
|
34
|
+
detail = p.get("mode", "period_end")
|
|
35
|
+
return f"{proposal['source_request_id']}:{proposal['action']}:{target}:{detail}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], "tuple[int, Dict[str, str], bytes]"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: float):
|
|
42
|
+
req = urllib.request.Request(url, data=body, method=method, headers=headers)
|
|
43
|
+
try:
|
|
44
|
+
with urllib.request.urlopen(req, timeout=timeout) as res: # noqa: S310 (https base URL by default)
|
|
45
|
+
return res.status, dict(res.headers), res.read()
|
|
46
|
+
except urllib.error.HTTPError as e:
|
|
47
|
+
return e.code, dict(e.headers or {}), e.read()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _Section:
|
|
51
|
+
def __init__(self, client: "Colvo"):
|
|
52
|
+
self._c = client
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _Mandates(_Section):
|
|
56
|
+
def create(self, mandate: Json) -> Json:
|
|
57
|
+
"""Your trusted backend registers authority (backend key). Not retried: a mandate is not idempotent."""
|
|
58
|
+
return self._c._request("POST", "/v1/mandates", body=mandate)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _Operations(_Section):
|
|
62
|
+
def propose(self, proposal: Json, idempotency_key: Optional[str] = None) -> Json:
|
|
63
|
+
"""The agent proposes; the response carries the decision. Safe to retry (same effect, same key)."""
|
|
64
|
+
return self._c._request("POST", "/v1/operations", body=proposal, idempotency_key=idempotency_key or idempotency_key_for(proposal), retry=True)
|
|
65
|
+
|
|
66
|
+
def get(self, operation_id: str) -> Json:
|
|
67
|
+
return self._c._request("GET", f"/v1/operations/{urllib.parse.quote(operation_id)}", retry=True)
|
|
68
|
+
|
|
69
|
+
def wait_for(self, operation_id: str, until: str = "verified", timeout: float = 60.0, interval: float = 1.0) -> Json:
|
|
70
|
+
"""Poll until ``decided`` | ``executed`` | ``verified``; returns the last state on timeout."""
|
|
71
|
+
deadline = time.monotonic() + timeout
|
|
72
|
+
while True:
|
|
73
|
+
op = self.get(operation_id)
|
|
74
|
+
if _reached(op, until) or time.monotonic() >= deadline:
|
|
75
|
+
return op
|
|
76
|
+
time.sleep(interval)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _Observations(_Section):
|
|
80
|
+
def report(self, observation: Json) -> Json:
|
|
81
|
+
"""Observation mode: report an action the agent took itself (or chose not to). Not retried."""
|
|
82
|
+
return self._c._request("POST", "/v1/observations", body=observation)
|
|
83
|
+
|
|
84
|
+
def get(self, observation_id: str) -> Json:
|
|
85
|
+
return self._c._request("GET", f"/v1/observations/{urllib.parse.quote(observation_id)}", retry=True)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Colvo:
|
|
89
|
+
"""``Colvo(api_key="cak_...")``. Backend key for mandates, agent key for proposals and observations."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, api_key: str, base_url: str = "https://colvo.app", timeout: float = 15.0, max_retries: int = 2, transport: Optional[Transport] = None):
|
|
92
|
+
if not api_key:
|
|
93
|
+
raise ValueError("Colvo: api_key is required")
|
|
94
|
+
self._key = api_key
|
|
95
|
+
self._base = base_url.rstrip("/")
|
|
96
|
+
self._timeout = timeout
|
|
97
|
+
self._max_retries = max_retries
|
|
98
|
+
self._transport = transport or _urllib_transport
|
|
99
|
+
self.mandates = _Mandates(self)
|
|
100
|
+
self.operations = _Operations(self)
|
|
101
|
+
self.observations = _Observations(self)
|
|
102
|
+
|
|
103
|
+
def _request(self, method: str, path: str, body: Optional[Json] = None, idempotency_key: Optional[str] = None, retry: bool = False) -> Json:
|
|
104
|
+
headers = {"authorization": f"Bearer {self._key}", "accept": "application/json", "user-agent": "colvo-sdk-python/0.1.0"}
|
|
105
|
+
data = None
|
|
106
|
+
if body is not None:
|
|
107
|
+
headers["content-type"] = "application/json"
|
|
108
|
+
data = json.dumps(body).encode()
|
|
109
|
+
if idempotency_key:
|
|
110
|
+
headers["idempotency-key"] = idempotency_key
|
|
111
|
+
retries = self._max_retries if retry else 0
|
|
112
|
+
attempt = 0
|
|
113
|
+
while True:
|
|
114
|
+
try:
|
|
115
|
+
status, res_headers, raw = self._transport(method, f"{self._base}{path}", headers, data, self._timeout)
|
|
116
|
+
except Exception as e: # network error, timeout
|
|
117
|
+
if attempt < retries:
|
|
118
|
+
time.sleep(0.3 * 2 ** attempt)
|
|
119
|
+
attempt += 1
|
|
120
|
+
continue
|
|
121
|
+
raise ColvoError(0, "network_error", f"COLVO unreachable: {e}") from e
|
|
122
|
+
try:
|
|
123
|
+
payload = json.loads(raw.decode() or "{}")
|
|
124
|
+
except ValueError:
|
|
125
|
+
payload = {"message": raw.decode(errors="replace")}
|
|
126
|
+
if 200 <= status < 300:
|
|
127
|
+
return payload
|
|
128
|
+
if (status == 429 or status >= 500) and attempt < retries:
|
|
129
|
+
wait = res_headers.get("retry-after") or res_headers.get("Retry-After")
|
|
130
|
+
time.sleep(float(wait) if wait else 0.3 * 2 ** attempt)
|
|
131
|
+
attempt += 1
|
|
132
|
+
continue
|
|
133
|
+
code = payload.pop("error", f"http_{status}")
|
|
134
|
+
message = payload.pop("message", f"HTTP {status}")
|
|
135
|
+
raise ColvoError(status, code, message, payload)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _reached(op: Json, until: str) -> bool:
|
|
139
|
+
approval = op.get("approval") or {}
|
|
140
|
+
if op.get("decision") in ("DENY", "HOLD"):
|
|
141
|
+
return True
|
|
142
|
+
if approval and approval.get("decision") not in ("PENDING", "APPROVED"):
|
|
143
|
+
return True
|
|
144
|
+
if until == "decided":
|
|
145
|
+
return op.get("decision") == "ALLOW" or approval.get("decision") == "APPROVED"
|
|
146
|
+
executed = op.get("exec_state") in ("ACKNOWLEDGED", "FAILED", "CANCELED_BEFORE_SEND", "PROVIDER_REVIEW")
|
|
147
|
+
if until == "executed":
|
|
148
|
+
return executed
|
|
149
|
+
return op.get("verify_state") in ("VERIFIED", "MISMATCH", "UNVERIFIABLE") or op.get("exec_state") in ("FAILED", "CANCELED_BEFORE_SEND")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _money(minor: Any, currency: Any) -> str:
|
|
153
|
+
cur = str(currency or "").lower()
|
|
154
|
+
amount = f"{(int(minor or 0)) / 100:.2f}"
|
|
155
|
+
return f"€{amount}" if cur == "eur" else f"{amount} {cur.upper()}".strip()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def message_for(op: Json, **overrides: str) -> str:
|
|
159
|
+
"""What the agent may honestly tell the customer. Map it in code, not in the prompt."""
|
|
160
|
+
refund = op.get("action") == "refund.create"
|
|
161
|
+
params = op.get("parameters") or {}
|
|
162
|
+
amt = _money(params.get("amount_minor"), params.get("currency"))
|
|
163
|
+
thing = f"{amt} refund" if refund else "cancellation"
|
|
164
|
+
approval = op.get("approval") or {}
|
|
165
|
+
decision, exec_state, verify = op.get("decision"), op.get("exec_state"), op.get("verify_state")
|
|
166
|
+
if approval.get("decision") == "REJECTED":
|
|
167
|
+
return overrides.get("rejected", f"A colleague reviewed your request and couldn’t approve the {thing} — they’ll follow up by email.")
|
|
168
|
+
if approval.get("decision") == "EXPIRED" or exec_state == "CANCELED_BEFORE_SEND":
|
|
169
|
+
return overrides.get("expired", "I’ve passed your request to a colleague; they’ll follow up by email.")
|
|
170
|
+
if decision == "DENY":
|
|
171
|
+
return overrides.get("deny", "I can’t make that refund myself — I’ve passed it to a colleague who will follow up." if refund else "I can’t make that change myself — I’ve passed it to a colleague who will follow up.")
|
|
172
|
+
if decision == "HOLD":
|
|
173
|
+
return overrides.get("hold", "I’ve logged your request; it will be processed shortly.")
|
|
174
|
+
if decision == "REVIEW" and approval.get("decision") == "PENDING":
|
|
175
|
+
return overrides.get("review", f"I’ve asked a colleague to approve your {thing} — you’ll get an email as soon as it’s done.")
|
|
176
|
+
if verify in ("MISMATCH", "UNVERIFIABLE") or exec_state == "FAILED":
|
|
177
|
+
return overrides.get("problem", f"There was a problem processing your {thing}; a colleague is looking into it.")
|
|
178
|
+
if verify == "VERIFIED":
|
|
179
|
+
return overrides.get("verified", f"Your {amt} refund is confirmed — it will reach your card in a few days." if refund else "Your cancellation is confirmed.")
|
|
180
|
+
return overrides.get("allow", f"Your {amt} refund is on its way back to your card." if refund else "Your cancellation is being processed.")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: colvo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: COLVO SDK — propose agent actions through Guard, report observations, read decisions. Standard library only.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://colvo.app/docs/move-to-guard
|
|
7
|
+
Keywords: colvo,ai agents,guardrails,stripe,refunds,human in the loop
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# colvo (Python)
|
|
15
|
+
|
|
16
|
+
The agent proposes, COLVO decides (ALLOW / REVIEW / HOLD / DENY), executes exactly once and verifies against the provider.
|
|
17
|
+
Standard library only, Python 3.9+. Guide: https://colvo.app/docs/move-to-guard
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import os
|
|
21
|
+
from colvo import Colvo, ColvoError, message_for
|
|
22
|
+
|
|
23
|
+
colvo = Colvo(os.environ["COLVO_AGENT_KEY"])
|
|
24
|
+
op = colvo.operations.propose({
|
|
25
|
+
"mandate_id": mandate_id, "source_request_id": ticket_id, "action": "refund.create",
|
|
26
|
+
"parameters": {"payment_id": "pi_…", "amount_minor": 5000, "currency": "eur"},
|
|
27
|
+
})
|
|
28
|
+
reply(message_for(op))
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- `operations.propose(proposal, idempotency_key=None)` — safe retries with a deterministic idempotency key.
|
|
32
|
+
- `operations.get(id)`, `operations.wait_for(id, until="verified")`, `observations.report(obs)`, `mandates.create(m)` (backend key).
|
|
33
|
+
- Errors raise `ColvoError(status, code, message, details)`.
|
|
34
|
+
|
|
35
|
+
Tests: `python3 -m unittest discover -s sdks/python/tests`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
colvo
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "colvo"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "COLVO SDK — propose agent actions through Guard, report observations, read decisions. Standard library only."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["colvo", "ai agents", "guardrails", "stripe", "refunds", "human in the loop"]
|
|
13
|
+
classifiers = ["Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://colvo.app/docs/move-to-guard"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools]
|
|
19
|
+
packages = ["colvo"]
|
colvo-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import unittest.mock
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
8
|
+
from colvo import Colvo, ColvoError, idempotency_key_for, message_for # noqa: E402
|
|
9
|
+
|
|
10
|
+
PROPOSE = {"mandate_id": "m", "source_request_id": "ticket_1", "action": "refund.create", "parameters": {"payment_id": "pi_1", "amount_minor": 5000, "currency": "eur"}}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def op(**kw):
|
|
14
|
+
base = {"operation_id": "op1", "action": "refund.create", "parameters": PROPOSE["parameters"], "decision": "ALLOW", "exec_state": "QUEUED", "verify_state": "NOT_DUE", "approval": None}
|
|
15
|
+
base.update(kw)
|
|
16
|
+
return base
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FakeTransport:
|
|
20
|
+
def __init__(self, responses):
|
|
21
|
+
self.responses = list(responses)
|
|
22
|
+
self.calls = []
|
|
23
|
+
|
|
24
|
+
def __call__(self, method, url, headers, body, timeout):
|
|
25
|
+
self.calls.append((method, url, headers, body))
|
|
26
|
+
r = self.responses.pop(0)
|
|
27
|
+
if isinstance(r, Exception):
|
|
28
|
+
raise r
|
|
29
|
+
status, payload = r
|
|
30
|
+
return status, {}, json.dumps(payload).encode()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ClientTest(unittest.TestCase):
|
|
34
|
+
def test_propose_retries_with_same_idempotency_key(self):
|
|
35
|
+
t = FakeTransport([(503, {"error": "internal_error"}), (202, op())])
|
|
36
|
+
c = Colvo("cak_x", base_url="https://c.test/", transport=t)
|
|
37
|
+
with unittest.mock.patch("time.sleep"):
|
|
38
|
+
r = c.operations.propose(PROPOSE)
|
|
39
|
+
self.assertEqual(r["decision"], "ALLOW")
|
|
40
|
+
self.assertEqual(len(t.calls), 2)
|
|
41
|
+
self.assertEqual(t.calls[0][1], "https://c.test/v1/operations")
|
|
42
|
+
self.assertEqual(t.calls[0][2]["authorization"], "Bearer cak_x")
|
|
43
|
+
self.assertEqual(t.calls[0][2]["idempotency-key"], "ticket_1:refund.create:pi_1:5000eur")
|
|
44
|
+
self.assertEqual(t.calls[1][2]["idempotency-key"], t.calls[0][2]["idempotency-key"])
|
|
45
|
+
|
|
46
|
+
def test_errors_are_typed_and_mandates_not_retried(self):
|
|
47
|
+
t = FakeTransport([(500, {"error": "internal_error", "message": "boom"})])
|
|
48
|
+
with self.assertRaises(ColvoError) as ctx:
|
|
49
|
+
Colvo("k", transport=t).mandates.create({"project_id": "p"})
|
|
50
|
+
self.assertEqual((ctx.exception.status, ctx.exception.code, ctx.exception.message), (500, "internal_error", "boom"))
|
|
51
|
+
self.assertEqual(len(t.calls), 1)
|
|
52
|
+
t2 = FakeTransport([(409, {"error": "business_key_conflict", "message": "x", "existing": "op9"})])
|
|
53
|
+
with self.assertRaises(ColvoError) as ctx2:
|
|
54
|
+
Colvo("k", transport=t2).operations.propose(PROPOSE)
|
|
55
|
+
self.assertEqual(ctx2.exception.details, {"existing": "op9"})
|
|
56
|
+
with self.assertRaises(ColvoError) as ctx3:
|
|
57
|
+
Colvo("k", max_retries=0, transport=FakeTransport([OSError("reset")])).operations.get("op1")
|
|
58
|
+
self.assertEqual(ctx3.exception.code, "network_error")
|
|
59
|
+
|
|
60
|
+
def test_wait_for_and_idempotency_key(self):
|
|
61
|
+
t = FakeTransport([(200, op(exec_state="SENDING")), (200, op(exec_state="ACKNOWLEDGED", verify_state="VERIFIED"))])
|
|
62
|
+
self.assertEqual(Colvo("k", transport=t).operations.wait_for("op1", interval=0)["verify_state"], "VERIFIED")
|
|
63
|
+
self.assertEqual(idempotency_key_for({**PROPOSE, "action": "subscription.cancel", "parameters": {"subscription_id": "sub_1"}}), "ticket_1:subscription.cancel:sub_1:period_end")
|
|
64
|
+
|
|
65
|
+
def test_message_for_is_honest(self):
|
|
66
|
+
self.assertIn("€50.00 refund is on its way", message_for(op()))
|
|
67
|
+
self.assertIn("asked a colleague to approve", message_for(op(decision="REVIEW", exec_state="NOT_SENT", approval={"decision": "PENDING"})))
|
|
68
|
+
self.assertNotIn("on its way", message_for(op(decision="DENY", exec_state="NOT_SENT")))
|
|
69
|
+
self.assertEqual(message_for(op(decision="HOLD"), hold="custom"), "custom")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
import unittest.mock # noqa: F401
|
|
74
|
+
unittest.main()
|