agenitry 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.
@@ -0,0 +1,37 @@
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.js
7
+
8
+ # testing
9
+ /coverage
10
+
11
+ # next.js
12
+ /.next/
13
+ /out/
14
+
15
+ # production
16
+ /build
17
+
18
+ # misc
19
+ .DS_Store
20
+ *.pem
21
+
22
+ # debug
23
+ npm-debug.log*
24
+ yarn-debug.log*
25
+ yarn-error.log*
26
+
27
+ # local env files
28
+ .env*.local
29
+
30
+ # vercel
31
+ .vercel
32
+
33
+ # typescript
34
+ *.tsbuildinfo
35
+ next-env.d.ts
36
+
37
+ .gitnexus
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: agenitry
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Agenitry audit-trail API — log AI agent actions, query events, and pull stats in two lines of code.
5
+ Project-URL: Homepage, https://agenitry.com
6
+ Project-URL: Documentation, https://github.com/concya/agenitry#readme
7
+ Project-URL: Repository, https://github.com/concya/agenitry
8
+ Author-email: Agenitry <ola@agenitry.com>
9
+ License-Expression: MIT
10
+ Keywords: agenitry,ai-agents,audit,compliance,ledger,observability
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+
24
+ # Agenitry Python SDK
25
+
26
+ > The event ledger for AI agents. One line to log, one URL to verify.
27
+
28
+ ## The problem
29
+
30
+ Your AI agent takes actions — reservations, orders, comps, price changes — but nobody can see what happened or why. You're flying blind.
31
+
32
+ **Agenitry** gives every agent action a permanent, verifiable record. Log an event in one line. Share a URL. Done.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install agenitry
38
+ ```
39
+
40
+ Zero dependencies. Python 3.9+.
41
+
42
+ ## Quick start
43
+
44
+ ```python
45
+ from agenitry import Agenitry
46
+
47
+ agent = Agenitry(api_key="ag_your_key", venue_id="nobo-downtown")
48
+
49
+ # Log an event — fire and forget by default
50
+ agent.log(action="order_captured", amount=42.50, direction="inbound")
51
+
52
+ # Await confirmation if you need the event ID
53
+ event = agent.log(action="reservation_booked", amount=0, direction="inbound", await_confirmation=True)
54
+ print(event["id"]) # evt_abc123
55
+
56
+ # Query events
57
+ result = agent.events(action="order_captured", limit=10)
58
+ for e in result["events"]:
59
+ print(e["action"], e["amount"])
60
+
61
+ # Get stats
62
+ stats = agent.stats(period="7d")
63
+ print(stats["total_inbound"], stats["event_count"])
64
+ ```
65
+
66
+ ## Verify URL
67
+
68
+ Every event gets a permanent, shareable URL:
69
+
70
+ ```
71
+ https://api.agenitry.com/v1/verify/{venue_id}/{event_id}
72
+ ```
73
+
74
+ No login required. No dashboard needed. Just share the link and anyone can verify what happened.
75
+
76
+ ## API Reference
77
+
78
+ ### `Agenitry(api_key, venue_id, *, agent_id=None, base_url=None, max_retries=3, retry_base_delay=0.5)`
79
+
80
+ Create a new Agenitry client.
81
+
82
+ | Parameter | Type | Default | Description |
83
+ |-----------|------|---------|-------------|
84
+ | `api_key` | `str` | required | Your venue API key (`ag_...`) |
85
+ | `venue_id` | `str` | required | Your venue identifier |
86
+ | `agent_id` | `str` | `None` | Default agent ID for all events |
87
+ | `base_url` | `str` | `https://api.agenitry.com` | API base URL |
88
+ | `max_retries` | `int` | `3` | Max retry attempts on 429/5xx |
89
+ | `retry_base_delay` | `float` | `0.5` | Base delay in seconds (exponential backoff) |
90
+
91
+ ### `agent.log(*, action, amount=None, direction=None, agent_id=None, context=None, await_confirmation=False)`
92
+
93
+ Log an event.
94
+
95
+ | Parameter | Type | Default | Description |
96
+ |-----------|------|---------|-------------|
97
+ | `action` | `str` | required | Event action (see below) |
98
+ | `amount` | `float` | `None` | Dollar amount |
99
+ | `direction` | `str` | `None` | `inbound`, `outbound`, or `internal` |
100
+ | `agent_id` | `str` | constructor default | Agent that performed the action |
101
+ | `context` | `dict` | `None` | Arbitrary JSONB context data |
102
+ | `await_confirmation` | `bool` | `False` | If `True`, waits for server response |
103
+
104
+ **Fire and forget** (default): `log()` returns immediately with `{id: "", status: "logged"}`. If the request fails, it's silently swallowed. Perfect for non-critical logging.
105
+
106
+ **Await confirmation**: `log()` waits for the server response and raises `AgenitryError` on failure. Use when you need the event ID or need to know it was persisted.
107
+
108
+ ### Event Actions
109
+
110
+ | Action | Description | Direction | Example |
111
+ |--------|-------------|-----------|---------|
112
+ | `order_captured` | Agent captured an order | `inbound` | Voice agent took a $42.50 takeout order |
113
+ | `reservation_booked` | Agent booked a reservation | `inbound` | Chatbot reserved table 7 for 8pm |
114
+ | `price_changed` | Agent changed a price | `internal` | Agent updated happy hour draft from $6 to $7 |
115
+ | `item_86d` | Agent marked an item as unavailable | `internal` | Agent 86'd the tuna special |
116
+ | `comp_issued` | Agent issued a comp | `outbound` | Agent comped dessert for a regular |
117
+ | `purchase_order` | Agent placed a purchase order | `outbound` | Agent ordered 50 lbs of salmon |
118
+
119
+ ### `agent.events(*, agent_id=None, action=None, direction=None, limit=None, offset=None, context=None)`
120
+
121
+ Query events for the venue.
122
+
123
+ | Parameter | Type | Default | Description |
124
+ |-----------|------|---------|-------------|
125
+ | `agent_id` | `str` | `None` | Filter by agent |
126
+ | `action` | `str` | `None` | Filter by action type |
127
+ | `direction` | `str` | `None` | Filter by direction |
128
+ | `limit` | `int` | `50` | Max events to return |
129
+ | `offset` | `int` | `0` | Pagination offset |
130
+ | `context` | `dict` | `None` | JSONB contains filter |
131
+
132
+ Returns a dict with `total`, `limit`, `offset`, `has_more`, and `events` list.
133
+
134
+ ### `agent.stats(*, period=None)`
135
+
136
+ Get aggregate stats for the venue.
137
+
138
+ | Parameter | Type | Default | Description |
139
+ |-----------|------|---------|-------------|
140
+ | `period` | `str` | `today` | `today`, `7d`, `30d`, or `90d` |
141
+
142
+ Returns a dict with `total_inbound`, `total_outbound`, `event_count`, and `by_agent` breakdown.
143
+
144
+ ### `AgenitryError`
145
+
146
+ Raised on API errors. Attributes:
147
+
148
+ | Attribute | Type | Description |
149
+ |-----------|------|-------------|
150
+ | `status` | `int` or `None` | HTTP status code (if available) |
151
+ | `body` | `dict` or `None` | Parsed response body (if JSON) |
152
+ | `code` | `str` | Error code: `NETWORK`, `HTTP`, or `PARSE` |
153
+
154
+ ### `create_agenitry(api_key, venue_id, **kwargs)`
155
+
156
+ Factory function. Returns an `Agenitry` instance. Same arguments as the constructor.
157
+
158
+ ## Type Aliases
159
+
160
+ ```python
161
+ EventAction = Literal[
162
+ "order_captured", "reservation_booked", "price_changed",
163
+ "item_86d", "comp_issued", "purchase_order"
164
+ ]
165
+
166
+ EventDirection = Literal["inbound", "outbound", "internal"]
167
+
168
+ StatsPeriod = Literal["today", "7d", "30d", "90d"]
169
+ ```
170
+
171
+ ## License
172
+
173
+ MIT
@@ -0,0 +1,150 @@
1
+ # Agenitry Python SDK
2
+
3
+ > The event ledger for AI agents. One line to log, one URL to verify.
4
+
5
+ ## The problem
6
+
7
+ Your AI agent takes actions — reservations, orders, comps, price changes — but nobody can see what happened or why. You're flying blind.
8
+
9
+ **Agenitry** gives every agent action a permanent, verifiable record. Log an event in one line. Share a URL. Done.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install agenitry
15
+ ```
16
+
17
+ Zero dependencies. Python 3.9+.
18
+
19
+ ## Quick start
20
+
21
+ ```python
22
+ from agenitry import Agenitry
23
+
24
+ agent = Agenitry(api_key="ag_your_key", venue_id="nobo-downtown")
25
+
26
+ # Log an event — fire and forget by default
27
+ agent.log(action="order_captured", amount=42.50, direction="inbound")
28
+
29
+ # Await confirmation if you need the event ID
30
+ event = agent.log(action="reservation_booked", amount=0, direction="inbound", await_confirmation=True)
31
+ print(event["id"]) # evt_abc123
32
+
33
+ # Query events
34
+ result = agent.events(action="order_captured", limit=10)
35
+ for e in result["events"]:
36
+ print(e["action"], e["amount"])
37
+
38
+ # Get stats
39
+ stats = agent.stats(period="7d")
40
+ print(stats["total_inbound"], stats["event_count"])
41
+ ```
42
+
43
+ ## Verify URL
44
+
45
+ Every event gets a permanent, shareable URL:
46
+
47
+ ```
48
+ https://api.agenitry.com/v1/verify/{venue_id}/{event_id}
49
+ ```
50
+
51
+ No login required. No dashboard needed. Just share the link and anyone can verify what happened.
52
+
53
+ ## API Reference
54
+
55
+ ### `Agenitry(api_key, venue_id, *, agent_id=None, base_url=None, max_retries=3, retry_base_delay=0.5)`
56
+
57
+ Create a new Agenitry client.
58
+
59
+ | Parameter | Type | Default | Description |
60
+ |-----------|------|---------|-------------|
61
+ | `api_key` | `str` | required | Your venue API key (`ag_...`) |
62
+ | `venue_id` | `str` | required | Your venue identifier |
63
+ | `agent_id` | `str` | `None` | Default agent ID for all events |
64
+ | `base_url` | `str` | `https://api.agenitry.com` | API base URL |
65
+ | `max_retries` | `int` | `3` | Max retry attempts on 429/5xx |
66
+ | `retry_base_delay` | `float` | `0.5` | Base delay in seconds (exponential backoff) |
67
+
68
+ ### `agent.log(*, action, amount=None, direction=None, agent_id=None, context=None, await_confirmation=False)`
69
+
70
+ Log an event.
71
+
72
+ | Parameter | Type | Default | Description |
73
+ |-----------|------|---------|-------------|
74
+ | `action` | `str` | required | Event action (see below) |
75
+ | `amount` | `float` | `None` | Dollar amount |
76
+ | `direction` | `str` | `None` | `inbound`, `outbound`, or `internal` |
77
+ | `agent_id` | `str` | constructor default | Agent that performed the action |
78
+ | `context` | `dict` | `None` | Arbitrary JSONB context data |
79
+ | `await_confirmation` | `bool` | `False` | If `True`, waits for server response |
80
+
81
+ **Fire and forget** (default): `log()` returns immediately with `{id: "", status: "logged"}`. If the request fails, it's silently swallowed. Perfect for non-critical logging.
82
+
83
+ **Await confirmation**: `log()` waits for the server response and raises `AgenitryError` on failure. Use when you need the event ID or need to know it was persisted.
84
+
85
+ ### Event Actions
86
+
87
+ | Action | Description | Direction | Example |
88
+ |--------|-------------|-----------|---------|
89
+ | `order_captured` | Agent captured an order | `inbound` | Voice agent took a $42.50 takeout order |
90
+ | `reservation_booked` | Agent booked a reservation | `inbound` | Chatbot reserved table 7 for 8pm |
91
+ | `price_changed` | Agent changed a price | `internal` | Agent updated happy hour draft from $6 to $7 |
92
+ | `item_86d` | Agent marked an item as unavailable | `internal` | Agent 86'd the tuna special |
93
+ | `comp_issued` | Agent issued a comp | `outbound` | Agent comped dessert for a regular |
94
+ | `purchase_order` | Agent placed a purchase order | `outbound` | Agent ordered 50 lbs of salmon |
95
+
96
+ ### `agent.events(*, agent_id=None, action=None, direction=None, limit=None, offset=None, context=None)`
97
+
98
+ Query events for the venue.
99
+
100
+ | Parameter | Type | Default | Description |
101
+ |-----------|------|---------|-------------|
102
+ | `agent_id` | `str` | `None` | Filter by agent |
103
+ | `action` | `str` | `None` | Filter by action type |
104
+ | `direction` | `str` | `None` | Filter by direction |
105
+ | `limit` | `int` | `50` | Max events to return |
106
+ | `offset` | `int` | `0` | Pagination offset |
107
+ | `context` | `dict` | `None` | JSONB contains filter |
108
+
109
+ Returns a dict with `total`, `limit`, `offset`, `has_more`, and `events` list.
110
+
111
+ ### `agent.stats(*, period=None)`
112
+
113
+ Get aggregate stats for the venue.
114
+
115
+ | Parameter | Type | Default | Description |
116
+ |-----------|------|---------|-------------|
117
+ | `period` | `str` | `today` | `today`, `7d`, `30d`, or `90d` |
118
+
119
+ Returns a dict with `total_inbound`, `total_outbound`, `event_count`, and `by_agent` breakdown.
120
+
121
+ ### `AgenitryError`
122
+
123
+ Raised on API errors. Attributes:
124
+
125
+ | Attribute | Type | Description |
126
+ |-----------|------|-------------|
127
+ | `status` | `int` or `None` | HTTP status code (if available) |
128
+ | `body` | `dict` or `None` | Parsed response body (if JSON) |
129
+ | `code` | `str` | Error code: `NETWORK`, `HTTP`, or `PARSE` |
130
+
131
+ ### `create_agenitry(api_key, venue_id, **kwargs)`
132
+
133
+ Factory function. Returns an `Agenitry` instance. Same arguments as the constructor.
134
+
135
+ ## Type Aliases
136
+
137
+ ```python
138
+ EventAction = Literal[
139
+ "order_captured", "reservation_booked", "price_changed",
140
+ "item_86d", "comp_issued", "purchase_order"
141
+ ]
142
+
143
+ EventDirection = Literal["inbound", "outbound", "internal"]
144
+
145
+ StatsPeriod = Literal["today", "7d", "30d", "90d"]
146
+ ```
147
+
148
+ ## License
149
+
150
+ MIT
@@ -0,0 +1,354 @@
1
+ """
2
+ Agenitry SDK — Official Python client for the Agenitry audit-trail API.
3
+
4
+ Usage:
5
+ from agenitry import Agenitry
6
+
7
+ agent = Agenitry(api_key="ag_your_key", venue_id="my-venue")
8
+
9
+ # Fire-and-forget — never blocks, never raises
10
+ agent.log(action="order_captured", amount=42.50)
11
+
12
+ # Query events
13
+ result = agent.events(limit=10)
14
+
15
+ # Pull stats
16
+ stats = agent.stats(period="7d")
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import time
23
+ from typing import Any, Literal, Optional
24
+ from urllib.request import Request, urlopen
25
+ from urllib.error import HTTPError, URLError
26
+ from urllib.parse import urlencode, quote
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Types
30
+ # ---------------------------------------------------------------------------
31
+
32
+ EventAction = Literal[
33
+ "order_captured",
34
+ "reservation_booked",
35
+ "price_changed",
36
+ "item_86d",
37
+ "comp_issued",
38
+ "purchase_order",
39
+ ]
40
+
41
+ EventDirection = Literal["inbound", "outbound", "internal"]
42
+
43
+ StatsPeriod = Literal["today", "7d", "30d", "all"]
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Error
48
+ # ---------------------------------------------------------------------------
49
+
50
+ class AgenitryError(Exception):
51
+ """Error thrown by the Agenitry SDK.
52
+
53
+ Attributes:
54
+ status: HTTP status code (0 if no response was received).
55
+ body: Parsed response body (if available).
56
+ code: Machine-readable error code ('NETWORK', 'TIMEOUT', 'API').
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ message: str,
62
+ status: int = 0,
63
+ body: Any = None,
64
+ code: Literal["NETWORK", "TIMEOUT", "API"] = "API",
65
+ ):
66
+ super().__init__(message)
67
+ self.status = status
68
+ self.body = body
69
+ self.code = code
70
+
71
+ def __repr__(self) -> str:
72
+ return f"AgenitryError(status={self.status}, code={self.code}, message={self.args[0]!r})"
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Helpers
77
+ # ---------------------------------------------------------------------------
78
+
79
+ _DEFAULT_BASE_URL = "https://api.agenitry.com"
80
+ _DEFAULT_MAX_RETRIES = 2
81
+ _DEFAULT_RETRY_BASE_DELAY = 0.5 # seconds
82
+
83
+
84
+ def _is_retryable(status: int) -> bool:
85
+ return status == 429 or status >= 500
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Client
90
+ # ---------------------------------------------------------------------------
91
+
92
+ class Agenitry:
93
+ """Agenitry client — the main entry point for the SDK.
94
+
95
+ Usage:
96
+ from agenitry import Agenitry
97
+
98
+ agent = Agenitry(api_key="ag_xxx", venue_id="my-venue")
99
+ agent.log(action="order_captured", amount=42.5)
100
+ """
101
+
102
+ def __init__(
103
+ self,
104
+ api_key: str,
105
+ venue_id: str,
106
+ *,
107
+ base_url: str = _DEFAULT_BASE_URL,
108
+ agent_id: str = "default",
109
+ max_retries: int = _DEFAULT_MAX_RETRIES,
110
+ retry_base_delay: float = _DEFAULT_RETRY_BASE_DELAY,
111
+ ):
112
+ if not api_key:
113
+ raise AgenitryError("Agenitry: `api_key` is required", code="API")
114
+ if not venue_id:
115
+ raise AgenitryError("Agenitry: `venue_id` is required", code="API")
116
+
117
+ self.api_key = api_key
118
+ self.venue_id = venue_id
119
+ self.base_url = base_url.rstrip("/")
120
+ self.agent_id = agent_id
121
+ self.max_retries = max_retries
122
+ self.retry_base_delay = retry_base_delay
123
+
124
+ # -------------------------------------------------------------------
125
+ # log() — fire-and-forget event logging
126
+ # -------------------------------------------------------------------
127
+
128
+ def log(
129
+ self,
130
+ action: EventAction,
131
+ *,
132
+ amount: Optional[float] = None,
133
+ currency: Optional[str] = None,
134
+ direction: Optional[EventDirection] = None,
135
+ context: Optional[dict[str, Any]] = None,
136
+ reason: Optional[str] = None,
137
+ agent_id: Optional[str] = None,
138
+ await_confirmation: bool = False,
139
+ ) -> dict[str, Any]:
140
+ """Log an event to the audit trail.
141
+
142
+ This method is **fire-and-forget** by default — it never raises.
143
+ If you need confirmation, set ``await_confirmation=True``.
144
+
145
+ Args:
146
+ action: What happened. One of the six canonical actions.
147
+ amount: Dollar amount associated with the event.
148
+ currency: ISO-4217 currency code. Defaults to "USD".
149
+ direction: Direction of flow.
150
+ context: Arbitrary JSON context (metadata, order details, etc.).
151
+ reason: Human-readable reason for the event.
152
+ agent_id: Override the default agent_id for this event.
153
+ await_confirmation: If True, raise on errors instead of swallowing.
154
+
155
+ Returns:
156
+ dict with id, status, created_at (empty on fire-and-forget failure).
157
+ """
158
+ body: dict[str, Any] = {
159
+ "venue_id": self.venue_id,
160
+ "agent_id": agent_id or self.agent_id,
161
+ "action": action,
162
+ }
163
+ if amount is not None:
164
+ body["amount"] = amount
165
+ if currency is not None:
166
+ body["currency"] = currency
167
+ if direction is not None:
168
+ body["direction"] = direction
169
+ if context is not None:
170
+ body["context"] = context
171
+ if reason is not None:
172
+ body["reason"] = reason
173
+
174
+ try:
175
+ return self._request("POST", "/v1/events", body=body)
176
+ except AgenitryError:
177
+ if await_confirmation:
178
+ raise
179
+ # Fire-and-forget: swallow the error
180
+ return {
181
+ "id": "",
182
+ "status": "logged",
183
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
184
+ }
185
+
186
+ # -------------------------------------------------------------------
187
+ # events() — query the audit trail
188
+ # -------------------------------------------------------------------
189
+
190
+ def events(
191
+ self,
192
+ *,
193
+ agent_id: Optional[str] = None,
194
+ action: Optional[EventAction] = None,
195
+ context: Optional[dict[str, Any]] = None,
196
+ limit: int = 50,
197
+ offset: int = 0,
198
+ ) -> dict[str, Any]:
199
+ """Fetch events for the venue.
200
+
201
+ Args:
202
+ agent_id: Filter by agent_id.
203
+ action: Filter by action type.
204
+ context: Filter by context (JSONB contains).
205
+ limit: Max events to return (1-200).
206
+ offset: Pagination offset.
207
+
208
+ Returns:
209
+ dict with venue_id, total, limit, offset, has_more, events.
210
+ """
211
+ params: dict[str, str] = {
212
+ "limit": str(limit),
213
+ "offset": str(offset),
214
+ }
215
+ if agent_id is not None:
216
+ params["agent_id"] = agent_id
217
+ if action is not None:
218
+ params["action"] = action
219
+ if context is not None:
220
+ params["context"] = json.dumps(context)
221
+
222
+ path = f"/v1/events/{quote(self.venue_id, safe='')}"
223
+ return self._request("GET", path, params=params)
224
+
225
+ # -------------------------------------------------------------------
226
+ # stats() — aggregated statistics
227
+ # -------------------------------------------------------------------
228
+
229
+ def stats(
230
+ self,
231
+ *,
232
+ period: StatsPeriod = "today",
233
+ ) -> dict[str, Any]:
234
+ """Fetch aggregated stats for the venue.
235
+
236
+ Args:
237
+ period: Aggregation period. One of "today", "7d", "30d", "all".
238
+
239
+ Returns:
240
+ dict with venue_id, period, total_inbound, total_outbound,
241
+ event_count, by_agent.
242
+ """
243
+ path = f"/v1/events/{quote(self.venue_id, safe='')}/stats"
244
+ return self._request("GET", path, params={"period": period})
245
+
246
+ # -------------------------------------------------------------------
247
+ # Internal HTTP helper with retry
248
+ # -------------------------------------------------------------------
249
+
250
+ def _request(
251
+ self,
252
+ method: str,
253
+ path: str,
254
+ *,
255
+ body: Optional[dict[str, Any]] = None,
256
+ params: Optional[dict[str, str]] = None,
257
+ ) -> dict[str, Any]:
258
+ url = f"{self.base_url}{path}"
259
+ if params:
260
+ url = f"{url}?{urlencode(params)}"
261
+
262
+ data = json.dumps(body).encode("utf-8") if body else None
263
+
264
+ last_error: AgenitryError | None = None
265
+
266
+ for attempt in range(self.max_retries + 1):
267
+ try:
268
+ req = Request(url, data=data, method=method)
269
+ req.add_header("Content-Type", "application/json")
270
+ req.add_header("Authorization", f"Bearer {self.api_key}")
271
+
272
+ with urlopen(req, timeout=30) as resp:
273
+ return json.loads(resp.read().decode("utf-8"))
274
+
275
+ except HTTPError as e:
276
+ status = e.code
277
+ try:
278
+ resp_body = json.loads(e.read().decode("utf-8"))
279
+ except Exception:
280
+ resp_body = None
281
+
282
+ if not _is_retryable(status):
283
+ raise AgenitryError(
284
+ message=f"Agenitry API error: {status} {e.reason}",
285
+ status=status,
286
+ body=resp_body,
287
+ )
288
+
289
+ last_error = AgenitryError(
290
+ message=f"Agenitry API error (retryable): {status} {e.reason}",
291
+ status=status,
292
+ )
293
+
294
+ except URLError as e:
295
+ raise AgenitryError(
296
+ message=f"Agenitry network error: {e.reason}",
297
+ code="NETWORK",
298
+ )
299
+
300
+ except Exception as e:
301
+ raise AgenitryError(
302
+ message=f"Agenitry error: {e}",
303
+ code="API",
304
+ )
305
+
306
+ # Exponential backoff before retry
307
+ if attempt < self.max_retries:
308
+ delay = self.retry_base_delay * (2 ** attempt)
309
+ time.sleep(delay)
310
+
311
+ raise last_error or AgenitryError("Agenitry: all retries exhausted")
312
+
313
+
314
+ # ---------------------------------------------------------------------------
315
+ # Convenience factory
316
+ # ---------------------------------------------------------------------------
317
+
318
+ def create_agenitry(
319
+ api_key: str,
320
+ venue_id: str,
321
+ *,
322
+ base_url: str = _DEFAULT_BASE_URL,
323
+ agent_id: str = "default",
324
+ max_retries: int = _DEFAULT_MAX_RETRIES,
325
+ retry_base_delay: float = _DEFAULT_RETRY_BASE_DELAY,
326
+ ) -> Agenitry:
327
+ """Create an Agenitry client in one line.
328
+
329
+ Usage:
330
+ from agenitry import create_agenitry
331
+ agent = create_agenitry(api_key="ag_xxx", venue_id="my-venue")
332
+ """
333
+ return Agenitry(
334
+ api_key=api_key,
335
+ venue_id=venue_id,
336
+ base_url=base_url,
337
+ agent_id=agent_id,
338
+ max_retries=max_retries,
339
+ retry_base_delay=retry_base_delay,
340
+ )
341
+
342
+
343
+ # ---------------------------------------------------------------------------
344
+ # Public API
345
+ # ---------------------------------------------------------------------------
346
+
347
+ __all__ = [
348
+ "Agenitry",
349
+ "AgenitryError",
350
+ "create_agenitry",
351
+ "EventAction",
352
+ "EventDirection",
353
+ "StatsPeriod",
354
+ ]
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "agenitry"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the Agenitry audit-trail API — log AI agent actions, query events, and pull stats in two lines of code."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "Agenitry", email = "ola@agenitry.com" },
14
+ ]
15
+ keywords = ["agenitry", "audit", "ai-agents", "ledger", "compliance", "observability"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Software Development :: Libraries :: Python Modules",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://agenitry.com"
31
+ Documentation = "https://github.com/concya/agenitry#readme"
32
+ Repository = "https://github.com/concya/agenitry"
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
@@ -0,0 +1,309 @@
1
+ """Tests for the Agenitry Python SDK — mirrors the TypeScript SDK test suite."""
2
+
3
+ import json
4
+ from unittest.mock import patch, MagicMock
5
+ from urllib.error import HTTPError, URLError
6
+ from io import BytesIO
7
+
8
+ import pytest
9
+
10
+ from agenitry import Agenitry, AgenitryError, create_agenitry
11
+
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Helpers
15
+ # ---------------------------------------------------------------------------
16
+
17
+ def make_response(data, status=200):
18
+ """Create a mock response object for urlopen."""
19
+ resp = MagicMock()
20
+ resp.read.return_value = json.dumps(data).encode("utf-8")
21
+ resp.__enter__ = MagicMock(return_value=resp)
22
+ resp.__exit__ = MagicMock(return_value=False)
23
+ return resp
24
+
25
+
26
+ def make_http_error(status, reason="Error", body=None):
27
+ """Create an HTTPError for testing."""
28
+ error_body = json.dumps(body or {}).encode("utf-8") if body else b""
29
+ return HTTPError(
30
+ url="https://api.agenitry.com/v1/events",
31
+ code=status,
32
+ msg=reason,
33
+ hdrs={},
34
+ fp=BytesIO(error_body),
35
+ )
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Constructor tests
40
+ # ---------------------------------------------------------------------------
41
+
42
+ class TestConstructor:
43
+ def test_requires_api_key(self):
44
+ with pytest.raises(AgenitryError, match="api_key"):
45
+ Agenitry(api_key="", venue_id="my-venue")
46
+
47
+ def test_requires_venue_id(self):
48
+ with pytest.raises(AgenitryError, match="venue_id"):
49
+ Agenitry(api_key="ag_xxx", venue_id="")
50
+
51
+ def test_uses_default_base_url(self):
52
+ agent = Agenitry(api_key="ag_xxx", venue_id="my-venue")
53
+ assert agent.base_url == "https://api.agenitry.com"
54
+
55
+ def test_strips_trailing_slashes(self):
56
+ agent = Agenitry(api_key="ag_xxx", venue_id="my-venue", base_url="https://api.agenitry.com///")
57
+ assert agent.base_url == "https://api.agenitry.com"
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # create_agenitry factory
62
+ # ---------------------------------------------------------------------------
63
+
64
+ class TestCreateAgenitry:
65
+ def test_returns_agenitry_instance(self):
66
+ agent = create_agenitry(api_key="ag_xxx", venue_id="my-venue")
67
+ assert isinstance(agent, Agenitry)
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # log() tests
72
+ # ---------------------------------------------------------------------------
73
+
74
+ class TestLog:
75
+ @patch("agenitry.urlopen")
76
+ def test_sends_post_to_events(self, mock_urlopen):
77
+ mock_urlopen.return_value = make_response(
78
+ {"id": "evt_123", "status": "logged", "created_at": "2025-01-01T00:00:00Z"}
79
+ )
80
+ agent = Agenitry(api_key="ag_test", venue_id="my-venue")
81
+ result = agent.log(action="order_captured", amount=42.5, await_confirmation=True)
82
+
83
+ assert result["id"] == "evt_123"
84
+ assert result["status"] == "logged"
85
+ call_args = mock_urlopen.call_args
86
+ req = call_args[0][0]
87
+ assert req.method == "POST"
88
+ assert req.full_url == "https://api.agenitry.com/v1/events"
89
+
90
+ @patch("agenitry.urlopen")
91
+ def test_uses_agent_id_from_payload(self, mock_urlopen):
92
+ mock_urlopen.return_value = make_response(
93
+ {"id": "evt_123", "status": "logged", "created_at": "2025-01-01T00:00:00Z"}
94
+ )
95
+ agent = Agenitry(api_key="ag_test", venue_id="my-venue", agent_id="default-agent")
96
+ agent.log(action="order_captured", agent_id="custom-agent", await_confirmation=True)
97
+
98
+ body = json.loads(mock_urlopen.call_args[0][0].data)
99
+ assert body["agent_id"] == "custom-agent"
100
+
101
+ @patch("agenitry.urlopen")
102
+ def test_sends_authorization_header(self, mock_urlopen):
103
+ mock_urlopen.return_value = make_response(
104
+ {"id": "evt_123", "status": "logged", "created_at": "2025-01-01T00:00:00Z"}
105
+ )
106
+ agent = Agenitry(api_key="ag_test", venue_id="my-venue")
107
+ agent.log(action="order_captured", await_confirmation=True)
108
+
109
+ req = mock_urlopen.call_args[0][0]
110
+ assert req.get_header("Authorization") == "Bearer ag_test"
111
+
112
+ @patch("agenitry.urlopen")
113
+ def test_fire_and_forget_swallows_errors(self, mock_urlopen):
114
+ mock_urlopen.side_effect = URLError("Connection refused")
115
+ agent = Agenitry(api_key="ag_test", venue_id="my-venue")
116
+ result = agent.log(action="order_captured") # should NOT raise
117
+ assert result["status"] == "logged"
118
+ assert result["id"] == ""
119
+
120
+ @patch("agenitry.urlopen")
121
+ def test_await_mode_propagates_errors(self, mock_urlopen):
122
+ mock_urlopen.side_effect = URLError("Connection refused")
123
+ agent = Agenitry(api_key="ag_test", venue_id="my-venue")
124
+ with pytest.raises(AgenitryError, match="network error"):
125
+ agent.log(action="order_captured", await_confirmation=True)
126
+
127
+ @patch("agenitry.urlopen")
128
+ def test_retries_on_429(self, mock_urlopen):
129
+ mock_urlopen.side_effect = [
130
+ make_http_error(429, "Too Many Requests"),
131
+ make_response({"id": "evt_123", "status": "logged", "created_at": "2025-01-01T00:00:00Z"}),
132
+ ]
133
+ agent = Agenitry(api_key="ag_test", venue_id="my-venue", max_retries=2, retry_base_delay=0.01)
134
+ result = agent.log(action="order_captured", await_confirmation=True)
135
+ assert result["id"] == "evt_123"
136
+
137
+ @patch("agenitry.urlopen")
138
+ def test_retries_on_500(self, mock_urlopen):
139
+ mock_urlopen.side_effect = [
140
+ make_http_error(500, "Internal Server Error"),
141
+ make_response({"id": "evt_123", "status": "logged", "created_at": "2025-01-01T00:00:00Z"}),
142
+ ]
143
+ agent = Agenitry(api_key="ag_test", venue_id="my-venue", max_retries=2, retry_base_delay=0.01)
144
+ result = agent.log(action="order_captured", await_confirmation=True)
145
+ assert result["id"] == "evt_123"
146
+
147
+ @patch("agenitry.urlopen")
148
+ def test_does_not_retry_on_400(self, mock_urlopen):
149
+ mock_urlopen.side_effect = make_http_error(400, "Bad Request", {"error": "Invalid action"})
150
+ agent = Agenitry(api_key="ag_test", venue_id="my-venue", max_retries=2, retry_base_delay=0.01)
151
+ with pytest.raises(AgenitryError) as exc_info:
152
+ agent.log(action="order_captured", await_confirmation=True)
153
+ assert exc_info.value.status == 400
154
+
155
+ @patch("agenitry.urlopen")
156
+ def test_throws_agenitry_error_with_status_and_body(self, mock_urlopen):
157
+ mock_urlopen.side_effect = make_http_error(401, "Unauthorized", {"error": "Invalid API key"})
158
+ agent = Agenitry(api_key="ag_test", venue_id="my-venue")
159
+ with pytest.raises(AgenitryError) as exc_info:
160
+ agent.log(action="order_captured", await_confirmation=True)
161
+ assert exc_info.value.status == 401
162
+ assert exc_info.value.body == {"error": "Invalid API key"}
163
+
164
+ @patch("agenitry.urlopen")
165
+ def test_throws_network_error_on_url_error(self, mock_urlopen):
166
+ mock_urlopen.side_effect = URLError("Connection refused")
167
+ agent = Agenitry(api_key="ag_test", venue_id="my-venue")
168
+ with pytest.raises(AgenitryError) as exc_info:
169
+ agent.log(action="order_captured", await_confirmation=True)
170
+ assert exc_info.value.code == "NETWORK"
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # events() tests
175
+ # ---------------------------------------------------------------------------
176
+
177
+ class TestEvents:
178
+ @patch("agenitry.urlopen")
179
+ def test_fetches_events_with_default_params(self, mock_urlopen):
180
+ mock_urlopen.return_value = make_response(
181
+ {"venue_id": "my-venue", "total": 1, "limit": 50, "offset": 0, "has_more": False, "events": []}
182
+ )
183
+ agent = Agenitry(api_key="ag_test", venue_id="my-venue")
184
+ result = agent.events()
185
+ assert result["venue_id"] == "my-venue"
186
+
187
+ @patch("agenitry.urlopen")
188
+ def test_passes_query_parameters(self, mock_urlopen):
189
+ mock_urlopen.return_value = make_response(
190
+ {"venue_id": "my-venue", "total": 0, "limit": 10, "offset": 0, "has_more": False, "events": []}
191
+ )
192
+ agent = Agenitry(api_key="ag_test", venue_id="my-venue")
193
+ agent.events(agent_id="voice-agent", action="order_captured", limit=10)
194
+ req = mock_urlopen.call_args[0][0]
195
+ assert "agent_id=voice-agent" in req.full_url
196
+ assert "action=order_captured" in req.full_url
197
+ assert "limit=10" in req.full_url
198
+
199
+ @patch("agenitry.urlopen")
200
+ def test_url_encodes_venue_ids(self, mock_urlopen):
201
+ mock_urlopen.return_value = make_response(
202
+ {"venue_id": "my venue", "total": 0, "limit": 50, "offset": 0, "has_more": False, "events": []}
203
+ )
204
+ agent = Agenitry(api_key="ag_test", venue_id="my venue")
205
+ agent.events()
206
+ req = mock_urlopen.call_args[0][0]
207
+ assert "my%20venue" in req.full_url
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # stats() tests
212
+ # ---------------------------------------------------------------------------
213
+
214
+ class TestStats:
215
+ @patch("agenitry.urlopen")
216
+ def test_fetches_stats_with_default_period(self, mock_urlopen):
217
+ mock_urlopen.return_value = make_response(
218
+ {"venue_id": "my-venue", "period": "today", "total_inbound": 0, "total_outbound": 0, "event_count": 0, "by_agent": {}}
219
+ )
220
+ agent = Agenitry(api_key="ag_test", venue_id="my-venue")
221
+ result = agent.stats()
222
+ assert result["period"] == "today"
223
+
224
+ @patch("agenitry.urlopen")
225
+ def test_passes_period_parameter(self, mock_urlopen):
226
+ mock_urlopen.return_value = make_response(
227
+ {"venue_id": "my-venue", "period": "7d", "total_inbound": 0, "total_outbound": 0, "event_count": 0, "by_agent": {}}
228
+ )
229
+ agent = Agenitry(api_key="ag_test", venue_id="my-venue")
230
+ agent.stats(period="7d")
231
+ req = mock_urlopen.call_args[0][0]
232
+ assert "period=7d" in req.full_url
233
+
234
+ @patch("agenitry.urlopen")
235
+ def test_returns_by_agent_breakdown(self, mock_urlopen):
236
+ mock_urlopen.return_value = make_response(
237
+ {"venue_id": "my-venue", "period": "7d", "total_inbound": 100, "total_outbound": 50, "event_count": 10, "by_agent": {"agent-1": {"events": 10, "inbound": 100, "outbound": 50}}}
238
+ )
239
+ agent = Agenitry(api_key="ag_test", venue_id="my-venue")
240
+ result = agent.stats(period="7d")
241
+ assert "agent-1" in result["by_agent"]
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # All 6 event actions
246
+ # ---------------------------------------------------------------------------
247
+
248
+ class TestEventActions:
249
+ @patch("agenitry.urlopen")
250
+ def test_logs_action_order_captured(self, mock_urlopen):
251
+ _test_action(mock_urlopen, "order_captured")
252
+
253
+ @patch("agenitry.urlopen")
254
+ def test_logs_action_reservation_booked(self, mock_urlopen):
255
+ _test_action(mock_urlopen, "reservation_booked")
256
+
257
+ @patch("agenitry.urlopen")
258
+ def test_logs_action_price_changed(self, mock_urlopen):
259
+ _test_action(mock_urlopen, "price_changed")
260
+
261
+ @patch("agenitry.urlopen")
262
+ def test_logs_action_item_86d(self, mock_urlopen):
263
+ _test_action(mock_urlopen, "item_86d")
264
+
265
+ @patch("agenitry.urlopen")
266
+ def test_logs_action_comp_issued(self, mock_urlopen):
267
+ _test_action(mock_urlopen, "comp_issued")
268
+
269
+ @patch("agenitry.urlopen")
270
+ def test_logs_action_purchase_order(self, mock_urlopen):
271
+ _test_action(mock_urlopen, "purchase_order")
272
+
273
+
274
+ def _test_action(mock_urlopen, action):
275
+ mock_urlopen.return_value = make_response(
276
+ {"id": "evt_123", "status": "logged", "created_at": "2025-01-01T00:00:00Z"}
277
+ )
278
+ agent = Agenitry(api_key="ag_test", venue_id="my-venue")
279
+ result = agent.log(action=action, await_confirmation=True)
280
+ body = json.loads(mock_urlopen.call_args[0][0].data)
281
+ assert body["action"] == action
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # All 3 event directions
286
+ # ---------------------------------------------------------------------------
287
+
288
+ class TestEventDirections:
289
+ @patch("agenitry.urlopen")
290
+ def test_logs_direction_inbound(self, mock_urlopen):
291
+ _test_direction(mock_urlopen, "inbound")
292
+
293
+ @patch("agenitry.urlopen")
294
+ def test_logs_direction_outbound(self, mock_urlopen):
295
+ _test_direction(mock_urlopen, "outbound")
296
+
297
+ @patch("agenitry.urlopen")
298
+ def test_logs_direction_internal(self, mock_urlopen):
299
+ _test_direction(mock_urlopen, "internal")
300
+
301
+
302
+ def _test_direction(mock_urlopen, direction):
303
+ mock_urlopen.return_value = make_response(
304
+ {"id": "evt_123", "status": "logged", "created_at": "2025-01-01T00:00:00Z"}
305
+ )
306
+ agent = Agenitry(api_key="ag_test", venue_id="my-venue")
307
+ agent.log(action="order_captured", direction=direction, await_confirmation=True)
308
+ body = json.loads(mock_urlopen.call_args[0][0].data)
309
+ assert body["direction"] == direction