fivexer 0.4.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.
- fivexer-0.4.0/.gitignore +33 -0
- fivexer-0.4.0/LICENSE +21 -0
- fivexer-0.4.0/PKG-INFO +289 -0
- fivexer-0.4.0/README.md +255 -0
- fivexer-0.4.0/pyproject.toml +88 -0
- fivexer-0.4.0/src/fivexer/__init__.py +368 -0
- fivexer-0.4.0/src/fivexer/_specs.py +750 -0
- fivexer-0.4.0/src/fivexer/client.py +1413 -0
- fivexer-0.4.0/src/fivexer/errors.py +41 -0
- fivexer-0.4.0/src/fivexer/models.py +2886 -0
- fivexer-0.4.0/src/fivexer/py.typed +0 -0
- fivexer-0.4.0/src/fivexer/supervisor.py +365 -0
- fivexer-0.4.0/src/fivexer/webhook.py +116 -0
- fivexer-0.4.0/src/fivexer/worker.py +575 -0
- fivexer-0.4.0/tests/conftest.py +179 -0
- fivexer-0.4.0/tests/test_async.py +163 -0
- fivexer-0.4.0/tests/test_async_parity.py +753 -0
- fivexer-0.4.0/tests/test_contract_parity.py +137 -0
- fivexer-0.4.0/tests/test_decisions_stats.py +89 -0
- fivexer-0.4.0/tests/test_edge_cases.py +187 -0
- fivexer-0.4.0/tests/test_errors_quota_retry.py +181 -0
- fivexer-0.4.0/tests/test_learning.py +159 -0
- fivexer-0.4.0/tests/test_notifications.py +149 -0
- fivexer-0.4.0/tests/test_operator_actions.py +235 -0
- fivexer-0.4.0/tests/test_request_building.py +505 -0
- fivexer-0.4.0/tests/test_response_parsing.py +418 -0
- fivexer-0.4.0/tests/test_skills.py +75 -0
- fivexer-0.4.0/tests/test_stats_and_presence.py +178 -0
- fivexer-0.4.0/tests/test_supervisor.py +589 -0
- fivexer-0.4.0/tests/test_task_operations.py +436 -0
- fivexer-0.4.0/tests/test_task_rich_data.py +318 -0
- fivexer-0.4.0/tests/test_tasks.py +187 -0
- fivexer-0.4.0/tests/test_teams_and_onboarding.py +429 -0
- fivexer-0.4.0/tests/test_webhook.py +90 -0
- fivexer-0.4.0/tests/test_worker_portal.py +411 -0
- fivexer-0.4.0/tests/test_worker_self_service.py +532 -0
- fivexer-0.4.0/tests/test_workers.py +102 -0
- fivexer-0.4.0/tests/test_workflows.py +268 -0
fivexer-0.4.0/.gitignore
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Build artifacts
|
|
2
|
+
python/dist/
|
|
3
|
+
python/build/
|
|
4
|
+
python/.venv/
|
|
5
|
+
*.egg-info/
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.pyc
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.mypy_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
htmlcov/
|
|
12
|
+
.coverage
|
|
13
|
+
.coverage.*
|
|
14
|
+
|
|
15
|
+
# Java / Gradle
|
|
16
|
+
java/.gradle/
|
|
17
|
+
java/build/
|
|
18
|
+
java/*/build/
|
|
19
|
+
*.class
|
|
20
|
+
# Build output jars only (keep the gradle-wrapper.jar committed)
|
|
21
|
+
java/build/**/*.jar
|
|
22
|
+
java/build/libs/*.jar
|
|
23
|
+
*.war
|
|
24
|
+
|
|
25
|
+
# IDE
|
|
26
|
+
.idea/
|
|
27
|
+
.vscode/
|
|
28
|
+
*.iml
|
|
29
|
+
|
|
30
|
+
# OS
|
|
31
|
+
.DS_Store
|
|
32
|
+
php/build
|
|
33
|
+
php/vendor
|
fivexer-0.4.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fivexer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
fivexer-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fivexer
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Typed Python client for the Fivexer Platform /v1 routing API
|
|
5
|
+
Project-URL: Homepage, https://github.com/fivexer/sdks
|
|
6
|
+
Project-URL: Repository, https://github.com/fivexer/sdks
|
|
7
|
+
Project-URL: Documentation, https://github.com/fivexer/sdks#readme
|
|
8
|
+
Author: Fivexer
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: assignment,fivexer,redis,routing,task-routing,taskrouter
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
23
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Requires-Dist: httpx>=0.24
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-cov>=4; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
32
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# fivexer (Python)
|
|
36
|
+
|
|
37
|
+
Typed Python client for the **Fivexer Platform** `/v1` routing API. Create workers and tasks;
|
|
38
|
+
the platform matches them continuously and notifies you via signed webhooks.
|
|
39
|
+
|
|
40
|
+
- **Sync + async**: `Fivexer` and `AsyncFivexer`, with identical surfaces
|
|
41
|
+
- **Two credential planes**: `Fivexer` (workspace `sk_` key) and `FivexerWorker` (worker `wt_` token)
|
|
42
|
+
- **Zero-config deps**: only `httpx`
|
|
43
|
+
- **Typed**: dataclass models, full type hints, `mypy --strict` clean
|
|
44
|
+
- **Resilient**: automatic retry on `429`/`5xx` honoring `Retry-After`, idempotency keys on creates
|
|
45
|
+
- **Observable**: every response updates `client.quota` from `X-Quota-*` headers
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install fivexer
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Quickstart
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from fivexer import Fivexer, CreateTask, UpsertWorker
|
|
57
|
+
|
|
58
|
+
client = Fivexer(base_url="https://api.5xer.com", api_key="sk_test_...")
|
|
59
|
+
|
|
60
|
+
# Register a worker
|
|
61
|
+
client.workers.upsert(UpsertWorker(id="agent_1", tags=["english", "billing"]))
|
|
62
|
+
|
|
63
|
+
# Create a task — it is queued for matching
|
|
64
|
+
task = client.tasks.create(CreateTask(tags=["english", "billing"], priority=90))
|
|
65
|
+
print(task.id, task.status) # task_8fk2 queued
|
|
66
|
+
|
|
67
|
+
# Inspect who got it, and why
|
|
68
|
+
client.workers.queue("agent_1")
|
|
69
|
+
client.decisions.list(task_id=task.id)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Async is identical (`AsyncFivexer`), just `await` each call.
|
|
73
|
+
|
|
74
|
+
## Resource groups
|
|
75
|
+
|
|
76
|
+
| Group | Operations |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `client.tasks` | `create` `create_many` `check` `get` `list` `cancel` `accept` `ack` `reject` `complete` `assign` `escalate` `parked` `scheduled` `unpark` `set_priority` `suggest_workers` |
|
|
79
|
+
| `client.tasks.context` | `get` `set` `clear` |
|
|
80
|
+
| `client.tasks.comments` | `add` `list` `remove` |
|
|
81
|
+
| `client.tasks.attachments` | `create` `confirm` `list` `download` `remove` `upload` |
|
|
82
|
+
| `client.workers` | `upsert` `list` `get` `patch` `set_availability` `queue` `remove` |
|
|
83
|
+
| `client.skills` | `create` `list` `get` `patch` `remove` `suggest` |
|
|
84
|
+
| `client.teams` | `create` `list` `get` `patch` `remove` `members` `set_members` |
|
|
85
|
+
| `client.join_links` | `create` `list` `revoke` |
|
|
86
|
+
| `client.identities` | `list` `invite` `resend_invite` `create` `update` `remove` |
|
|
87
|
+
| `client.decisions` | `list` |
|
|
88
|
+
| `client.workflows` | `list` `get` `save` `remove` `run` `list_runs` |
|
|
89
|
+
| `client.runs` | `list` `get` `steps` `cancel` `complete_step` `fail_step` |
|
|
90
|
+
| `client.learning` | `status` `worker_stats` `preview_weights` `apply_weights` `revert_weights` `feedback` `reward` `feedback_bulk` `reset` |
|
|
91
|
+
| `client.notifications.sequences` | `list` `create` `get` `update` `remove` |
|
|
92
|
+
| `client.notifications.channels` | `list` `create` `get` `update` `remove` |
|
|
93
|
+
| `client.stats()` / `client.history` | `stats()` `sla_stats()` `queue_audit()` `portal()` `timeseries` `workers` |
|
|
94
|
+
| `client.team` / `client.breaks` | `presence` `metrics` |
|
|
95
|
+
|
|
96
|
+
`history`, `team` and `breaks` need the control plane; a data-plane-only deployment raises
|
|
97
|
+
`FivexerApiError` with code `history_unavailable`.
|
|
98
|
+
|
|
99
|
+
## Operator actions
|
|
100
|
+
|
|
101
|
+
Hand a task to a specific worker, or take a worker off the line:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
result = client.tasks.assign("task_8fk2", "agent_2")
|
|
105
|
+
result.previous_worker_id # "agent_1" when it was taken from someone
|
|
106
|
+
|
|
107
|
+
# "Back in ten minutes" — keeps their unaccepted backlog
|
|
108
|
+
client.workers.set_availability("agent_1", False)
|
|
109
|
+
|
|
110
|
+
# "Gone for the day" — requeues the backlog so others inherit it now
|
|
111
|
+
released = client.workers.set_availability("agent_1", False, release_backlog=True)
|
|
112
|
+
released.released_task_ids # ["task_8fk2", "task_9aa3"]
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`force=True` on `assign` bypasses the paused/backlog/veto/prior-rejection checks (worker
|
|
116
|
+
existence is still enforced).
|
|
117
|
+
|
|
118
|
+
## Rich task data
|
|
119
|
+
|
|
120
|
+
A task can carry a title, description, free-form context and references — inline at creation or
|
|
121
|
+
written separately:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from fivexer import CreateTask, TaskReferenceInput, AddComment
|
|
125
|
+
|
|
126
|
+
client.tasks.create(CreateTask(
|
|
127
|
+
tags=["billing"],
|
|
128
|
+
title="Refund request",
|
|
129
|
+
context={"orderId": "41"},
|
|
130
|
+
references=[TaskReferenceInput(url="https://crm.example/o/41", label="Order 41")],
|
|
131
|
+
))
|
|
132
|
+
|
|
133
|
+
client.tasks.comments.add("task_8fk2", AddComment(body="Called the customer back",
|
|
134
|
+
worker_id="agent_1"))
|
|
135
|
+
|
|
136
|
+
# Reserve -> PUT the bytes to object storage -> confirm, in one call
|
|
137
|
+
client.tasks.attachments.upload("task_8fk2", pdf_bytes,
|
|
138
|
+
filename="receipt.pdf", content_type="application/pdf")
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Worker portal plane
|
|
142
|
+
|
|
143
|
+
A worker works their own queue with a `wt_` session token. `login()` adopts both the token and
|
|
144
|
+
the worker id, so later calls need no extra wiring:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from fivexer import FivexerWorker, WorkerLogin
|
|
148
|
+
|
|
149
|
+
worker = FivexerWorker(base_url="https://api.5xer.com")
|
|
150
|
+
worker.login(WorkerLogin(workspace_id="ws_1", worker_id="agent_1", pin="4821"))
|
|
151
|
+
|
|
152
|
+
queue = worker.queue()
|
|
153
|
+
detail = worker.task_detail(queue.task_ids[0])
|
|
154
|
+
worker.accept(detail.id)
|
|
155
|
+
worker.complete(detail.id, {"refunded": True})
|
|
156
|
+
|
|
157
|
+
worker.start_break("lunch")
|
|
158
|
+
worker.end_break() # None when no break was open — a normal outcome, not an error
|
|
159
|
+
worker.metrics_today()
|
|
160
|
+
worker.team_presence()
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
A worker can also sign in without a password at all — through a QR join link, or an emailed
|
|
164
|
+
invite. Both mint a session, and the client adopts it, so the next call is already authenticated:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from fivexer import AcceptWorkerInvite, JoinWorkspace
|
|
168
|
+
|
|
169
|
+
worker = FivexerWorker(base_url="https://api.5xer.com")
|
|
170
|
+
|
|
171
|
+
# QR self-registration: the worker id is generated server-side — show it to them, it is the
|
|
172
|
+
# username they type at the PIN screen next time.
|
|
173
|
+
result = worker.join(JoinWorkspace(token="<from the QR>", name="Ada", pin="4821"))
|
|
174
|
+
result.pending_approval # True -> no work routes until an operator admits them
|
|
175
|
+
|
|
176
|
+
# Emailed invite: setting the PIN *is* the sign-in.
|
|
177
|
+
worker.accept_invite(AcceptWorkerInvite(token="<from the link>", pin="4821"))
|
|
178
|
+
|
|
179
|
+
worker.refresh() # rotate in place; False means "re-authenticate", not an error
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
And manage their own shift, skills and notifications:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from fivexer import ChangePin, PushSubscriptionInput, WorkerSkillLevel
|
|
186
|
+
|
|
187
|
+
worker.me() # who am I, am I on shift, do skills still need setting
|
|
188
|
+
worker.set_availability(True) # workers are created off shift — this is what matches them
|
|
189
|
+
worker.set_skills([WorkerSkillLevel(skill_id="sk_1", level=4)]) # replaces the whole set
|
|
190
|
+
worker.change_pin(ChangePin(current_pin="4821", new_pin="9137"))
|
|
191
|
+
worker.metrics_window("30d")
|
|
192
|
+
|
|
193
|
+
# Web Push. Check config first: `enabled=False` means this deployment has no VAPID keypair,
|
|
194
|
+
# and a browser only gives you one permission prompt.
|
|
195
|
+
if worker.push_config().enabled:
|
|
196
|
+
worker.push_subscribe(PushSubscriptionInput(endpoint="https://fcm/...", p256dh="...", auth="..."))
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`AsyncFivexerWorker` is the awaited mirror. The token is scoped to exactly one worker and cannot
|
|
200
|
+
reach task creation or worker management — calling an action before `login()` raises
|
|
201
|
+
`worker_id_required` locally rather than guessing an id.
|
|
202
|
+
|
|
203
|
+
## Operator onboarding
|
|
204
|
+
|
|
205
|
+
Getting workers into a workspace, from the `sk_` side:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
from fivexer import CreateJoinLink, InviteWorkerIdentity, UpsertTeam
|
|
209
|
+
|
|
210
|
+
client.teams.create(UpsertTeam(key="billing", name="Billing")) # `tag` is derived, and routes
|
|
211
|
+
|
|
212
|
+
invite = client.identities.invite(InviteWorkerIdentity(email="ada@example.com", label="Ada"))
|
|
213
|
+
invite.email_status # 'mailer_unconfigured' is common — then invite_url is the only delivery
|
|
214
|
+
invite.invite_url # credential-equivalent until consumed; treat it as a secret
|
|
215
|
+
|
|
216
|
+
link = client.join_links.create(CreateJoinLink(label="Warehouse hires", max_uses=25))
|
|
217
|
+
link.join_url # returned only here — a lost link is re-created, never recovered
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Supervisor plane
|
|
221
|
+
|
|
222
|
+
A crew lead watches and unblocks work rather than doing it. A session is redeemed from a
|
|
223
|
+
single-use link an owner generated in the console — there is no login and no refresh, so an
|
|
224
|
+
expired session means "get a new link":
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
from fivexer import AcceptSupervisorInvite, FivexerSupervisor
|
|
228
|
+
|
|
229
|
+
sup = FivexerSupervisor("https://api.5xer.com")
|
|
230
|
+
sup.accept_invite(AcceptSupervisorInvite(token="<from the link>"))
|
|
231
|
+
|
|
232
|
+
board = sup.overview() # counts, crew (busiest first) and parked work, in ONE request
|
|
233
|
+
board.counts.oldest_wait_ms
|
|
234
|
+
board.crew[0].worker_id # the busiest crew member
|
|
235
|
+
|
|
236
|
+
sup.unpark(board.parked[0].id) # back to the queue
|
|
237
|
+
sup.assign("task_8fk2", "agent_1")
|
|
238
|
+
sup.set_availability("agent_1", False, release_backlog=True)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`AsyncFivexerSupervisor` is the awaited mirror. Scope is enforced server-side: a task from
|
|
242
|
+
another crew is a 403, not a silent move. `session_expires_at` is epoch-milliseconds here, not
|
|
243
|
+
the ISO string the worker plane uses — the two planes genuinely differ on the wire.
|
|
244
|
+
|
|
245
|
+
## Error handling
|
|
246
|
+
|
|
247
|
+
Non-2xx responses raise `FivexerApiError` with the API's `code` and, on `402`/`429`, the quota
|
|
248
|
+
snapshot and `retry_after`:
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
from fivexer import FivexerApiError
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
client.tasks.create(CreateTask(tags=["english"]))
|
|
255
|
+
except FivexerApiError as e:
|
|
256
|
+
print(e.status_code, e.code) # 429 rate_limited
|
|
257
|
+
print(e.retry_after) # 60.0
|
|
258
|
+
print(e.quota.task_rate_remaining) # 0
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Webhooks
|
|
262
|
+
|
|
263
|
+
Verify the `x-fivexer-signature` header (HMAC-SHA256, ±5 min replay window) with the pure
|
|
264
|
+
`Webhook` helper — pass the **raw** request body:
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
from fivexer import Webhook
|
|
268
|
+
|
|
269
|
+
event = Webhook.construct_event(
|
|
270
|
+
payload=request.body, # raw bytes
|
|
271
|
+
header=request.headers["x-fivexer-signature"],
|
|
272
|
+
secret="whsec_...",
|
|
273
|
+
)
|
|
274
|
+
print(event.event) # task.matched
|
|
275
|
+
print(event.data) # { taskId, workerId, ... }
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Development
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
pip install -e ".[dev]"
|
|
282
|
+
pytest # 90% line + branch gate is in pyproject.toml's addopts
|
|
283
|
+
ruff check src tests
|
|
284
|
+
mypy src/fivexer
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
## License
|
|
288
|
+
|
|
289
|
+
MIT.
|
fivexer-0.4.0/README.md
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# fivexer (Python)
|
|
2
|
+
|
|
3
|
+
Typed Python client for the **Fivexer Platform** `/v1` routing API. Create workers and tasks;
|
|
4
|
+
the platform matches them continuously and notifies you via signed webhooks.
|
|
5
|
+
|
|
6
|
+
- **Sync + async**: `Fivexer` and `AsyncFivexer`, with identical surfaces
|
|
7
|
+
- **Two credential planes**: `Fivexer` (workspace `sk_` key) and `FivexerWorker` (worker `wt_` token)
|
|
8
|
+
- **Zero-config deps**: only `httpx`
|
|
9
|
+
- **Typed**: dataclass models, full type hints, `mypy --strict` clean
|
|
10
|
+
- **Resilient**: automatic retry on `429`/`5xx` honoring `Retry-After`, idempotency keys on creates
|
|
11
|
+
- **Observable**: every response updates `client.quota` from `X-Quota-*` headers
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install fivexer
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from fivexer import Fivexer, CreateTask, UpsertWorker
|
|
23
|
+
|
|
24
|
+
client = Fivexer(base_url="https://api.5xer.com", api_key="sk_test_...")
|
|
25
|
+
|
|
26
|
+
# Register a worker
|
|
27
|
+
client.workers.upsert(UpsertWorker(id="agent_1", tags=["english", "billing"]))
|
|
28
|
+
|
|
29
|
+
# Create a task — it is queued for matching
|
|
30
|
+
task = client.tasks.create(CreateTask(tags=["english", "billing"], priority=90))
|
|
31
|
+
print(task.id, task.status) # task_8fk2 queued
|
|
32
|
+
|
|
33
|
+
# Inspect who got it, and why
|
|
34
|
+
client.workers.queue("agent_1")
|
|
35
|
+
client.decisions.list(task_id=task.id)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Async is identical (`AsyncFivexer`), just `await` each call.
|
|
39
|
+
|
|
40
|
+
## Resource groups
|
|
41
|
+
|
|
42
|
+
| Group | Operations |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `client.tasks` | `create` `create_many` `check` `get` `list` `cancel` `accept` `ack` `reject` `complete` `assign` `escalate` `parked` `scheduled` `unpark` `set_priority` `suggest_workers` |
|
|
45
|
+
| `client.tasks.context` | `get` `set` `clear` |
|
|
46
|
+
| `client.tasks.comments` | `add` `list` `remove` |
|
|
47
|
+
| `client.tasks.attachments` | `create` `confirm` `list` `download` `remove` `upload` |
|
|
48
|
+
| `client.workers` | `upsert` `list` `get` `patch` `set_availability` `queue` `remove` |
|
|
49
|
+
| `client.skills` | `create` `list` `get` `patch` `remove` `suggest` |
|
|
50
|
+
| `client.teams` | `create` `list` `get` `patch` `remove` `members` `set_members` |
|
|
51
|
+
| `client.join_links` | `create` `list` `revoke` |
|
|
52
|
+
| `client.identities` | `list` `invite` `resend_invite` `create` `update` `remove` |
|
|
53
|
+
| `client.decisions` | `list` |
|
|
54
|
+
| `client.workflows` | `list` `get` `save` `remove` `run` `list_runs` |
|
|
55
|
+
| `client.runs` | `list` `get` `steps` `cancel` `complete_step` `fail_step` |
|
|
56
|
+
| `client.learning` | `status` `worker_stats` `preview_weights` `apply_weights` `revert_weights` `feedback` `reward` `feedback_bulk` `reset` |
|
|
57
|
+
| `client.notifications.sequences` | `list` `create` `get` `update` `remove` |
|
|
58
|
+
| `client.notifications.channels` | `list` `create` `get` `update` `remove` |
|
|
59
|
+
| `client.stats()` / `client.history` | `stats()` `sla_stats()` `queue_audit()` `portal()` `timeseries` `workers` |
|
|
60
|
+
| `client.team` / `client.breaks` | `presence` `metrics` |
|
|
61
|
+
|
|
62
|
+
`history`, `team` and `breaks` need the control plane; a data-plane-only deployment raises
|
|
63
|
+
`FivexerApiError` with code `history_unavailable`.
|
|
64
|
+
|
|
65
|
+
## Operator actions
|
|
66
|
+
|
|
67
|
+
Hand a task to a specific worker, or take a worker off the line:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
result = client.tasks.assign("task_8fk2", "agent_2")
|
|
71
|
+
result.previous_worker_id # "agent_1" when it was taken from someone
|
|
72
|
+
|
|
73
|
+
# "Back in ten minutes" — keeps their unaccepted backlog
|
|
74
|
+
client.workers.set_availability("agent_1", False)
|
|
75
|
+
|
|
76
|
+
# "Gone for the day" — requeues the backlog so others inherit it now
|
|
77
|
+
released = client.workers.set_availability("agent_1", False, release_backlog=True)
|
|
78
|
+
released.released_task_ids # ["task_8fk2", "task_9aa3"]
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`force=True` on `assign` bypasses the paused/backlog/veto/prior-rejection checks (worker
|
|
82
|
+
existence is still enforced).
|
|
83
|
+
|
|
84
|
+
## Rich task data
|
|
85
|
+
|
|
86
|
+
A task can carry a title, description, free-form context and references — inline at creation or
|
|
87
|
+
written separately:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from fivexer import CreateTask, TaskReferenceInput, AddComment
|
|
91
|
+
|
|
92
|
+
client.tasks.create(CreateTask(
|
|
93
|
+
tags=["billing"],
|
|
94
|
+
title="Refund request",
|
|
95
|
+
context={"orderId": "41"},
|
|
96
|
+
references=[TaskReferenceInput(url="https://crm.example/o/41", label="Order 41")],
|
|
97
|
+
))
|
|
98
|
+
|
|
99
|
+
client.tasks.comments.add("task_8fk2", AddComment(body="Called the customer back",
|
|
100
|
+
worker_id="agent_1"))
|
|
101
|
+
|
|
102
|
+
# Reserve -> PUT the bytes to object storage -> confirm, in one call
|
|
103
|
+
client.tasks.attachments.upload("task_8fk2", pdf_bytes,
|
|
104
|
+
filename="receipt.pdf", content_type="application/pdf")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Worker portal plane
|
|
108
|
+
|
|
109
|
+
A worker works their own queue with a `wt_` session token. `login()` adopts both the token and
|
|
110
|
+
the worker id, so later calls need no extra wiring:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from fivexer import FivexerWorker, WorkerLogin
|
|
114
|
+
|
|
115
|
+
worker = FivexerWorker(base_url="https://api.5xer.com")
|
|
116
|
+
worker.login(WorkerLogin(workspace_id="ws_1", worker_id="agent_1", pin="4821"))
|
|
117
|
+
|
|
118
|
+
queue = worker.queue()
|
|
119
|
+
detail = worker.task_detail(queue.task_ids[0])
|
|
120
|
+
worker.accept(detail.id)
|
|
121
|
+
worker.complete(detail.id, {"refunded": True})
|
|
122
|
+
|
|
123
|
+
worker.start_break("lunch")
|
|
124
|
+
worker.end_break() # None when no break was open — a normal outcome, not an error
|
|
125
|
+
worker.metrics_today()
|
|
126
|
+
worker.team_presence()
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
A worker can also sign in without a password at all — through a QR join link, or an emailed
|
|
130
|
+
invite. Both mint a session, and the client adopts it, so the next call is already authenticated:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from fivexer import AcceptWorkerInvite, JoinWorkspace
|
|
134
|
+
|
|
135
|
+
worker = FivexerWorker(base_url="https://api.5xer.com")
|
|
136
|
+
|
|
137
|
+
# QR self-registration: the worker id is generated server-side — show it to them, it is the
|
|
138
|
+
# username they type at the PIN screen next time.
|
|
139
|
+
result = worker.join(JoinWorkspace(token="<from the QR>", name="Ada", pin="4821"))
|
|
140
|
+
result.pending_approval # True -> no work routes until an operator admits them
|
|
141
|
+
|
|
142
|
+
# Emailed invite: setting the PIN *is* the sign-in.
|
|
143
|
+
worker.accept_invite(AcceptWorkerInvite(token="<from the link>", pin="4821"))
|
|
144
|
+
|
|
145
|
+
worker.refresh() # rotate in place; False means "re-authenticate", not an error
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
And manage their own shift, skills and notifications:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from fivexer import ChangePin, PushSubscriptionInput, WorkerSkillLevel
|
|
152
|
+
|
|
153
|
+
worker.me() # who am I, am I on shift, do skills still need setting
|
|
154
|
+
worker.set_availability(True) # workers are created off shift — this is what matches them
|
|
155
|
+
worker.set_skills([WorkerSkillLevel(skill_id="sk_1", level=4)]) # replaces the whole set
|
|
156
|
+
worker.change_pin(ChangePin(current_pin="4821", new_pin="9137"))
|
|
157
|
+
worker.metrics_window("30d")
|
|
158
|
+
|
|
159
|
+
# Web Push. Check config first: `enabled=False` means this deployment has no VAPID keypair,
|
|
160
|
+
# and a browser only gives you one permission prompt.
|
|
161
|
+
if worker.push_config().enabled:
|
|
162
|
+
worker.push_subscribe(PushSubscriptionInput(endpoint="https://fcm/...", p256dh="...", auth="..."))
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`AsyncFivexerWorker` is the awaited mirror. The token is scoped to exactly one worker and cannot
|
|
166
|
+
reach task creation or worker management — calling an action before `login()` raises
|
|
167
|
+
`worker_id_required` locally rather than guessing an id.
|
|
168
|
+
|
|
169
|
+
## Operator onboarding
|
|
170
|
+
|
|
171
|
+
Getting workers into a workspace, from the `sk_` side:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from fivexer import CreateJoinLink, InviteWorkerIdentity, UpsertTeam
|
|
175
|
+
|
|
176
|
+
client.teams.create(UpsertTeam(key="billing", name="Billing")) # `tag` is derived, and routes
|
|
177
|
+
|
|
178
|
+
invite = client.identities.invite(InviteWorkerIdentity(email="ada@example.com", label="Ada"))
|
|
179
|
+
invite.email_status # 'mailer_unconfigured' is common — then invite_url is the only delivery
|
|
180
|
+
invite.invite_url # credential-equivalent until consumed; treat it as a secret
|
|
181
|
+
|
|
182
|
+
link = client.join_links.create(CreateJoinLink(label="Warehouse hires", max_uses=25))
|
|
183
|
+
link.join_url # returned only here — a lost link is re-created, never recovered
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Supervisor plane
|
|
187
|
+
|
|
188
|
+
A crew lead watches and unblocks work rather than doing it. A session is redeemed from a
|
|
189
|
+
single-use link an owner generated in the console — there is no login and no refresh, so an
|
|
190
|
+
expired session means "get a new link":
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from fivexer import AcceptSupervisorInvite, FivexerSupervisor
|
|
194
|
+
|
|
195
|
+
sup = FivexerSupervisor("https://api.5xer.com")
|
|
196
|
+
sup.accept_invite(AcceptSupervisorInvite(token="<from the link>"))
|
|
197
|
+
|
|
198
|
+
board = sup.overview() # counts, crew (busiest first) and parked work, in ONE request
|
|
199
|
+
board.counts.oldest_wait_ms
|
|
200
|
+
board.crew[0].worker_id # the busiest crew member
|
|
201
|
+
|
|
202
|
+
sup.unpark(board.parked[0].id) # back to the queue
|
|
203
|
+
sup.assign("task_8fk2", "agent_1")
|
|
204
|
+
sup.set_availability("agent_1", False, release_backlog=True)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`AsyncFivexerSupervisor` is the awaited mirror. Scope is enforced server-side: a task from
|
|
208
|
+
another crew is a 403, not a silent move. `session_expires_at` is epoch-milliseconds here, not
|
|
209
|
+
the ISO string the worker plane uses — the two planes genuinely differ on the wire.
|
|
210
|
+
|
|
211
|
+
## Error handling
|
|
212
|
+
|
|
213
|
+
Non-2xx responses raise `FivexerApiError` with the API's `code` and, on `402`/`429`, the quota
|
|
214
|
+
snapshot and `retry_after`:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
from fivexer import FivexerApiError
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
client.tasks.create(CreateTask(tags=["english"]))
|
|
221
|
+
except FivexerApiError as e:
|
|
222
|
+
print(e.status_code, e.code) # 429 rate_limited
|
|
223
|
+
print(e.retry_after) # 60.0
|
|
224
|
+
print(e.quota.task_rate_remaining) # 0
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Webhooks
|
|
228
|
+
|
|
229
|
+
Verify the `x-fivexer-signature` header (HMAC-SHA256, ±5 min replay window) with the pure
|
|
230
|
+
`Webhook` helper — pass the **raw** request body:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from fivexer import Webhook
|
|
234
|
+
|
|
235
|
+
event = Webhook.construct_event(
|
|
236
|
+
payload=request.body, # raw bytes
|
|
237
|
+
header=request.headers["x-fivexer-signature"],
|
|
238
|
+
secret="whsec_...",
|
|
239
|
+
)
|
|
240
|
+
print(event.event) # task.matched
|
|
241
|
+
print(event.data) # { taskId, workerId, ... }
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## Development
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
pip install -e ".[dev]"
|
|
248
|
+
pytest # 90% line + branch gate is in pyproject.toml's addopts
|
|
249
|
+
ruff check src tests
|
|
250
|
+
mypy src/fivexer
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## License
|
|
254
|
+
|
|
255
|
+
MIT.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fivexer"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "Typed Python client for the Fivexer Platform /v1 routing API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICEN[CS]E*"]
|
|
13
|
+
authors = [{ name = "Fivexer" }]
|
|
14
|
+
keywords = ["fivexer", "routing", "task-routing", "assignment", "taskrouter", "redis"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
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
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
27
|
+
"Programming Language :: Python :: Implementation :: PyPy",
|
|
28
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
29
|
+
"Typing :: Typed",
|
|
30
|
+
]
|
|
31
|
+
dependencies = [
|
|
32
|
+
"httpx>=0.24",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=7",
|
|
38
|
+
"pytest-cov>=4",
|
|
39
|
+
"ruff>=0.4",
|
|
40
|
+
"mypy>=1.10",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://github.com/fivexer/sdks"
|
|
45
|
+
Repository = "https://github.com/fivexer/sdks"
|
|
46
|
+
Documentation = "https://github.com/fivexer/sdks#readme"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["src/fivexer"]
|
|
50
|
+
# PEP 561: without this marker mypy and pyright ignore the inline annotations entirely, so a
|
|
51
|
+
# package advertising "Typing :: Typed" would ship no usable types to anyone installing it.
|
|
52
|
+
artifacts = ["src/fivexer/py.typed"]
|
|
53
|
+
|
|
54
|
+
[tool.ruff]
|
|
55
|
+
line-length = 120
|
|
56
|
+
target-version = "py39"
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint]
|
|
59
|
+
select = ["E", "F", "I", "UP", "B", "W"]
|
|
60
|
+
|
|
61
|
+
[tool.mypy]
|
|
62
|
+
python_version = "3.10"
|
|
63
|
+
strict = true
|
|
64
|
+
warn_unused_ignores = true
|
|
65
|
+
|
|
66
|
+
[tool.pytest.ini_options]
|
|
67
|
+
testpaths = ["tests"]
|
|
68
|
+
# Tests import their fixtures as `from tests.conftest import ...`, which needs the project root
|
|
69
|
+
# on sys.path. `python -m pytest` puts the CWD there implicitly and a bare `pytest` does not, so
|
|
70
|
+
# without this the suite passes locally and fails to collect in CI — which is exactly what it did.
|
|
71
|
+
pythonpath = ["."]
|
|
72
|
+
# The coverage gate runs on a bare `pytest`, not only in CI, so a local run cannot pass while
|
|
73
|
+
# the project is below threshold. --cov-branch is what makes the 90% floor mean line AND branch.
|
|
74
|
+
addopts = "-q --cov=fivexer --cov-branch --cov-report=term-missing --cov-fail-under=90"
|
|
75
|
+
|
|
76
|
+
[tool.coverage.run]
|
|
77
|
+
branch = true
|
|
78
|
+
source = ["fivexer"]
|
|
79
|
+
|
|
80
|
+
[tool.coverage.report]
|
|
81
|
+
# Fail on either metric: pytest-cov's --cov-fail-under checks the combined rate, and with
|
|
82
|
+
# branch=true that rate already folds branches in.
|
|
83
|
+
skip_covered = false
|
|
84
|
+
exclude_lines = [
|
|
85
|
+
"pragma: no cover",
|
|
86
|
+
"if TYPE_CHECKING:",
|
|
87
|
+
"raise NotImplementedError",
|
|
88
|
+
]
|