From 9ab0975d37991246105c80669d0aeeb4197673c5 Mon Sep 17 00:00:00 2001 From: Cody Halley Date: Mon, 17 Aug 2026 21:16:51 -0500 Subject: [PATCH] feat!: switch HTTP transport from httpx to httpx2 (0.2.0) httpx development has stalled; httpx2 is the Pydantic-maintained fork with the same API (import name httpx2, httpcore vendored as httpcore2, truststore instead of certifi). BREAKING CHANGE: a custom client passed via Api(client=) must now be an httpx2.AsyncClient. --- AGENTS.md | 8 ++-- CHANGELOG.md | 11 ++++++ CONTRIBUTING.md | 2 +- README.md | 12 +++--- pyproject.toml | 8 ++-- scripts/generate_endpoints.py | 4 +- src/aiopynetbox/api.py | 16 ++++---- src/aiopynetbox/exceptions.py | 8 ++-- tests/conftest.py | 68 +++++++++++++++++----------------- tests/test_client.py | 8 ++-- tests/test_retry.py | 4 +- uv.lock | 69 +++++++++++++++++++++-------------- 12 files changed, 123 insertions(+), 95 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 28b4dcc..29497a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project -`aiopynetbox` - a fully async NetBox API client, built from scratch with httpx. It is inspired by [pynetbox](https://github.com/netbox-community/pynetbox) (the popular sync client) but is **not a port**: pynetbox's core ergonomics depend on sync-only Python protocols that cannot be awaited, so the API surface here is deliberately different (see Design constraints below). +`aiopynetbox` - a fully async NetBox API client, built from scratch with httpx2 (the Pydantic-maintained fork of httpx). It is inspired by [pynetbox](https://github.com/netbox-community/pynetbox) (the popular sync client) but is **not a port**: pynetbox's core ergonomics depend on sync-only Python protocols that cannot be awaited, so the API surface here is deliberately different (see Design constraints below). Package layout: `src/aiopynetbox/`, tests in `tests/`. Managed with `uv`. @@ -31,7 +31,7 @@ pynetbox ideas worth keeping (they're pure Python, no I/O): app/endpoint attribu ## Architecture -All HTTP funnels through `Api._request_response()` ([api.py](src/aiopynetbox/api.py)) - auth headers (v1 `Token`/v2 `nbt_` `Bearer`) and error raising (POST 409 -> `AllocationError`, everything else non-success -> `RequestError`) live there and nowhere else, as does the retry loop (429 retried for any method honoring Retry-After; 502/503/504 and httpx.TransportError retried for GET only, since ambiguous writes may have been processed; `Api(retries=)` bounds attempts, `_backoff()` does capped exponential backoff with jitter); `Api._request()` adds JSON decoding (`_decode` -> `ContentError`). Detail-path callers (`Endpoint.get(id)`, `full_details()`, `save()`) use `_request_response` directly to capture the `ETag` header: records store it as `_etag` and `save()` sends `If-Match` (NetBox 4.6+ optimistic locking; stale ETag -> 412 `RequestError`); repeat `full_details()` calls send `If-None-Match` and a 304 (allowed through `_request_response` only when that header was sent) keeps current data without re-parsing). `App.__getattr__` ([app.py](src/aiopynetbox/app.py)) turns any attribute into an `Endpoint` ([endpoint.py](src/aiopynetbox/endpoint.py)), which builds URLs (`_`->`-`) and returns `Record`/`RecordSet` ([response.py](src/aiopynetbox/response.py)). `PluginsApp` (also app.py) routes `nb.plugins.` into `/api/plugins//`. +All HTTP funnels through `Api._request_response()` ([api.py](src/aiopynetbox/api.py)) - auth headers (v1 `Token`/v2 `nbt_` `Bearer`) and error raising (POST 409 -> `AllocationError`, everything else non-success -> `RequestError`) live there and nowhere else, as does the retry loop (429 retried for any method honoring Retry-After; 502/503/504 and httpx2.TransportError retried for GET only, since ambiguous writes may have been processed; `Api(retries=)` bounds attempts, `_backoff()` does capped exponential backoff with jitter); `Api._request()` adds JSON decoding (`_decode` -> `ContentError`). Detail-path callers (`Endpoint.get(id)`, `full_details()`, `save()`) use `_request_response` directly to capture the `ETag` header: records store it as `_etag` and `save()` sends `If-Match` (NetBox 4.6+ optimistic locking; stale ETag -> 412 `RequestError`); repeat `full_details()` calls send `If-None-Match` and a 304 (allowed through `_request_response` only when that header was sent) keeps current data without re-parsing). `App.__getattr__` ([app.py](src/aiopynetbox/app.py)) turns any attribute into an `Endpoint` ([endpoint.py](src/aiopynetbox/endpoint.py)), which builds URLs (`_`->`-`) and returns `Record`/`RecordSet` ([response.py](src/aiopynetbox/response.py)). `PluginsApp` (also app.py) routes `nb.plugins.` into `/api/plugins//`. `Endpoint.__init__` resolves its Record subclass from `ENDPOINT_MODELS` in [models.py](src/aiopynetbox/models.py) (`"/"` keys, e.g. `ipam/prefixes` -> `Prefixes` with `available_ips`/`available_prefixes` properties returning a `DetailEndpoint`; `core/data-sources` -> `DataSources` with a `sync` trigger). `register_model()` is the public way to add entries (plugin endpoints use the `"plugins/"` app key). `DetailEndpoint.list()` reuses `RecordSet` - its plain-list branch handles non-paginated detail routes. `App.endpoint(name)` bypasses the `_` -> `-` slug conversion for literal-underscore endpoints. Import order matters: api -> app -> endpoint -> models -> response; response only TYPE_CHECKING-imports the others. @@ -49,12 +49,12 @@ Key mechanics in `response.py`: The package is fully type-annotated (`from __future__ import annotations` everywhere) and ships `py.typed`. `__version__` comes from package metadata via `importlib.metadata`. -Tests run entirely against `FakeNetbox` in [tests/conftest.py](tests/conftest.py) - an in-memory NetBox behind `httpx.MockTransport` (no network, no mocking library). Extend it when adding endpoints/behaviors. +Tests run entirely against `FakeNetbox` in [tests/conftest.py](tests/conftest.py) - an in-memory NetBox behind `httpx2.MockTransport` (no network, no mocking library). Extend it when adding endpoints/behaviors. Not implemented yet (deliberately, add only when needed): napalm helpers (NetBox dropped built-in napalm in 3.5), cable trace helpers, file uploads (multipart), OpenAPI filter validation. ## Conventions -- httpx `AsyncClient` is the only HTTP transport; the client should be usable as an async context manager (`async with aiopynetbox.api(...) as nb:`) so the connection pool is closed deterministically. The context manager is one-shot; `aclose()` closes only clients the Api created - a `client=` passed in is the caller's to close (httpx convention). +- httpx2 `AsyncClient` is the only HTTP transport; the client should be usable as an async context manager (`async with aiopynetbox.api(...) as nb:`) so the connection pool is closed deterministically. The context manager is one-shot; `aclose()` closes only clients the Api created - a `client=` passed in is the caller's to close (httpx2 convention). - No sync wrapper/facade unless explicitly requested. - A local reference clone of pynetbox may exist in the session scratchpad, not in this repo - never vendor pynetbox code without noting its Apache 2.0 license. diff --git a/CHANGELOG.md b/CHANGELOG.md index 44c1204..f5bdf2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-08-17 + +### Changed + +- Switched the HTTP transport from httpx to + [httpx2](https://github.com/pydantic/httpx2), the Pydantic-maintained + fork (httpx development has stalled). The API is unchanged, but a + custom client passed via `client=` must now be an `httpx2.AsyncClient` + (`pip install httpx2`, `import httpx2`). + ## [0.1.0] - 2026-07-24 Initial release. @@ -57,4 +67,5 @@ Initial release. - A runnable FastAPI example (`examples/fastapi_app.py`) showing the app-state / lifespan usage pattern. +[0.2.0]: https://github.com/challey74/aiopynetbox/releases/tag/v0.2.0 [0.1.0]: https://github.com/challey74/aiopynetbox/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c5c7751..016b482 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ All four checks run in CI and must pass. ## Testing conventions Tests run entirely against `FakeNetbox` in `tests/conftest.py`, an -in-memory NetBox served through `httpx.MockTransport`. Tests never touch +in-memory NetBox served through `httpx2.MockTransport`. Tests never touch the network and never require a real NetBox instance. If your change needs an endpoint or behavior the fake doesn't model yet, extend the fake. diff --git a/README.md b/README.md index 3202690..ae39d76 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Python versions](https://img.shields.io/pypi/pyversions/aiopynetbox)](https://pypi.org/project/aiopynetbox/) [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE) -Fully async NetBox API client for Python, built on [httpx](https://www.python-httpx.org/). +Fully async NetBox API client for Python, built on [httpx2](https://github.com/pydantic/httpx2). Inspired by [pynetbox](https://github.com/netbox-community/pynetbox), redesigned for asyncio. This is not a port: pynetbox's core ergonomics (lazy attribute @@ -70,7 +70,7 @@ that isn't loaded raises `AttributeError` telling you to ## Features -- **Explicit async everywhere**: `httpx.AsyncClient` under the hood, used as +- **Explicit async everywhere**: `httpx2.AsyncClient` under the hood, used as an async context manager so the connection pool closes deterministically. - **Concurrent pagination**: after the first page reveals the count, the remaining pages are fetched in parallel (bounded by `max_concurrency`, @@ -162,18 +162,18 @@ async def lifespan(app: FastAPI): One shared instance is safe under concurrent requests. See [examples/fastapi_app.py](examples/fastapi_app.py) for a runnable app. -### Custom httpx client +### Custom httpx2 client -Pass your own `httpx.AsyncClient` for custom SSL, proxies, event hooks, or +Pass your own `httpx2.AsyncClient` for custom SSL, proxies, event hooks, or `MockTransport` in tests: ```python -client = httpx.AsyncClient(verify="/path/to/ca.pem", timeout=60) +client = httpx2.AsyncClient(verify="/path/to/ca.pem", timeout=60) async with aiopynetbox.api(url, token=token, client=client) as nb: ... ``` -Per httpx convention, a client you pass in is yours to close: `aclose()` +Per httpx2 convention, a client you pass in is yours to close: `aclose()` and the context manager only close clients the Api created itself, so one client can safely back several Api instances. diff --git a/pyproject.toml b/pyproject.toml index 17fffda..347891e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "aiopynetbox" -version = "0.1.0" -description = "Fully async NetBox API client built on httpx" +version = "0.2.0" +description = "Fully async NetBox API client built on httpx2" readme = "README.md" authors = [ { name = "Cody Halley" } @@ -9,7 +9,7 @@ authors = [ license = "Apache-2.0" license-files = ["LICENSE"] requires-python = ">=3.11" -keywords = ["netbox", "async", "asyncio", "httpx", "api", "client", "ipam", "dcim"] +keywords = ["netbox", "async", "asyncio", "httpx2", "api", "client", "ipam", "dcim"] classifiers = [ "Development Status :: 4 - Beta", "Framework :: AsyncIO", @@ -25,7 +25,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "httpx>=0.28.1", + "httpx2>=2.0.0", ] [project.urls] diff --git a/scripts/generate_endpoints.py b/scripts/generate_endpoints.py index ac06182..5037059 100644 --- a/scripts/generate_endpoints.py +++ b/scripts/generate_endpoints.py @@ -24,7 +24,7 @@ from pathlib import Path from typing import Any -import httpx +import httpx2 DEFAULT_URL = "https://demo.netbox.dev/api/schema/" SRC = Path(__file__).resolve().parent.parent / "src" / "aiopynetbox" @@ -181,7 +181,7 @@ def endpoint_stub(app: str, attr: str, filters: list[str], fields: list[str]) -> def main() -> None: url = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_URL - resp = httpx.get( + resp = httpx2.get( url, params={"format": "json"}, headers={"Accept": "application/json"}, diff --git a/src/aiopynetbox/api.py b/src/aiopynetbox/api.py index 9dda235..9e90839 100644 --- a/src/aiopynetbox/api.py +++ b/src/aiopynetbox/api.py @@ -7,7 +7,7 @@ from types import TracebackType from typing import Any, Self -import httpx +import httpx2 from aiopynetbox.app import PluginsApp from aiopynetbox.apps_generated import ( @@ -67,7 +67,7 @@ class Api: Retry-After); transient 502/503/504 and connection failures retry for GETs only, since an ambiguous write may have been processed. 0 disables. - client: Custom httpx.AsyncClient (SSL config, proxies, mock + client: Custom httpx2.AsyncClient (SSL config, proxies, mock transports). A supplied client is yours to close; the Api closes only clients it creates itself. """ @@ -81,7 +81,7 @@ def __init__( max_concurrency: int = 4, pagination: str = "offset", retries: int = 3, - client: httpx.AsyncClient | None = None, + client: httpx2.AsyncClient | None = None, ) -> None: if pagination not in ("offset", "cursor"): raise ValueError("pagination must be 'offset' or 'cursor'") @@ -98,7 +98,7 @@ def __init__( self._client = ( client if client is not None - else httpx.AsyncClient(timeout=timeout, follow_redirects=True) + else httpx2.AsyncClient(timeout=timeout, follow_redirects=True) ) self.circuits = CircuitsApp(self, "circuits") @@ -128,7 +128,7 @@ async def aclose(self) -> None: """Close the connection pool, if this Api created it. A client passed in via `client=` is the caller's to close - (httpx convention), so sharing one client across Api instances + (httpx2 convention), so sharing one client across Api instances is safe. """ if self._owns_client: @@ -159,7 +159,7 @@ async def _request_response( params: dict[str, Any] | None = None, json: Any = None, headers: dict[str, str] | None = None, - ) -> httpx.Response: + ) -> httpx2.Response: merged = { "Accept": "application/json", **self._auth_headers(), @@ -172,7 +172,7 @@ async def _request_response( resp = await self._client.request( method, url, params=params, json=json, headers=merged ) - except httpx.TransportError: + except httpx2.TransportError: # An ambiguous failure is only safely repeatable for GETs: # a timed-out write may have been processed server-side. if method != "GET" or attempt >= self.retries: @@ -199,7 +199,7 @@ async def _request_response( attempt += 1 @staticmethod - def _decode(resp: httpx.Response) -> Any: + def _decode(resp: httpx2.Response) -> Any: try: return resp.json() except ValueError: diff --git a/src/aiopynetbox/exceptions.py b/src/aiopynetbox/exceptions.py index fdd59aa..c13544d 100644 --- a/src/aiopynetbox/exceptions.py +++ b/src/aiopynetbox/exceptions.py @@ -2,13 +2,13 @@ from __future__ import annotations -import httpx +import httpx2 class RequestError(Exception): """NetBox returned a non-success HTTP response.""" - def __init__(self, response: httpx.Response) -> None: + def __init__(self, response: httpx2.Response) -> None: self.response = response self.status_code = response.status_code self.url = str(response.url) @@ -28,7 +28,7 @@ class AllocationError(Exception): """NetBox returned 409 Conflict for an allocation request (e.g. available-ips with no room left).""" - def __init__(self, response: httpx.Response) -> None: + def __init__(self, response: httpx2.Response) -> None: self.response = response self.url = str(response.url) self.error = "The requested allocation could not be fulfilled." @@ -38,7 +38,7 @@ def __init__(self, response: httpx.Response) -> None: class ContentError(Exception): """A successful response contained non-JSON content.""" - def __init__(self, response: httpx.Response) -> None: + def __init__(self, response: httpx2.Response) -> None: self.response = response self.url = str(response.url) self.error = ( diff --git a/tests/conftest.py b/tests/conftest.py index ca7d110..82bc197 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ import json import re -import httpx +import httpx2 import pytest import aiopynetbox @@ -73,7 +73,7 @@ def make_device(i, name, serial="", site_id=1): class FakeNetbox: - """Minimal in-memory NetBox served through httpx.MockTransport.""" + """Minimal in-memory NetBox served through httpx2.MockTransport.""" def __init__(self, devices=None, page_size=50): self.devices = {d["id"]: d for d in (devices or [])} @@ -83,7 +83,7 @@ def __init__(self, devices=None, page_size=50): self.next_id = max(self.devices, default=0) + 1 # Failure injection for retry tests: each entry is consumed by one # request before normal routing. An int is an HTTP status to return; - # "transport" raises httpx.ConnectError. + # "transport" raises httpx2.ConnectError. self.fail_next = [] def handler(self, request): @@ -91,26 +91,26 @@ def handler(self, request): if self.fail_next: failure = self.fail_next.pop(0) if failure == "transport": - raise httpx.ConnectError("injected failure") - return httpx.Response( + raise httpx2.ConnectError("injected failure") + return httpx2.Response( failure, json={"detail": "injected"}, headers={"Retry-After": "0"} ) path = request.url.path params = request.url.params if path == "/api/" and request.method == "GET": - return httpx.Response(200, json={}, headers={"API-Version": "4.5"}) + return httpx2.Response(200, json={}, headers={"API-Version": "4.5"}) if path == "/api/status/": - return httpx.Response(200, json={"netbox-version": "4.5.0"}) + return httpx2.Response(200, json={"netbox-version": "4.5.0"}) if path == "/api/schema/": - return httpx.Response(200, json={"openapi": "3.0.3", "paths": {}}) + return httpx2.Response(200, json={"openapi": "3.0.3", "paths": {}}) if path == "/api/users/tokens/provision/" and request.method == "POST": body = json.loads(request.content) if body["username"] == "v1user": - return httpx.Response( + return httpx2.Response( 201, json={"id": 2, "display": "t", "key": "plainv1token"} ) - return httpx.Response( + return httpx2.Response( 201, json={ "id": 1, @@ -121,20 +121,22 @@ def handler(self, request): }, ) if path == "/api/plugins/installed-plugins/": - return httpx.Response(200, json=[{"name": "test_plugin", "version": "1.0"}]) + return httpx2.Response( + 200, json=[{"name": "test_plugin", "version": "1.0"}] + ) if path == "/api/core/data-sources/1/" and request.method == "GET": - return httpx.Response(200, json=DATA_SOURCE_FULL) + return httpx2.Response(200, json=DATA_SOURCE_FULL) if path == "/api/core/data-sources/1/sync/" and request.method == "POST": synced = dict(DATA_SOURCE_FULL) synced["status"] = {"value": "syncing", "label": "Syncing"} - return httpx.Response(200, json=synced) + return httpx2.Response(200, json=synced) if path == "/api/ipam/prefixes/1/" and request.method == "GET": - return httpx.Response(200, json=PREFIX_FULL) + return httpx2.Response(200, json=PREFIX_FULL) if path == "/api/ipam/prefixes/1/available-ips/": if request.method == "GET": - return httpx.Response( + return httpx2.Response( 200, json=[ {"family": 4, "address": f"10.0.0.{i}/29", "vrf": None} @@ -144,7 +146,7 @@ def handler(self, request): body = json.loads(request.content) items = body if isinstance(body, list) else [body] if len(items) > 3: - return httpx.Response( + return httpx2.Response( 409, json={"detail": "Insufficient available IPs."} ) created = [] @@ -157,36 +159,36 @@ def handler(self, request): ip.update(item) created.append(ip) payload = created if isinstance(body, list) else created[0] - return httpx.Response(201, json=payload) + return httpx2.Response(201, json=payload) if m := re.fullmatch(r"/api/dcim/sites/(\d+)/", path): site = self.sites.get(int(m.group(1))) if not site: - return httpx.Response(404, json={"detail": "Not found."}) - return httpx.Response(200, json=site) + return httpx2.Response(404, json={"detail": "Not found."}) + return httpx2.Response(200, json=site) if m := re.fullmatch(r"/api/dcim/devices/(\d+)/", path): device = self.devices.get(int(m.group(1))) if not device: - return httpx.Response(404, json={"detail": "Not found."}) + return httpx2.Response(404, json={"detail": "Not found."}) etag = f'"etag-{device["id"]}"' if request.method == "PATCH": if request.headers.get("If-Match", etag) != etag: - return httpx.Response(412, json={"detail": "Precondition failed."}) + return httpx2.Response(412, json={"detail": "Precondition failed."}) device.update(json.loads(request.content)) - return httpx.Response( + return httpx2.Response( 200, json=device, headers={"ETag": f'"etag-{device["id"]}-v2"'} ) if request.method == "DELETE": del self.devices[device["id"]] - return httpx.Response(204) + return httpx2.Response(204) if request.headers.get("If-None-Match") == etag: - return httpx.Response(304, headers={"ETag": etag}) - return httpx.Response(200, json=device, headers={"ETag": etag}) + return httpx2.Response(304, headers={"ETag": etag}) + return httpx2.Response(200, json=device, headers={"ETag": etag}) if path == "/api/dcim/devices/": if request.method == "OPTIONS": - return httpx.Response(200, json=DEVICE_OPTIONS) + return httpx2.Response(200, json=DEVICE_OPTIONS) if request.method == "PATCH": body = json.loads(request.content) updated = [] @@ -194,12 +196,12 @@ def handler(self, request): device = self.devices[item["id"]] device.update({k: v for k, v in item.items() if k != "id"}) updated.append(device) - return httpx.Response(200, json=updated) + return httpx2.Response(200, json=updated) if request.method == "DELETE": body = json.loads(request.content) for item in body: del self.devices[item["id"]] - return httpx.Response(204) + return httpx2.Response(204) if request.method == "POST": body = json.loads(request.content) created = [] @@ -210,7 +212,7 @@ def handler(self, request): self.next_id += 1 created.append(device) payload = created if isinstance(body, list) else created[0] - return httpx.Response(201, json=payload) + return httpx2.Response(201, json=payload) matches = [ d for d in self.devices.values() @@ -234,7 +236,7 @@ def handler(self, request): if len(remaining) > limit else None ) - return httpx.Response( + return httpx2.Response( 200, json={ "count": None, @@ -246,7 +248,7 @@ def handler(self, request): offset = int(params.get("offset", 0)) page = matches[offset : offset + limit] has_next = offset + limit < len(matches) - return httpx.Response( + return httpx2.Response( 200, json={ "count": len(matches), @@ -258,7 +260,7 @@ def handler(self, request): }, ) - return httpx.Response(500, json={"error": f"unhandled path {path}"}) + return httpx2.Response(500, json={"error": f"unhandled path {path}"}) @pytest.fixture @@ -275,7 +277,7 @@ def fake(): def make_api(fake, token="abc123", **kwargs): - client = httpx.AsyncClient(transport=httpx.MockTransport(fake.handler)) + client = httpx2.AsyncClient(transport=httpx2.MockTransport(fake.handler)) return aiopynetbox.api(BASE, token=token, client=client, **kwargs) diff --git a/tests/test_client.py b/tests/test_client.py index 3ded3cd..501f7c3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 import pytest from conftest import BASE, FakeNetbox, make_api, make_device @@ -115,9 +115,9 @@ async def test_request_error_on_500(nb): async def test_content_error_on_non_json(): def handler(request): - return httpx.Response(200, text="not netbox") + return httpx2.Response(200, text="not netbox") - client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) async with aiopynetbox.api(BASE, client=client) as nb: with pytest.raises(aiopynetbox.ContentError): await nb.dcim.devices.get(1) @@ -131,7 +131,7 @@ async def test_context_manager_closes_owned_client(): async def test_context_manager_leaves_supplied_client_open(fake): - client = httpx.AsyncClient(transport=httpx.MockTransport(fake.handler)) + client = httpx2.AsyncClient(transport=httpx2.MockTransport(fake.handler)) async with aiopynetbox.api(BASE, token="abc123", client=client) as nb: await nb.dcim.devices.get(1) assert not client.is_closed diff --git a/tests/test_retry.py b/tests/test_retry.py index 09ab53c..f2a962b 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -1,4 +1,4 @@ -import httpx +import httpx2 import pytest from conftest import make_api @@ -50,7 +50,7 @@ async def test_transport_error_retried_for_get(nb, fake): async def test_transport_error_not_retried_for_post(nb, fake): fake.fail_next = ["transport"] - with pytest.raises(httpx.ConnectError): + with pytest.raises(httpx2.ConnectError): await nb.dcim.devices.create(name="sw-new") assert len(fake.requests) == 1 diff --git a/uv.lock b/uv.lock index b80b4c3..445e361 100644 --- a/uv.lock +++ b/uv.lock @@ -1,13 +1,17 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' or sys_platform != 'emscripten'", +] [[package]] name = "aiopynetbox" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ - { name = "httpx" }, + { name = "httpx2" }, ] [package.dev-dependencies] @@ -19,7 +23,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "httpx", specifier = ">=0.28.1" }] +requires-dist = [{ name = "httpx2", specifier = ">=2.0.0" }] [package.metadata.requires-dev] dev = [ @@ -34,23 +38,14 @@ name = "anyio" version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "idna", marker = "python_full_version < '3.12' or sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] -[[package]] -name = "certifi" -version = "2026.7.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -70,31 +65,42 @@ wheels = [ ] [[package]] -name = "httpcore" -version = "1.0.9" +name = "httpcore2" +version = "2.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, - { name = "h11" }, + { name = "h11", marker = "python_full_version < '3.12' or sys_platform != 'emscripten'" }, + { name = "truststore", marker = "python_full_version < '3.12' or sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/83/a896fc59940fc5a6e2aff3a4be1d92fa890112936803b331cae75a993c34/httpcore2-2.10.0.tar.gz", hash = "sha256:13c0cc3d1919d4f28457f60cd2c2abe04113a8af184ccf1142811beba936f9dc", size = 67427, upload-time = "2026-08-09T09:11:32.123Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4f/d149104195a35e2853a2fc203a8e3477747e58c80e17dda686dace174383/httpcore2-2.10.0-py3-none-any.whl", hash = "sha256:7df06cfb34070cae4f7c89be69dc1095eca138e9704ceffb98d25c1912ab6f01", size = 83000, upload-time = "2026-08-09T09:11:29.555Z" }, ] [[package]] -name = "httpx" -version = "0.28.1" +name = "httpx2" +version = "2.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/3d/f9a8c07a3884f3e5b26205e8436a18b3af61c5d53192c3bea235574dbbec/httpx2-2.10.0.tar.gz", hash = "sha256:8741d7329fe2c7885fc9ceb61c8217acfb87a85f75723714b89ebf7ad7196338", size = 98749, upload-time = "2026-08-09T09:11:33.24Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/6d/a637d52449d98a6892d9a4dc0262587afdb6a66f201871842dce5a97b1c1/httpx2-2.10.0-py3-none-any.whl", hash = "sha256:5e3194a432701e1cc6f69a8b1b2fa199ef907013fede8d9a09a2c5b7b8141a18", size = 94355, upload-time = "2026-08-09T09:11:30.882Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -218,6 +224,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"