nimbio-community-api 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.
Files changed (26) hide show
  1. nimbio_community_api-0.1.0/.gitignore +33 -0
  2. nimbio_community_api-0.1.0/AGENTS.md +130 -0
  3. nimbio_community_api-0.1.0/CHANGELOG.md +28 -0
  4. nimbio_community_api-0.1.0/LICENSE +21 -0
  5. nimbio_community_api-0.1.0/PKG-INFO +288 -0
  6. nimbio_community_api-0.1.0/README.md +246 -0
  7. nimbio_community_api-0.1.0/examples/async_quickstart.py +35 -0
  8. nimbio_community_api-0.1.0/examples/sync_quickstart.py +43 -0
  9. nimbio_community_api-0.1.0/pyproject.toml +101 -0
  10. nimbio_community_api-0.1.0/src/nimbio_community_api/__init__.py +79 -0
  11. nimbio_community_api-0.1.0/src/nimbio_community_api/_async.py +212 -0
  12. nimbio_community_api-0.1.0/src/nimbio_community_api/_base.py +303 -0
  13. nimbio_community_api-0.1.0/src/nimbio_community_api/_environments.py +49 -0
  14. nimbio_community_api-0.1.0/src/nimbio_community_api/_exceptions.py +141 -0
  15. nimbio_community_api-0.1.0/src/nimbio_community_api/_sync.py +222 -0
  16. nimbio_community_api-0.1.0/src/nimbio_community_api/_version.py +3 -0
  17. nimbio_community_api-0.1.0/src/nimbio_community_api/models.py +451 -0
  18. nimbio_community_api-0.1.0/src/nimbio_community_api/py.typed +0 -0
  19. nimbio_community_api-0.1.0/tests/conftest.py +15 -0
  20. nimbio_community_api-0.1.0/tests/test_async_client.py +231 -0
  21. nimbio_community_api-0.1.0/tests/test_community_endpoints.py +170 -0
  22. nimbio_community_api-0.1.0/tests/test_config.py +49 -0
  23. nimbio_community_api-0.1.0/tests/test_exceptions.py +74 -0
  24. nimbio_community_api-0.1.0/tests/test_models.py +170 -0
  25. nimbio_community_api-0.1.0/tests/test_sync_client.py +171 -0
  26. nimbio_community_api-0.1.0/tests/test_transport.py +283 -0
