Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
5 changes: 5 additions & 0 deletions docs/source/release-history.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
====================
Expand Down
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ dependencies = [
"bluesky-queueserver-api",
"cachetools",
"fastapi",
"httpx",
"httpx2",
"ldap3",
"orjson",
"pamela",
Expand Down Expand Up @@ -50,7 +50,6 @@ dev = [
"pytest-xprocess",
"pytest-split",
"py",
"respx",
"sphinx",
"ipython",
"numpydoc",
Expand Down
19 changes: 10 additions & 9 deletions src/bluesky_httpserver/authenticators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/bluesky_httpserver/authorization/api_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import time as ttime
from collections.abc import Iterable

import httpx
import httpx2
import jsonschema
import yaml

Expand Down Expand Up @@ -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()
Expand Down
29 changes: 22 additions & 7 deletions src/bluesky_httpserver/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"

Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions src/bluesky_httpserver/tests/httpx2_mock.py
Original file line number Diff line number Diff line change
@@ -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
44 changes: 37 additions & 7 deletions src/bluesky_httpserver/tests/test_authenticators.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,26 @@
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,
OIDCAuthenticator,
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"))
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions src/bluesky_httpserver/tests/test_httpx2_access.py
Original file line number Diff line number Diff line change
@@ -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"'
3 changes: 2 additions & 1 deletion src/bluesky_httpserver/tests/test_oidc_authenticators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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"},
Expand Down
Loading