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 }} 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) ==================== diff --git a/pyproject.toml b/pyproject.toml index 5fd7d63..61d0c75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "bluesky-queueserver-api", "cachetools", "fastapi", - "httpx", + "httpx2", "ldap3", "orjson", "pamela", @@ -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 6f24887..5940ccd 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}, @@ -454,10 +454,11 @@ 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) - except (httpx.HTTPStatusError, httpx.RequestError, ValueError): + except (httpx2.HTTPStatusError, httpx2.RequestError, ValueError): logger.warning("Graph lookup failed") username = None if profile: @@ -566,7 +567,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 +589,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/conftest.py b/src/bluesky_httpserver/tests/conftest.py index 5dbe740..a45a540 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 @@ -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,16 +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(return_value=httpx.Response(httpx.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}) + mock_router = MockRouter() + mock_router.get(oidc_well_known_url).mock( + return_value=httpx2.Response(httpx2.codes.OK, json=well_known_response) + ) + mock_router.get(well_known_response["jwks_uri"]).mock( + return_value=httpx2.Response(httpx2.codes.OK, json={"keys": json_web_keyset}) + ) + 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 respx_mock + 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 98be453..fc31a4e 100644 --- a/src/bluesky_httpserver/tests/test_authenticators.py +++ b/src/bluesky_httpserver/tests/test_authenticators.py @@ -6,18 +6,18 @@ 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 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")) @@ -183,11 +184,40 @@ 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) +@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 = {} @@ -225,7 +255,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 +314,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 +506,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_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 044ac76..c5fa289 100644 --- a/src/bluesky_httpserver/tests/test_oidc_proxied_authenticators.py +++ b/src/bluesky_httpserver/tests/test_oidc_proxied_authenticators.py @@ -1,11 +1,12 @@ """Tests for OIDC Authenticator functionality.""" -import httpx +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: @@ -26,7 +27,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"},