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.
Files changed (38) hide show
  1. fivexer-0.4.0/.gitignore +33 -0
  2. fivexer-0.4.0/LICENSE +21 -0
  3. fivexer-0.4.0/PKG-INFO +289 -0
  4. fivexer-0.4.0/README.md +255 -0
  5. fivexer-0.4.0/pyproject.toml +88 -0
  6. fivexer-0.4.0/src/fivexer/__init__.py +368 -0
  7. fivexer-0.4.0/src/fivexer/_specs.py +750 -0
  8. fivexer-0.4.0/src/fivexer/client.py +1413 -0
  9. fivexer-0.4.0/src/fivexer/errors.py +41 -0
  10. fivexer-0.4.0/src/fivexer/models.py +2886 -0
  11. fivexer-0.4.0/src/fivexer/py.typed +0 -0
  12. fivexer-0.4.0/src/fivexer/supervisor.py +365 -0
  13. fivexer-0.4.0/src/fivexer/webhook.py +116 -0
  14. fivexer-0.4.0/src/fivexer/worker.py +575 -0
  15. fivexer-0.4.0/tests/conftest.py +179 -0
  16. fivexer-0.4.0/tests/test_async.py +163 -0
  17. fivexer-0.4.0/tests/test_async_parity.py +753 -0
  18. fivexer-0.4.0/tests/test_contract_parity.py +137 -0
  19. fivexer-0.4.0/tests/test_decisions_stats.py +89 -0
  20. fivexer-0.4.0/tests/test_edge_cases.py +187 -0
  21. fivexer-0.4.0/tests/test_errors_quota_retry.py +181 -0
  22. fivexer-0.4.0/tests/test_learning.py +159 -0
  23. fivexer-0.4.0/tests/test_notifications.py +149 -0
  24. fivexer-0.4.0/tests/test_operator_actions.py +235 -0
  25. fivexer-0.4.0/tests/test_request_building.py +505 -0
  26. fivexer-0.4.0/tests/test_response_parsing.py +418 -0
  27. fivexer-0.4.0/tests/test_skills.py +75 -0
  28. fivexer-0.4.0/tests/test_stats_and_presence.py +178 -0
  29. fivexer-0.4.0/tests/test_supervisor.py +589 -0
  30. fivexer-0.4.0/tests/test_task_operations.py +436 -0
  31. fivexer-0.4.0/tests/test_task_rich_data.py +318 -0
  32. fivexer-0.4.0/tests/test_tasks.py +187 -0
  33. fivexer-0.4.0/tests/test_teams_and_onboarding.py +429 -0
  34. fivexer-0.4.0/tests/test_webhook.py +90 -0
  35. fivexer-0.4.0/tests/test_worker_portal.py +411 -0
  36. fivexer-0.4.0/tests/test_worker_self_service.py +532 -0
  37. fivexer-0.4.0/tests/test_workers.py +102 -0
  38. fivexer-0.4.0/tests/test_workflows.py +268 -0
@@ -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.
@@ -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
+ ]