From f0e98372672f7c40a2061b78be7a0e95a0dc5edd Mon Sep 17 00:00:00 2001 From: pastld Date: Thu, 29 Oct 2026 14:15:59 -0600 Subject: [PATCH 1/4] Httpx to httpx2 migration --- pyproject.toml | 2 +- src/bluesky_httpserver/authenticators.py | 18 +++++++++--------- .../authorization/api_access.py | 4 ++-- src/bluesky_httpserver/tests/__init__.py | 5 +++++ src/bluesky_httpserver/tests/conftest.py | 8 +++++--- .../tests/test_authenticators.py | 12 ++++++------ .../tests/test_oidc_proxied_authenticators.py | 4 ++-- 7 files changed, 30 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5fd7d63..f27e7ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "bluesky-queueserver-api", "cachetools", "fastapi", - "httpx", + "httpx2", "ldap3", "orjson", "pamela", diff --git a/src/bluesky_httpserver/authenticators.py b/src/bluesky_httpserver/authenticators.py index 6f24887..6b6a860 100644 --- a/src/bluesky_httpserver/authenticators.py +++ b/src/bluesky_httpserver/authenticators.py @@ -9,7 +9,7 @@ from datetime import timedelta from typing import Any, cast -import httpx +import httpx2 from cachetools import TTLCache, cached from fastapi import APIRouter, Request from fastapi.security import OAuth2, OAuth2AuthorizationCodeBearer @@ -151,7 +151,7 @@ def __init__( @functools.cached_property def _config_from_oidc_url(self) -> dict[str, Any]: - response: httpx.Response = httpx.get(self._well_known_url) + response: httpx2.Response = httpx2.get(self._well_known_url) response.raise_for_status() return response.json() @@ -179,8 +179,8 @@ def token_endpoint(self) -> str: return cast(str, self._config_from_oidc_url.get("token_endpoint")) @functools.cached_property - def authorization_endpoint(self) -> httpx.URL: - return httpx.URL(cast(str, self._config_from_oidc_url.get("authorization_endpoint"))) + def authorization_endpoint(self) -> httpx2.URL: + return httpx2.URL(cast(str, self._config_from_oidc_url.get("authorization_endpoint"))) @functools.cached_property def device_authorization_endpoint(self) -> str: @@ -192,7 +192,7 @@ def end_session_endpoint(self) -> str: @cached(TTLCache(maxsize=1, ttl=timedelta(hours=1).total_seconds())) def keys(self) -> list[str]: - return httpx.get(self.jwks_uri).raise_for_status().json().get("keys", []) + return httpx2.get(self.jwks_uri).raise_for_status().json().get("keys", []) def decode_token(self, id_token: str, access_token: str | None = None) -> dict[str, Any]: return jwt.decode( @@ -419,7 +419,7 @@ async def graph_lookup(self, access_token, user_param): """Uses the access token provided in the auth flow to lookup a user parameter""" headers = {"Authorization": f"Bearer {access_token}"} - async with httpx.AsyncClient() as client: + async with httpx2.AsyncClient() as client: response = await client.get( "https://graph.microsoft.com/v1.0/me", params={"$select": user_param}, @@ -457,7 +457,7 @@ async def get_username_from_graph(self, access_token): try: profile = await self.graph_lookup(access_token, self.graph_username_attribute) logger.debug("Graph Profile: %r", profile) - except (httpx.HTTPStatusError, httpx.RequestError, ValueError): + except (httpx2.HTTPStatusError, httpx2.RequestError, ValueError): logger.warning("Graph lookup failed") username = None if profile: @@ -566,7 +566,7 @@ async def exchange_code( client_secret: str, redirect_uri: str, extra_scopes: list[str] | None = None, -) -> httpx.Response: +) -> httpx2.Response: """Exchange an authorization code for tokens at the IdP token endpoint. Explicitly requests ``openid offline_access`` scopes in the token POST body @@ -588,7 +588,7 @@ async def exchange_code( if extra_scopes: scopes.update(extra_scopes) auth_value = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() - response = httpx.post( + response = httpx2.post( url=token_uri, data={ "grant_type": "authorization_code", diff --git a/src/bluesky_httpserver/authorization/api_access.py b/src/bluesky_httpserver/authorization/api_access.py index 54263d9..55a3fd1 100644 --- a/src/bluesky_httpserver/authorization/api_access.py +++ b/src/bluesky_httpserver/authorization/api_access.py @@ -5,7 +5,7 @@ import time as ttime from collections.abc import Iterable -import httpx +import httpx2 import jsonschema import yaml @@ -610,7 +610,7 @@ async def update_access_info(self): Send a single request to the API server and update locally stored access control info. """ access_api = f"/instrument/{self._instrument.lower()}/qserver/access" - async with httpx.AsyncClient(base_url=self._base_url, timeout=self._http_timeout) as client: + async with httpx2.AsyncClient(base_url=self._base_url, timeout=self._http_timeout) as client: response = await client.get(access_api) response.raise_for_status() groups = response.json() diff --git a/src/bluesky_httpserver/tests/__init__.py b/src/bluesky_httpserver/tests/__init__.py index e69de29..099f55c 100644 --- a/src/bluesky_httpserver/tests/__init__.py +++ b/src/bluesky_httpserver/tests/__init__.py @@ -0,0 +1,5 @@ +import httpx2 + +# RESPX currently imports and patches the ``httpx`` module. Register HTTPX2's +# migration alias before pytest loads conftest.py and its RESPX import. +httpx2.alias_httpx() diff --git a/src/bluesky_httpserver/tests/conftest.py b/src/bluesky_httpserver/tests/conftest.py index 5dbe740..691de39 100644 --- a/src/bluesky_httpserver/tests/conftest.py +++ b/src/bluesky_httpserver/tests/conftest.py @@ -2,7 +2,7 @@ import time as ttime from typing import Any -import httpx +import httpx2 import pytest import requests from bluesky_queueserver.manager.comms import zmq_single_request @@ -256,9 +256,11 @@ def mock_oidc_server( well_known_response: dict[str, Any], json_web_keyset: list[dict[str, Any]], ) -> MockRouter: - respx_mock.get(oidc_well_known_url).mock(return_value=httpx.Response(httpx.codes.OK, json=well_known_response)) + respx_mock.get(oidc_well_known_url).mock( + return_value=httpx2.Response(httpx2.codes.OK, json=well_known_response) + ) respx_mock.get(well_known_response["jwks_uri"]).mock( - return_value=httpx.Response(httpx.codes.OK, json={"keys": json_web_keyset}) + return_value=httpx2.Response(httpx2.codes.OK, json={"keys": json_web_keyset}) ) return respx_mock diff --git a/src/bluesky_httpserver/tests/test_authenticators.py b/src/bluesky_httpserver/tests/test_authenticators.py index 98be453..446f495 100644 --- a/src/bluesky_httpserver/tests/test_authenticators.py +++ b/src/bluesky_httpserver/tests/test_authenticators.py @@ -6,7 +6,7 @@ from typing import Any from unittest.mock import MagicMock -import httpx +import httpx2 import pytest from cryptography.hazmat.primitives.asymmetric import rsa from fastapi import HTTPException @@ -183,7 +183,7 @@ def mock_decode_token(self, id_token, access_token): async def test_proxied_oidc_token_retrieval(oidc_well_known_url: str, mock_oidc_server: MockRouter): authenticator = ProxiedOIDCAuthenticator("tiled", "tiled", oidc_well_known_url, device_flow_client_id="tiled-cli") - test_request = httpx.Request("GET", "http://example.com", headers={"Authorization": "bearer FOO"}) + test_request = httpx2.Request("GET", "http://example.com", headers={"Authorization": "bearer FOO"}) assert "FOO" == await authenticator.oauth2_schema(test_request) @@ -225,7 +225,7 @@ async def test_OIDCAuthenticator_mock( } mock_oidc_server.post(well_known_response["token_endpoint"]).mock( - return_value=httpx.Response( + return_value=httpx2.Response( 200, json={ "access_token": "mock-access-token", @@ -284,7 +284,7 @@ async def test_OIDCAuthenticator_token_exchange_failure( well_known_response, ): mock_oidc_server.post(well_known_response["token_endpoint"]).mock( - return_value=httpx.Response( + return_value=httpx2.Response( 400, json={ "error": "invalid_client", @@ -476,8 +476,8 @@ def __init__(self): def copy_with(self, params): self.captured_params = params - # Return an httpx.URL so RedirectResponse can str() it cleanly. - return httpx.URL("https://idp.example.com/authorize").copy_with(params=params) + # Return an httpx2.URL so RedirectResponse can str() it cleanly. + return httpx2.URL("https://idp.example.com/authorize").copy_with(params=params) @pytest.mark.asyncio diff --git a/src/bluesky_httpserver/tests/test_oidc_proxied_authenticators.py b/src/bluesky_httpserver/tests/test_oidc_proxied_authenticators.py index 044ac76..f641d79 100644 --- a/src/bluesky_httpserver/tests/test_oidc_proxied_authenticators.py +++ b/src/bluesky_httpserver/tests/test_oidc_proxied_authenticators.py @@ -1,6 +1,6 @@ """Tests for OIDC Authenticator functionality.""" -import httpx +import httpx2 import pytest from respx import MockRouter @@ -26,7 +26,7 @@ async def test_proxied_oidc_oauth2_schema( ) # Create a mock request with Authorization header - test_request = httpx.Request( + test_request = httpx2.Request( "GET", "http://example.com/api/test", headers={"Authorization": "Bearer TEST_TOKEN"}, From ed4281dc2f1997e919b8b351bdc083c7dc689295 Mon Sep 17 00:00:00 2001 From: pastld Date: Thu, 29 Oct 2026 14:29:37 -0600 Subject: [PATCH 2/4] SEcond attempt with sol --- pyproject.toml | 1 - src/bluesky_httpserver/authenticators.py | 1 + src/bluesky_httpserver/tests/__init__.py | 5 --- src/bluesky_httpserver/tests/conftest.py | 23 ++++++++--- src/bluesky_httpserver/tests/httpx2_mock.py | 40 +++++++++++++++++++ .../tests/test_authenticators.py | 32 ++++++++++++++- .../tests/test_httpx2_access.py | 25 ++++++++++++ .../tests/test_oidc_authenticators.py | 3 +- .../tests/test_oidc_proxied_authenticators.py | 3 +- 9 files changed, 119 insertions(+), 14 deletions(-) create mode 100644 src/bluesky_httpserver/tests/httpx2_mock.py create mode 100644 src/bluesky_httpserver/tests/test_httpx2_access.py diff --git a/pyproject.toml b/pyproject.toml index f27e7ee..61d0c75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,6 @@ dev = [ "pytest-xprocess", "pytest-split", "py", - "respx", "sphinx", "ipython", "numpydoc", diff --git a/src/bluesky_httpserver/authenticators.py b/src/bluesky_httpserver/authenticators.py index 6b6a860..5940ccd 100644 --- a/src/bluesky_httpserver/authenticators.py +++ b/src/bluesky_httpserver/authenticators.py @@ -454,6 +454,7 @@ async def get_username_from_graph(self, access_token): If no username is found, there are errors in looking up the graphAPI username, or whatever it returns None """ + profile = None try: profile = await self.graph_lookup(access_token, self.graph_username_attribute) logger.debug("Graph Profile: %r", profile) diff --git a/src/bluesky_httpserver/tests/__init__.py b/src/bluesky_httpserver/tests/__init__.py index 099f55c..e69de29 100644 --- a/src/bluesky_httpserver/tests/__init__.py +++ b/src/bluesky_httpserver/tests/__init__.py @@ -1,5 +0,0 @@ -import httpx2 - -# RESPX currently imports and patches the ``httpx`` module. Register HTTPX2's -# migration alias before pytest loads conftest.py and its RESPX import. -httpx2.alias_httpx() diff --git a/src/bluesky_httpserver/tests/conftest.py b/src/bluesky_httpserver/tests/conftest.py index 691de39..a45a540 100644 --- a/src/bluesky_httpserver/tests/conftest.py +++ b/src/bluesky_httpserver/tests/conftest.py @@ -12,14 +12,16 @@ ) from cryptography.hazmat.primitives.asymmetric import rsa from jose.backends import RSAKey -from respx import MockRouter from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from xprocess import ProcessStarter +import bluesky_httpserver.authenticators as authenticators import bluesky_httpserver.server as bqss from bluesky_httpserver.database.base import Base +from .httpx2_mock import MockRouter + SERVER_ADDRESS = "localhost" SERVER_PORT = "60610" @@ -251,18 +253,29 @@ def json_web_keyset(keys: tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]) -> list[di @pytest.fixture def mock_oidc_server( - respx_mock: MockRouter, + monkeypatch: pytest.MonkeyPatch, oidc_well_known_url: str, well_known_response: dict[str, Any], json_web_keyset: list[dict[str, Any]], ) -> MockRouter: - respx_mock.get(oidc_well_known_url).mock( + mock_router = MockRouter() + mock_router.get(oidc_well_known_url).mock( return_value=httpx2.Response(httpx2.codes.OK, json=well_known_response) ) - respx_mock.get(well_known_response["jwks_uri"]).mock( + mock_router.get(well_known_response["jwks_uri"]).mock( return_value=httpx2.Response(httpx2.codes.OK, json={"keys": json_web_keyset}) ) - return respx_mock + monkeypatch.setattr( + authenticators.httpx2, + "get", + lambda url, **kwargs: mock_router.request("GET", url, **kwargs), + ) + monkeypatch.setattr( + authenticators.httpx2, + "post", + lambda url, **kwargs: mock_router.request("POST", url, **kwargs), + ) + return mock_router @pytest.fixture diff --git a/src/bluesky_httpserver/tests/httpx2_mock.py b/src/bluesky_httpserver/tests/httpx2_mock.py new file mode 100644 index 0000000..f5ff992 --- /dev/null +++ b/src/bluesky_httpserver/tests/httpx2_mock.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass + +import httpx2 + + +@dataclass +class Call: + request: httpx2.Request + + +class Route: + def __init__(self, router: "MockRouter", method: str, url: str): + self._router = router + self._method = method + self._url = url + + def mock(self, *, return_value: httpx2.Response) -> None: + self._router._responses[self._method, self._url] = return_value + + +class MockRouter: + def __init__(self): + self._responses: dict[tuple[str, str], httpx2.Response] = {} + self.calls: list[Call] = [] + + def get(self, url: str) -> Route: + return Route(self, "GET", url) + + def post(self, url: str) -> Route: + return Route(self, "POST", url) + + def request(self, method: str, url: str, **kwargs) -> httpx2.Response: + request = httpx2.Request(method, url, **kwargs) + self.calls.append(Call(request)) + try: + response = self._responses[method, str(request.url)] + except KeyError as exc: + raise AssertionError(f"Unexpected HTTPX2 request: {method} {request.url}") from exc + response.request = request + return response diff --git a/src/bluesky_httpserver/tests/test_authenticators.py b/src/bluesky_httpserver/tests/test_authenticators.py index 446f495..fc31a4e 100644 --- a/src/bluesky_httpserver/tests/test_authenticators.py +++ b/src/bluesky_httpserver/tests/test_authenticators.py @@ -12,12 +12,12 @@ from fastapi import HTTPException from fastapi.security import SecurityScopes from jose import ExpiredSignatureError, jwt -from respx import MockRouter from starlette.datastructures import URL, QueryParams from starlette.requests import Request from bluesky_httpserver import authentication as _auth +from .. import authenticators as authenticators_module from ..authenticators import ( EntraAuthenticator, LDAPAuthenticator, @@ -25,6 +25,7 @@ ProxiedOIDCAuthenticator, UserSessionState, ) +from .httpx2_mock import MockRouter LDAP_TEST_HOST = os.environ.get("QSERVER_TEST_LDAP_HOST", "localhost") LDAP_TEST_PORT = int(os.environ.get("QSERVER_TEST_LDAP_PORT", "1389")) @@ -188,6 +189,35 @@ async def test_proxied_oidc_token_retrieval(oidc_well_known_url: str, mock_oidc_ assert "FOO" == await authenticator.oauth2_schema(test_request) +@pytest.mark.asyncio +@pytest.mark.parametrize("status_code, expected", [(200, "alice"), (503, None)]) +async def test_entra_graph_lookup_httpx2( + mock_oidc_server, oidc_well_known_url, monkeypatch, status_code, expected +): + authenticator = EntraAuthenticator( + audience="tiled", + client_id="tiled", + well_known_uri=oidc_well_known_url, + device_flow_client_id="tiled-cli", + graph_username_attribute="userPrincipalName", + ) + + def handle_request(request): + assert request.url == "https://graph.microsoft.com/v1.0/me?%24select=userPrincipalName" + assert request.headers["Authorization"] == "Bearer graph-token" + return httpx2.Response(status_code, json={"userPrincipalName": "alice"}) + + client_class = httpx2.AsyncClient + transport = httpx2.MockTransport(handle_request) + monkeypatch.setattr( + authenticators_module.httpx2, + "AsyncClient", + lambda **kwargs: client_class(transport=transport, **kwargs), + ) + + assert await authenticator.get_username_from_graph("graph-token") == expected + + def create_mock_oidc_request(query_params=None): if query_params is None: query_params = {} diff --git a/src/bluesky_httpserver/tests/test_httpx2_access.py b/src/bluesky_httpserver/tests/test_httpx2_access.py new file mode 100644 index 0000000..24a0d6c --- /dev/null +++ b/src/bluesky_httpserver/tests/test_httpx2_access.py @@ -0,0 +1,25 @@ +import httpx2 +import pytest + +from bluesky_httpserver.authorization import ServerBasedAPIAccessControl, api_access + + +@pytest.mark.asyncio +async def test_server_based_access_control_httpx2(monkeypatch): + def handle_request(request): + assert request.method == "GET" + assert request.url == "https://access.example.com/instrument/tst/qserver/access" + return httpx2.Response(200, json={"user": {"alice": {"first_name": "Alice"}}}) + + client_class = httpx2.AsyncClient + transport = httpx2.MockTransport(handle_request) + monkeypatch.setattr( + api_access.httpx2, + "AsyncClient", + lambda **kwargs: client_class(transport=transport, **kwargs), + ) + + manager = ServerBasedAPIAccessControl(instrument="TST", base_url="https://access.example.com") + await manager.update_access_info() + assert manager.get_user_roles("alice") == {"user"} + assert manager.get_displayed_user_name("alice") == 'alice "Alice"' diff --git a/src/bluesky_httpserver/tests/test_oidc_authenticators.py b/src/bluesky_httpserver/tests/test_oidc_authenticators.py index 953f87b..9915f08 100644 --- a/src/bluesky_httpserver/tests/test_oidc_authenticators.py +++ b/src/bluesky_httpserver/tests/test_oidc_authenticators.py @@ -6,10 +6,11 @@ import pytest from cryptography.hazmat.primitives.asymmetric import rsa from jose import ExpiredSignatureError, jwt -from respx import MockRouter from bluesky_httpserver.authenticators import OIDCAuthenticator +from .httpx2_mock import MockRouter + def create_token(issued: bool, expired: bool) -> dict[str, Any]: """Create a test JWT token.""" diff --git a/src/bluesky_httpserver/tests/test_oidc_proxied_authenticators.py b/src/bluesky_httpserver/tests/test_oidc_proxied_authenticators.py index f641d79..c5fa289 100644 --- a/src/bluesky_httpserver/tests/test_oidc_proxied_authenticators.py +++ b/src/bluesky_httpserver/tests/test_oidc_proxied_authenticators.py @@ -2,10 +2,11 @@ import httpx2 import pytest -from respx import MockRouter from bluesky_httpserver.authenticators import ProxiedOIDCAuthenticator +from .httpx2_mock import MockRouter + @pytest.mark.filterwarnings("ignore::DeprecationWarning") class TestProxiedOIDCAuthenticator: From 5531e957bc3219d3db93ac463d9c045113434a80 Mon Sep 17 00:00:00 2001 From: pastld Date: Fri, 30 Oct 2026 07:16:53 -0600 Subject: [PATCH 3/4] Updating release-history --- docs/source/release-history.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/source/release-history.rst b/docs/source/release-history.rst index 0b74c0b..aa5ad11 100644 --- a/docs/source/release-history.rst +++ b/docs/source/release-history.rst @@ -11,6 +11,11 @@ Removed - Support for Python 3.10. The minimum supported Python version is now 3.11. +Changed +------- + +- Replace the `httpx` dependency with `httpx2`. + v0.0.14 (2026-01-21) ==================== From b95bb15ffc33c110979af645946d090da85d84ae Mon Sep 17 00:00:00 2001 From: pastld Date: Fri, 30 Oct 2026 08:35:03 -0600 Subject: [PATCH 4/4] Noticed a repeated line and cleaning it up --- .github/workflows/pre-commit.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index bce0120..a3ea0fb 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -36,7 +36,6 @@ jobs: - name: Install dev dependencies run: | - set -vxeuo pipefail set -vxeuo pipefail pixi install --environment=${{ env.python_env }} pixi list --environment=${{ env.python_env }}