From d30ebe96d2993af9bd2b69f46c0f73faa3b03b60 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 30 Jun 2026 00:07:30 +0100 Subject: [PATCH] Swap `httpx` for `httpx2` `httpx` has stopped getting updates and now gives a warning from `starlette`. `httpx2` is a drop-in replacement. Fixes #359 --- dev-requirements.txt | 35 ++++++++++++++++++++++-- pyproject.toml | 2 +- src/labthings_fastapi/client/__init__.py | 24 ++++++++-------- src/labthings_fastapi/outputs/blob.py | 12 ++++---- tests/test_action_manager.py | 4 +-- tests/test_thing_client.py | 6 ++-- 6 files changed, 56 insertions(+), 27 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index 7ca9f741..5b747517 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -13,6 +13,7 @@ annotated-types==0.7.0 anyio==4.14.0 # via # httpx + # httpx2 # labthings-fastapi # starlette # watchfiles @@ -81,6 +82,10 @@ email-validator==2.3.0 # via # fastapi # pydantic +exceptiongroup==1.3.1 + # via + # anyio + # pytest fastapi==0.135.4 # via labthings-fastapi fastapi-cli==0.0.27 @@ -109,24 +114,29 @@ flake8-rst-docstrings==0.4.0 h11==0.16.0 # via # httpcore + # httpcore2 # uvicorn html5lib==1.1 # via sphinx-toolbox httpcore==1.0.9 # via httpx +httpcore2==2.5.0 + # via httpx2 httptools==0.8.0 # via uvicorn httpx==0.28.1 # via # fastapi # fastapi-cloud-cli - # labthings-fastapi +httpx2==2.5.0 + # via labthings-fastapi idna==3.18 # via # anyio # apeye-core # email-validator # httpx + # httpx2 # requests # sphinx-prompt ifaddr==0.2.0 @@ -325,14 +335,26 @@ sphinxcontrib-serializinghtml==2.0.0 # via sphinx sphobjinv==2.4 # via labthings-fastapi -standard-imghdr==3.10.14 - # via sphinx-jinja2-compat starlette==1.3.1 # via fastapi tabulate==0.10.0 # via sphinx-toolbox tinycss2==1.5.1 # via dict2css +tomli==2.4.1 + # via + # coverage + # fastapi-cli + # flake8-pyproject + # labthings-fastapi + # mypy + # pydoclint + # pytest + # sphinx +truststore==0.10.4 + # via + # httpcore2 + # httpx2 typer==0.26.7 # via # fastapi-cli @@ -341,17 +363,24 @@ types-jsonschema==4.26.0.20260518 # via labthings-fastapi typing-extensions==4.15.0 # via + # anyio + # astroid # beautifulsoup4 # domdf-python-tools + # exceptiongroup # fastapi + # httpx2 # labthings-fastapi # mypy # pydantic # pydantic-core # pydantic-extra-types + # referencing # rich-toolkit # sphinx-toolbox + # starlette # typing-inspection + # uvicorn typing-inspection==0.4.2 # via # fastapi diff --git a/pyproject.toml b/pyproject.toml index cd70b41c..5a4869f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "jsonschema", "typing_extensions", "anyio ~=4.0", - "httpx", + "httpx2", "fastapi[all]~=0.135.0", "zeroconf >=0.28.0", ] diff --git a/src/labthings_fastapi/client/__init__.py b/src/labthings_fastapi/client/__init__.py index 64a567cf..3cceea85 100644 --- a/src/labthings_fastapi/client/__init__.py +++ b/src/labthings_fastapi/client/__init__.py @@ -1,7 +1,7 @@ """Code to access `~lt.Thing` features over HTTP. This module defines a base class for controlling LabThings-FastAPI over HTTP. -It is based on `httpx`, and attempts to create a simple wrapper such that +It is based on `httpx2`, and attempts to create a simple wrapper such that each Action becomes a method and each Property becomes an attribute. """ @@ -12,7 +12,7 @@ from typing import Any, Optional, Union from urllib.parse import urljoin, urlparse -import httpx +import httpx2 from pydantic import BaseModel, TypeAdapter, ValidationError from typing_extensions import Self # 3.9, 3.10 compatibility @@ -86,7 +86,7 @@ def invocation_href(invocation: dict) -> str: def poll_invocation( - client: httpx.Client, + client: httpx2.Client, invocation: dict, interval: float = 0.5, first_interval: float = 0.05, @@ -99,7 +99,7 @@ def poll_invocation( has completed, whether it was successful, and retrieve its output. - :param client: the `httpx.Client` to use for HTTP requests. + :param client: the `httpx2.Client` to use for HTTP requests. :param invocation: the dictionary returned from the initial POST request. :param interval: sets how frequently we poll, in seconds. :param first_interval: sets how long we wait before the first @@ -139,12 +139,12 @@ class ThingClient: creates a subclass with the right attributes. """ - def __init__(self, base_url: str, client: Optional[httpx.Client] = None) -> None: + def __init__(self, base_url: str, client: Optional[httpx2.Client] = None) -> None: """Create a ThingClient connected to a remote Thing. :param base_url: the base URL of the Thing. This should be the URL of the Thing Description document. - :param client: an optional `httpx.Client` object to use for all + :param client: an optional `httpx2.Client` object to use for all HTTP requests. This may be a `fastapi.TestClient` object for testing purposes. """ @@ -152,7 +152,7 @@ def __init__(self, base_url: str, client: Optional[httpx.Client] = None) -> None server = f"{parsed.scheme}://{parsed.netloc}" self.server = server self.path = parsed.path - self.client = client or httpx.Client(base_url=server) + self.client = client or httpx2.Client(base_url=server) def get_property(self, path: str) -> Any: """Make a GET request to retrieve the value of a property. @@ -253,7 +253,7 @@ def invoke_action(self, path: str, **kwargs: Any) -> Any: # noqa: DOC503 # error DOC503 for this function. raise _invocation_error(invocation) - def follow_link(self, response: dict, rel: str) -> httpx.Response: + def follow_link(self, response: dict, rel: str) -> httpx2.Response: """Follow a link in a response object, by its `rel` attribute. :param response: is the dictionary returned by e.g. `.poll_invocation`. @@ -268,7 +268,7 @@ def follow_link(self, response: dict, rel: str) -> httpx.Response: return r @classmethod - def from_url(cls, thing_url: str, client: Optional[httpx.Client] = None) -> Self: + def from_url(cls, thing_url: str, client: Optional[httpx2.Client] = None) -> Self: """Create a ThingClient from a URL. This will dynamically create a subclass with properties and actions, @@ -276,7 +276,7 @@ def from_url(cls, thing_url: str, client: Optional[httpx.Client] = None) -> Self :param thing_url: The base URL of the Thing, which should also be the URL of its Thing Description. - :param client: is an optional `httpx.Client` object. If not present, + :param client: is an optional `httpx2.Client` object. If not present, one will be created. This is particularly useful if you need to set HTTP options, or if you want to work with a local server object for testing purposes (see `fastapi.TestClient`). @@ -284,7 +284,7 @@ def from_url(cls, thing_url: str, client: Optional[httpx.Client] = None) -> Self :return: a `~lt.ThingClient` subclass with properties and methods that match the retrieved Thing Description (see :ref:`wot_thing`). """ - td_client = client or httpx + td_client = client or httpx2 r = td_client.get(thing_url) r.raise_for_status() subclass = cls.subclass_from_td(r.json()) @@ -460,7 +460,7 @@ def add_property(cls: type[ThingClient], property_name: str, property: dict) -> ) -def _construct_failed_to_invoke_message(path: str, response: httpx.Response) -> str: +def _construct_failed_to_invoke_message(path: str, response: httpx2.Response) -> str: """Format an error for ThingClient to raise if an invocation fails to start. :param path: The path of the action diff --git a/src/labthings_fastapi/outputs/blob.py b/src/labthings_fastapi/outputs/blob.py index f01f1759..c3f6275e 100644 --- a/src/labthings_fastapi/outputs/blob.py +++ b/src/labthings_fastapi/outputs/blob.py @@ -98,7 +98,7 @@ def get_image(self) -> MyImageBlob: ) from weakref import WeakValueDictionary -import httpx +import httpx2 from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse, Response from pydantic import ( @@ -195,18 +195,18 @@ class RemoteBlobData(BlobData): """ def __init__( - self, media_type: str, href: str, client: httpx.Client | None = None + self, media_type: str, href: str, client: httpx2.Client | None = None ) -> None: """Create a reference to remote `.Blob` data. :param media_type: the MIME type of the data. :param href: the URL where it may be downloaded. - :param client: if supplied, this `httpx.Client` will be used to + :param client: if supplied, this `httpx2.Client` will be used to download the data. """ super().__init__(media_type=media_type) self._href = href - self._client = client or httpx.Client() + self._client = client or httpx2.Client() def get_href(self) -> str: """Return the URL to download the data. @@ -882,7 +882,7 @@ def from_file(cls, file: str, media_type: str | None = None) -> Self: def from_url( cls, href: str, - client: httpx.Client | None = None, + client: httpx2.Client | None = None, media_type: str | None = None, ) -> Self: """Create a `.Blob` that references data at a URL. @@ -892,7 +892,7 @@ def from_url( of `.Blob` that has set ``media_type``. :param href: the URL where the data may be downloaded. - :param client: if supplied, this `httpx.Client` will be used to + :param client: if supplied, this `httpx2.Client` will be used to download the data. :param media_type: the media type of the supplied data, defaults to the ``media_type`` attribute of this class. diff --git a/tests/test_action_manager.py b/tests/test_action_manager.py index 11026bb6..f86107e0 100644 --- a/tests/test_action_manager.py +++ b/tests/test_action_manager.py @@ -1,6 +1,6 @@ import time -import httpx +import httpx2 import pytest import labthings_fastapi as lt @@ -59,7 +59,7 @@ def test_action_expires(client): invocation["status"] = "running" # Force an extra poll # When the second action runs, the first one should expire # so polling it again should give a 404. - with pytest.raises(httpx.HTTPStatusError): + with pytest.raises(httpx2.HTTPStatusError): poll_task(client, invocation) diff --git a/tests/test_thing_client.py b/tests/test_thing_client.py index 69a473c2..d594b848 100644 --- a/tests/test_thing_client.py +++ b/tests/test_thing_client.py @@ -2,7 +2,7 @@ import re -import httpx +import httpx2 import pytest import labthings_fastapi as lt @@ -288,8 +288,8 @@ def test_poll_invocation_errors(mocker): "id": 0, } # This error conforms to "problem_details" format - client = mocker.Mock(spec=httpx.Client) - response = mocker.Mock(spec=httpx.Response) + client = mocker.Mock(spec=httpx2.Client) + response = mocker.Mock(spec=httpx2.Response) response.is_error = True response.status_code = 500 response.json = mocker.MagicMock(return_value={"detail": "expected error text"})