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.
- agenitry-0.1.0/.gitignore +37 -0
- agenitry-0.1.0/PKG-INFO +173 -0
- agenitry-0.1.0/README.md +150 -0
- agenitry-0.1.0/agenitry/__init__.py +354 -0
- agenitry-0.1.0/pyproject.toml +35 -0
- agenitry-0.1.0/tests/test_agenitry.py +309 -0
|
@@ -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
|
agenitry-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
agenitry-0.1.0/README.md
ADDED
|
@@ -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
|