@@ -0,0 +1,33 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ .eggs/
7
+ build/
8
+ dist/
9
+ wheels/
10
+
11
+ # Virtual envs
12
+ .venv/
13
+ venv/
14
+ env/
15
+
16
+ # Tooling caches
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ .tox/
21
+ htmlcov/
22
+ .coverage
23
+ .coverage.*
24
+
25
+ # Editors / OS
26
+ .idea/
27
+ .vscode/
28
+ *.swp
29
+ .DS_Store
30
+
31
+ # Secrets — never commit a real API key
32
+ .env
33
+ *.local.ini
@@ -0,0 +1,130 @@
1
+ # AGENTS.md — using `nimbio-community-api` from an LLM/agent
2
+
3
+ This is a compact, copy-pasteable reference for coding agents and quick sessions.
4
+ Everything here is real and current with the package.
5
+
6
+ ## Install & import
7
+
8
+ ```bash
9
+ pip install nimbio-community-api
10
+ ```
11
+
12
+ ```python
13
+ from nimbio_community_api import NimbioClient # sync
14
+ from nimbio_community_api import AsyncNimbioClient # async
15
+ ```
16
+
17
+ ## Authenticate
18
+
19
+ A key is required. It looks like `nimbio_test_<22 chars>` or `nimbio_live_<22 chars>`.
20
+
21
+ ```python
22
+ client = NimbioClient("nimbio_test_...") # explicit
23
+ client = NimbioClient() # reads NIMBIO_API_KEY
24
+ client = NimbioClient("nimbio_live_...", environment="dev")
25
+ client = NimbioClient("nimbio_test_...", base_url="http://localhost:8000")
26
+ ```
27
+
28
+ - `environment`: `"prod"` (default → api.nimbio.com), `"dev"` (→ api.nimbio.dev),
29
+ `"local"` (→ localhost:8000). Or set `base_url` to override.
30
+ - **test vs live is the KEY, not a flag.** A `nimbio_test_*` key never fires a
31
+ gate / sends a real message. Check with `client.mode` → `"test"` | `"live"`.
32
+ - Env vars: `NIMBIO_API_KEY`, `NIMBIO_ENV`, `NIMBIO_BASE_URL`.
33
+
34
+ ## The whole API (sync — drop the `with`/use `await` for async)
35
+
36
+ ```python
37
+ with NimbioClient("nimbio_test_...") as client:
38
+ client.me() # -> Me (account_id, key.usage…)
39
+ client.health() # -> Health (ok, wamp) — never raises on 503
40
+ client.mode # -> "test" | "live" | None (no network)
41
+
42
+ # Reads (community-scoped key required)
43
+ client.community.gate_status() # -> GateStatus (.latches: list[Latch])
44
+ client.community.members() # -> Members (.accepted/.unaccepted/.removed)
45
+ client.community.key_statuses() # -> KeyStatuses (.keys, .hold_opens)
46
+ client.community.keys() # -> list[CommunityKey]
47
+
48
+ # Writes (test key = simulated, live key = real)
49
+ client.community.open("LATCH_ID", note="...", idempotency_key="...") # -> OpenResult
50
+ client.community.message("text") # -> WriteResult
51
+ client.community.add_member("+15551234567", ["KEY_ID"]) # -> WriteResult
52
+ client.community.grant_keys(ACCOUNT_COMMUNITY_ID, ["KEY_ID"]) # -> WriteResult
53
+ client.community.revoke_keys(ACCOUNT_COMMUNITY_ID, ["KEY_ID"],
54
+ remove_member=False) # -> WriteResult
55
+ client.community.set_keys_disabled(ACCOUNT_COMMUNITY_ID, ["KEY_ID"],
56
+ disabled=True) # -> WriteResult
57
+
58
+ # Logs (community must have Access Log History enabled)
59
+ client.community.member_access_logs(ACCOUNT_COMMUNITY_ID, window="last_30") # last_30|30_60|60_90
60
+ client.community.access_log(page=0) # -> AccessLogPage (.logs, .has_more)
61
+ client.community.gate_status_log(page=0) # -> GateStatusLogPage
62
+ for row in client.community.iter_access_log(): # auto-paginates all pages
63
+ ...
64
+ ```
65
+
66
+ Async is identical with `await`, and the iterators are `async for`:
67
+
68
+ ```python
69
+ async with AsyncNimbioClient("nimbio_test_...") as client:
70
+ me = await client.me()
71
+ await client.community.open("LATCH_ID")
72
+ async for row in client.community.iter_access_log():
73
+ ...
74
+ ```
75
+
76
+ ## ID vocabulary (important)
77
+
78
+ - **`latch_id`** — from `gate_status().latches[i].latch_id`.
79
+ - **`key_id`** (community key id) — from `keys()[i].id` or `key_statuses()`.
80
+ Used everywhere keys are granted/revoked/disabled.
81
+ - **`account_community_id`** — a member's id, from
82
+ `members().accepted[i].account_community_id`. Used to address a member.
83
+
84
+ ## Return values
85
+
86
+ Every model exposes typed attributes **and** the full payload on `.raw`. Writes
87
+ return a `WriteResult` whose `.result` is the outcome string
88
+ (`"member_added"`, `"keys_granted"`, `"sent"`, or `"simulated"`); extra fields
89
+ are reachable with `result["field"]`, `result.get("field")`, or `result.raw`.
90
+
91
+ ```python
92
+ r = client.community.add_member("+15551234567", ["KEY_ID"])
93
+ r.result # "member_added" (live) or "simulated" (test)
94
+ r.simulated # True on a test key
95
+ r.get("account_community_id")
96
+ ```
97
+
98
+ ## Errors (always wrap network/side-effecting calls)
99
+
100
+ ```python
101
+ from nimbio_community_api import (
102
+ APIError, AuthenticationError, PermissionDeniedError,
103
+ RateLimitError, GateNotOpenedError,
104
+ )
105
+
106
+ try:
107
+ client.community.open("LATCH_ID")
108
+ except GateNotOpenedError: # 504 — gate didn't confirm in time
109
+ ...
110
+ except PermissionDeniedError as e: # 403 — wrong scope / open denied / not a community key
111
+ print(e.code) # machine code, e.g. "open_denied", "not_community_key"
112
+ except RateLimitError as e: # 429
113
+ print(e.retry_after) # seconds, may be None
114
+ except APIError as e: # any other HTTP >= 400
115
+ print(e.status_code, e.code, e.message, e.request_id)
116
+ ```
117
+
118
+ `APIError` always has `.status_code`, `.code`, `.message`, `.request_id`.
119
+ Config problems (missing key, bad environment) raise `NimbioConfigError`
120
+ *before* any request. Network failures raise `APIConnectionError` /
121
+ `APITimeoutError`.
122
+
123
+ ## Safety tips for agents
124
+
125
+ - Default to a **test key** while iterating; `assert client.mode == "test"` to
126
+ hard-stop accidental live opens.
127
+ - `open()` and member writes are **side-effecting** with a live key. Read first
128
+ (`gate_status`, `members`, `keys`) to discover valid ids before writing.
129
+ - The community `open` is **synchronous** and can take ~15–18s; the default
130
+ client timeout (30s) already accounts for this.
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ All notable changes to `nimbio-community-api` are documented here. This project
4
+ adheres to [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.1.0] - 2026-06-30
9
+
10
+ ### Added
11
+ - Initial release.
12
+ - Synchronous `NimbioClient` and asynchronous `AsyncNimbioClient`, sharing one
13
+ request/parse/retry core.
14
+ - `client.me()`, `client.health()`, and the `client.community.*` namespace
15
+ covering gate status, members, key statuses, keys, opens, messages, member
16
+ key management, and access/gate-status logs.
17
+ - Typed, tolerant dataclass response models (`.raw` always retained); ships
18
+ `py.typed`.
19
+ - Environment selection (`prod` / `dev` / `local`) plus `base_url` override;
20
+ test-vs-live mode inferred from the API key (`client.mode`).
21
+ - Configuration via arguments or `NIMBIO_API_KEY` / `NIMBIO_ENV` /
22
+ `NIMBIO_BASE_URL`.
23
+ - Typed exception hierarchy mapping the API error envelope, with automatic
24
+ retries (429 + 5xx, honoring `Retry-After`).
25
+ - Log pagination helpers (`iter_access_log`, `iter_gate_status_log`).
26
+
27
+ [Unreleased]: https://github.com/nimbio-labs/nimbio-python-community-api/compare/v0.1.0...HEAD
28
+ [0.1.0]: https://github.com/nimbio-labs/nimbio-python-community-api/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nimbio
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.
@@ -0,0 +1,288 @@
1
+ Metadata-Version: 2.4
2
+ Name: nimbio-community-api
3
+ Version: 0.1.0
4
+ Summary: Official Python client for the Nimbio community API (api.nimbio.com) — sync + async.
5
+ Project-URL: Homepage, https://api.nimbio.com
6
+ Project-URL: Documentation, https://github.com/nimbio-labs/nimbio-python-community-api#readme
7
+ Project-URL: Source, https://github.com/nimbio-labs/nimbio-python-community-api
8
+ Project-URL: Issues, https://github.com/nimbio-labs/nimbio-python-community-api/issues
9
+ Project-URL: Changelog, https://github.com/nimbio-labs/nimbio-python-community-api/blob/main/CHANGELOG.md
10
+ Author: Nimbio
11
+ License: MIT
12
+ License-File: LICENSE
13
+ Keywords: access-control,api,client,community,gate,nimbio,sdk
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: Implementation :: CPython
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.9
28
+ Requires-Dist: httpx<1,>=0.23
29
+ Provides-Extra: dev
30
+ Requires-Dist: mypy>=1.8; extra == 'dev'
31
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
32
+ Requires-Dist: pytest-cov>=4.1; extra == 'dev'
33
+ Requires-Dist: pytest>=7.4; extra == 'dev'
34
+ Requires-Dist: respx>=0.20; extra == 'dev'
35
+ Requires-Dist: ruff>=0.4; extra == 'dev'
36
+ Provides-Extra: test
37
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'test'
38
+ Requires-Dist: pytest-cov>=4.1; extra == 'test'
39
+ Requires-Dist: pytest>=7.4; extra == 'test'
40
+ Requires-Dist: respx>=0.20; extra == 'test'
41
+ Description-Content-Type: text/markdown
42
+
43
+ # nimbio-community-api
44
+
45
+ Official Python client for the **Nimbio community API** ([api.nimbio.com](https://api.nimbio.com)).
46
+
47
+ Manage a Nimbio community programmatically: read gate status, open gates, add and
48
+ manage members and their keys, send community messages, and pull access logs —
49
+ from sync **or** async Python, with full type hints.
50
+
51
+ ```bash
52
+ pip install nimbio-community-api
53
+ ```
54
+
55
+ - ✅ **Sync and async** — `NimbioClient` for any script, `AsyncNimbioClient` for asyncio.
56
+ - ✅ **Typed** — dataclass response models with autocomplete; ships `py.typed`.
57
+ - ✅ **Test vs live** — inferred automatically from your API key.
58
+ - ✅ **One dependency** — just [`httpx`](https://www.python-httpx.org/).
59
+ - ✅ **Built-in retries**, a clean exception hierarchy, and log pagination helpers.
60
+
61
+ ---
62
+
63
+ ## Quickstart
64
+
65
+ ### Sync
66
+
67
+ ```python
68
+ from nimbio_community_api import NimbioClient
69
+
70
+ with NimbioClient("nimbio_test_your_key_here") as client:
71
+ print(client.me().account_id)
72
+
73
+ for latch in client.community.gate_status().latches:
74
+ print(latch.latch_name, "->", latch.status)
75
+
76
+ # Open a gate. A test key simulates; a live key fires the gate.
77
+ result = client.community.open("latch-id-123", note="front gate")
78
+ print(result.result) # "simulated" (test key) or "opened" (live key)
79
+ ```
80
+
81
+ ### Async
82
+
83
+ ```python
84
+ import asyncio
85
+ from nimbio_community_api import AsyncNimbioClient
86
+
87
+ async def main():
88
+ async with AsyncNimbioClient("nimbio_live_your_key_here", environment="dev") as client:
89
+ me = await client.me()
90
+ print(me.account_id)
91
+ await client.community.open("latch-id-123")
92
+
93
+ asyncio.run(main())
94
+ ```
95
+
96
+ The two clients have an **identical method surface** — the async version just
97
+ returns awaitables and exposes async iterators.
98
+
99
+ ---
100
+
101
+ ## Configuration
102
+
103
+ You can configure the client with arguments or environment variables. Precedence
104
+ is **arguments > environment variables > defaults**.
105
+
106
+ | Argument | Env var | Default | Notes |
107
+ |---|---|---|---|
108
+ | `api_key` | `NIMBIO_API_KEY` | — (required) | `nimbio_test_…` or `nimbio_live_…` |
109
+ | `environment` | `NIMBIO_ENV` | `"prod"` | `"prod"`, `"dev"`, or `"local"` |
110
+ | `base_url` | `NIMBIO_BASE_URL` | — | Overrides `environment` entirely |
111
+ | `timeout` | — | `30.0` | Seconds; the community open is synchronous (~15–18s) |
112
+ | `max_retries` | — | `2` | Retries 429 + 5xx with backoff, honoring `Retry-After` |
113
+
114
+ ```python
115
+ # Picks up NIMBIO_API_KEY and NIMBIO_ENV from the environment:
116
+ client = NimbioClient()
117
+ ```
118
+
119
+ ### Environments vs. test/live
120
+
121
+ These are **two independent axes**:
122
+
123
+ - **Environment** = *which server* you talk to (`prod` → `api.nimbio.com`,
124
+ `dev` → `api.nimbio.dev`, `local` → `localhost:8000`).
125
+ - **Test vs live** = *what the key does*, determined by the key itself. A
126
+ `nimbio_test_*` key runs the full pipeline (auth, rate limits, scope checks,
127
+ validation) but never fires a gate or sends a real message; a `nimbio_live_*`
128
+ key performs the action. Check it without a network call via `client.mode`.
129
+
130
+ ```python
131
+ client = NimbioClient("nimbio_test_...")
132
+ assert client.mode == "test" # great as a guard before destructive calls
133
+ ```
134
+
135
+ ---
136
+
137
+ ## API reference
138
+
139
+ ### Top level
140
+
141
+ | Method | Returns | Description |
142
+ |---|---|---|
143
+ | `client.me()` | `Me` | Key metadata + live usage counters |
144
+ | `client.health()` | `Health` | Backend reachability (unauthenticated; never raises on 503) |
145
+ | `client.mode` | `"test"`/`"live"`/`None` | Key mode, derived locally |
146
+ | `client.close()` / `await client.aclose()` | — | Close the underlying HTTP client |
147
+
148
+ ### `client.community` — reads
149
+
150
+ | Method | Returns |
151
+ |---|---|
152
+ | `gate_status()` | `GateStatus` — latest sensed state per latch |
153
+ | `members()` | `Members` — accepted / unaccepted / removed |
154
+ | `key_statuses()` | `KeyStatuses` — live key + latch state, hold-opens |
155
+ | `keys()` | `list[CommunityKey]` — keys with their access restrictions |
156
+
157
+ ### `client.community` — writes
158
+
159
+ | Method | Returns |
160
+ |---|---|
161
+ | `open(latch_id, *, note=None, idempotency_key=None)` | `OpenResult` |
162
+ | `message(message)` | `WriteResult` |
163
+ | `add_member(phone_number, key_ids)` | `WriteResult` |
164
+ | `grant_keys(account_community_id, key_ids)` | `WriteResult` |
165
+ | `revoke_keys(account_community_id, key_ids, *, remove_member=False)` | `WriteResult` |
166
+ | `set_keys_disabled(account_community_id, key_ids, disabled)` | `WriteResult` |
167
+
168
+ ### `client.community` — logs
169
+
170
+ | Method | Returns |
171
+ |---|---|
172
+ | `member_access_logs(account_community_id, *, window="last_30")` | `MemberAccessLogPage` |
173
+ | `access_log(*, page=0)` | `AccessLogPage` |
174
+ | `gate_status_log(*, page=0)` | `GateStatusLogPage` |
175
+ | `iter_access_log(*, start_page=0)` | iterator of `AccessLogEntry` (walks pages) |
176
+ | `iter_gate_status_log(*, start_page=0)` | iterator of `GateStatusLogEntry` |
177
+
178
+ `window` is one of `"last_30"`, `"30_60"`, `"60_90"`. The community-wide log
179
+ methods are paginated 1000 rows per page; the `iter_*` helpers walk every page
180
+ for you (and are `async for` iterators on the async client).
181
+
182
+ Every response object keeps the full decoded JSON on `.raw`, so any field not
183
+ yet surfaced as a typed attribute is still available.
184
+
185
+ ---
186
+
187
+ ## Error handling
188
+
189
+ Every non-2xx response raises a typed exception carrying the API's error
190
+ envelope (`code`, `message`, `request_id`, `status_code`).
191
+
192
+ ```python
193
+ from nimbio_community_api import (
194
+ NimbioClient, RateLimitError, PermissionDeniedError, GateNotOpenedError,
195
+ APIError,
196
+ )
197
+
198
+ with NimbioClient("nimbio_live_...") as client:
199
+ try:
200
+ client.community.open("latch-id-123")
201
+ except GateNotOpenedError:
202
+ print("Gate did not confirm the open in time (504).")
203
+ except PermissionDeniedError as e:
204
+ print("Not allowed:", e.code) # e.g. "open_denied"
205
+ except RateLimitError as e:
206
+ print("Slow down, retry after", e.retry_after, "s")
207
+ except APIError as e:
208
+ print(e.status_code, e.code, e.message, e.request_id)
209
+ ```
210
+
211
+ Exception hierarchy:
212
+
213
+ ```
214
+ NimbioError
215
+ ├── NimbioConfigError # missing key / bad environment (no request made)
216
+ ├── APIConnectionError # DNS/TCP/TLS failure
217
+ │ └── APITimeoutError # request timed out
218
+ └── APIError # any HTTP >= 400 (has .status_code, .code, .request_id)
219
+ ├── BadRequestError # 400
220
+ ├── AuthenticationError # 401
221
+ ├── PermissionDeniedError # 403 (wrong scope, open denied, ...)
222
+ ├── NotFoundError # 404
223
+ ├── RateLimitError # 429 (has .retry_after)
224
+ ├── GateNotOpenedError # 504 did_not_open
225
+ ├── UpstreamError # 502 / 503
226
+ └── ServerError # other 5xx
227
+ ```
228
+
229
+ ---
230
+
231
+ ## Bring your own HTTP client
232
+
233
+ Pass an existing `httpx` client to share connection pools, proxies, or custom
234
+ transports (useful for testing and advanced deployments):
235
+
236
+ ```python
237
+ import httpx
238
+ from nimbio_community_api import NimbioClient
239
+
240
+ http = httpx.Client(timeout=10, proxies="http://localhost:8888")
241
+ client = NimbioClient("nimbio_test_...", http_client=http)
242
+ # You own `http`'s lifecycle when you pass it in; client.close() won't close it.
243
+ ```
244
+
245
+ ---
246
+
247
+ ## Development
248
+
249
+ ```bash
250
+ python -m venv .venv && source .venv/bin/activate
251
+ pip install -e '.[dev]'
252
+ pytest # respx-mocked, no network
253
+ pytest --cov=nimbio_community_api --cov-report=term-missing # with coverage
254
+ ruff check .
255
+ mypy
256
+ ```
257
+
258
+ Common tasks are wrapped in a `Makefile` (run `make help` to list them):
259
+
260
+ ```bash
261
+ make install # pip install -e '.[dev]'
262
+ make test # run the suite
263
+ make check # lint + type-check + coverage (what CI runs)
264
+ make build # build sdist + wheel
265
+ ```
266
+
267
+ To run the suite across every installed Python (3.9–3.13) in isolated envs, use
268
+ [`tox`](https://tox.wiki):
269
+
270
+ ```bash
271
+ tox # all interpreters + lint + type
272
+ tox -e py311 # a single interpreter
273
+ ```
274
+
275
+ The test suite is fully mocked with [`respx`](https://lundberg.github.io/respx/)
276
+ — it never touches the network — and covers every endpoint, the model parsers,
277
+ the error mapping, retries, and transport edge cases (100% line + branch
278
+ coverage; CI enforces a 95% floor).
279
+
280
+ > Dependency extras: `pip install -e '.[test]'` installs just the test runner;
281
+ > `'.[dev]'` adds ruff + mypy on top.
282
+
283
+ See [`AGENTS.md`](AGENTS.md) for an LLM/agent-oriented usage cheat sheet and
284
+ [`examples/`](examples/) for runnable scripts.
285
+
286
+ ## License
287
+
288
+ MIT — see [LICENSE](LICENSE).