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 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,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ colvo/__init__.py
4
+ colvo/client.py
5
+ colvo.egg-info/PKG-INFO
6
+ colvo.egg-info/SOURCES.txt
7
+ colvo.egg-info/dependency_links.txt
8
+ colvo.egg-info/top_level.txt
9
+ tests/test_client.py
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()