From 3a7455e0600550633d9897f0b106315881adb8a3 Mon Sep 17 00:00:00 2001 From: Doug Addy Date: Fri, 8 May 2026 09:57:29 +0100 Subject: [PATCH 01/18] chore: clarify Sphinx doc build paths in gitignore Move the explicit doc/_build/ rule next to Sphinx-related comments so local html/latex output stays ignored; keep _autosummary documented separately. Co-authored-by: Cursor --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4decd066..4db8270f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,7 +30,11 @@ _old/ build/ dist/ -# autogenerated docs +# Documentation (Sphinx) +# Full build tree from doc/Makefile / doc/make.bat (html, latex, .doctrees, etc.) +doc/_build/ + +# autogenerated API stubs (autosummary), under doc/source when present _autosummary From fd340fb813b9643ee3d0a58c09662d2dfbf6eecc Mon Sep 17 00:00:00 2001 From: Doug Addy Date: Fri, 8 May 2026 09:57:55 +0100 Subject: [PATCH 02/18] feat(common): vendor CaseInsensitiveDict and narrow types to httpx Vendor Requests-style CaseInsensitiveDict (Apache-2.0 attribution) for session headers and exception snapshots. Export CaseInsensitiveDict from the public package. Restrict ApiException / ApiClientBase signatures to httpx.Response. Co-authored-by: Cursor --- src/ansys/openapi/common/__init__.py | 5 +- src/ansys/openapi/common/_base/_types.py | 4 +- .../openapi/common/_case_insensitive_dict.py | 119 ++++++++++++++++++ src/ansys/openapi/common/_exceptions.py | 38 ++++-- 4 files changed, 153 insertions(+), 13 deletions(-) create mode 100644 src/ansys/openapi/common/_case_insensitive_dict.py diff --git a/src/ansys/openapi/common/__init__.py b/src/ansys/openapi/common/__init__.py index bd140434..d360c277 100644 --- a/src/ansys/openapi/common/__init__.py +++ b/src/ansys/openapi/common/__init__.py @@ -28,6 +28,7 @@ from ._api_client import ApiClient from ._base import ApiBase, ApiClientBase, ModelBase, Unset, Unset_Type +from ._case_insensitive_dict import CaseInsensitiveDict from ._exceptions import ( ApiConnectionException, ApiException, @@ -35,13 +36,15 @@ UndefinedObjectWarning, ) from ._session import ApiClientFactory, AuthenticationScheme, OIDCSessionBuilder -from ._util import SessionConfiguration, generate_user_agent +from ._util import SessionConfiguration, TransportConfiguration, generate_user_agent __all__ = [ "ApiClient", "ApiClientFactory", "AuthenticationScheme", + "CaseInsensitiveDict", "SessionConfiguration", + "TransportConfiguration", "ApiException", "ApiConnectionException", "AuthenticationWarning", diff --git a/src/ansys/openapi/common/_base/_types.py b/src/ansys/openapi/common/_base/_types.py index 040b1e78..95733d91 100644 --- a/src/ansys/openapi/common/_base/_types.py +++ b/src/ansys/openapi/common/_base/_types.py @@ -26,7 +26,7 @@ import pprint from typing import Any, Dict, List, Literal, Mapping, Optional, Tuple, Union -import requests +import httpx PrimitiveType = Union[float, bool, bytes, str, int] DeserializedType = Union[ @@ -139,7 +139,7 @@ def call_api( _preload_content: bool = True, _request_timeout: Union[float, Tuple[float, float], None] = None, response_type_map: Optional[Mapping[int, Union[str, None]]] = None, - ) -> Union[requests.Response, DeserializedType, None]: + ) -> Union[httpx.Response, DeserializedType, None]: """Provide method signature for calling the API.""" diff --git a/src/ansys/openapi/common/_case_insensitive_dict.py b/src/ansys/openapi/common/_case_insensitive_dict.py new file mode 100644 index 00000000..d020ca09 --- /dev/null +++ b/src/ansys/openapi/common/_case_insensitive_dict.py @@ -0,0 +1,119 @@ +# Copyright (C) 2022 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# ----------------------------------------------------------------------------- +# The ``CaseInsensitiveDict`` class below is vendored from the Requests library +# (``requests/structures.py``, Requests 2.32.x). Original work: +# +# Copyright 2019 Kenneth Reitz +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Edits for vendoring: type annotations, import ``Iterator`` from ``collections.abc``, +# and use ``Mapping`` / ``MutableMapping`` from ``collections.abc`` (Python 3 only). +# ----------------------------------------------------------------------------- + +from __future__ import annotations + +from collections import OrderedDict +from collections.abc import Iterable, Iterator, Mapping, MutableMapping +from typing import Any + +# ``CaseInsensitiveDict`` (Requests) supports construction and ``.update`` with +# any mapping or iterable of string key / value pairs; keep the constructor wide. +_Data = Mapping[str, Any] | Iterable[tuple[str, Any]] | None + + +class CaseInsensitiveDict(MutableMapping[str, Any]): + """A case-insensitive ``dict``-like object. + + Implements all methods and operations of + ``MutableMapping`` as well as dict's ``copy``. Also + provides ``lower_items``. + + All keys are expected to be strings. The structure remembers the + case of the last key to be set, and ``iter(instance)``, + ``keys()``, ``items()``, ``iterkeys()``, and ``iteritems()`` + will contain case-sensitive keys. However, querying and contains + testing is case insensitive:: + + cid = CaseInsensitiveDict() + cid['Accept'] = 'application/json' + cid['aCCEPT'] == 'application/json' # True + list(cid) == ['Accept'] # True + + For example, ``headers['content-encoding']`` will return the + value of a ``'Content-Encoding'`` response header, regardless + of how the header name was originally stored. + + If the constructor, ``.update``, or equality comparison + operations are given keys that have equal ``.lower()``s, the + behavior is undefined. + """ + + _store: OrderedDict[str, tuple[str, Any]] + + def __init__(self, data: _Data = None, **kwargs: Any) -> None: + self._store = OrderedDict() + if data is None: + data = {} + self.update(data, **kwargs) + + def __setitem__(self, key: str, value: Any) -> None: + # Use the lowercased key for lookups, but store the actual + # key alongside the value. + self._store[key.lower()] = (key, value) + + def __getitem__(self, key: str) -> Any: + return self._store[key.lower()][1] + + def __delitem__(self, key: str) -> None: + del self._store[key.lower()] + + def __iter__(self) -> Iterator[str]: + return (casedkey for casedkey, mappedvalue in self._store.values()) + + def __len__(self) -> int: + return len(self._store) + + def lower_items(self) -> Iterator[tuple[str, Any]]: + """Like iteritems(), but with all lowercase keys.""" + return ((lowerkey, keyval[1]) for (lowerkey, keyval) in self._store.items()) + + def __eq__(self, other: object) -> Any: + if isinstance(other, Mapping): + other = CaseInsensitiveDict(other) + else: + return NotImplemented + # Compare insensitively + return dict(self.lower_items()) == dict(other.lower_items()) + + # Copy is required + def copy(self) -> CaseInsensitiveDict: + return CaseInsensitiveDict(self._store.values()) + + def __repr__(self) -> str: + return str(dict(self.items())) diff --git a/src/ansys/openapi/common/_exceptions.py b/src/ansys/openapi/common/_exceptions.py index c25a147c..2addd477 100644 --- a/src/ansys/openapi/common/_exceptions.py +++ b/src/ansys/openapi/common/_exceptions.py @@ -20,16 +20,29 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, Optional -from requests.structures import CaseInsensitiveDict +from ._case_insensitive_dict import CaseInsensitiveDict if TYPE_CHECKING: - import requests + import httpx from ansys.openapi.common._base._types import DeserializedType +def _response_url(response: "httpx.Response") -> str: + url = getattr(response, "url", "") + return str(url) + + +def _response_reason(response: "httpx.Response") -> str: + return response.reason_phrase + + +def _response_headers_for_exception(response: "httpx.Response") -> CaseInsensitiveDict: + return CaseInsensitiveDict(dict(response.headers)) + + class ApiConnectionException(Exception): """ Provides the exception to raise when connection to the API server fails. @@ -38,12 +51,15 @@ class ApiConnectionException(Exception): Parameters ---------- - response : requests.Response + response : httpx.Response Response from the server. """ - def __init__(self, response: "requests.Response"): - exception_message = f"Request url '{response.url}' failed with reason {response.status_code}: {response.reason}." + def __init__(self, response: "httpx.Response"): + exception_message = ( + f"Request url '{_response_url(response)}' failed with reason " + f"{response.status_code}: {_response_reason(response)}." + ) if response.text: exception_message += f"\n{response.text}" super().__init__(exception_message) @@ -114,15 +130,17 @@ def __init__( @classmethod def from_response( - cls, http_response: "requests.Response", exception_model: "DeserializedType" = None + cls, + http_response: "httpx.Response", + exception_model: "DeserializedType" = None, ) -> "ApiException": - """Initialize object from a requests.Response object.""" + """Initialize object from an HTTP response object.""" new = cls( status_code=http_response.status_code, - reason_phrase=http_response.reason, + reason_phrase=_response_reason(http_response), body=http_response.text, exception_model=exception_model, - headers=http_response.headers, + headers=_response_headers_for_exception(http_response), ) return new From 233d8246fb004f9b1351dcdbc56d37425bd613ff Mon Sep 17 00:00:00 2001 From: Doug Addy Date: Fri, 8 May 2026 09:58:01 +0100 Subject: [PATCH 03/18] feat(common): add RetryingHTTPTransport and httpx session utilities Introduce urllib3-style retries on a custom httpx.HTTPTransport. Wire TransportConfiguration into httpx.Client construction, WWW-Authenticate collection via httpx.Headers.get_list, and drop the old requests session shims from utilities. Co-authored-by: Cursor --- src/ansys/openapi/common/_retry_transport.py | 148 +++++++++++++++++++ src/ansys/openapi/common/_util.py | 116 ++++++++++++--- 2 files changed, 244 insertions(+), 20 deletions(-) create mode 100644 src/ansys/openapi/common/_retry_transport.py diff --git a/src/ansys/openapi/common/_retry_transport.py b/src/ansys/openapi/common/_retry_transport.py new file mode 100644 index 00000000..a03680d1 --- /dev/null +++ b/src/ansys/openapi/common/_retry_transport.py @@ -0,0 +1,148 @@ +# Copyright (C) 2022 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Synchronous :class:`httpx.HTTPTransport` with retries. + +This mirrors historical resilience from ``urllib3.Retry`` + ``requests.HTTPAdapter`` +while staying inside ``httpx``'s transport layer. + +**Semantics** + +* ``SessionConfiguration.retry_count`` is the **maximum number of attempts** per logical + request (including the first try). It maps directly to ``max_attempts`` below. +* **HTTP status retries**: responses whose status is in ``retry_status_codes`` trigger + another attempt if attempts remain. Codes include **400** (see migration plan), + **429**, and **502–504**, plus **500** and **503**. +* **Which methods**: retries apply to ``DELETE``, ``GET``, ``HEAD``, ``OPTIONS``, + ``PATCH``, ``POST``, and ``PUT``. Retrying ``POST`` when the server returns e.g. **400** + before accepting work can duplicate side effects—callers must assume that risk where + servers are flaky (same caveat as urllib3-style retries on non-idempotent verbs). +* **Transport errors**: connection failures, timeouts, low-level read/write errors, and + ``RemoteProtocolError`` are retried with exponential backoff (same backoff shape as + urllib3: ``backoff_factor * 2**attempt`` seconds between attempts). + +This layer does **not** duplicate httpcore connection-pool ``retries`` (leave those at 0); +retry behaviour is controlled only in this transport. +""" + +from __future__ import annotations + +import time +from typing import Any, Collection, FrozenSet + +import httpx + +from ._logger import logger + +_DEFAULT_RETRY_STATUSES: FrozenSet[int] = frozenset({400, 429, 500, 502, 503, 504}) +_DEFAULT_RETRY_METHODS: FrozenSet[str] = frozenset( + {"DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"} +) + + +def _retryable_transport_exceptions() -> tuple[type[BaseException], ...]: + """Exceptions treated like urllib3 connection/read retries.""" + return ( + httpx.TimeoutException, + httpx.NetworkError, + httpx.ProxyError, + httpx.RemoteProtocolError, + ) + + +class RetryingHTTPTransport(httpx.HTTPTransport): + """HTTP transport that retries failed requests up to ``max_attempts`` times.""" + + def __init__( + self, + *, + max_attempts: int = 3, + backoff_factor: float = 0.3, + retry_status_codes: Collection[int] | None = None, + retry_http_methods: Collection[str] | None = None, + **transport_kwargs: Any, + ) -> None: + """ + Parameters + ---------- + max_attempts + Total attempts per request (minimum 1). Matches ``SessionConfiguration.retry_count``. + backoff_factor + Multiplier for exponential backoff between attempts (urllib3-style). + retry_status_codes + HTTP statuses that trigger a retry when attempts remain. + retry_http_methods + Upper-case method names eligible for HTTP status retries. + **transport_kwargs + Forwarded to :class:`httpx.HTTPTransport` (``verify``, ``cert``, ``proxy``, etc.). + """ + super().__init__(retries=0, **transport_kwargs) + self._max_attempts = max(1, max_attempts) + self._backoff_factor = backoff_factor + self._retry_status_codes = frozenset(retry_status_codes or _DEFAULT_RETRY_STATUSES) + self._retry_http_methods = frozenset( + m.upper() for m in (retry_http_methods or _DEFAULT_RETRY_METHODS) + ) + self._retry_exceptions = _retryable_transport_exceptions() + + def handle_request(self, request: httpx.Request) -> httpx.Response: + method_upper = request.method.upper() + for attempt in range(self._max_attempts): + try: + response = super().handle_request(request) + except self._retry_exceptions: + if attempt >= self._max_attempts - 1: + raise + self._sleep_backoff(attempt) + logger.debug( + "Retrying HTTP request after transport error " + f"(attempt {attempt + 2}/{self._max_attempts})" + ) + continue + + if ( + response.status_code in self._retry_status_codes + and method_upper in self._retry_http_methods + and attempt < self._max_attempts - 1 + ): + self._drain_response(response) + self._sleep_backoff(attempt) + logger.debug( + "Retrying HTTP request after status " + f"{response.status_code} (attempt {attempt + 2}/{self._max_attempts})" + ) + continue + + return response + + raise AssertionError("retry loop fell through") # pragma: no cover + + def _sleep_backoff(self, attempt_index: int) -> None: + delay = self._backoff_factor * (2**attempt_index) + time.sleep(delay) + + @staticmethod + def _drain_response(response: httpx.Response) -> None: + try: + response.read() + finally: + response.close() diff --git a/src/ansys/openapi/common/_util.py b/src/ansys/openapi/common/_util.py index 38dc17b3..913b6e3d 100644 --- a/src/ansys/openapi/common/_util.py +++ b/src/ansys/openapi/common/_util.py @@ -36,9 +36,11 @@ cast, ) +import httpx import pyparsing as pp -import requests -from requests.structures import CaseInsensitiveDict +from ._case_insensitive_dict import CaseInsensitiveDict + +from ._retry_transport import RetryingHTTPTransport class CaseInsensitiveOrderedDict(OrderedDict): @@ -202,22 +204,20 @@ def parse_authenticate(value: str) -> CaseInsensitiveOrderedDict: return parser.parse_header(value) -def set_session_kwargs(session: requests.Session, property_dict: "RequestsConfiguration") -> None: - """Set session parameters from the dictionary provided. +def collect_www_authenticate_raw_values(response: httpx.Response) -> list[str]: + """Return each raw ``WWW-Authenticate`` challenge line from ``response``. - Parameters - ---------- - session : :obj:`requests.Session` - Session object to configure. - property_dict : dict - Mapping from requests session parameter to value. + Multiple header field lines are preserved as separate entries (RFC 9110). ``httpx`` + exposes these via :meth:`httpx.Headers.get_list`. """ - for k, v in property_dict.items(): - session.__dict__[k] = v + return [v.strip() for v in response.headers.get_list("www-authenticate") if v.strip()] -class RequestsConfiguration(TypedDict): - """Configuration for requests session.""" +class TransportConfiguration(TypedDict): + """Serializable HTTP transport settings used to build :class:`httpx.Client`. + + These keys feed :func:`httpx_client_init_kwargs` for ``httpx.Client`` construction. + """ cert: Union[None, str, Tuple[str, str]] verify: Union[None, str, bool] @@ -227,6 +227,46 @@ class RequestsConfiguration(TypedDict): max_redirects: int +def httpx_client_init_kwargs(configuration: TransportConfiguration) -> dict[str, Any]: + """Build keyword arguments for :class:`httpx.Client` from transport configuration. + + ``requests`` accepts a per-scheme ``proxies`` mapping on the session. ``httpx`` uses a + single ``proxy`` URL for the default transport in the common case. When exactly one + proxy URL is configured, it is passed through; otherwise a :class:`NotImplementedError` + is raised until full per-scheme routing is implemented. + + Parameters + ---------- + configuration : TransportConfiguration + Output of :meth:`SessionConfiguration.get_transport_configuration`. + + Returns + ------- + dict[str, Any] + Keyword arguments suitable for ``httpx.Client(**kwargs)``. + """ + headers = configuration["headers"] + kwargs: dict[str, Any] = { + "cert": configuration["cert"], + "verify": configuration["verify"], + "cookies": configuration["cookies"], + "headers": dict(headers) if headers else {}, + "max_redirects": configuration["max_redirects"], + # requests follows redirects by default; match that for future Client wiring. + "follow_redirects": True, + } + proxies = configuration["proxies"] + if proxies: + if len(proxies) == 1: + kwargs["proxy"] = next(iter(proxies.values())) + else: + raise NotImplementedError( + "Multiple proxy mappings are not yet mapped to httpx.Client mounts; " + "configure a single proxy URL or extend httpx_client_init_kwargs." + ) + return kwargs + + class SessionConfiguration: """Provides configuration for the API client session. @@ -313,11 +353,11 @@ def _verify(self) -> Union[None, bool, str]: else: return self.cert_store_path - def get_configuration_for_requests( + def get_transport_configuration( self, - ) -> "RequestsConfiguration": - """Retrieve the configuration as a dictionary, with keys corresponding to ``requests`` session properties.""" - output: RequestsConfiguration = { + ) -> TransportConfiguration: + """Retrieve settings as a mapping aligned with HTTP client transport configuration.""" + output: TransportConfiguration = { "cert": self._cert, "verify": self._verify, "cookies": self.cookies, @@ -328,11 +368,11 @@ def get_configuration_for_requests( return output @classmethod - def from_dict(cls, configuration_dict: "RequestsConfiguration") -> "SessionConfiguration": + def from_dict(cls, configuration_dict: TransportConfiguration) -> "SessionConfiguration": """ Create a :class:`SessionConfiguration` object from its dictionary form. - This is the inverse of the :meth:`.get_configuration_for_requests` method. + This is the inverse of the :meth:`.get_transport_configuration` method. Parameters ---------- @@ -371,6 +411,42 @@ def from_dict(cls, configuration_dict: "RequestsConfiguration") -> "SessionConfi return new +def create_httpx_client_from_session_configuration( + session_configuration: SessionConfiguration, +) -> httpx.Client: + """Create a synchronous :class:`httpx.Client` from a :class:`SessionConfiguration`. + + Uses :class:`~ansys.openapi.common._retry_transport.RetryingHTTPTransport` so connection + failures, timeouts, and selected HTTP status codes are retried according to + ``session_configuration.retry_count`` (maximum total attempts per request). + + Parameters + ---------- + session_configuration : SessionConfiguration + Source configuration for TLS, cookies, headers, redirects, timeout, proxies, and retries. + + Returns + ------- + httpx.Client + Configured HTTP client. + """ + kwargs = httpx_client_init_kwargs(session_configuration.get_transport_configuration()) + kwargs["timeout"] = session_configuration.request_timeout + + verify = kwargs.pop("verify", True) + cert = kwargs.pop("cert", None) + proxy = kwargs.pop("proxy", None) + + kwargs["transport"] = RetryingHTTPTransport( + verify=verify, + cert=cert, + proxy=proxy, + max_attempts=max(1, session_configuration.retry_count), + backoff_factor=0.3, + ) + return httpx.Client(**kwargs) + + def generate_user_agent(package_name: str, package_version: str) -> str: """Generate a user-agent string in the form * *. From b2d87d730a76ff20e7c3fdbea64af9c8684a3920 Mon Sep 17 00:00:00 2001 From: Doug Addy Date: Fri, 8 May 2026 09:58:06 +0100 Subject: [PATCH 04/18] feat(common): migrate ApiClient, factory, and OIDC to httpx Require httpx.Client for ApiClient.request paths and deserialization. Rebuild ApiClientFactory on httpx with platform auth (NTLM, Negotiate, OIDC) and fix probe URL handling. Port OIDCSessionFactory to httpx-auth and typed TransportConfiguration round-trips. Co-authored-by: Cursor --- src/ansys/openapi/common/_api_client.py | 230 ++++++++++++++---------- src/ansys/openapi/common/_oidc.py | 228 +++++++++++++---------- src/ansys/openapi/common/_session.py | 180 ++++++++----------- 3 files changed, 344 insertions(+), 294 deletions(-) diff --git a/src/ansys/openapi/common/_api_client.py b/src/ansys/openapi/common/_api_client.py index b7543dfe..332393de 100644 --- a/src/ansys/openapi/common/_api_client.py +++ b/src/ansys/openapi/common/_api_client.py @@ -27,7 +27,7 @@ import os import re import tempfile -from types import ModuleType +from types import ModuleType, TracebackType from typing import ( IO, Any, @@ -46,7 +46,7 @@ import warnings from dateutil.parser import parse -import requests +import httpx from ._base import ApiClientBase, DeserializedType, ModelBase, PrimitiveType, SerializedType, Unset from ._exceptions import ApiException, UndefinedObjectWarning @@ -54,6 +54,32 @@ from ._util import SessionConfiguration +def _close_distinct_httpx_auth_clients(rest_client: httpx.Client) -> None: + """Close extra :class:`~httpx.Client` instances held by auth handlers (e.g. OIDC IdP). + + ``httpx-auth`` OAuth flows attach a dedicated client for token endpoint traffic so TLS + settings can differ from the API client. Closing only the API client would otherwise + leave that pool open. + """ + auth = getattr(rest_client, "auth", None) + if auth is None: + return + modes = getattr(auth, "authentication_modes", None) + modes_list = list(modes) if modes is not None else [auth] + seen: set[int] = set() + for mode in modes_list: + token_client = getattr(mode, "client", None) + if not isinstance(token_client, httpx.Client): + continue + if token_client is rest_client: + continue + tid = id(token_client) + if tid in seen: + continue + seen.add(tid) + token_client.close() + + # noinspection DuplicatedCode class ApiClient(ApiClientBase): """Provides a generic API client for OpenAPI client library builds. @@ -65,8 +91,8 @@ class ApiClient(ApiClientBase): Parameters ---------- - session : requests.Session - Base session object that the API client is to use. + session : httpx.Client + HTTP client the API client uses (typically from :class:`ApiClientFactory`). api_url : str Base URL for the API. All generated endpoint URLs are relative to this address. configuration : SessionConfiguration @@ -74,7 +100,8 @@ class ApiClient(ApiClientBase): Examples -------- - >>> client = ApiClient(requests.Session(), + >>> transport = httpx.MockTransport(lambda request: httpx.Response(200)) + >>> client = ApiClient(httpx.Client(transport=transport), ... 'http://my-api.com/API/v1.svc', ... SessionConfiguration()) ... @@ -85,11 +112,16 @@ class ApiClient(ApiClientBase): :class:`SessionConfiguration`. >>> session_config = SessionConfiguration(cert_store_path='./self-signed-cert.pem') - ... ssl_client = ApiClient(requests.Session(), + ... ssl_client = ApiClient(httpx.Client(transport=transport), ... 'https://secure-api/API/v1.svc', ... session_config) ... ssl_client + + Notes + ----- + Call :meth:`close` when finished, or use ``with ApiClient(...) as client:``, so the + underlying HTTP client releases its connection pool. """ PRIMITIVE_TYPES = (float, bool, bytes, str, int) @@ -107,7 +139,7 @@ class ApiClient(ApiClientBase): def __init__( self, - session: requests.Session, + session: httpx.Client, api_url: str, configuration: SessionConfiguration, ): @@ -115,6 +147,31 @@ def __init__( self.api_url = api_url self.rest_client = session self.configuration = configuration + self._closed = False + + def close(self) -> None: + """Close the underlying HTTP session or client and release connections. + + When ``rest_client`` is an :class:`~httpx.Client` whose auth handler owns a separate + client (OpenID Connect token traffic to the IdP), that client is closed as well. + """ + if self._closed: + return + self._closed = True + rc = self.rest_client + _close_distinct_httpx_auth_clients(rc) + rc.close() + + def __enter__(self) -> "ApiClient": + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + self.close() def __repr__(self) -> str: """Printable representation of the object.""" @@ -132,7 +189,8 @@ def setup_client(self, models: ModuleType) -> None: Examples -------- - >>> client = ApiClient(requests.Session(), + >>> tc = httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(200))) + >>> client = ApiClient(tc, ... 'http://my-api.com/API/v1.svc', ... SessionConfiguration()) ... import ApiModels as model_module @@ -158,7 +216,7 @@ def __call_api( _preload_content: bool = True, _request_timeout: Union[float, Tuple[float, float], None] = None, response_type_map: Optional[Mapping[int, Union[str, None]]] = None, - ) -> Union[requests.Response, DeserializedType, None]: + ) -> Union[httpx.Response, DeserializedType, None]: # header parameters header_params = header_params or {} if header_params: @@ -209,7 +267,7 @@ def __call_api( self.last_response = response_data logger.debug(f"response body: {response_data.text}") - return_data: Union[requests.Response, DeserializedType, None] = response_data + return_data: Union[httpx.Response, DeserializedType, None] = response_data if _preload_content: _response_type = response_type if response_type_map is not None: @@ -273,13 +331,15 @@ def sanitize_for_serialization(self, obj: Any) -> Any: Examples -------- - >>> client = ApiClient(requests.Session(), + >>> tc = httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(200))) + >>> client = ApiClient(tc, ... 'http://my-api.com/API/v1.svc', ... SessionConfiguration()) ... client.sanitize_for_serialization({'key': 'value'}) {'key': 'value'} - >>> client = ApiClient(requests.Session(), + >>> tc = httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(200))) + >>> client = ApiClient(tc, ... 'http://my-api.com/API/v1.svc', ... SessionConfiguration()) ... client.sanitize_for_serialization(datetime.datetime(2015, 10, 21, 10, 5, 10)) @@ -310,7 +370,7 @@ def sanitize_for_serialization(self, obj: Any) -> Any: return {key: self.sanitize_for_serialization(val) for key, val in obj_dict.items()} def deserialize( - self, response: requests.Response, response_type: Optional[str] + self, response: httpx.Response, response_type: Optional[str] ) -> DeserializedType: """Deserialize the response into an object. @@ -327,26 +387,26 @@ def deserialize( Parameters ---------- - response : requests.Response + response : httpx.Response Response object received from the API. response_type : str String name of the class represented. Examples -------- - >>> client = ApiClient(requests.Session(), + >>> tc = httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(200))) + >>> client = ApiClient(tc, ... 'http://my-api.com/API/v1.svc', ... SessionConfiguration()) - ... api_response = requests.Response() - ... api_response._content = b"{'key': 'value'}" + ... api_response = httpx.Response(200, content=b'{"key": "value"}') ... client.deserialize(api_response, 'Dict[str, str]]') {'key': 'value'} - >>> client = ApiClient(requests.Session(), + >>> tc = httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(200))) + >>> client = ApiClient(tc, ... 'http://my-api.com/API/v1.svc', ... SessionConfiguration()) - ... api_response = requests.Response() - ... api_response._content = b"'2015-10-21T10:05:10'" + ... api_response = httpx.Response(200, content=b"'2015-10-21T10:05:10'") ... client.deserialize(api_response, 'datetime.datetime') datetime.datetime(2015, 10, 21, 10, 5, 10) """ @@ -457,7 +517,7 @@ def call_api( _preload_content: bool = True, _request_timeout: Union[float, Tuple[float, float], None] = None, response_type_map: Optional[Mapping[int, Union[str, None]]] = None, - ) -> Union[requests.Response, DeserializedType, None]: + ) -> Union[httpx.Response, DeserializedType, None]: """Make the HTTP request and return the deserialized data. Parameters @@ -515,6 +575,12 @@ def call_api( response_type_map, ) + @staticmethod + def _url_with_query_string(url: str, query_params: Optional[str]) -> str: + if not query_params: + return url + return f"{url}&{query_params}" if "?" in url else f"{url}?{query_params}" + def request( self, method: str, @@ -527,7 +593,7 @@ def request( body: Optional[Any] = None, _preload_content: bool = True, _request_timeout: Union[float, Tuple[float, float], None] = None, - ) -> requests.Response: + ) -> httpx.Response: """Make the HTTP request and return it directly. Parameters @@ -553,75 +619,57 @@ def request( It can also be a pair (tuple) of (connection, read) timeouts. This parameter overrides the session-level timeout setting. """ + rc = self.rest_client + if not isinstance(rc, httpx.Client): + raise TypeError("ApiClient requires an httpx.Client instance.") + url_effective = ApiClient._url_with_query_string(url, query_params) + # httpx 0.28+ no longer accepts ``stream=`` on high-level methods; use + # ``Client.stream()`` only if true incremental reads are required. + kw: Dict[str, Any] = { + "headers": headers, + "timeout": _request_timeout, + } + body_kw: Dict[str, Any] = {} + if post_params is not None: + body_kw["files"] = post_params + if body is not None: + if post_params is not None: + # Multipart: mapping/list uses ``data``; raw text/bytes must use ``content`` + # (httpx deprecates ``data=`` for raw bodies). + if isinstance(body, str): + body_kw["content"] = body.encode("utf-8") + elif isinstance(body, bytes): + body_kw["content"] = body + else: + body_kw["data"] = body + elif isinstance(body, bytes): + body_kw["content"] = body + elif isinstance(body, str): + body_kw["content"] = body.encode("utf-8") + else: + body_kw["data"] = body if method == "GET": - return self.rest_client.get( - url, - params=query_params, - stream=_preload_content, - timeout=_request_timeout, - headers=headers, - ) - elif method == "HEAD": - return self.rest_client.head( - url, - params=query_params, - stream=_preload_content, - timeout=_request_timeout, - headers=headers, - ) - elif method == "OPTIONS": - return self.rest_client.options( - url, - params=query_params, - headers=headers, - files=post_params, - stream=_preload_content, - timeout=_request_timeout, - data=body, - ) - elif method == "POST": - return self.rest_client.post( - url, - params=query_params, - headers=headers, - files=post_params, - stream=_preload_content, - timeout=_request_timeout, - data=body, - ) - elif method == "PUT": - return self.rest_client.put( - url, - params=query_params, - headers=headers, - files=post_params, - stream=_preload_content, - timeout=_request_timeout, - data=body, - ) - elif method == "PATCH": - return self.rest_client.patch( - url, - params=query_params, - headers=headers, - files=post_params, - stream=_preload_content, - timeout=_request_timeout, - data=body, - ) - elif method == "DELETE": - return self.rest_client.delete( - url, - params=query_params, - headers=headers, - stream=_preload_content, - timeout=_request_timeout, - data=body, - ) - else: - raise ValueError( - "http method must be `GET`, `HEAD`, `OPTIONS`, `POST`, `PATCH`, `PUT`, or `DELETE`." + return rc.get(url_effective, **kw) + if method == "HEAD": + return rc.head(url_effective, **kw) + if method == "OPTIONS": + return rc.request( + "OPTIONS", + url_effective, + **kw, + **body_kw, ) + if method == "POST": + return rc.post(url_effective, **kw, **body_kw) + if method == "PUT": + return rc.put(url_effective, **kw, **body_kw) + if method == "PATCH": + return rc.patch(url_effective, **kw, **body_kw) + if method == "DELETE": + return rc.request("DELETE", url_effective, **kw, **body_kw) + raise ValueError( + "http method must be `GET`, `HEAD`, `OPTIONS`, `POST`, `PATCH`, `PUT`, or `DELETE`." + ) @staticmethod def parameters_to_tuples( @@ -765,7 +813,7 @@ def select_header_content_type(content_types: Optional[List[str]]) -> str: else: return content_types[0] - def __deserialize_file(self, response: requests.Response) -> str: + def __deserialize_file(self, response: httpx.Response) -> str: """Deserialize the body to a file. This method saves the response body in a file in a temporary folder, @@ -773,7 +821,7 @@ def __deserialize_file(self, response: requests.Response) -> str: Parameters ---------- - response : requests.Response + response : httpx.Response The API response object to deserialize. """ fd, path = tempfile.mkstemp(dir=self.configuration.temp_folder_path) diff --git a/src/ansys/openapi/common/_oidc.py b/src/ansys/openapi/common/_oidc.py index 8dc94d74..3fa9c872 100644 --- a/src/ansys/openapi/common/_oidc.py +++ b/src/ansys/openapi/common/_oidc.py @@ -19,13 +19,15 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from typing import Optional + +from __future__ import annotations + import urllib.parse +from typing import Any, Optional +import httpx import keyring -import requests -from requests.models import CaseInsensitiveDict -from requests_auth import ( # type: ignore[import-untyped, unused-ignore] +from httpx_auth import ( InvalidGrantRequest, OAuth2, OAuth2AuthorizationCodePKCE, @@ -33,16 +35,14 @@ from ._logger import logger from ._util import ( - RequestsConfiguration, + CaseInsensitiveOrderedDict, SessionConfiguration, + TransportConfiguration, + collect_www_authenticate_raw_values, + create_httpx_client_from_session_configuration, parse_authenticate, - set_session_kwargs, ) -TYPE_CHECKING = False -if TYPE_CHECKING: - from . import SessionConfiguration - class OIDCSessionFactory: """ @@ -51,11 +51,15 @@ class OIDCSessionFactory: This class uses either the provided token credentials or authorizes a user with a browser-based interactive prompt. + Flow (unchanged): the **resource server** returns ``401`` with ``WWW-Authenticate: Bearer ...`` + containing IdP-specific parameters (``authority``, ``clientid``, ``redirecturi``, optional + ``scope`` / ``apiAudience``). Those parameters drive discovery against that authority's + ``/.well-known/openid-configuration`` before building the OAuth2 PKCE handler—so different + APIs can target different identity providers. + Parameters ---------- - initial_session : requests.Session - Session to use while negotiating with the identity provider. - initial_response : requests.Response + initial_response : httpx.Response Initial 401 response from the API server when no ``Authorization`` header is provided. api_session_configuration : SessionConfiguration, optional Configuration settings for connections to the API server. @@ -66,76 +70,96 @@ class OIDCSessionFactory: ----- The ``headers`` field in ``idp_session_configuration`` is not fully respected. The ``Accept`` and ``Content-Type`` headers will be overridden. Other settings are respected. + + OAuth2 / token flows use :class:`httpx.Client` with ``httpx-auth`` (PKCE), aligned with the rest + of the migration to ``httpx``. + + Token exchange and refresh POSTs to the identity provider use a dedicated IdP-configured client + (from ``idp_session_configuration``), separate from the API client, so TLS settings such as a + custom CA bundle apply to the IdP regardless of the API client's defaults (including HTTPX's + certifi-based verification). """ def __init__( self, - initial_session: requests.Session, - initial_response: requests.Response, + initial_response: httpx.Response, api_session_configuration: Optional[SessionConfiguration] = None, idp_session_configuration: Optional[SessionConfiguration] = None, ) -> None: - self._api_url = initial_response.url + self._api_url = str(initial_response.url) logger.debug("Creating OIDC session handler...") self._authenticate_parameters = self._parse_unauthorized_header(initial_response) - if api_session_configuration is None: - api_session_configuration = SessionConfiguration() - if idp_session_configuration is None: - idp_session_configuration = SessionConfiguration() - - self._api_session_configuration = api_session_configuration.get_configuration_for_requests() - self._idp_session_configuration = OIDCSessionFactory._override_idp_header( - idp_session_configuration.get_configuration_for_requests() - ) + api_sc = api_session_configuration or SessionConfiguration() + idp_sc = idp_session_configuration or SessionConfiguration() - self._oauth_requests_session = initial_session - set_session_kwargs(self._oauth_requests_session, self._idp_session_configuration) + self._api_session_configuration = api_sc.get_transport_configuration() + idp_transport = OIDCSessionFactory._override_idp_header(idp_sc.get_transport_configuration()) + self._idp_session_configuration = idp_transport - self._well_known_parameters = self._fetch_and_parse_well_known( - self._authenticate_parameters["authority"] - ) + discovery_sc = SessionConfiguration.from_dict(idp_transport) + discovery_sc.retry_count = idp_sc.retry_count + discovery_sc.request_timeout = idp_sc.request_timeout + with create_httpx_client_from_session_configuration(discovery_sc) as discovery_client: + self._well_known_parameters = OIDCSessionFactory._fetch_and_parse_well_known( + discovery_client, + self._authenticate_parameters["authority"], + ) self._add_api_audience_if_set() + oauth_sc = SessionConfiguration.from_dict(idp_transport) + oauth_sc.retry_count = idp_sc.retry_count + oauth_sc.request_timeout = idp_sc.request_timeout + self._oauth_httpx_client = create_httpx_client_from_session_configuration(oauth_sc) + logger.info("Configuring session...") - scopes = ( - self._authenticate_parameters["scope"] - if "scope" in self._authenticate_parameters - else [] - ) + scopes_raw = self._authenticate_parameters.get("scope") + scopes_list: list[str] = [] + if scopes_raw is not None and scopes_raw != []: + if isinstance(scopes_raw, str): + scopes_list = scopes_raw.split() + elif isinstance(scopes_raw, (list, tuple)): + scopes_list = [str(s) for s in scopes_raw] + else: + scopes_list = [str(scopes_raw)] + + pkce_kwargs: dict[str, Any] = { + "redirect_uri_port": 32284, + "client": self._oauth_httpx_client, + "client_id": self._authenticate_parameters["clientid"], + } + if scopes_list: + pkce_kwargs["scope"] = " ".join(scopes_list) + if "apiAudience" in self._authenticate_parameters: + pkce_kwargs["audience"] = self._authenticate_parameters["apiAudience"] self._auth = OAuth2AuthorizationCodePKCE( authorization_url=self._well_known_parameters["authorization_endpoint"], token_url=self._well_known_parameters["token_endpoint"], - redirect_uri_port=32284, - audience=( - self._authenticate_parameters["apiAudience"] - if "apiAudience" in self._authenticate_parameters - else None - ), - client_id=self._authenticate_parameters["clientid"], - scope=scopes, - session=self._oauth_requests_session, + **pkce_kwargs, ) - # If using Auth0 we cannot provide an audience with requests - # to the token endpoint with grant_type=refresh_token. This - # causes the token to be returned without the audience - # required to access the user_info endpoint. + # If using Auth0 we cannot send ``audience`` on refresh_token grants to the token + # endpoint: the token is returned without the audience required for some APIs. self._auth.refresh_data.pop("audience", None) - self._authorized_session = requests.Session() - set_session_kwargs(self._authorized_session, self._api_session_configuration) + api_client_sc = SessionConfiguration.from_dict(self._api_session_configuration) + api_client_sc.retry_count = api_sc.retry_count + api_client_sc.request_timeout = api_sc.request_timeout + self._authorized_httpx_client = create_httpx_client_from_session_configuration( + api_client_sc + ) + logger.info("Configuration complete.") - def get_session_with_access_token(self, access_token: str) -> requests.Session: - """Create a :class:`~requests.Session` object with provided access token. + def get_session_with_access_token(self, access_token: str) -> httpx.Client: + """Create an :class:`~httpx.Client` with provided access token. - This method configures a session with the provided access token, if the token is invalid, - or has expired, the session will be unable to authenticate. + This method configures a client with the provided access token, if the token is invalid, + or has expired, the client will be unable to authenticate. Parameters ---------- @@ -145,13 +169,13 @@ def get_session_with_access_token(self, access_token: str) -> requests.Session: logger.info("Setting access token...") if access_token is None: raise ValueError("Must provide a value for 'access_token', not None") - self._authorized_session.headers["Authorization"] = f"Bearer {access_token}" - return self._authorized_session + self._authorized_httpx_client.headers["Authorization"] = f"Bearer {access_token}" + return self._authorized_httpx_client - def get_session_with_provided_token(self, refresh_token: str) -> requests.Session: - """Create a :class:`OAuth2Session` object with provided refresh token. + def get_session_with_provided_token(self, refresh_token: str) -> httpx.Client: + """Create an :class:`~httpx.Client` using the provided refresh token. - This method configures a session to request an access token with the provided refresh token, + This method configures a client to request an access token with the provided refresh token, an access token will be requested immediately. Parameters @@ -167,22 +191,20 @@ def get_session_with_provided_token(self, refresh_token: str) -> requests.Sessio except InvalidGrantRequest as excinfo: logger.debug(str(excinfo)) raise ValueError("The provided refresh token was invalid, please request a new token.") - # noinspection PyProtectedMember with OAuth2.token_cache._forbid_concurrent_missing_token_function_call: # type: ignore[unused-ignore] # If we were provided with a new refresh token it's likely that the Identity # Provider is configured to rotate refresh tokens. Store the new one and # discard the old one. Otherwise, use the existing refresh token. if new_refresh_token is not None: refresh_token = new_refresh_token - # noinspection PyProtectedMember OAuth2.token_cache._add_access_token(state, token, expires_in, refresh_token) - self._authorized_session.auth = self._auth - return self._authorized_session + self._authorized_httpx_client.auth = self._auth + return self._authorized_httpx_client def get_session_with_stored_token( self, token_name: str = "ansys-openapi-common-oidc" - ) -> requests.Session: - """Create a :class:`OAuth2Session` object with a stored token. + ) -> httpx.Client: + """Create an :class:`~httpx.Client` using a stored refresh token. This method uses a token stored in the system keyring to authenticate the session. It requires a correctly configured system keyring backend. @@ -203,10 +225,8 @@ def get_session_with_stored_token( return self.get_session_with_provided_token(refresh_token=refresh_token) - def get_session_with_interactive_authorization( - self, login_timeout: int = 60 - ) -> requests.Session: - """Create a :class:`OAuth2Session` object, authorizing the user via the system web browser. + def get_session_with_interactive_authorization(self, login_timeout: int = 60) -> httpx.Client: + """Create an :class:`~httpx.Client`, authorizing the user via the system web browser. Parameters ---------- @@ -214,31 +234,37 @@ def get_session_with_interactive_authorization( Number of seconds to wait for the user to authenticate. The default is ``60s``. """ self._auth.timeout = login_timeout - self._authorized_session.auth = self._auth - self._authorized_session.get(self._api_url) - return self._authorized_session + self._authorized_httpx_client.auth = self._auth + self._authorized_httpx_client.get(self._api_url) + return self._authorized_httpx_client @staticmethod def _parse_unauthorized_header( - unauthorized_response: "requests.Response", - ) -> "CaseInsensitiveDict": - """Extract required parameters from the response's ``WWW-Authenticate`` header. + unauthorized_response: httpx.Response, + ) -> CaseInsensitiveOrderedDict: + """Extract required parameters from the response's ``WWW-Authenticate`` header(s). This method validates that OIDC is enabled and all information required to configure the session has been provided. Parameters ---------- - unauthorized_response : requests.Response + unauthorized_response : httpx.Response Response obtained by fetching the target URI with no ``Authorization`` header. """ logger.debug("Parsing bearer authentication parameters...") - auth_header = unauthorized_response.headers["WWW-Authenticate"] - authenticate_parameters = parse_authenticate(auth_header) - if "bearer" not in authenticate_parameters: + raw_values = collect_www_authenticate_raw_values(unauthorized_response) + if not raw_values: + raise ConnectionError( + "Unable to connect with OpenID Connect: no www-authenticate header was provided." + ) + authenticate_parameters_merged = CaseInsensitiveOrderedDict() + for chunk in raw_values: + authenticate_parameters_merged.update(parse_authenticate(chunk)) + if "bearer" not in authenticate_parameters_merged: logger.debug( "Detected authentication methods: " - + ", ".join([method for method in authenticate_parameters.keys()]) + + ", ".join([method for method in authenticate_parameters_merged.keys()]) ) raise ConnectionError( "Unable to connect with OpenID Connect: not supported on this server." @@ -246,9 +272,11 @@ def _parse_unauthorized_header( mandatory_headers = ["redirecturi", "authority", "clientid"] missing_headers = [] - bearer_parameters: Optional["CaseInsensitiveDict"] = authenticate_parameters["bearer"] - if bearer_parameters is None: - bearer_parameters = CaseInsensitiveDict() + bearer_parameters_raw = authenticate_parameters_merged["bearer"] + if bearer_parameters_raw is None: + bearer_parameters = CaseInsensitiveOrderedDict() + else: + bearer_parameters = CaseInsensitiveOrderedDict(bearer_parameters_raw) for header_name in mandatory_headers: if header_name not in bearer_parameters: @@ -272,24 +300,34 @@ def _parse_unauthorized_header( else: return bearer_parameters - def _fetch_and_parse_well_known(self, url: str) -> CaseInsensitiveDict: + @staticmethod + def _fetch_and_parse_well_known( + client: httpx.Client, url: str + ) -> CaseInsensitiveOrderedDict: """Fetch and process the required parameters from identity provider's the well-known endpoint. Perform a GET request to the endpoint and verify that the required parameters are returned. Parameters ---------- + client : httpx.Client + HTTP client used to reach the identity provider (transport settings from IdP session configuration). url : str - URL referencing the OpenID identity provider's well-known endpoint. + URL referencing the OpenID identity provider's well-known endpoint host (``authority``). """ logger.info(f"Fetching configuration information from Identity Provider {url}") if not url.endswith("/"): url += "/" well_known_endpoint = urllib.parse.urljoin(url, ".well-known/openid-configuration") - authority_response = self._oauth_requests_session.get(well_known_endpoint) + authority_response = client.get(well_known_endpoint) logger.debug("Received configuration:") - oidc_configuration = CaseInsensitiveDict(authority_response.json()) # type: CaseInsensitiveDict + payload = authority_response.json() + if not isinstance(payload, dict): + raise ConnectionError( + "Unable to connect with OpenID Connect: well-known document was not a JSON object." + ) + oidc_configuration = CaseInsensitiveOrderedDict(payload) mandatory_parameters = ["authorization_endpoint", "token_endpoint"] missing_headers = [] @@ -317,22 +355,22 @@ def _fetch_and_parse_well_known(self, url: str) -> CaseInsensitiveDict: @staticmethod def _override_idp_header( - requests_configuration: RequestsConfiguration, - ) -> RequestsConfiguration: + transport_configuration: TransportConfiguration, + ) -> TransportConfiguration: """Override user-provided ``Accept`` and ``Content-Type`` headers. Required to ensure correct response from the OpenID identity provider. Parameters ---------- - requests_configuration : RequestsConfiguration + transport_configuration : TransportConfiguration Configuration options for connection to the OpenID identity provider. """ - if requests_configuration["headers"] is not None: - headers = requests_configuration["headers"] + if transport_configuration["headers"] is not None: + headers = transport_configuration["headers"] headers["accept"] = "application/json" headers["content-type"] = "application/x-www-form-urlencoded;charset=UTF-8" - return requests_configuration + return transport_configuration def _add_api_audience_if_set(self) -> None: """Set the ``ApiAudience`` header on connection to the API. @@ -341,7 +379,7 @@ def _add_api_audience_if_set(self) -> None: """ if "apiAudience" not in self._authenticate_parameters: return - mi_headers: CaseInsensitiveDict = self._api_session_configuration["headers"] + mi_headers = self._api_session_configuration["headers"] mi_headers["audience"] = self._authenticate_parameters["apiAudience"] - idp_headers: CaseInsensitiveDict = self._idp_session_configuration["headers"] + idp_headers = self._idp_session_configuration["headers"] idp_headers["audience"] = self._authenticate_parameters["apiAudience"] diff --git a/src/ansys/openapi/common/_session.py b/src/ansys/openapi/common/_session.py index 1ef41ecb..7236774d 100644 --- a/src/ansys/openapi/common/_session.py +++ b/src/ansys/openapi/common/_session.py @@ -22,14 +22,12 @@ from enum import Enum import os -from typing import TYPE_CHECKING, Any, Literal, Mapping, Optional, Tuple, TypeVar, Union +from typing import TYPE_CHECKING, Literal, Optional, TypeVar +from urllib.parse import urlparse, urlunparse import warnings -import requests -from requests.adapters import HTTPAdapter -from requests.auth import HTTPBasicAuth -from requests_ntlm import HttpNtlmAuth -from urllib3.util.retry import Retry +import httpx +from httpx import BasicAuth from . import __version__ from ._api_client import ApiClient @@ -38,9 +36,10 @@ from ._util import ( CaseInsensitiveOrderedDict, SessionConfiguration, + collect_www_authenticate_raw_values, + create_httpx_client_from_session_configuration, generate_user_agent, parse_authenticate, - set_session_kwargs, ) if TYPE_CHECKING: @@ -53,21 +52,17 @@ try: # noinspection PyUnresolvedReferences import keyring - import requests_auth # type: ignore[import-untyped, unused-ignore] # noqa: F401 + import httpx_auth # noqa: F401 from ._oidc import OIDCSessionFactory except ImportError: _oidc_enabled = False if os.name == "nt": - # noinspection PyUnresolvedReferences - from requests_negotiate_sspi import HttpNegotiateAuth as NegotiateAuth # type: ignore - _platform_windows = True else: try: - # noinspection PyUnresolvedReferences - from requests_kerberos import HTTPKerberosAuth as NegotiateAuth # type: ignore + import httpx_gssapi # noqa: F401 _linux_kerberos_enabled = True except ImportError: @@ -110,18 +105,21 @@ class ApiClientFactory: """Creates a factory that configures an API client for use with autogenerated Swagger clients. This method handles setup of the retry strategy, session-level timeout, and any additional - configurations for requests. Authentication must be configured afterwards using one of + configuration for the HTTP client. Authentication must be configured afterwards using one of the other class methods. + Call :meth:`close` when the factory (or any :class:`ApiClient` from :meth:`connect`) is no + longer needed, so the underlying HTTP client releases connections. + Parameters ---------- api_url : str Base URL of the API server. session_configuration : ~ansys.openapi.common.SessionConfiguration, optional - Additional configuration settings for the requests session. + Additional configuration settings for the HTTP client. """ - _session: requests.Session + _session: httpx.Client _api_url: str _auth_header: "CaseInsensitiveOrderedDict" _configured: bool @@ -129,7 +127,6 @@ class ApiClientFactory: def __init__( self, api_url: str, session_configuration: Optional[SessionConfiguration] = None ) -> None: - self._session = requests.Session() self._api_url = api_url self._configured = False logger.info(f"Creating new session at '{api_url}") @@ -142,35 +139,36 @@ def __init__( session_configuration.headers["User-Agent"] = user_agent self._session_configuration = session_configuration + self._session = create_httpx_client_from_session_configuration(session_configuration) + logger.debug( - f"Setting requests session parameter 'max_retries' " - f"with value '{self._session_configuration.retry_count}'" - ) - logger.debug( - f"Setting requests session parameter 'timeout' " + f"Configured httpx client default timeout " f"with value '{self._session_configuration.request_timeout}'" ) - - retry_strategy = Retry( - total=self._session_configuration.retry_count, - backoff_factor=1, - status_forcelist=[400, 429, 500, 502, 503, 504], + logger.debug( + "Retry policy: RetryingHTTPTransport " + f"(max_attempts={self._session_configuration.retry_count})." ) - transport_adapter = _RequestsTimeoutAdapter( - timeout=self._session_configuration.request_timeout, - max_retries=retry_strategy, - ) - self._session.mount("https://", transport_adapter) - self._session.mount("http://", transport_adapter) - - config_dict = self._session_configuration.get_configuration_for_requests() - for k, v in config_dict.items(): - if v is not None: - logger.debug(f"Setting requests session parameter '{k}' with value '{v}'") - set_session_kwargs(self._session, config_dict) logger.info("Base session created.") + @staticmethod + def _root_probe_url(api_url: str) -> str: + """Normalize URL for the initial ``GET`` probe against the API. + + Bare ``scheme://host[:port]`` URLs omit the path segment that maps to the ASGI ``"/"`` + route in typical FastAPI apps; normalize those to ``scheme://host[:port]/``. + + If ``api_url`` already contains a non-root path (for example ``http://host/service``), + that path is preserved without forcing an extra trailing slash. + """ + p = urlparse(api_url) + if p.path in ("", "/"): + normalized_path = "/" + else: + normalized_path = p.path.rstrip("/") or "/" + return urlunparse((p.scheme, p.netloc, normalized_path, p.params, p.query, p.fragment)) + def _validate_builder(self) -> None: if not self._configured: raise ValueError("No authentication configured yet.") @@ -193,6 +191,10 @@ def connect(self) -> ApiClient: self._validate_builder() return ApiClient(self._session, self._api_url, self._session_configuration) + def close(self) -> None: + """Close the underlying HTTP client and release pooled connections.""" + self._session.close() + def with_anonymous(self: Api_Client_Factory) -> Api_Client_Factory: """Set up client authentication for anonymous use. @@ -270,7 +272,7 @@ def with_credentials( logger.debug(f"Setting domain for username, connecting as '{username}'.") if authentication_scheme == AuthenticationScheme.AUTO: - initial_response = self._session.get(self._api_url) + initial_response = self._session.get(ApiClientFactory._root_probe_url(self._api_url)) if self.__handle_initial_response(initial_response): return self headers = self.__get_authenticate_header(initial_response) @@ -288,6 +290,8 @@ def with_credentials( ): if _platform_windows: logger.debug("Attempting to connect with NTLM authentication...") + from httpx_ntlm import HttpNtlmAuth + self._session.auth = HttpNtlmAuth(username, password) self.__test_connection() logger.info("Connection successful.") @@ -295,7 +299,7 @@ def with_credentials( return self if "Basic" in headers or authentication_scheme == AuthenticationScheme.BASIC: logger.debug("Attempting connection with Basic authentication...") - self._session.auth = HTTPBasicAuth(username, password) + self._session.auth = BasicAuth(username, password) self.__test_connection() logger.info("Connection successful.") self._configured = True @@ -333,7 +337,7 @@ def with_autologon(self: Api_Client_Factory) -> Api_Client_Factory: raise ImportError( "Kerberos is not enabled. To use it, run `pip install ansys-openapi-common[linux-kerberos]`." ) - initial_response = self._session.get(self._api_url) + initial_response = self._session.get(ApiClientFactory._root_probe_url(self._api_url)) if self.__handle_initial_response(initial_response): return self headers = self.__get_authenticate_header(initial_response) @@ -341,9 +345,17 @@ def with_autologon(self: Api_Client_Factory) -> Api_Client_Factory: "Detected authentication methods: " + ", ".join([method for method in headers.keys()]) ) if "Negotiate" in headers: - logger.debug(f"Using {NegotiateAuth.__qualname__} as a Negotiate backend.") logger.debug("Attempting connection with Negotiate authentication...") - self._session.auth = NegotiateAuth() + if _platform_windows: + from httpx_negotiate_sspi import HttpSspiAuth + + logger.debug(f"Using {HttpSspiAuth.__qualname__} as a Negotiate backend.") + self._session.auth = HttpSspiAuth() + else: + from httpx_gssapi import HTTPSPNEGOAuth + + logger.debug(f"Using {HTTPSPNEGOAuth.__qualname__} as a Negotiate backend.") + self._session.auth = HTTPSPNEGOAuth() self.__test_connection() logger.info("Connection successful.") self._configured = True @@ -359,7 +371,7 @@ def with_oidc( Parameters ---------- idp_session_configuration : ~ansys.openapi.common.SessionConfiguration, optional - Additional configuration settings for the requests session when connected to the OpenID identity provider. + Additional configuration settings for the HTTP client when connected to the OpenID identity provider. Returns ------- @@ -374,12 +386,11 @@ def with_oidc( raise ImportError( "OpenID Connect features are not enabled. To use them, run `pip install ansys-openapi-common[oidc]`." ) - initial_response = self._session.get(self._api_url) + initial_response = self._session.get(ApiClientFactory._root_probe_url(self._api_url)) if self.__handle_initial_response(initial_response): return OIDCSessionBuilder(self) session_factory = OIDCSessionFactory( - self._session, initial_response, self._session_configuration, idp_session_configuration, @@ -392,9 +403,8 @@ def __test_connection(self) -> Literal[True]: If the server returns a 2XX status code, the method returns ``True``. Otherwise, the method will throw an :obj:`APIConnectionError` object with the status code and the reason phrase. If the - underlying requests method returns an exception of its own, it is left to propagate as-is - (for example, a :obj:`~requests.exceptions.SSLException` object if the remote certificate - is untrusted). + underlying HTTP client raises an exception of its own, it is left to propagate as-is + (for example, an SSL error if the remote certificate is untrusted). Returns ------- @@ -406,14 +416,14 @@ def __test_connection(self) -> Literal[True]: APIConnectionError If the API server returns a status code other than 2XX. """ - resp = self._session.get(self._api_url) + resp = self._session.get(ApiClientFactory._root_probe_url(self._api_url)) if 200 <= resp.status_code < 300: return True else: raise ApiConnectionException(resp) def __handle_initial_response( - self, initial_response: requests.Response + self, initial_response: httpx.Response ) -> "Optional[ApiClientFactory]": """Verify that an initial 401 response is returned if we expect to require authentication. @@ -423,7 +433,7 @@ def __handle_initial_response( Parameters ---------- - initial_response : requests.Response + initial_response : httpx.Response Response from querying the API server. Raises @@ -453,13 +463,13 @@ def __handle_initial_response( @staticmethod def __get_authenticate_header( - response: requests.Response, + response: httpx.Response, ) -> "CaseInsensitiveOrderedDict": - """Extract the ``WWW-Authenticate`` header from a requests response. + """Extract the ``WWW-Authenticate`` header from an HTTP response. Parameters ---------- - response : requests.Response + response : httpx.Response Raw response from the API server. Raises @@ -467,9 +477,13 @@ def __get_authenticate_header( ValueError If the response contains no ``WWW-Authenticate`` header to be parsed. """ - if "www-authenticate" not in response.headers: + raw_values = collect_www_authenticate_raw_values(response) + if not raw_values: raise ValueError("No www-authenticate header was provided. Cannot continue...") - return parse_authenticate(response.headers["www-authenticate"]) + merged = CaseInsensitiveOrderedDict() + for chunk in raw_values: + merged.update(parse_authenticate(chunk)) + return merged class OIDCSessionBuilder: @@ -615,53 +629,3 @@ def authorize(self, login_timeout: int = 60) -> ApiClientFactory: ) self._client_factory._configured = True return self._client_factory - - -class _RequestsTimeoutAdapter(HTTPAdapter): - """Requests transport adapter to provide a default timeout for all requests sent to the API server. - - Attributes - ---------- - timeout : int, optional - Time in seconds to wait for a response from the API server. The default is ``31s``. - """ - - timeout: int = 31 - - def __init__(self, *args: Any, **kwargs: Any) -> None: - if "timeout" in kwargs: - self.timeout = kwargs["timeout"] - del kwargs["timeout"] - super().__init__(*args, **kwargs) - - def send( - self, - request: requests.PreparedRequest, - stream: bool = False, - timeout: Union[None, float, Tuple[float, float], Tuple[float, None]] = None, - verify: Union[bool, str] = True, - cert: Union[None, bytes, str, Tuple[Union[bytes, str], Union[bytes, str]]] = None, - proxies: Optional[Mapping[str, str]] = None, - ) -> requests.Response: - """Send a request to the API. - - If no timeout is specified on the request, it is set to the provided value. - - Parameters - ---------- - request : requests.PreparedRequest - Request to the API. - stream : bool, optional - Whether to stream the request content. The default is ``False``. - timeout : Union[None, float, Tuple[float, float], Tuple[float, None]] - How long to wait for the server to send data before giving up, as either a float or a - :ref:`(connect timeout, read timeout) ` tuple. - verify : Union[bool, str] - Either a Boolean that controls whether we verify the server's TLS certificate or a string - that must be a path to a CA bundle to use. - cert : None, bytes, str, Tuple[Union[bytes, str], Union[bytes, str]] - User-provided client certificate to send with the request, optionally with password. - proxies : Mapping[str, str], optional - Dictionary of proxies to apply to the request. - """ - return super().send(request, stream, timeout or self.timeout, verify, cert, proxies) From bc3b3fa1774f1a332bbcf819640dc0523312d366 Mon Sep 17 00:00:00 2001 From: Doug Addy Date: Fri, 8 May 2026 09:58:11 +0100 Subject: [PATCH 05/18] test: migrate suite to pytest-httpx and httpx clients Replace requests-mock with pytest-httpx; align dispatch and multipart tests with httpx.Client.request. Update session, OIDC, exception, and integration tests for httpx responses and factory behaviour. Add retry transport unit tests. Co-authored-by: Cursor --- tests/integration/test_anonymous.py | 56 ++- tests/integration/test_basic.py | 72 +-- tests/integration/test_negotiate.py | 56 ++- tests/test_api_client.py | 523 +++++++++++---------- tests/test_exceptions.py | 20 +- tests/test_missing_imports.py | 4 +- tests/test_oidc.py | 215 ++++----- tests/test_retry_transport.py | 92 ++++ tests/test_session_configuration.py | 145 +++--- tests/test_session_creation.py | 685 +++++++++++++++------------- 10 files changed, 1010 insertions(+), 858 deletions(-) create mode 100644 tests/test_retry_transport.py diff --git a/tests/integration/test_anonymous.py b/tests/integration/test_anonymous.py index 51c0f3b6..5ee819e9 100644 --- a/tests/integration/test_anonymous.py +++ b/tests/integration/test_anonymous.py @@ -70,24 +70,33 @@ def server(self): def test_can_connect(self): client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) - _ = client_factory.with_anonymous().connect() + try: + _ = client_factory.with_anonymous().connect() + finally: + client_factory.close() def test_get_health_returns_200_ok(self): client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) - client = client_factory.with_anonymous().connect() + try: + client = client_factory.with_anonymous().connect() - resp = client.request("GET", TEST_URL + "/test_api") - assert resp.status_code == 200 - assert "OK" in resp.text + resp = client.request("GET", TEST_URL + "/test_api") + assert resp.status_code == 200 + assert "OK" in resp.text + finally: + client_factory.close() def test_basic_credentials_raises_warning(self): client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) - with pytest.warns(AuthenticationWarning, match="anonymous"): - client = client_factory.with_credentials("TEST_USER", "TEST_PASS").connect() + try: + with pytest.warns(AuthenticationWarning, match="anonymous"): + client = client_factory.with_credentials("TEST_USER", "TEST_PASS").connect() - resp = client.request("GET", TEST_URL + "/test_api") - assert resp.status_code == 200 - assert "OK" in resp.text + resp = client.request("GET", TEST_URL + "/test_api") + assert resp.status_code == 200 + assert "OK" in resp.text + finally: + client_factory.close() def test_patch_model(self): from .. import models @@ -108,15 +117,18 @@ def test_patch_model(self): upload_data = {"ListOfStrings": ["red", "yellow", "green"]} client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) - client = client_factory.with_anonymous().connect() - client.setup_client(models) - - response = client.call_api( - resource_path, - method, - path_params=path_params, - body=upload_data, - response_type=response_type, - _return_http_data_only=True, - ) - assert response == deserialized_response + try: + client = client_factory.with_anonymous().connect() + client.setup_client(models) + + response = client.call_api( + resource_path, + method, + path_params=path_params, + body=upload_data, + response_type=response_type, + _return_http_data_only=True, + ) + assert response == deserialized_response + finally: + client_factory.close() diff --git a/tests/integration/test_basic.py b/tests/integration/test_basic.py index aa4573b4..65de42ce 100644 --- a/tests/integration/test_basic.py +++ b/tests/integration/test_basic.py @@ -87,28 +87,39 @@ def run_server(): class BasicTestCases: def test_can_connect(self, auth_mode): client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) - _ = client_factory.with_credentials( - TEST_USER, TEST_PASS, authentication_scheme=auth_mode - ).connect() + try: + _ = client_factory.with_credentials( + TEST_USER, TEST_PASS, authentication_scheme=auth_mode + ).connect() + finally: + client_factory.close() def test_invalid_user_return_401(self, auth_mode): client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) - with pytest.raises(ApiConnectionException) as exception_info: - _ = client_factory.with_credentials( - "eve", "password", authentication_scheme=auth_mode - ).connect() - assert exception_info.value.response.status_code == 401 - assert "Unauthorized" in exception_info.value.response.reason + try: + with pytest.raises(ApiConnectionException) as exception_info: + _ = client_factory.with_credentials( + "eve", "password", authentication_scheme=auth_mode + ).connect() + resp = exception_info.value.response + reason_text = getattr(resp, "reason_phrase", None) or getattr(resp, "reason", "") + assert resp.status_code == 401 + assert "Unauthorized" in reason_text + finally: + client_factory.close() def test_get_health_returns_200_ok(self, auth_mode): client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) - client = client_factory.with_credentials( - TEST_USER, TEST_PASS, authentication_scheme=auth_mode - ).connect() + try: + client = client_factory.with_credentials( + TEST_USER, TEST_PASS, authentication_scheme=auth_mode + ).connect() - resp = client.request("GET", TEST_URL + "/test_api") - assert resp.status_code == 200 - assert "OK" in resp.text + resp = client.request("GET", TEST_URL + "/test_api") + assert resp.status_code == 200 + assert "OK" in resp.text + finally: + client_factory.close() def test_patch_model(self, auth_mode): from .. import models @@ -129,20 +140,23 @@ def test_patch_model(self, auth_mode): upload_data = {"ListOfStrings": ["red", "yellow", "green"]} client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) - client = client_factory.with_credentials( - TEST_USER, TEST_PASS, authentication_scheme=auth_mode - ).connect() - client.setup_client(models) - - response = client.call_api( - resource_path, - http_method, - path_params=path_params, - body=upload_data, - response_type=response_type, - _return_http_data_only=True, - ) - assert response == deserialized_response + try: + client = client_factory.with_credentials( + TEST_USER, TEST_PASS, authentication_scheme=auth_mode + ).connect() + client.setup_client(models) + + response = client.call_api( + resource_path, + http_method, + path_params=path_params, + body=upload_data, + response_type=response_type, + _return_http_data_only=True, + ) + assert response == deserialized_response + finally: + client_factory.close() @pytest.mark.parametrize("auth_mode", [AuthenticationScheme.AUTO, AuthenticationScheme.BASIC]) diff --git a/tests/integration/test_negotiate.py b/tests/integration/test_negotiate.py index 0c11dcfc..69ab562d 100644 --- a/tests/integration/test_negotiate.py +++ b/tests/integration/test_negotiate.py @@ -94,15 +94,21 @@ def server(self): def test_can_connect(self): client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) - _ = client_factory.with_autologon().connect() + try: + _ = client_factory.with_autologon().connect() + finally: + client_factory.close() def test_get_health_returns_200_ok(self): client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) - client = client_factory.with_autologon().connect() + try: + client = client_factory.with_autologon().connect() - resp = client.request("GET", TEST_URL + "/test_api") - assert resp.status_code == 200 - assert "OK" in resp.text + resp = client.request("GET", TEST_URL + "/test_api") + assert resp.status_code == 200 + assert "OK" in resp.text + finally: + client_factory.close() def test_patch_model(self): from .. import models @@ -123,18 +129,21 @@ def test_patch_model(self): upload_data = {"ListOfStrings": ["red", "yellow", "green"]} client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) - client = client_factory.with_autologon().connect() - client.setup_client(models) - - response = client.call_api( - resource_path, - method, - path_params=path_params, - body=upload_data, - response_type=response_type, - _return_http_data_only=True, - ) - assert response == deserialized_response + try: + client = client_factory.with_autologon().connect() + client.setup_client(models) + + response = client.call_api( + resource_path, + method, + path_params=path_params, + body=upload_data, + response_type=response_type, + _return_http_data_only=True, + ) + assert response == deserialized_response + finally: + client_factory.close() @pytest.mark.skipif(sys.platform == "win32", reason="No portable KDC is available at present") @@ -168,7 +177,12 @@ async def get_forbidden(request: Request): ) def test_bad_principal_returns_403(self): client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) - with pytest.raises(ApiConnectionException) as excinfo: - _ = client_factory.with_autologon().connect() - assert excinfo.value.response.status_code == 403 - assert excinfo.value.response.reason == "Forbidden" + try: + with pytest.raises(ApiConnectionException) as excinfo: + _ = client_factory.with_autologon().connect() + resp = excinfo.value.response + assert resp.status_code == 403 + reason_text = getattr(resp, "reason_phrase", None) or getattr(resp, "reason", "") + assert "Forbidden" in reason_text + finally: + client_factory.close() diff --git a/tests/test_api_client.py b/tests/test_api_client.py index bf33428e..3afa1c24 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -27,15 +27,11 @@ import secrets import sys import tempfile -from typing import IO, Dict, Iterable, List, Tuple, Union +from typing import IO, Any, Dict, Iterable, List, Tuple, Union import uuid +import httpx import pytest -import requests -from requests.packages.urllib3.response import HTTPResponse -import requests_mock -from requests_mock.request import _RequestObjectProxy -from requests_mock.response import _FakeConnection, _IOReader from ansys.openapi.common import ( ApiClient, @@ -55,9 +51,11 @@ @pytest.fixture def blank_client(): - session = requests.Session() + transport = httpx.MockTransport(lambda request: httpx.Response(200)) + session = httpx.Client(transport=transport) client = ApiClient(session, TEST_URL, SessionConfiguration()) yield client + client.close() def test_repr(blank_client): @@ -65,6 +63,44 @@ def test_repr(blank_client): assert type(blank_client).__name__ in str(blank_client) +def test_close_is_idempotent(mocker): + transport = httpx.MockTransport(lambda request: httpx.Response(200)) + session = httpx.Client(transport=transport) + close_mock = mocker.patch.object(session, "close") + client = ApiClient(session, TEST_URL, SessionConfiguration()) + client.close() + client.close() + close_mock.assert_called_once() + + +def test_context_manager_closes_session(mocker): + transport = httpx.MockTransport(lambda request: httpx.Response(200)) + session = httpx.Client(transport=transport) + close_mock = mocker.patch.object(session, "close") + with ApiClient(session, TEST_URL, SessionConfiguration()): + pass + close_mock.assert_called_once() + + +def test_close_disposes_distinct_httpx_auth_token_client(): + class DummyAuth(httpx.Auth): + """Minimal auth exposing a separate token client (same shape as ``httpx-auth`` OAuth).""" + + def __init__(self, token_client: httpx.Client) -> None: + self.client = token_client + + def sync_auth_flow(self, request: httpx.Request): + yield request + + transport = httpx.MockTransport(lambda request: httpx.Response(200)) + inner = httpx.Client(transport=transport) + outer = httpx.Client(transport=transport, auth=DummyAuth(inner)) + api = ApiClient(outer, TEST_URL, SessionConfiguration()) + api.close() + assert inner.is_closed + assert outer.is_closed + + class TestParameterHandling: @pytest.fixture(autouse=True) def _blank_client(self, blank_client): @@ -527,11 +563,7 @@ def test_deserialize_wrong_type_raises_type_error_simple(self, type_name, value) class TestResponseParsing: - from requests.adapters import HTTPAdapter - - _http_adapter = HTTPAdapter() - _connection = _FakeConnection() - """Test handling of requests.Response objects based on response_type""" + """Test handling of ``httpx.Response`` objects based on ``response_type``.""" @pytest.fixture(autouse=True) def _blank_client(self, blank_client): @@ -539,39 +571,23 @@ def _blank_client(self, blank_client): def create_response( self, - json_: Dict = None, + json_=None, text: str = None, content: bytes = None, headers=None, content_type="application/json", ): - body = _IOReader() + if headers is None: + headers = {} + headers = dict(headers) + headers.setdefault("Content-Type", content_type) if json_ is not None: - text = json.dumps(json_) + return httpx.Response(200, json=json_, headers=headers) if text is not None: - content = text.encode("utf-8") + return httpx.Response(200, content=text.encode("utf-8"), headers=headers) if content is not None: - body = _IOReader(content) - status = 200 - reason = "OK" - if headers is None: - headers = {} - headers["Content-Type"] = content_type - - raw = HTTPResponse( - status=status, - reason=reason, - headers=headers, - body=body or _IOReader(b""), - decode_content=False, - preload_content=False, - original_response=None, - ) - - request = requests.Request() - response = self._http_adapter.build_response(request, raw) - response.connection = self._connection - return response + return httpx.Response(200, content=content, headers=headers) + return httpx.Response(200, content=b"", headers=headers) def test_response_is_not_deserialized_if_type_is_none(self, mocker): data = {"one": 1, "two": 2, "three": 3} @@ -704,7 +720,9 @@ class TestRequestDispatch: "int": 12, "array": ["foo", "bar"], "bool": False, - "object": {"none": None, "float": 3.1}, + # Nested dicts are not accepted by httpx multipart encoding when combined with ``files=``; + # this class only asserts verb dispatch kwargs, not deep JSON shape. + "extra": "scalar", } verbs = ("GET", "HEAD", "OPTIONS", "POST", "PATCH", "PUT", "DELETE") @@ -723,31 +741,56 @@ def send_request(self, verb: str): ) def assert_responses(self, verb, request_mock): - kwarg_assertions = { - "params": self.query_params, - "stream": self.stream, - "timeout": self.timeout, - "headers": self.header_params, - } - if verb in VERBS_WITH_BODY: - kwarg_assertions["data"] = self.body - if verb in VERBS_WITH_FILE_PARAMS: - kwarg_assertions["files"] = self.post_params - - request_mock.assert_called() - request_mock.assert_called_once_with(self.url, **kwarg_assertions) + expected_url = ApiClient._url_with_query_string(self.url, self.query_params) + base_kw = {"headers": self.header_params, "timeout": self.timeout} + body_kw: Dict[str, Any] = {} + if self.post_params is not None: + body_kw["files"] = self.post_params + if self.body is not None: + if self.post_params is not None: + body_kw["data"] = self.body + elif isinstance(self.body, bytes): + body_kw["content"] = self.body + else: + body_kw["data"] = self.body + + if verb == "GET": + request_mock.assert_called_once_with(expected_url, **base_kw) + elif verb == "HEAD": + request_mock.assert_called_once_with(expected_url, **base_kw) + elif verb == "OPTIONS": + request_mock.assert_called_once_with( + "OPTIONS", expected_url, **base_kw, **body_kw + ) + elif verb == "POST": + request_mock.assert_called_once_with(expected_url, **base_kw, **body_kw) + elif verb == "PATCH": + request_mock.assert_called_once_with(expected_url, **base_kw, **body_kw) + elif verb == "PUT": + request_mock.assert_called_once_with(expected_url, **base_kw, **body_kw) + elif verb == "DELETE": + request_mock.assert_called_once_with( + "DELETE", expected_url, **base_kw, **body_kw + ) + else: + raise AssertionError(verb) @pytest.fixture(autouse=True) def _blank_client(self): - self._transport = requests.Session() + transport = httpx.MockTransport(lambda request: httpx.Response(200)) + self._transport = httpx.Client(transport=transport) self._client = ApiClient(self._transport, TEST_URL, SessionConfiguration()) + yield + self._client.close() @pytest.mark.parametrize(("verb", "method_call"), (zip(verbs, method_names))) def test_request_dispatch(self, mocker, verb, method_call): # TODO: Can we move the logic deciding which parameters must be provided into the test, rather than the # function above? - request_mock = mocker.patch.object(requests.Session, method_call) - request_mock.return_value = True + # ``ApiClient`` uses ``Client.request()`` for OPTIONS and DELETE, not ``.options()`` / ``.delete()``. + patch_attr = "request" if verb in ("OPTIONS", "DELETE") else method_call + request_mock = mocker.patch.object(httpx.Client, patch_attr) + request_mock.return_value = httpx.Response(200) _ = self.send_request(verb) self.assert_responses(verb, request_mock) @@ -763,13 +806,12 @@ class TestResponseHandling: def _blank_client(self): from . import models - self._transport = requests.Session() - self._client = ApiClient(self._transport, TEST_URL, SessionConfiguration()) + self._client = ApiClient(httpx.Client(), TEST_URL, SessionConfiguration()) self._client.setup_client(models) - self._adapter = requests_mock.Adapter() - self._transport.mount(TEST_URL, self._adapter) + yield + self._client.close() - def test_get_health_info(self): + def test_get_health_info(self, httpx_mock): """This test represents a simple get request to a health style endpoint, returning 200 OK as a response. It also exercises the full response handling, checking the headers and status as well as the text. Other tests will not do this.""" @@ -778,11 +820,11 @@ def test_get_health_info(self): expected_url = TEST_URL + resource_path - self._adapter.register_uri( - "GET", - expected_url, + httpx_mock.add_response( + url=expected_url, + method="GET", status_code=200, - content="OK".encode("utf-8"), + content=b"OK", headers={"Content-Type": "text/plain"}, ) response, status_code, headers = self._client.call_api( @@ -794,7 +836,7 @@ def test_get_health_info(self): assert "Content-Type" in headers assert headers["Content-Type"] == "text/plain" - def test_post_model(self): + def test_post_model(self, httpx_mock): """This test represents uploading a new record to a server, the server will respond with 201 created and a string ID for the new object""" @@ -805,11 +847,6 @@ def test_post_model(self): "Boolean": False, } - def content_json_matcher(request: _RequestObjectProxy): - json_body = request.text - json_obj = json.loads(json_body) - return json_obj == expected_request - resource_path = "/models" method = "POST" response_type = "str" @@ -827,29 +864,29 @@ def content_json_matcher(request: _RequestObjectProxy): created_id = str(uuid.uuid4()) - with requests_mock.Mocker() as m: - m.post( - expected_url, - additional_matcher=content_json_matcher, - status_code=201, + def match_post(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert str(request.url) == expected_url + assert json.loads(request.content.decode()) == expected_request + return httpx.Response( + 201, text=created_id, headers={"Content-Type": "text/plain"}, ) - response, status_code, headers = self._client.call_api( - resource_path, method, body=upload_data, response_type=response_type - ) + + httpx_mock.add_callback(match_post, url=expected_url, method="POST") + response, status_code, headers = self._client.call_api( + resource_path, method, body=upload_data, response_type=response_type + ) assert response == created_id assert status_code == 201 assert "Content-Type" in headers assert headers["Content-Type"] == "text/plain" - def test_post_model_sets_content_type_header(self): + def test_post_model_sets_content_type_header(self, httpx_mock): """When a dict or model body is serialized to JSON, Content-Type: application/json must be set automatically on the outgoing request so that strict servers accept it.""" - def content_type_matcher(request: _RequestObjectProxy): - return request.headers.get("Content-Type") == "application/json" - resource_path = "/models" method = "POST" expected_url = TEST_URL + resource_path @@ -863,24 +900,19 @@ def content_type_matcher(request: _RequestObjectProxy): bool_property=False, ) - with requests_mock.Mocker() as m: - m.post( - expected_url, - additional_matcher=content_type_matcher, - status_code=201, - text=str(uuid.uuid4()), - ) - self._client.call_api(resource_path, method, body=upload_data, response_type="str") + def match_post(request: httpx.Request) -> httpx.Response: + assert request.headers.get("Content-Type") == "application/json" + return httpx.Response(201, text=str(uuid.uuid4())) + + httpx_mock.add_callback(match_post, url=expected_url, method="POST") + self._client.call_api(resource_path, method, body=upload_data, response_type="str") - def test_post_model_preserves_caller_content_type_header(self): + def test_post_model_preserves_caller_content_type_header(self, httpx_mock): """If the caller explicitly sets a Content-Type header it must not be overwritten by the automatic JSON content-type logic.""" caller_content_type = "application/vnd.example+json" - def content_type_matcher(request: _RequestObjectProxy): - return request.headers.get("Content-Type") == caller_content_type - resource_path = "/models" method = "POST" expected_url = TEST_URL + resource_path @@ -894,22 +926,20 @@ def content_type_matcher(request: _RequestObjectProxy): bool_property=False, ) - with requests_mock.Mocker() as m: - m.post( - expected_url, - additional_matcher=content_type_matcher, - status_code=201, - text=str(uuid.uuid4()), - ) - self._client.call_api( - resource_path, - method, - body=upload_data, - header_params={"Content-Type": caller_content_type}, - response_type="str", - ) + def match_post(request: httpx.Request) -> httpx.Response: + assert request.headers.get("Content-Type") == caller_content_type + return httpx.Response(201, text=str(uuid.uuid4())) + + httpx_mock.add_callback(match_post, url=expected_url, method="POST") + self._client.call_api( + resource_path, + method, + body=upload_data, + header_params={"Content-Type": caller_content_type}, + response_type="str", + ) - def test_post_model_does_not_modify_other_headers(self): + def test_post_model_does_not_modify_other_headers(self, httpx_mock): """Caller-supplied headers other than Content-Type must be passed through unmodified when a JSON body causes Content-Type: application/json to be injected automatically.""" @@ -919,11 +949,6 @@ def test_post_model_does_not_modify_other_headers(self): "Authorization": "Bearer sometoken", } - def headers_matcher(request: _RequestObjectProxy): - return request.headers.get("Content-Type") == "application/json" and all( - request.headers.get(k) == v for k, v in extra_headers.items() - ) - resource_path = "/models" method = "POST" expected_url = TEST_URL + resource_path @@ -937,22 +962,22 @@ def headers_matcher(request: _RequestObjectProxy): bool_property=False, ) - with requests_mock.Mocker() as m: - m.post( - expected_url, - additional_matcher=headers_matcher, - status_code=201, - text=str(uuid.uuid4()), - ) - self._client.call_api( - resource_path, - method, - body=upload_data, - header_params=extra_headers, - response_type="str", - ) + def match_post(request: httpx.Request) -> httpx.Response: + assert request.headers.get("Content-Type") == "application/json" + for k, v in extra_headers.items(): + assert request.headers.get(k) == v + return httpx.Response(201, text=str(uuid.uuid4())) + + httpx_mock.add_callback(match_post, url=expected_url, method="POST") + self._client.call_api( + resource_path, + method, + body=upload_data, + header_params=extra_headers, + response_type="str", + ) - def test_get_model_raises_exception_with_deserialized_response(self): + def test_get_model_raises_exception_with_deserialized_response(self, httpx_mock): """This test represents getting an object from a server which returns a defined exception object when the requested id does not exist.""" @@ -976,9 +1001,9 @@ def test_get_model_raises_exception_with_deserialized_response(self): } response_type_map = {200: "ExampleModel", 404: "ExampleException"} - self._adapter.register_uri( - "GET", - expected_url, + httpx_mock.add_response( + url=expected_url, + method="GET", status_code=404, json=response, headers={"Content-Type": "application/json"}, @@ -999,7 +1024,7 @@ def test_get_model_raises_exception_with_deserialized_response(self): assert exception_model.exception_code == exception_code assert exception_model.stack_trace == stack_trace - def test_get_model_raises_exception_with_no_deserialized_response(self): + def test_get_model_raises_exception_with_no_deserialized_response(self, httpx_mock): """This test represents getting an object from a server which returns a defined exception object when the requested id does not exist.""" @@ -1023,9 +1048,9 @@ def test_get_model_raises_exception_with_no_deserialized_response(self): } response_type_map = {200: "ExampleModel", 404: "ExampleException"} - self._adapter.register_uri( - "GET", - expected_url, + httpx_mock.add_response( + url=expected_url, + method="GET", status_code=500, json=response, headers={"Content-Type": "application/json"}, @@ -1039,7 +1064,7 @@ def test_get_model_raises_exception_with_no_deserialized_response(self): assert "Content-Type" in e.value.headers assert e.value.headers["Content-Type"] == "application/json" - def test_get_object_with_preload_false_returns_raw_response(self): + def test_get_object_with_preload_false_returns_raw_response(self, httpx_mock): """This test represents getting an object from a server where we do not want to deserialize the response immediately""" @@ -1056,26 +1081,26 @@ def test_get_object_with_preload_false_returns_raw_response(self): } response_type_map = {200: "ExampleModel"} - with requests_mock.Mocker() as m: - m.get( - expected_url, - status_code=200, - json=api_response, - headers={"Content-Type": "application/json"}, - ) - response = self._client.call_api( - resource_path, - method, - response_type_map=response_type_map, - _preload_content=False, - _return_http_data_only=True, - ) + httpx_mock.add_response( + url=expected_url, + method="GET", + status_code=200, + json=api_response, + headers={"Content-Type": "application/json"}, + ) + response = self._client.call_api( + resource_path, + method, + response_type_map=response_type_map, + _preload_content=False, + _return_http_data_only=True, + ) - assert isinstance(response, requests.Response) + assert isinstance(response, httpx.Response) assert response.status_code == 200 - assert response.text == json.dumps(api_response) + assert response.json() == api_response - def test_get_object_with_preload_false_raises_exception(self): + def test_get_object_with_preload_false_raises_exception(self, httpx_mock): """This test represents getting an object from a server where we do not want to deserialize the response immediately, but an exception is returned.""" @@ -1100,28 +1125,29 @@ def test_get_object_with_preload_false_raises_exception(self): response_type_map = {200: "ExampleModel", 404: "ExampleException"} - with requests_mock.Mocker() as m: - m.get( - expected_url, - status_code=404, + def respond_404(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 404, json=api_response, - reason="Not Found", headers={"Content-Type": "application/json"}, + extensions={"reason_phrase": b"Not Found"}, + ) + + httpx_mock.add_callback(respond_404, url=expected_url, method="GET") + with pytest.raises(ApiException) as e: + _ = self._client.call_api( + resource_path, + method, + response_type_map=response_type_map, + _preload_content=False, + _return_http_data_only=True, ) - with pytest.raises(ApiException) as e: - _ = self._client.call_api( - resource_path, - method, - response_type_map=response_type_map, - _preload_content=False, - _return_http_data_only=True, - ) assert e.value.status_code == 404 assert e.value.reason_phrase == "Not Found" - assert e.value.body == json.dumps(api_response) + assert json.loads(e.value.body) == api_response - def test_patch_object(self): + def test_patch_object(self, httpx_mock): """This test represents updating a value on an existing record using a custom json payload. The new object is returned. This questionable API accepts an ID as a query param and returns the updated object """ @@ -1146,11 +1172,6 @@ def test_patch_object(self): bool_property=False, ) - def content_json_matcher(request: _RequestObjectProxy): - json_body = request.text - json_obj = json.loads(json_body) - return json_obj == expected_request - resource_path = "/models/{ID}" method = "PATCH" record_id = str(uuid.uuid4()) @@ -1162,34 +1183,31 @@ def content_json_matcher(request: _RequestObjectProxy): upload_data = expected_request - with requests_mock.Mocker() as m: - m.patch( - expected_url, - additional_matcher=content_json_matcher, - status_code=200, + def match_patch(request: httpx.Request) -> httpx.Response: + assert json.loads(request.content.decode()) == expected_request + return httpx.Response( + 200, json=response, headers={"Content-Type": "application/json"}, ) - response = self._client.call_api( - resource_path, - method, - path_params=path_params, - body=upload_data, - response_type=response_type, - _return_http_data_only=True, - ) + + httpx_mock.add_callback(match_patch, url=expected_url, method="PATCH") + response = self._client.call_api( + resource_path, + method, + path_params=path_params, + body=upload_data, + response_type=response_type, + _return_http_data_only=True, + ) assert response == deserialized_response - def test_delete_object(self): + def test_delete_object(self, httpx_mock): """This test represents the deletion of a record by string ID, the server responds with 404 as the object does not exist""" record_id = str(uuid.uuid4()) - def content_json_matcher(request: _RequestObjectProxy): - request_body = request.text - return request_body == record_id - resource_path = "/models" method = "DELETE" @@ -1197,23 +1215,26 @@ def content_json_matcher(request: _RequestObjectProxy): expected_url = TEST_URL + resource_path + f"?recordId={record_id}" - with requests_mock.Mocker() as m: - m.delete( - expected_url, - additional_matcher=content_json_matcher, - status_code=404, - reason="Not Found", + def match_delete(request: httpx.Request) -> httpx.Response: + assert request.method == "DELETE" + assert str(request.url) == expected_url + assert request.content.decode() == record_id + return httpx.Response( + 404, headers={"Content-Type": "text/plain"}, + extensions={"reason_phrase": b"Not Found"}, + ) + + httpx_mock.add_callback(match_delete, method="DELETE") + with pytest.raises(ApiException) as excinfo: + _ = self._client.call_api( + resource_path, + method, + query_params=query_params, + body=record_id, + response_type="str", + _return_http_data_only=True, ) - with pytest.raises(ApiException) as excinfo: - _ = self._client.call_api( - resource_path, - method, - query_params=query_params, - body=record_id, - response_type="str", - _return_http_data_only=True, - ) assert excinfo.value.status_code == 404 assert excinfo.value.reason_phrase == "Not Found" @@ -1239,13 +1260,9 @@ def create_files_for_test(file_count: int) -> Tuple[List[str], List[bytes]]: for file_name in file_name_list: os.remove(file_name) - def test_file_upload(self, file_context): + def test_file_upload(self, file_context, httpx_mock): """This test represents an endpoint which accepts a file upload, the server will respond with 413""" - def content_json_matcher(request: _RequestObjectProxy): - request_body = request.body - return b"file_content" and file_content in request_body - resource_path = "/files" method = "POST" @@ -1255,23 +1272,24 @@ def content_json_matcher(request: _RequestObjectProxy): file_name = file_names[0] file_content = file_contents[0] - with requests_mock.Mocker() as m: - m.post( - expected_url, - additional_matcher=content_json_matcher, - status_code=413, - reason="Payload Too Large", + def match_upload(request: httpx.Request) -> httpx.Response: + assert file_content in request.content + return httpx.Response( + 413, headers={"Content-Type": "text/plain"}, + extensions={"reason_phrase": b"Payload Too Large"}, + ) + + httpx_mock.add_callback(match_upload, url=expected_url, method="POST") + with pytest.raises(ApiException) as excinfo: + _ = self._client.call_api( + resource_path, + method, + files={"file_content": file_name}, + header_params={"Content-Disposition": f'filename="{file_name}"'}, + response_type="str", + _return_http_data_only=True, ) - with pytest.raises(ApiException) as excinfo: - _ = self._client.call_api( - resource_path, - method, - files={"file_content": file_name}, - header_params={"Content-Disposition": f'filename="{file_name}"'}, - response_type="str", - _return_http_data_only=True, - ) assert excinfo.value.status_code == 413 assert excinfo.value.reason_phrase == "Payload Too Large" @@ -1287,12 +1305,11 @@ class TestMultipleResponseTypesHandling: def _blank_client(self): from .models import example_model - self._transport = requests.Session() - self._client = ApiClient(self._transport, TEST_URL, SessionConfiguration()) + self._client = ApiClient(httpx.Client(), TEST_URL, SessionConfiguration()) self._client.setup_client(example_model) - self._adapter = requests_mock.Adapter() - self._transport.mount(TEST_URL, self._adapter) self._model = example_model + yield + self._client.close() @pytest.mark.parametrize( ["response_code", "response_type_map", "response_type", "expected_type"], @@ -1320,6 +1337,7 @@ def _blank_client(self): def test_response_type_handling( self, mocker, + httpx_mock, response_code: int, response_type_map, response_type, @@ -1330,17 +1348,17 @@ def test_response_type_handling( expected_url = TEST_URL + resource_path deserialize_mock = mocker.patch.object(ApiClient, "deserialize") - with requests_mock.Mocker() as m: - m.post( - expected_url, - status_code=response_code, - ) - _ = self._client.call_api( - resource_path, - method, - response_type=response_type, - response_type_map=response_type_map, - ) + httpx_mock.add_response( + url=expected_url, + method="POST", + status_code=response_code, + ) + _ = self._client.call_api( + resource_path, + method, + response_type=response_type, + response_type_map=response_type_map, + ) deserialize_mock.assert_called_once() last_call_pos_args = deserialize_mock.call_args[0] @@ -1368,6 +1386,7 @@ def test_response_type_handling( def test_response_type_handling_of_exceptions( self, mocker, + httpx_mock, response_code: int, response_type_map, response_type, @@ -1378,18 +1397,18 @@ def test_response_type_handling_of_exceptions( expected_url = TEST_URL + resource_path deserialize_mock = mocker.patch.object(ApiClient, "deserialize") - with requests_mock.Mocker() as m: - m.get( - expected_url, - status_code=response_code, + httpx_mock.add_response( + url=expected_url, + method="GET", + status_code=response_code, + ) + with pytest.raises(ApiException): + _ = self._client.call_api( + resource_path, + method, + response_type=response_type, + response_type_map=response_type_map, ) - with pytest.raises(ApiException): - _ = self._client.call_api( - resource_path, - method, - response_type=response_type, - response_type_map=response_type_map, - ) deserialize_mock.assert_called_once() last_call_pos_args = deserialize_mock.call_args[0] diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 77006ac9..555eb93a 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -22,12 +22,13 @@ import uuid +import httpx import pytest -import requests -from requests.utils import CaseInsensitiveDict -from requests_mock import Mocker - -from ansys.openapi.common import ApiConnectionException, ApiException +from ansys.openapi.common import ( + ApiConnectionException, + ApiException, + CaseInsensitiveDict, +) from ansys.openapi.common._exceptions import AuthenticationWarning @@ -39,9 +40,12 @@ def test_api_connection_exception_repr(): "text": "You do not have permission to access this resource", } - with Mocker() as m: - m.get(**args) - response = requests.get(args["url"]) + request = httpx.Request("GET", args["url"]) + response = httpx.Response( + args["status_code"], + request=request, + content=args["text"].encode("utf-8"), + ) assert response.status_code == args["status_code"] api_connection_exception = ApiConnectionException(response) diff --git a/tests/test_missing_imports.py b/tests/test_missing_imports.py index 90f92472..31746c5d 100644 --- a/tests/test_missing_imports.py +++ b/tests/test_missing_imports.py @@ -58,7 +58,7 @@ def mocked_import(self, name, *args): return self.real_import(name, *args) def test_create_oidc_with_no_extra_throws(self, mocker): - self.blocked_import = "requests_auth" + self.blocked_import = "httpx_auth" mocker.patch("builtins.__import__", side_effect=self.mocked_import) from ansys.openapi.common import ApiClientFactory @@ -71,7 +71,7 @@ def test_create_oidc_with_no_extra_throws(self, mocker): @pytest.mark.skipif(os.name == "nt", reason="Test only applies to linux") def test_create_autologon_on_linux_with_no_extra_throws(self, mocker): - self.blocked_import = "requests_kerberos" + self.blocked_import = "httpx_gssapi" mocker.patch("builtins.__import__", side_effect=self.mocked_import) from ansys.openapi.common import ApiClientFactory diff --git a/tests/test_oidc.py b/tests/test_oidc.py index 71fec2fc..911feded 100644 --- a/tests/test_oidc.py +++ b/tests/test_oidc.py @@ -25,10 +25,9 @@ from urllib.parse import parse_qs from covertable import make +import httpx import pytest -import requests -from requests_auth import OAuth2 -import requests_mock +from httpx_auth import OAuth2 from ansys.openapi.common import ApiClientFactory from ansys.openapi.common._oidc import OIDCSessionFactory @@ -47,11 +46,10 @@ @pytest.fixture def authenticate_parsing_fixture(): - response = requests.Response() - response.url = "http://www.example.com" - response.encoding = "utf-8" - response.status_code = 401 - response.reason = "Unauthorized" + response = httpx.Response( + 401, + request=httpx.Request("GET", "http://www.example.com"), + ) yield response @@ -110,67 +108,55 @@ def test_valid_header_returns_correct_values(authenticate_parsing_fixture): "authority_url", ["https://www.example.com/", "https://www.example.com", "https://www.example.com/api/"], ) -def test_valid_well_known_parsed_correctly(authority_url): +def test_valid_well_known_parsed_correctly(httpx_mock, authority_url): response = json.dumps(WELL_KNOWN_PARAMETERS) - with requests_mock.Mocker() as requests_mocker: - if not authority_url.endswith("/"): - authority_url += "/" - requests_mocker.get( - f"{authority_url}.well-known/openid-configuration", - status_code=200, - text=response, - ) - mock_factory = Mock() - mock_factory._oauth_requests_session = requests.Session() - mock_factory._idp_session_configuration = {} - mock_factory._api_session_configuration = {} - output = OIDCSessionFactory._fetch_and_parse_well_known(mock_factory, authority_url) - for k, v in WELL_KNOWN_PARAMETERS.items(): - assert output[k] == v - assert output[k.upper()] == v + if not authority_url.endswith("/"): + authority_url += "/" + httpx_mock.add_response( + url=f"{authority_url}.well-known/openid-configuration", + method="GET", + text=response, + ) + with httpx.Client() as client: + output = OIDCSessionFactory._fetch_and_parse_well_known(client, authority_url) + for k, v in WELL_KNOWN_PARAMETERS.items(): + assert output[k] == v + assert output[k.upper()] == v @pytest.mark.parametrize("missing_parameter", WELL_KNOWN_PARAMETERS.keys()) -def test_missing_well_known_parameters_throws(missing_parameter): +def test_missing_well_known_parameters_throws(httpx_mock, missing_parameter): parameters = WELL_KNOWN_PARAMETERS.copy() del parameters[missing_parameter] response = json.dumps(parameters) identity_provider_url = "http://www.example.com/" - with requests_mock.Mocker() as requests_mocker: - requests_mocker.get( - "{}.well-known/openid-configuration".format(identity_provider_url), - status_code=200, - text=response, - ) - mock_factory = Mock() - mock_factory._oauth_requests_session = requests.Session() - mock_factory._idp_session_configuration = {} - mock_factory._api_session_configuration = {} + httpx_mock.add_response( + url=f"{identity_provider_url}.well-known/openid-configuration", + method="GET", + text=response, + ) + with httpx.Client() as client: with pytest.raises(ConnectionError) as exception_info: - _ = OIDCSessionFactory._fetch_and_parse_well_known(mock_factory, identity_provider_url) - assert "Unable to connect with OpenID Connect" in str(exception_info.value) - assert missing_parameter in str(exception_info.value) + _ = OIDCSessionFactory._fetch_and_parse_well_known(client, identity_provider_url) + assert "Unable to connect with OpenID Connect" in str(exception_info.value) + assert missing_parameter in str(exception_info.value) -def test_multiple_missing_well_known_parameters_throws(): +def test_multiple_missing_well_known_parameters_throws(httpx_mock): parameters = {} response = json.dumps(parameters) identity_provider_url = "http://www.example.com/" - with requests_mock.Mocker() as requests_mocker: - requests_mocker.get( - "{}.well-known/openid-configuration".format(identity_provider_url), - status_code=200, - text=response, - ) - mock_factory = Mock() - mock_factory._oauth_requests_session = requests.Session() - mock_factory._idp_session_configuration = {} - mock_factory._api_session_configuration = {} + httpx_mock.add_response( + url=f"{identity_provider_url}.well-known/openid-configuration", + method="GET", + text=response, + ) + with httpx.Client() as client: with pytest.raises(ConnectionError) as exception_info: - _ = OIDCSessionFactory._fetch_and_parse_well_known(mock_factory, identity_provider_url) - assert "Unable to connect with OpenID Connect" in str(exception_info.value) - for header_value in WELL_KNOWN_PARAMETERS: - assert header_value in str(exception_info.value) + _ = OIDCSessionFactory._fetch_and_parse_well_known(client, identity_provider_url) + assert "Unable to connect with OpenID Connect" in str(exception_info.value) + for header_value in WELL_KNOWN_PARAMETERS: + assert header_value in str(exception_info.value) @pytest.mark.parametrize( @@ -207,7 +193,7 @@ def test_setting_access_token_sets_access_token(): example_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30" expected_header = f"Bearer {example_token}" mock_factory = Mock() - mock_factory._authorized_session = requests.Session() + mock_factory._authorized_httpx_client = httpx.Client() session = OIDCSessionFactory.get_session_with_access_token( mock_factory, access_token=example_token ) @@ -223,13 +209,15 @@ def test_setting_refresh_token_with_no_token_throws(): def test_setting_refresh_token_sets_refresh_token(): refresh_token = "dGhpcyBpcyBhIHRva2VuLCBob25lc3Qh" + OAuth2.token_cache.clear() session = get_session_from_mock_factory_with_refresh_token(refresh_token) session.auth.refresh_token.assert_called_once_with(refresh_token) - assert OAuth2.token_cache.tokens[0][2] == refresh_token + stored = next(iter(OAuth2.token_cache.tokens.values())) + assert stored[2] == refresh_token -def test_invalid_refresh_token_throws(): +def test_invalid_refresh_token_throws(httpx_mock): api_url = "https://mi-api.com/api" authority_url = "https://www.example.com/authority/" client_id = "b4e44bfa-6b73-4d6a-9df6-8055216a5836" @@ -245,39 +233,41 @@ def test_invalid_refresh_token_throws(): } ) - def match_token_request(request): - if request.text is None: - return False - data = parse_qs(request.text) - return ( + httpx_mock.add_response( + url=api_url, + method="GET", + status_code=401, + headers={"www-authenticate": authenticate_header}, + ) + httpx_mock.add_response( + url=f"{authority_url}.well-known/openid-configuration", + method="GET", + text=well_known_response, + ) + + def token_exchange_response(request: httpx.Request) -> httpx.Response: + if request.content is None: + return httpx.Response(400) + data = parse_qs(request.content.decode()) + if not ( data.get("client_id", "") == [client_id] and data.get("grant_type", "") == ["refresh_token"] and data.get("refresh_token", "") == [refresh_token] - ) - - with requests_mock.Mocker() as m: - m.get( - api_url, - status_code=401, - headers={"WWW-Authenticate": authenticate_header}, - ) - m.get( - f"{authority_url}.well-known/openid-configuration", - status_code=200, - text=well_known_response, - ) - m.post( - f"{authority_url}token", - status_code=401, - additional_matcher=match_token_request, + ): + return httpx.Response(400) + return httpx.Response( + 401, headers={"WWW-Authenticate": "Bearer error=invalid_token"}, ) - with pytest.raises(ValueError) as exception_info: - ApiClientFactory(api_url).with_oidc().with_token(refresh_token=refresh_token) - assert "refresh token was invalid" in str(exception_info) + httpx_mock.add_callback(token_exchange_response, url=f"{authority_url}token", method="POST") + + with pytest.raises(ValueError) as exception_info: + ApiClientFactory(api_url).with_oidc().with_token(refresh_token=refresh_token) + assert "refresh token was invalid" in str(exception_info.value) -def test_endpoint_with_refresh_configures_correctly(): + +def test_endpoint_with_refresh_configures_correctly(httpx_mock): secure_servicelayer_url = "https://localhost/mi_servicelayer" redirect_uri = "https://www.example.com/login/" authority_url = "https://www.example.com/authority/" @@ -293,52 +283,21 @@ def test_endpoint_with_refresh_configures_correctly(): } ) - with requests_mock.Mocker() as m: - m.get( - f"{authority_url}.well-known/openid-configuration", - status_code=200, - text=well_known_response, - ) - m.get( - secure_servicelayer_url, - status_code=401, - headers={"WWW-Authenticate": authenticate_header}, - ) - - session = ApiClientFactory(secure_servicelayer_url).with_oidc() - auth = session._session_factory._auth - - assert auth.token_url == f"{authority_url}token" - assert auth.refresh_data["client_id"] == client_id - - -def mock_oidc_session_builder(): - secure_servicelayer_url = "https://localhost/mi_servicelayer" - redirect_uri = "https://www.example.com/login/" - authority_url = "https://www.example.com/authority/" - client_id = "b4e44bfa-6b73-4d6a-9df6-8055216a5836" - authenticate_header = ( - f'Bearer redirecturi="{redirect_uri}", authority="{authority_url}", ' - f'clientid="{client_id}", scope="offline_access"' + httpx_mock.add_response( + url=secure_servicelayer_url, + method="GET", + status_code=401, + headers={"www-authenticate": authenticate_header}, ) - well_known_response = json.dumps( - { - "token_endpoint": f"{authority_url}token", - "authorization_endpoint": f"{authority_url}authorization", - } + + httpx_mock.add_response( + url=f"{authority_url}.well-known/openid-configuration", + method="GET", + text=well_known_response, ) - with requests_mock.Mocker() as m: - m.get( - f"{authority_url}.well-known/openid-configuration", - status_code=200, - text=well_known_response, - ) - m.get( - secure_servicelayer_url, - status_code=401, - headers={"WWW-Authenticate": authenticate_header}, - ) + session = ApiClientFactory(secure_servicelayer_url).with_oidc() + auth = session._session_factory._auth - session_builder = ApiClientFactory(secure_servicelayer_url).with_oidc() - return session_builder + assert auth.token_url == f"{authority_url}token" + assert auth.refresh_data["client_id"] == client_id diff --git a/tests/test_retry_transport.py b/tests/test_retry_transport.py new file mode 100644 index 00000000..faf315d9 --- /dev/null +++ b/tests/test_retry_transport.py @@ -0,0 +1,92 @@ +# Copyright (C) 2022 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import httpx + +from ansys.openapi.common import SessionConfiguration +from ansys.openapi.common._retry_transport import RetryingHTTPTransport +from ansys.openapi.common._util import create_httpx_client_from_session_configuration + +_URL = "https://example.test/resource" + + +def test_retries_http_503_then_ok(httpx_mock): + httpx_mock.add_response(url=_URL, method="GET", status_code=503) + httpx_mock.add_response(url=_URL, method="GET", status_code=200, text="ok") + with create_httpx_client_from_session_configuration( + SessionConfiguration(retry_count=3) + ) as client: + r = client.get(_URL) + assert r.status_code == 200 + assert r.text == "ok" + assert len(httpx_mock.get_requests(url=_URL)) == 2 + + +def test_retries_stop_after_max_attempts(httpx_mock): + httpx_mock.add_response(url=_URL, method="GET", status_code=503) + httpx_mock.add_response(url=_URL, method="GET", status_code=503) + httpx_mock.add_response(url=_URL, method="GET", status_code=503) + with create_httpx_client_from_session_configuration( + SessionConfiguration(retry_count=3) + ) as client: + r = client.get(_URL) + assert r.status_code == 503 + assert len(httpx_mock.get_requests(url=_URL)) == 3 + + +def test_retry_count_one_skips_status_retry(httpx_mock): + httpx_mock.add_response(url=_URL, method="GET", status_code=503) + with create_httpx_client_from_session_configuration( + SessionConfiguration(retry_count=1) + ) as client: + r = client.get(_URL) + assert r.status_code == 503 + assert len(httpx_mock.get_requests(url=_URL)) == 1 + + +def test_retries_connect_error(httpx_mock): + httpx_mock.add_exception(httpx.ConnectError("refused"), url=_URL, method="GET") + httpx_mock.add_response(url=_URL, method="GET", status_code=200, text="ok") + with create_httpx_client_from_session_configuration( + SessionConfiguration(retry_count=3) + ) as client: + r = client.get(_URL) + assert r.status_code == 200 + assert len(httpx_mock.get_requests(url=_URL)) == 2 + + +def test_status_retry_not_applied_for_disallowed_method(httpx_mock): + """Non-whitelisted methods do not trigger HTTP status retries.""" + url = "https://example.test/other" + httpx_mock.add_response(url=url, method="TRACE", status_code=503) + transport = RetryingHTTPTransport( + max_attempts=3, + retry_http_methods=["GET"], + verify=True, + ) + try: + request = httpx.Request("TRACE", url) + response = transport.handle_request(request) + assert response.status_code == 503 + assert len(httpx_mock.get_requests(url=url)) == 1 + finally: + transport.close() diff --git a/tests/test_session_configuration.py b/tests/test_session_configuration.py index ae18c2d7..b00a1014 100644 --- a/tests/test_session_configuration.py +++ b/tests/test_session_configuration.py @@ -24,15 +24,17 @@ import secrets import tempfile import time -from unittest.mock import MagicMock +import httpx import pytest -import requests -from requests.utils import CaseInsensitiveDict -from ansys.openapi.common import SessionConfiguration -from ansys.openapi.common._session import _RequestsTimeoutAdapter -from ansys.openapi.common._util import RequestsConfiguration +from ansys.openapi.common import CaseInsensitiveDict, SessionConfiguration +from ansys.openapi.common._retry_transport import RetryingHTTPTransport +from ansys.openapi.common._session import ApiClientFactory +from ansys.openapi.common._util import ( + TransportConfiguration, + create_httpx_client_from_session_configuration, +) CLIENT_CERT_PATH = "./client-cert.pem" CLIENT_CERT_KEY = "5up3rS3c43t!" @@ -41,7 +43,7 @@ def test_defaults(): - output = SessionConfiguration().get_configuration_for_requests() + output = SessionConfiguration().get_transport_configuration() assert output["cert"] is None assert output["verify"] assert len(output["cookies"]) == 0 @@ -53,25 +55,25 @@ def test_defaults(): def test_cert_path_returns_str(): output = SessionConfiguration( client_cert_path=CLIENT_CERT_PATH - ).get_configuration_for_requests() + ).get_transport_configuration() assert output["cert"] == CLIENT_CERT_PATH def test_cert_path_and_key_returns_tuple(): output = SessionConfiguration( client_cert_path=CLIENT_CERT_PATH, client_cert_key=CLIENT_CERT_KEY - ).get_configuration_for_requests() + ).get_transport_configuration() assert output["cert"] == (CLIENT_CERT_PATH, CLIENT_CERT_KEY) @pytest.mark.parametrize("verify", [True, False]) def test_verify_returns_valid(verify): - output = SessionConfiguration(verify_ssl=verify).get_configuration_for_requests() + output = SessionConfiguration(verify_ssl=verify).get_transport_configuration() assert output["verify"] == verify def test_verify_with_path_returns_path(): - output = SessionConfiguration(cert_store_path=CA_CERT_PATH).get_configuration_for_requests() + output = SessionConfiguration(cert_store_path=CA_CERT_PATH).get_transport_configuration() assert output["verify"] == CA_CERT_PATH @@ -79,7 +81,7 @@ def test_verify_with_path_returns_path(): def header_test_fixture(): config = SessionConfiguration() config.headers.update({"lower_case": True, "LoWeR_CaSe": True}) - output = config.get_configuration_for_requests() + output = config.get_transport_configuration() yield output["headers"] @@ -104,7 +106,7 @@ def test_update_headers_indistinct(header_test_fixture): def test_proxies(): - output = SessionConfiguration(proxies=PROXY_CONFIG).get_configuration_for_requests() + output = SessionConfiguration(proxies=PROXY_CONFIG).get_transport_configuration() assert output["proxies"] == PROXY_CONFIG @@ -131,21 +133,17 @@ def test_cookies(): rfc2109=True, ) cookie_jar.set_cookie(test_cookie) - output = SessionConfiguration(cookies=cookie_jar).get_configuration_for_requests() + output = SessionConfiguration(cookies=cookie_jar).get_transport_configuration() assert output["cookies"] is not None - request = requests.Request( - method="GET", - url="http://www.testdomain.com:443/test/", - cookies=output["cookies"], - ) - prepared_request = request.prepare() - assert "Cookie" in prepared_request.headers - assert "131071" in prepared_request.headers["Cookie"] + with httpx.Client(cookies=output["cookies"]) as client: + req = client.build_request("GET", "http://www.testdomain.com:443/test/") + assert "cookie" in req.headers + assert "131071" in req.headers["cookie"] def test_redirects(): - output = SessionConfiguration(max_redirects=12000).get_configuration_for_requests() + output = SessionConfiguration(max_redirects=12000).get_transport_configuration() assert output["max_redirects"] == 12000 @@ -233,7 +231,7 @@ def test_verify_ssl_with_int_throws(self): assert "int" in str(excinfo.value) def test_assign_all_values(self): - test_input: RequestsConfiguration = self.blank_input + test_input: TransportConfiguration = self.blank_input test_input["verify"] = CA_CERT_PATH cookie_jar = http.cookiejar.CookieJar() @@ -278,66 +276,41 @@ def test_assign_all_values(self): assert config_object.cookies is not None - request = requests.Request( - method="GET", - url="http://www.testdomain.com:443/test/", - cookies=config_object.cookies, + with httpx.Client(cookies=config_object.cookies) as client: + req = client.build_request("GET", "http://www.testdomain.com:443/test/") + assert "cookie" in req.headers + assert "131071" in req.headers["cookie"] + + +class TestHttpxClientTransportFromSessionConfiguration: + """Timeout and retry settings from :class:`SessionConfiguration` apply to ``httpx.Client``.""" + + def test_default_timeout_matches_session_configuration(self): + config = SessionConfiguration() + with create_httpx_client_from_session_configuration(config) as client: + assert client.timeout == httpx.Timeout(config.request_timeout) + + def test_custom_request_timeout(self): + config = SessionConfiguration(request_timeout=17) + with create_httpx_client_from_session_configuration(config) as client: + assert client.timeout == httpx.Timeout(17) + + def test_retry_count_maps_to_transport_max_attempts(self): + config = SessionConfiguration(retry_count=7) + with create_httpx_client_from_session_configuration(config) as client: + assert isinstance(client._transport, RetryingHTTPTransport) + assert client._transport._max_attempts == 7 + + +class TestWwwAuthenticateHeaderMerging: + def test_multiple_header_lines_merged(self): + headers = httpx.Headers( + [ + ("www-authenticate", "Negotiate"), + ("www-authenticate", 'Basic realm="example.com"'), + ] ) - prepared_request = request.prepare() - assert "Cookie" in prepared_request.headers - assert "131071" in prepared_request.headers["Cookie"] - - -class TestTimeoutAdapter: - TEST_URL = "https://www.testdomain.com/test" - DEFAULT_TIMEOUT = 31 - - @pytest.fixture - def test_request(self): - yield requests.Request("GET", self.TEST_URL) - - @staticmethod - def check_timeout(patched_urlopen: MagicMock, connect_timeout: int, read_timeout: int): - patched_urlopen.assert_called_once() - assert "timeout" in patched_urlopen.call_args[1] - timeout = patched_urlopen.call_args[1]["timeout"] - assert timeout.connect_timeout == connect_timeout - assert timeout.read_timeout == read_timeout - - def test_get_default_timeout(self): - adapter = _RequestsTimeoutAdapter() - assert adapter.timeout == self.DEFAULT_TIMEOUT - - def test_default_timeout_is_applied_to_request(self, mocker, test_request): - adapter = _RequestsTimeoutAdapter() - connection = adapter.get_connection_with_tls_context(test_request.prepare(), True) - patched_urlopen = mocker.patch.object(connection, "urlopen") - adapter.send(test_request.prepare()) - self.check_timeout(patched_urlopen, self.DEFAULT_TIMEOUT, self.DEFAULT_TIMEOUT) - - def test_custom_timeout_int_is_applied_to_request(self, mocker, test_request): - timeout = 10 - adapter = _RequestsTimeoutAdapter(timeout=timeout) - connection = adapter.get_connection_with_tls_context(test_request.prepare(), True) - patched_urlopen = mocker.patch.object(connection, "urlopen") - adapter.send(test_request.prepare()) - self.check_timeout(patched_urlopen, timeout, timeout) - - def test_custom_timeout_tuple_is_applied_to_request(self, mocker, test_request): - timeout = (10, 100) - adapter = _RequestsTimeoutAdapter(timeout=timeout) - connection = adapter.get_connection_with_tls_context(test_request.prepare(), True) - patched_urlopen = mocker.patch.object(connection, "urlopen") - adapter.send(test_request.prepare()) - self.check_timeout(patched_urlopen, *timeout) - - def test_custom_max_retries_is_applied_to_request(self, mocker, test_request): - max_retries = 99 - adapter = _RequestsTimeoutAdapter(max_retries=max_retries) - connection = adapter.get_connection_with_tls_context(test_request.prepare(), True) - patched_urlopen = mocker.patch.object(connection, "urlopen") - adapter.send(test_request.prepare()) - patched_urlopen.assert_called_once() - assert "retries" in patched_urlopen.call_args[1] - retry_obj = patched_urlopen.call_args[1]["retries"] - assert retry_obj.total == max_retries + response = httpx.Response(401, headers=headers) + parsed = ApiClientFactory._ApiClientFactory__get_authenticate_header(response) + assert "negotiate" in parsed + assert "basic" in parsed diff --git a/tests/test_session_creation.py b/tests/test_session_creation.py index ce203f2c..5ed7dacd 100644 --- a/tests/test_session_creation.py +++ b/tests/test_session_creation.py @@ -27,10 +27,8 @@ import sys from urllib.parse import parse_qs +import httpx import pytest -import requests -import requests_mock -import requests_ntlm from ansys.openapi.common import ( ApiClientFactory, @@ -48,35 +46,72 @@ ) REFRESH_TOKEN = ( "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMzQ1Njc4OTAxIiwibmFtZSI6IkphbmUgU21pdGgiLCJpYXQiOjE" - "1MTYyMzkwMjJ9.Gm9bqy4CL4_mXKPYrnt2nHGxGM_WaLGpGHrYE_U9uJQ" + "5MTYyMzkwMjJ9.Gm9bqy4CL4_mXKPYrnt2nHGxGM_WaLGpGHrYE_U9uJQ" ) +# NTLM pytest-httpx shared handshake (pyspnego via httpx-ntlm; regenerate if deps change — versions in test_can_connect_with_ntlm). +_NTLM_PATCHED_URANDOM = b"\xde\xad\xbe\xef\xde\xad\xbe\xef" +_NTLM_CANNED_EXPECT1 = {"Authorization": "NTLM TlRMTVNTUAABAAAAN4II4gAAAAAoAAAAAAAAACgAAAAADAAAAAAADw=="} +_NTLM_CANNED_CHALLENGE_WWW = ( + "NTLM TlRMTVNTUAACAAAAHgAeADgAAAA1gori1CEifyE0ovkAAAAAAAAAAJgAmABWAAAAC" + "gBhSgAAAA9UAEUAUwBUAFcATwBSAEsAUwBUAEEAVABJAE8ATgACAB4AVABFAFMAVABXAE8AUgBLAFMAVABBAFQASQB" + "PAE4AAQAeAFQARQBTAFQAVwBPAFIASwBTAFQAQQBUAEkATwBOAAQAHgBUAEUAUwBUAFcATwBSAEsAUwBUAEEAVABJA" + "E8ATgADAB4AVABFAFMAVABXAE8AUgBLAFMAVABBAFQASQBPAE4ABwAIADbWHPMoRNcBAAAAAA==" +) +_NTLM_CANNED_CHALLENGE_HEADERS = {"www-authenticate": _NTLM_CANNED_CHALLENGE_WWW} +# Type 3 for NOT_A_TEST_USER / PASSWORD with _NTLM_PATCHED_URANDOM and _NTLM_CANNED_CHALLENGE_WWW (invalid-credentials test). +_NTLM_INVALID_CREDENTIALS_EXPECT2 = { + "Authorization": ( + "NTLM TlRMTVNTUAADAAAAGAAYAFgAAAD0APQAcAAAAAAAAABkAQAAHgAeAGQBAAAeAB4AggEAAAgACACgAQAANYKK4gAM" + "AAAAAAAPGm05CYoNx3Q/B2D6gQMJzgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACIxIvAaPoXT0oTj1k48KMgBAQAAAAAAADbWHPMoRNcB3q2+796tvu8AAAAAAgAeAFQARQBTAFQAVwBPAFIASwBTAFQAQQBUAEkATwBOAAEAHgBUAEUAUwBUAFcATwBSAEsAUwBUAEEAVABJAE8ATgAEAB4AVABFAFMAVABXAE8AUgBLAFMAVABBAFQASQBPAE4AAwAeAFQARQBTAFQAVwBPAFIASwBTAFQAQQBUAEkATwBOAAcACAA21hzzKETXAQkAIABoAG8AcwB0AC8AdQBuAHMAcABlAGMAaQBmAGkAZQBkAAYABAACAAAAAAAAAAAAAABOAE8AVABfAEEAXwBUAEUAUwBUAF8AVQBTAEUAUgBTAE4AUABTAC0AQgBFAEMARgBDADYANABMAE4AQwBkT+LI/a3T7Q==" + ) +} + + +def _ntlm_backend_available() -> bool: + """False when ``httpx_ntlm`` cannot load (e.g. Windows without MIT Kerberos / spnego chain).""" + if os.name != "nt": + return True + try: + from httpx_ntlm import HttpNtlmAuth # noqa: F401 + + return True + except OSError: + return False + + +def _response_reason(response): + """Reason phrase from an ``httpx.Response``.""" + return getattr(response, "reason_phrase", getattr(response, "reason", "")) -def test_anonymous(): - with requests_mock.Mocker() as m: - m.get(SERVICELAYER_URL, status_code=200, reason="OK", text="Connection OK") - _ = ApiClientFactory(SERVICELAYER_URL).with_anonymous() + +def test_anonymous(httpx_mock): + httpx_mock.add_response(url=SERVICELAYER_URL, method="GET", status_code=200, text="Connection OK") + _ = ApiClientFactory(SERVICELAYER_URL).with_anonymous() @pytest.mark.parametrize( ("status_code", "reason_phrase"), [(403, "Forbidden"), (404, "Not Found"), (500, "Internal Server Error")], ) -def test_other_status_codes_throw(status_code, reason_phrase): - with requests_mock.Mocker() as m: - m.get(SERVICELAYER_URL, status_code=status_code, reason=reason_phrase) - with pytest.raises(ApiConnectionException) as excinfo: - _ = ApiClientFactory(SERVICELAYER_URL).with_anonymous() - assert excinfo.value.response.status_code == status_code - assert excinfo.value.response.reason == reason_phrase +def test_other_status_codes_throw(status_code, reason_phrase, httpx_mock): + httpx_mock.add_response( + url=SERVICELAYER_URL, + method="GET", + status_code=status_code, + is_reusable=True, + ) + with pytest.raises(ApiConnectionException) as excinfo: + _ = ApiClientFactory(SERVICELAYER_URL).with_anonymous() + assert excinfo.value.response.status_code == status_code + assert _response_reason(excinfo.value.response) == reason_phrase -def test_missing_www_authenticate_throws(): - with requests_mock.Mocker() as m: - m.get(SERVICELAYER_URL, status_code=401, reason="Unauthorized") - with pytest.raises(ValueError) as excinfo: - _ = ApiClientFactory(SERVICELAYER_URL).with_autologon() - assert "www-authenticate" in str(excinfo.value) +def test_missing_www_authenticate_throws(httpx_mock): + httpx_mock.add_response(url=SERVICELAYER_URL, method="GET", status_code=401) + with pytest.raises(ValueError) as excinfo: + _ = ApiClientFactory(SERVICELAYER_URL).with_autologon() + assert "www-authenticate" in str(excinfo.value) def test_unconfigured_builder_throws(): @@ -86,69 +121,69 @@ def test_unconfigured_builder_throws(): assert "authentication" in str(excinfo.value) -def test_can_connect_with_basic(): - with requests_mock.Mocker() as m: - m.get( - SERVICELAYER_URL, - status_code=401, - headers={"WWW-Authenticate": 'Basic realm="localhost"'}, - ) - m.get( - SERVICELAYER_URL, - status_code=200, - request_headers={"Authorization": "Basic VEVTVF9VU0VSOlBBU1NXT1JE"}, - ) - _ = ApiClientFactory(SERVICELAYER_URL).with_credentials( - username="TEST_USER", password="PASSWORD" - ) +def test_can_connect_with_basic(httpx_mock): + httpx_mock.add_response( + url=SERVICELAYER_URL, + method="GET", + status_code=401, + headers={"www-authenticate": 'Basic realm="localhost"'}, + ) + httpx_mock.add_response( + url=SERVICELAYER_URL, + method="GET", + status_code=200, + match_headers={"Authorization": "Basic VEVTVF9VU0VSOlBBU1NXT1JE"}, + ) + _ = ApiClientFactory(SERVICELAYER_URL).with_credentials(username="TEST_USER", password="PASSWORD") -def test_can_connect_with_pre_emptive_basic(): - with requests_mock.Mocker() as m: - m.get( - SERVICELAYER_URL, - status_code=200, - request_headers={"Authorization": "Basic VEVTVF9VU0VSOlBBU1NXT1JE"}, - ) - _ = ApiClientFactory(SERVICELAYER_URL).with_credentials( - username="TEST_USER", - password="PASSWORD", - authentication_scheme=AuthenticationScheme.BASIC, - ) - assert m.called_once +def test_can_connect_with_pre_emptive_basic(httpx_mock): + httpx_mock.add_response( + url=SERVICELAYER_URL, + method="GET", + status_code=200, + match_headers={"Authorization": "Basic VEVTVF9VU0VSOlBBU1NXT1JE"}, + ) + _ = ApiClientFactory(SERVICELAYER_URL).with_credentials( + username="TEST_USER", + password="PASSWORD", + authentication_scheme=AuthenticationScheme.BASIC, + ) + assert len(httpx_mock.get_requests(url=SERVICELAYER_URL)) == 1 -def test_can_connect_with_basic_and_domain(): - with requests_mock.Mocker() as m: - m.get( - SERVICELAYER_URL, - status_code=401, - headers={"WWW-Authenticate": 'Basic realm="localhost"'}, - ) - m.get( - SERVICELAYER_URL, - status_code=200, - request_headers={"Authorization": "Basic RE9NQUlOXFRFU1RfVVNFUjpQQVNTV09SRA=="}, - ) - _ = ApiClientFactory(SERVICELAYER_URL).with_credentials( - username="TEST_USER", password="PASSWORD", domain="DOMAIN" - ) +def test_can_connect_with_basic_and_domain(httpx_mock): + httpx_mock.add_response( + url=SERVICELAYER_URL, + method="GET", + status_code=401, + headers={"www-authenticate": 'Basic realm="localhost"'}, + ) + httpx_mock.add_response( + url=SERVICELAYER_URL, + method="GET", + status_code=200, + match_headers={"Authorization": "Basic RE9NQUlOXFRFU1RfVVNFUjpQQVNTV09SRA=="}, + ) + _ = ApiClientFactory(SERVICELAYER_URL).with_credentials( + username="TEST_USER", password="PASSWORD", domain="DOMAIN" + ) -def test_can_connect_with_pre_emptive_basic_and_domain(): - with requests_mock.Mocker() as m: - m.get( - SERVICELAYER_URL, - status_code=200, - request_headers={"Authorization": "Basic RE9NQUlOXFRFU1RfVVNFUjpQQVNTV09SRA=="}, - ) - _ = ApiClientFactory(SERVICELAYER_URL).with_credentials( - username="TEST_USER", - password="PASSWORD", - domain="DOMAIN", - authentication_scheme=AuthenticationScheme.BASIC, - ) - assert m.called_once +def test_can_connect_with_pre_emptive_basic_and_domain(httpx_mock): + httpx_mock.add_response( + url=SERVICELAYER_URL, + method="GET", + status_code=200, + match_headers={"Authorization": "Basic RE9NQUlOXFRFU1RfVVNFUjpQQVNTV09SRA=="}, + ) + _ = ApiClientFactory(SERVICELAYER_URL).with_credentials( + username="TEST_USER", + password="PASSWORD", + domain="DOMAIN", + authentication_scheme=AuthenticationScheme.BASIC, + ) + assert len(httpx_mock.get_requests(url=SERVICELAYER_URL)) == 1 # In Auto mode, the single call is during the initial request to retrieve the header @@ -165,21 +200,21 @@ def test_can_connect_with_pre_emptive_basic_and_domain(): AuthenticationScheme.NTLM, nullcontext(), marks=pytest.mark.skipif( - sys.platform != "win32", reason="NTLM only available on Windows" + sys.platform != "win32" or not _ntlm_backend_available(), + reason="NTLM requires Windows and a working httpx_ntlm/spnego stack (e.g. MIT Kerberos on Windows)", ), ), ], ) -def test_only_called_once_with_basic_when_anonymous_is_ok(auth_mode, expect_warning): - with requests_mock.Mocker() as m: - m.get(SERVICELAYER_URL, status_code=200) - with expect_warning: - _ = ApiClientFactory(SERVICELAYER_URL).with_credentials( - username="TEST_USER", - password="PASSWORD", - authentication_scheme=auth_mode, - ) - assert m.called_once +def test_only_called_once_with_basic_when_anonymous_is_ok(auth_mode, expect_warning, httpx_mock): + httpx_mock.add_response(url=SERVICELAYER_URL, method="GET", status_code=200) + with expect_warning: + _ = ApiClientFactory(SERVICELAYER_URL).with_credentials( + username="TEST_USER", + password="PASSWORD", + authentication_scheme=auth_mode, + ) + assert len(httpx_mock.get_requests(url=SERVICELAYER_URL)) == 1 @pytest.mark.parametrize( @@ -190,33 +225,64 @@ def test_only_called_once_with_basic_when_anonymous_is_ok(auth_mode, expect_warn pytest.param( AuthenticationScheme.NTLM, marks=pytest.mark.skipif( - sys.platform != "win32", reason="NTLM only available on Windows" + sys.platform != "win32" or not _ntlm_backend_available(), + reason="NTLM requires Windows and a working httpx_ntlm/spnego stack (e.g. MIT Kerberos on Windows)", ), ), ], ) -def test_throws_with_invalid_credentials(auth_mode): - with requests_mock.Mocker() as m: - UNAUTHORIZED = "Unauthorized_unique" - m.get( - SERVICELAYER_URL, +def test_throws_with_invalid_credentials(auth_mode, httpx_mock, mocker): + UNAUTHORIZED = "Unauthorized_unique" + + def unauthorized_response() -> httpx.Response: + return httpx.Response( + 401, + extensions={"reason_phrase": UNAUTHORIZED.encode("ascii")}, + ) + + if auth_mode == AuthenticationScheme.NTLM: + mocker.patch("os.urandom", return_value=_NTLM_PATCHED_URANDOM) + httpx_mock.add_response( + url=SERVICELAYER_URL, + method="GET", status_code=401, - headers={"WWW-Authenticate": 'Basic realm="localhost"'}, - reason=UNAUTHORIZED, + headers={"www-authenticate": "NTLM"}, ) - m.get( - SERVICELAYER_URL, - status_code=200, - request_headers={"Authorization": "Basic VEVTVF9VU0VSOlBBU1NXT1JE"}, + httpx_mock.add_response( + url=SERVICELAYER_URL, + method="GET", + status_code=401, + headers=_NTLM_CANNED_CHALLENGE_HEADERS, + match_headers=_NTLM_CANNED_EXPECT1, ) - with pytest.raises(ApiConnectionException) as exception_info: - _ = ApiClientFactory(SERVICELAYER_URL).with_credentials( - username="NOT_A_TEST_USER", - password="PASSWORD", - authentication_scheme=auth_mode, + httpx_mock.add_callback( + lambda request: unauthorized_response(), + url=SERVICELAYER_URL, + method="GET", + match_headers=_NTLM_INVALID_CREDENTIALS_EXPECT2, + ) + else: + if auth_mode == AuthenticationScheme.AUTO: + httpx_mock.add_response( + url=SERVICELAYER_URL, + method="GET", + status_code=401, + headers={"www-authenticate": 'Basic realm="localhost"'}, ) - assert exception_info.value.response.status_code == 401 - assert exception_info.value.response.reason == UNAUTHORIZED + httpx_mock.add_callback( + lambda request: unauthorized_response(), + url=SERVICELAYER_URL, + method="GET", + is_reusable=True, + ) + with pytest.raises(ApiConnectionException) as exception_info: + _ = ApiClientFactory(SERVICELAYER_URL).with_credentials( + username="NOT_A_TEST_USER", + password="PASSWORD", + authentication_scheme=auth_mode, + ) + assert exception_info.value.response.status_code == 401 + assert _response_reason(exception_info.value.response) == UNAUTHORIZED @pytest.mark.skipif(sys.platform != "linux", reason="NTLM only not supported on Linux") @@ -248,88 +314,84 @@ def wrapper(self, *args, **kwargs): return wrapper -class MockNTLMAuth(requests_ntlm.HttpNtlmAuth): - def __init__(self, username, password, session=None): - super().__init__(username, password, session, send_cbt=False) - - -@pytest.mark.skip(reason="Mock is not working in tox for some reason.") @pytest.mark.skipif(os.name != "nt", reason="NTLM is not currently supported on linux") @pytest.mark.parametrize("auth_mode", [AuthenticationScheme.AUTO, AuthenticationScheme.NTLM]) -def test_can_connect_with_ntlm(mocker, auth_mode): - expect1 = {"Authorization": "NTLM TlRMTVNTUAABAAAAMZCI4gAAAAAoAAAAAAAAACgAAAAGAbEdAAAADw=="} - response1 = { - "WWW-Authenticate": "NTLM TlRMTVNTUAACAAAAHgAeADgAAAA1gori1CEifyE0ovkAAAAAAAAAAJgAmABWAAAAC" - "gBhSgAAAA9UAEUAUwBUAFcATwBSAEsAUwBUAEEAVABJAE8ATgACAB4AVABFAFMAVABXAE8AUgBLAFMAVABBAFQASQB" - "PAE4AAQAeAFQARQBTAFQAVwBPAFIASwBTAFQAQQBUAEkATwBOAAQAHgBUAEUAUwBUAFcATwBSAEsAUwBUAEEAVABJA" - "E8ATgADAB4AVABFAFMAVABXAE8AUgBLAFMAVABBAFQASQBPAE4ABwAIADbWHPMoRNcBAAAAAA==" - } +def test_can_connect_with_ntlm(mocker, auth_mode, httpx_mock): + # expect2 was generated with pyspnego (NTLM via httpx-ntlm's spnego.client), os.urandom patched below, + # and the Type 2 challenge shared as _NTLM_CANNED_CHALLENGE_WWW. Regenerate after upgrading these deps (uv.lock): + # httpx-ntlm 1.4.0, pyspnego 0.12.0 expect2 = { - "Authorization": "NTLM TlRMTVNTUAADAAAAGAAYAGgAAADQANAAgAAAAAAAAABYAAAAEAAQAFgAAAAAAAAAaAAAAAg" - "ACABQAQAANYKK4gYBsR0AAAAPgNpphHi8APlNXyGtGcP/LUkASQBTAF8AVABlAHMAdAAAAAAAAAAAAAAAAAAAAAAAAAAA" - "AAAAAADBY98WhVO4ccHK2mJ3PQ+GAQEAAAAAAAA21hzzKETXAd6tvu/erb7vAAAAAAIAHgBUAEUAUwBUAFcATwBSAEsAU" - "wBUAEEAVABJAE8ATgABAB4AVABFAFMAVABXAE8AUgBLAFMAVABBAFQASQBPAE4ABAAeAFQARQBTAFQAVwBPAFIASwBTAF" - "QAQQBUAEkATwBOAAMAHgBUAEUAUwBUAFcATwBSAEsAUwBUAEEAVABJAE8ATgAHAAgANtYc8yhE1wEGAAQAAgAAAAAAAAA" - "AAAAAcTfJ2nPXFQA=" + "Authorization": ( + "NTLM TlRMTVNTUAADAAAAGAAYAFgAAAD0APQAcAAAAAAAAABkAQAAEAAQAGQBAAAeAB4AdAEAAAgACACSAQAANYKK4gAMAAAAAAAP" + "en6U3pufm3jdYWsqvyltPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANEE89x31gcm6C1VZREbI/UBAQAAAAAAADbWHPMoRNcB3q2+796tvu8AAAAAAgAeAFQARQBTAFQAVwBPAFIASwBTAFQAQQBUAEkATwBOAAEAHgBUAEUAUwBUAFcATwBSAEsAUwBUAEEAVABJAE8ATgAEAB4AVABFAFMAVABXAE8AUgBLAFMAVABBAFQASQBPAE4AAwAeAFQARQBTAFQAVwBPAFIASwBTAFQAQQBUAEkATwBOAAcACAA21hzzKETXAQkAIABoAG8AcwB0AC8AdQBuAHMAcABlAGMAaQBmAGkAZQBkAAYABAACAAAAAAAAAAAAAABJAEkAUwBfAFQAZQBzAHQAUwBOAFAAUwAtAEIARQBDAEYAQwA2ADQATABOAEMAlvUQdxoSkiQ=" + ) } - mocker.patch( - "os.urandom", - return_value=b"\xde\xad\xbe\xef\xde\xad\xbe\xef", - ) - - mocker.patch("_session.HttpNtlmAuth", MockNTLMAuth) + mocker.patch("os.urandom", return_value=_NTLM_PATCHED_URANDOM) - with requests_mock.Mocker() as m: - m.get( + # AUTO: discovery GET (no auth) and HttpNtlmAuth's first yield (no Authorization yet) both need + # the same minimal 401 + WWW-Authenticate: NTLM. NTLM-only skips discovery; single initial 401. + if auth_mode == AuthenticationScheme.AUTO: + httpx_mock.add_response( url=SERVICELAYER_URL, + method="GET", status_code=401, - headers={"WWW-Authenticate": "NTLM"}, + headers={"www-authenticate": "NTLM"}, + is_reusable=True, ) - m.get( + else: + httpx_mock.add_response( url=SERVICELAYER_URL, + method="GET", status_code=401, - headers=response1, - request_headers=expect1, - ) - m.get(url=SERVICELAYER_URL, status_code=200, request_headers=expect2) - - configuration = SessionConfiguration() - configuration.verify_ssl = False - _ = ApiClientFactory( - SERVICELAYER_URL, session_configuration=configuration - ).with_credentials( - username="IIS_Test", - password="rosebud", - authentication_scheme=auth_mode, - ) + headers={"www-authenticate": "NTLM"}, + ) + httpx_mock.add_response( + url=SERVICELAYER_URL, + method="GET", + status_code=401, + headers=_NTLM_CANNED_CHALLENGE_HEADERS, + match_headers=_NTLM_CANNED_EXPECT1, + ) + httpx_mock.add_response( + url=SERVICELAYER_URL, + method="GET", + status_code=200, + match_headers=expect2, + ) + + configuration = SessionConfiguration() + configuration.verify_ssl = False + _ = ApiClientFactory(SERVICELAYER_URL, session_configuration=configuration).with_credentials( + username="IIS_Test", + password="rosebud", + authentication_scheme=auth_mode, + ) def test_can_connect_with_negotiate(): pass -def test_only_called_once_with_autologon_when_anonymous_is_ok(): - with requests_mock.Mocker() as m: - m.get(SERVICELAYER_URL, status_code=200) - with pytest.warns(AuthenticationWarning, match="Continuing without credentials"): - _ = ApiClientFactory(SERVICELAYER_URL).with_autologon() - assert m.called_once +def test_only_called_once_with_autologon_when_anonymous_is_ok(httpx_mock): + httpx_mock.add_response(url=SERVICELAYER_URL, method="GET", status_code=200) + with pytest.warns(AuthenticationWarning, match="Continuing without credentials"): + _ = ApiClientFactory(SERVICELAYER_URL).with_autologon() + assert len(httpx_mock.get_requests(url=SERVICELAYER_URL)) == 1 def test_can_connect_with_oidc(): pass -def test_only_called_once_with_oidc_when_anonymous_is_ok(): - with requests_mock.Mocker() as m: - m.get(SERVICELAYER_URL, status_code=200) - with pytest.warns(AuthenticationWarning, match="Continuing without credentials"): - _ = ApiClientFactory(SERVICELAYER_URL).with_oidc().authorize() - assert m.called_once +def test_only_called_once_with_oidc_when_anonymous_is_ok(httpx_mock): + httpx_mock.add_response(url=SERVICELAYER_URL, method="GET", status_code=200) + with pytest.warns(AuthenticationWarning, match="Continuing without credentials"): + _ = ApiClientFactory(SERVICELAYER_URL).with_oidc().authorize() + assert len(httpx_mock.get_requests(url=SERVICELAYER_URL)) == 1 -def test_can_connect_with_oidc_using_token(): +def test_can_connect_with_oidc_using_token(httpx_mock): redirect_uri = "https://www.example.com/login/" authority_url = "https://www.example.com/authority/" client_id = "b4e44bfa-6b73-4d6a-9df6-8055216a5836" @@ -343,57 +405,56 @@ def test_can_connect_with_oidc_using_token(): "authorization_endpoint": f"{authority_url}authorization", } ) - token_response = json.dumps( - { - "access_token": ACCESS_TOKEN, - "expires_in": 3600, - "refresh_token": refresh_token, - } + + httpx_mock.add_response( + url=SECURE_SERVICELAYER_URL, + method="GET", + status_code=401, + headers={"www-authenticate": authenticate_header}, + ) + httpx_mock.add_response( + url=f"{authority_url}.well-known/openid-configuration", + method="GET", + text=well_known_response, ) - def match_token_request(request): - if request.text is None: - return False - data = parse_qs(request.text) - return ( + def token_ok(request: httpx.Request) -> httpx.Response: + if request.content is None: + return httpx.Response(400) + data = parse_qs(request.content.decode()) + if not ( data.get("client_id", "") == [client_id] and data.get("grant_type", "") == ["refresh_token"] and data.get("refresh_token", "") == [refresh_token] - ) - - with requests_mock.Mocker() as m: - m.get( - f"{authority_url}.well-known/openid-configuration", - status_code=200, - text=well_known_response, - ) - m.post( - f"{authority_url}token", - status_code=200, - additional_matcher=match_token_request, - text=token_response, - ) - m.get( - SECURE_SERVICELAYER_URL, - status_code=401, - headers={"WWW-Authenticate": authenticate_header}, - ) - m.get( - SECURE_SERVICELAYER_URL, - status_code=200, - request_headers={"Authorization": f"Bearer {ACCESS_TOKEN}"}, - ) - session = ( - ApiClientFactory(SECURE_SERVICELAYER_URL) - .with_oidc() - .with_token(refresh_token=refresh_token) - .connect() - ) - resp = session.rest_client.get(SECURE_SERVICELAYER_URL) - assert resp.status_code == 200 + ): + return httpx.Response(400) + return httpx.Response( + 200, + json={ + "access_token": ACCESS_TOKEN, + "expires_in": 3600, + "refresh_token": refresh_token, + }, + ) + + httpx_mock.add_callback(token_ok, url=f"{authority_url}token", method="POST") + httpx_mock.add_response( + url=SECURE_SERVICELAYER_URL, + method="GET", + status_code=200, + match_headers={"Authorization": f"Bearer {ACCESS_TOKEN}"}, + ) + session = ( + ApiClientFactory(SECURE_SERVICELAYER_URL) + .with_oidc() + .with_token(refresh_token=refresh_token) + .connect() + ) + resp = session.rest_client.get(SECURE_SERVICELAYER_URL) + assert resp.status_code == 200 -def test_can_connect_with_oidc_using_refresh_token(): +def test_can_connect_with_oidc_using_refresh_token(httpx_mock): redirect_uri = "https://www.example.com/login/" authority_url = "https://www.example.com/authority/" client_id = "b4e44bfa-6b73-4d6a-9df6-8055216a5836" @@ -407,57 +468,56 @@ def test_can_connect_with_oidc_using_refresh_token(): "authorization_endpoint": f"{authority_url}authorization", } ) - token_response = json.dumps( - { - "access_token": ACCESS_TOKEN, - "expires_in": 3600, - "refresh_token": refresh_token, - } + + httpx_mock.add_response( + url=SECURE_SERVICELAYER_URL, + method="GET", + status_code=401, + headers={"www-authenticate": authenticate_header}, + ) + httpx_mock.add_response( + url=f"{authority_url}.well-known/openid-configuration", + method="GET", + text=well_known_response, ) - def match_token_request(request): - if request.text is None: - return False - data = parse_qs(request.text) - return ( + def token_ok(request: httpx.Request) -> httpx.Response: + if request.content is None: + return httpx.Response(400) + data = parse_qs(request.content.decode()) + if not ( data.get("client_id", "") == [client_id] and data.get("grant_type", "") == ["refresh_token"] and data.get("refresh_token", "") == [refresh_token] - ) - - with requests_mock.Mocker() as m: - m.get( - f"{authority_url}.well-known/openid-configuration", - status_code=200, - text=well_known_response, - ) - m.post( - f"{authority_url}token", - status_code=200, - additional_matcher=match_token_request, - text=token_response, - ) - m.get( - SECURE_SERVICELAYER_URL, - status_code=401, - headers={"WWW-Authenticate": authenticate_header}, - ) - m.get( - SECURE_SERVICELAYER_URL, - status_code=200, - request_headers={"Authorization": f"Bearer {ACCESS_TOKEN}"}, - ) - session = ( - ApiClientFactory(SECURE_SERVICELAYER_URL) - .with_oidc() - .with_token(refresh_token=refresh_token) - .connect() - ) - resp = session.rest_client.get(SECURE_SERVICELAYER_URL) - assert resp.status_code == 200 + ): + return httpx.Response(400) + return httpx.Response( + 200, + json={ + "access_token": ACCESS_TOKEN, + "expires_in": 3600, + "refresh_token": refresh_token, + }, + ) + + httpx_mock.add_callback(token_ok, url=f"{authority_url}token", method="POST") + httpx_mock.add_response( + url=SECURE_SERVICELAYER_URL, + method="GET", + status_code=200, + match_headers={"Authorization": f"Bearer {ACCESS_TOKEN}"}, + ) + session = ( + ApiClientFactory(SECURE_SERVICELAYER_URL) + .with_oidc() + .with_token(refresh_token=refresh_token) + .connect() + ) + resp = session.rest_client.get(SECURE_SERVICELAYER_URL) + assert resp.status_code == 200 -def test_can_connect_with_oidc_using_access_token(): +def test_can_connect_with_oidc_using_access_token(httpx_mock): redirect_uri = "https://www.example.com/login/" authority_url = "https://www.example.com/authority/" client_id = "b4e44bfa-6b73-4d6a-9df6-8055216a5836" @@ -472,64 +532,74 @@ def test_can_connect_with_oidc_using_access_token(): f'Bearer redirecturi="{redirect_uri}", authority="{authority_url}", clientid="{client_id}"' ) - with requests_mock.Mocker() as m: - m.get( - f"{authority_url}.well-known/openid-configuration", - status_code=200, - text=well_known_response, - ) - m.get( - SECURE_SERVICELAYER_URL, - status_code=401, - headers={"WWW-Authenticate": authenticate_header}, - ) - m.get( - SECURE_SERVICELAYER_URL, - status_code=200, - request_headers={"Authorization": f"Bearer {access_token}"}, - ) - session = ( - ApiClientFactory(SECURE_SERVICELAYER_URL) - .with_oidc() - .with_access_token(access_token=access_token) - .connect() - ) - resp = session.rest_client.get(SECURE_SERVICELAYER_URL) - assert resp.status_code == 200 + httpx_mock.add_response( + url=SECURE_SERVICELAYER_URL, + method="GET", + status_code=401, + headers={"www-authenticate": authenticate_header}, + ) + httpx_mock.add_response( + url=f"{authority_url}.well-known/openid-configuration", + method="GET", + text=well_known_response, + ) + httpx_mock.add_response( + url=SECURE_SERVICELAYER_URL, + method="GET", + status_code=200, + match_headers={"Authorization": f"Bearer {access_token}"}, + ) + session = ( + ApiClientFactory(SECURE_SERVICELAYER_URL) + .with_oidc() + .with_access_token(access_token=access_token) + .connect() + ) + resp = session.rest_client.get(SECURE_SERVICELAYER_URL) + assert resp.status_code == 200 -def test_neither_basic_nor_ntlm_throws(): - with requests_mock.Mocker() as m: - m.get(SERVICELAYER_URL, status_code=401, headers={"WWW-Authenticate": "Bearer"}) - with pytest.raises(ConnectionError) as exception_info: - _ = ApiClientFactory(SERVICELAYER_URL).with_credentials( - username="TEST_USER", password="PASSWORD" - ) - assert "Unable to connect with credentials" in str(exception_info.value) +def test_neither_basic_nor_ntlm_throws(httpx_mock): + httpx_mock.add_response( + url=SERVICELAYER_URL, + method="GET", + status_code=401, + headers={"www-authenticate": "Bearer"}, + ) + with pytest.raises(ConnectionError) as exception_info: + _ = ApiClientFactory(SERVICELAYER_URL).with_credentials( + username="TEST_USER", password="PASSWORD" + ) + assert "Unable to connect with credentials" in str(exception_info.value) -def test_no_autologon_throws(): - with requests_mock.Mocker() as m: - m.get(SERVICELAYER_URL, status_code=401, headers={"WWW-Authenticate": "Bearer"}) - with pytest.raises(ConnectionError) as exception_info: - _ = ApiClientFactory(SERVICELAYER_URL).with_autologon() - assert "Unable to connect with autologon" in str(exception_info.value) +def test_no_autologon_throws(httpx_mock): + httpx_mock.add_response( + url=SERVICELAYER_URL, + method="GET", + status_code=401, + headers={"www-authenticate": "Bearer"}, + ) + with pytest.raises(ConnectionError) as exception_info: + _ = ApiClientFactory(SERVICELAYER_URL).with_autologon() + assert "Unable to connect with autologon" in str(exception_info.value) -def test_no_oidc_throws(): - with requests_mock.Mocker() as m: - m.get( - SERVICELAYER_URL, - status_code=401, - headers={"WWW-Authenticate": 'Basic realm="localhost"'}, + +def test_no_oidc_throws(httpx_mock): + httpx_mock.add_response( + url=SERVICELAYER_URL, + method="GET", + status_code=401, + headers={"www-authenticate": 'Basic realm="localhost"'}, + ) + with pytest.raises(ConnectionError) as exception_info: + _ = ( + ApiClientFactory(SERVICELAYER_URL) + .with_oidc() + .with_token(access_token=ACCESS_TOKEN, refresh_token=REFRESH_TOKEN) ) - with pytest.raises(ConnectionError) as exception_info: - _ = ( - ApiClientFactory(SERVICELAYER_URL) - .with_oidc() - .with_token(access_token=ACCESS_TOKEN, refresh_token=REFRESH_TOKEN) - ) - assert "Unable to connect with OpenID Connect" in str(exception_info.value) + assert "Unable to connect with OpenID Connect" in str(exception_info.value) def test_self_signed_throws(): @@ -538,11 +608,6 @@ def test_self_signed_throws(): def test_invalid_initial_response_raises_exception(): factory = ApiClientFactory(SERVICELAYER_URL) - with requests_mock.Mocker() as m: - m.get( - SERVICELAYER_URL, - status_code=404, - ) - resp = requests.get(SERVICELAYER_URL) + resp = httpx.Response(404, request=httpx.Request("GET", SERVICELAYER_URL)) with pytest.raises(ApiConnectionException, match=rf".*{SERVICELAYER_URL}.*404.*"): factory._ApiClientFactory__handle_initial_response(resp) From 37238ac7c03b859e3d7dcc3b7a8bee8a1ed821ae Mon Sep 17 00:00:00 2001 From: Doug Addy Date: Fri, 8 May 2026 09:58:17 +0100 Subject: [PATCH 06/18] build: drop requests runtime deps and tune mypy for optional auth Remove the requests stack from core dependencies; refresh the lockfile. Add per-module mypy overrides so Windows CI passes without per-import ignores when optional httpx auth extras are present or absent. Co-authored-by: Cursor --- pyproject.toml | 35 ++++++-- uv.lock | 222 +++++++++++++++++++++++-------------------------- 2 files changed, 132 insertions(+), 125 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 41f634ae..7b9ec86a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,11 +37,11 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "requests>=2.26", - "requests-negotiate-sspi>=0.5.2,<0.6; sys_platform == 'win32'", - "requests-ntlm>=1.1,<2.0", "pyparsing>=3.2,<3.4", "python-dateutil>=2.9", + "httpx>=0.27", + "httpx-ntlm>=1.4.0", + "httpx-negotiate-sspi>=0.28,<0.29; sys_platform == 'win32'", ] [dependency-groups] @@ -51,13 +51,12 @@ dev = [ "uvicorn", "fastapi", "pydantic", - "requests-mock", + "pytest-httpx>=0.35", "pytest-mock", "covertable", "mypy>=1.8.0", - "types-requests", "types-python-dateutil", - "requests_auth", + "httpx-auth>=0.22", "keyring", "sphinx-design>=0.6.0" ] @@ -75,11 +74,14 @@ dev-linux = [ [project.optional-dependencies] oidc = [ - "requests_auth>=8.0.0,<9.0.0", - "keyring>=22,<26" + "httpx-auth>=0.22", + "keyring>=22,<26", ] +# GSSAPI/Kerberos via python-gssapi is Linux-oriented. Windows Negotiate uses SSPI: +# ``httpx-negotiate-sspi``; do not add ``httpx-gssapi`` for win32. +# Validation / CI notes: doc/source/planning/httpx-migration.rst (negotiate-validation-strategy). linux-kerberos = [ - "requests-kerberos>=0.13,<0.16; sys_platform == 'linux'" + "httpx-gssapi>=0.6,<0.7; sys_platform == 'linux'" ] [tool.hatch.build.targets.wheel] @@ -127,6 +129,21 @@ explicit_package_bases = true mypy_path = "$MYPY_CONFIG_FILE_DIR/src" namespace_packages = true +# Third-party auth: optional Linux extras and untyped packages. Suppressing only +# ``import-untyped`` in the modules that import them keeps Windows CI (and +# ``warn_unused_ignores``) stable without ``# type: ignore`` on each import line. +[[tool.mypy.overrides]] +module = "httpx_gssapi" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "ansys.openapi.common._session" +disable_error_code = ["import-untyped"] + +[[tool.mypy.overrides]] +module = "ansys.openapi.common._oidc" +disable_error_code = ["import-untyped"] + [tool.towncrier] package = "ansys.openapi.common" directory = "doc/changelog.d" diff --git a/uv.lock b/uv.lock index 96a62871..2d6066aa 100644 --- a/uv.lock +++ b/uv.lock @@ -50,37 +50,36 @@ name = "ansys-openapi-common" version = "2.5.0.dev0" source = { editable = "." } dependencies = [ + { name = "httpx" }, + { name = "httpx-negotiate-sspi", marker = "sys_platform == 'win32'" }, + { name = "httpx-ntlm" }, { name = "pyparsing" }, { name = "python-dateutil" }, - { name = "requests" }, - { name = "requests-negotiate-sspi", marker = "sys_platform == 'win32'" }, - { name = "requests-ntlm" }, ] [package.optional-dependencies] linux-kerberos = [ - { name = "requests-kerberos", marker = "sys_platform == 'linux'" }, + { name = "httpx-gssapi", marker = "sys_platform == 'linux'" }, ] oidc = [ + { name = "httpx-auth" }, { name = "keyring" }, - { name = "requests-auth" }, ] [package.dev-dependencies] dev = [ { name = "covertable" }, { name = "fastapi" }, + { name = "httpx-auth" }, { name = "keyring" }, { name = "mypy" }, { name = "pydantic" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-httpx" }, { name = "pytest-mock" }, - { name = "requests-auth" }, - { name = "requests-mock" }, { name = "sphinx-design" }, { name = "types-python-dateutil" }, - { name = "types-requests" }, { name = "uvicorn" }, ] dev-linux = [ @@ -97,14 +96,14 @@ doc = [ [package.metadata] requires-dist = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "httpx-auth", marker = "extra == 'oidc'", specifier = ">=0.22" }, + { name = "httpx-gssapi", marker = "sys_platform == 'linux' and extra == 'linux-kerberos'", specifier = ">=0.6,<0.7" }, + { name = "httpx-negotiate-sspi", marker = "sys_platform == 'win32'", specifier = ">=0.28,<0.29" }, + { name = "httpx-ntlm", specifier = ">=1.4.0" }, { name = "keyring", marker = "extra == 'oidc'", specifier = ">=22,<26" }, { name = "pyparsing", specifier = ">=3.2,<3.4" }, { name = "python-dateutil", specifier = ">=2.9" }, - { name = "requests", specifier = ">=2.26" }, - { name = "requests-auth", marker = "extra == 'oidc'", specifier = ">=8.0.0,<9.0.0" }, - { name = "requests-kerberos", marker = "sys_platform == 'linux' and extra == 'linux-kerberos'", specifier = ">=0.13,<0.16" }, - { name = "requests-negotiate-sspi", marker = "sys_platform == 'win32'", specifier = ">=0.5.2,<0.6" }, - { name = "requests-ntlm", specifier = ">=1.1,<2.0" }, ] provides-extras = ["oidc", "linux-kerberos"] @@ -112,17 +111,16 @@ provides-extras = ["oidc", "linux-kerberos"] dev = [ { name = "covertable" }, { name = "fastapi" }, + { name = "httpx-auth", specifier = ">=0.22" }, { name = "keyring" }, { name = "mypy", specifier = ">=1.8.0" }, { name = "pydantic" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-httpx", specifier = ">=0.35" }, { name = "pytest-mock" }, - { name = "requests-auth" }, - { name = "requests-mock" }, { name = "sphinx-design", specifier = ">=0.6.0" }, { name = "types-python-dateutil" }, - { name = "types-requests" }, { name = "uvicorn" }, ] dev-linux = [{ name = "asgi-gssapi", marker = "sys_platform == 'linux'" }] @@ -647,6 +645,85 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +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" } +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" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +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" } +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" }, +] + +[[package]] +name = "httpx-auth" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/d4/6bd616f89d1ce43f602b62ec274e33beee6c2bce3d68396e692daafdb57d/httpx_auth-0.23.1.tar.gz", hash = "sha256:27b5a6022ad1b41a303b8737fa2e3e4bce6bbbe7ab67fed0b261359be62e0434", size = 121418, upload-time = "2025-01-07T18:47:20.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/23/a72f91bea596b522ac297b948ffee6decdedb535c034fca8062bd72981ce/httpx_auth-0.23.1-py3-none-any.whl", hash = "sha256:04f8bd0824efe3d9fb79690cc670b0da98ea809babb7aea04a72f334d4fd5ec5", size = 45328, upload-time = "2025-01-07T18:47:18.694Z" }, +] + +[[package]] +name = "httpx-gssapi" +version = "0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gssapi" }, + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/7e/3099ca8315522494d96cca35875ecb10c3f8ea877a89bea648e6f193d1ee/httpx_gssapi-0.6.tar.gz", hash = "sha256:03df3de8195e18c50690d44beadfdc514df8a2055cb5e773a2faa85fc3985f18", size = 37437, upload-time = "2025-11-11T04:58:05.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/97/1404b2affab61e7570a65b20db176e135566a5e5e44cd3fc7b5cedd6a4d3/httpx_gssapi-0.6-py3-none-any.whl", hash = "sha256:2b477c1d4be5f358b0fce021c31a55d345e75f0fa149c2daeaac48ad7738e6bc", size = 12735, upload-time = "2025-11-11T04:58:03.971Z" }, +] + +[[package]] +name = "httpx-negotiate-sspi" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pywin32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/55/77a03fd8ae106d193a1406eee64f58d539d05cad808d9529f7b9e30c918d/httpx_negotiate_sspi-0.28.1.tar.gz", hash = "sha256:140bedfe08e1282974af28663bc02a205e4dc64e304df34aecc405be375ebda6", size = 4881, upload-time = "2025-01-09T06:40:51.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/7b/0dc520bff51e77515d34660a696ab4dcfaf68997ce2fb75edf8dbcc32355/httpx_negotiate_sspi-0.28.1-py3-none-any.whl", hash = "sha256:750c5f98501ed62b481704d6e7782bb26a00d3bce03cc1ff28f560b09b2df7a2", size = 5207, upload-time = "2025-01-09T06:40:49.793Z" }, +] + +[[package]] +name = "httpx-ntlm" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pyspnego" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/9c/fee5d1a348ac85fe7c4c38a431edb007c0469c1f0170a2643eeba41a463d/httpx_ntlm-1.4.0.tar.gz", hash = "sha256:41be8a2ba85143484e617dcb917d4b19e3ae12afdc688622a4901034993b53e4", size = 3921, upload-time = "2023-11-03T11:26:39.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/ce/4032cfef13e7728fe978a01d3417619ab19e5a679e8d60a543d1ad1e9a10/httpx_ntlm-1.4.0-py3-none-any.whl", hash = "sha256:8adc6715c787b1621121165baff450441be050fabfc1cb0c7095d0eb074f976d", size = 4214, upload-time = "2023-11-03T11:26:37.484Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -761,12 +838,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] -[[package]] -name = "krb5" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/15/55a01be5f1816fe6d7d36fec4c6b2cb6f5264d289a015322562c582a81b7/krb5-0.9.0.tar.gz", hash = "sha256:4cdd2c85ff4770108edaf48fedf19888cf956ff374e2e97e40f8412b048caee6", size = 236761, upload-time = "2025-11-25T18:53:46.997Z" } - [[package]] name = "librt" version = "0.9.0" @@ -1339,18 +1410,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] -[[package]] -name = "pypiwin32" -version = "223" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pywin32" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/13/e8/4f38eb30c4dae36634a53c5b2cd73b517ea3607e10d00f61f2494449cec0/pypiwin32-223.tar.gz", hash = "sha256:71be40c1fbd28594214ecaecb58e7aa8b708eabfa0125c8a109ebd51edbd776a", size = 622, upload-time = "2018-02-26T00:43:23.994Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/1b/2f292bbd742e369a100c91faa0483172cd91a1a422a6692055ac920946c5/pypiwin32-223-py3-none-any.whl", hash = "sha256:67adf399debc1d5d14dffc1ab5acacb800da569754fafdc576b2a039485aa775", size = 1674, upload-time = "2018-02-26T00:43:23.108Z" }, -] - [[package]] name = "pyspnego" version = "0.12.0" @@ -1364,12 +1423,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/e9/95430b8f3b747ebd3b86a66484a79ef387167655bcb15ab416f563045565/pyspnego-0.12.0-py3-none-any.whl", hash = "sha256:84cc8dae6ad21e04b37c50c1d3c743f05f193e39498f6010cc68ec1146afd007", size = 130180, upload-time = "2025-09-02T18:51:04.938Z" }, ] -[package.optional-dependencies] -kerberos = [ - { name = "gssapi", marker = "sys_platform != 'win32'" }, - { name = "krb5", marker = "sys_platform != 'win32'" }, -] - [[package]] name = "pytest" version = "9.0.3" @@ -1402,6 +1455,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "pytest-httpx" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/42/f53c58570e80d503ade9dd42ce57f2915d14bcbe25f6308138143950d1d6/pytest_httpx-0.36.2.tar.gz", hash = "sha256:05a56527484f7f4e8c856419ea379b8dc359c36801c4992fdb330f294c690356", size = 57683, upload-time = "2026-04-09T13:57:19.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/55/1fa65f8e4fceb19dd6daa867c162ad845d547f6058cd92b4b02384a44777/pytest_httpx-0.36.2-py3-none-any.whl", hash = "sha256:d42ebd5679442dc7bfb0c48e0767b6562e9bc4534d805127b0084171886a5e22", size = 20315, upload-time = "2026-04-09T13:57:18.587Z" }, +] + [[package]] name = "pytest-mock" version = "3.15.1" @@ -1536,70 +1602,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] -[[package]] -name = "requests-auth" -version = "8.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2b/c7/3a1119e11477e789bf4a75cadf9c09cf3b6fd7df3c38011a71583346762b/requests_auth-8.0.0.tar.gz", hash = "sha256:ca2f2126d8a41e1d1615faa8cf8d5d62ea01d705f9ee99f470b9a44abd5dee82", size = 80146, upload-time = "2024-06-18T18:50:05.014Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/c6/a586233b044203b9faec662ed147421730fcbd16040c72753445abd8dced/requests_auth-8.0.0-py3-none-any.whl", hash = "sha256:7faf0c58cadb61d2398fed9ea412a38641d70a856b1db25db281f9057194f1ca", size = 39432, upload-time = "2024-06-18T18:50:02.733Z" }, -] - -[[package]] -name = "requests-kerberos" -version = "0.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "pyspnego", extra = ["kerberos"] }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/63/78/bedf4c6788a4502f8c8b6485a9a00b3006aaed34ebbccecc1b2265a3bc9f/requests_kerberos-0.15.0.tar.gz", hash = "sha256:437512e424413d8113181d696e56694ffa4259eb9a5fc4e803926963864eaf4e", size = 24410, upload-time = "2024-06-03T22:53:11.146Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/3b/ecf902be8375f30f0d7829a8bc56795cd7b0f2599280cf73f988a2999322/requests_kerberos-0.15.0-py2.py3-none-any.whl", hash = "sha256:ba9b0980b8489c93bfb13854fd118834e576d6700bfea3745cb2e62278cd16a6", size = 12169, upload-time = "2024-06-03T22:53:09.67Z" }, -] - -[[package]] -name = "requests-mock" -version = "1.12.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/92/32/587625f91f9a0a3d84688bf9cfc4b2480a7e8ec327cefd0ff2ac891fd2cf/requests-mock-1.12.1.tar.gz", hash = "sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401", size = 60901, upload-time = "2024-03-29T03:54:29.446Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/ec/889fbc557727da0c34a33850950310240f2040f3b1955175fdb2b36a8910/requests_mock-1.12.1-py2.py3-none-any.whl", hash = "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563", size = 27695, upload-time = "2024-03-29T03:54:27.64Z" }, -] - -[[package]] -name = "requests-negotiate-sspi" -version = "0.5.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pypiwin32" }, - { name = "requests" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/0b/99220cb4d3c8cf9b667a1cd13650d28b19e689ab7145085897e254d141a3/requests_negotiate_sspi-0.5.2-py2.py3-none-any.whl", hash = "sha256:84ca9e81cfd3f2bd5eede5f8eddec1d5b58d957efac5e7fc078a3b323d296b77", size = 7119, upload-time = "2018-10-10T09:34:31.166Z" }, -] - -[[package]] -name = "requests-ntlm" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "pyspnego" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/15/74/5d4e1815107e9d78c44c3ad04740b00efd1189e5a9ec11e5275b60864e54/requests_ntlm-1.3.0.tar.gz", hash = "sha256:b29cc2462623dffdf9b88c43e180ccb735b4007228a542220e882c58ae56c668", size = 16112, upload-time = "2024-06-09T23:52:04.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/5d/836b97537a390cf811b0488490c389c5a614f0a93acb23f347bd37a2d914/requests_ntlm-1.3.0-py3-none-any.whl", hash = "sha256:4c7534a7d0e482bb0928531d621be4b2c74ace437e88c5a357ceb7452d25a510", size = 6577, upload-time = "2024-06-09T23:52:03.241Z" }, -] - [[package]] name = "secretstorage" version = "3.5.0" @@ -1846,18 +1848,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/c6/eeba37bfee282a6a97f889faef9352d6172c6a5088eb9a4daf570d9d748d/types_python_dateutil-2.9.0.20260408-py3-none-any.whl", hash = "sha256:473139d514a71c9d1fbd8bb328974bedcb1cc3dba57aad04ffa4157f483c216f", size = 18437, upload-time = "2026-04-08T04:28:10.095Z" }, ] -[[package]] -name = "types-requests" -version = "2.33.0.20260408" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/69/6a/749dc53a54a3f35842c1f8197b3ca6b54af6d7458a1bfc75f6629b6da666/types_requests-2.33.0.20260408.tar.gz", hash = "sha256:95b9a86376807a216b2fb412b47617b202091c3ea7c078f47cc358d5528ccb7b", size = 23882, upload-time = "2026-04-08T04:34:49.33Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/b8/78fd6c037de4788c040fdd323b3369804400351b7827473920f6c1d03c10/types_requests-2.33.0.20260408-py3-none-any.whl", hash = "sha256:81f31d5ea4acb39f03be7bc8bed569ba6d5a9c5d97e89f45ac43d819b68ca50f", size = 20739, upload-time = "2026-04-08T04:34:48.325Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" From 4ad0cd94d652455ed7698d5be435e11ec09d80a3 Mon Sep 17 00:00:00 2001 From: Doug Addy Date: Fri, 8 May 2026 09:58:22 +0100 Subject: [PATCH 07/18] docs: add httpx migration planning note Link the engineering plan from the doc toctree; record vendoring, test strategy, and phased rollout decisions. Co-authored-by: Cursor --- doc/source/index.rst | 1 + doc/source/planning/httpx-migration.rst | 520 ++++++++++++++++++++++++ 2 files changed, 521 insertions(+) create mode 100644 doc/source/planning/httpx-migration.rst diff --git a/doc/source/index.rst b/doc/source/index.rst index 68257577..766c455b 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -46,3 +46,4 @@ effort to facilitate the use of Ansys technologies directly from Python. api/index contributing changelog + planning/httpx-migration diff --git a/doc/source/planning/httpx-migration.rst b/doc/source/planning/httpx-migration.rst new file mode 100644 index 00000000..bc31f291 --- /dev/null +++ b/doc/source/planning/httpx-migration.rst @@ -0,0 +1,520 @@ +.. + Planning note: this page tracks the library migration from ``requests`` to ``httpx``. + User-facing migration guides may be added later; see "Deferred" below. + +HTTP client migration: ``requests`` → ``httpx`` +================================================= + +.. note:: + + This document is an engineering plan and decision log for maintainers. It is not + end-user documentation. + +Goals +----- + +* Replace ``requests`` with ``httpx`` for synchronous HTTP from OpenAPI-Common while + preserving behavior that callers rely on (including retries on selected status codes, + authentication flows, and session configuration). +* Ship the change as a **major** semantic-version release. +* Keep the door open for optional **HTTP/2** and **async** APIs later without designing + the current spike around blocking assumptions. + +Non-goals (this spike) +---------------------- + +* **Downstream OpenAPI generator templates** — deferred until the library implementation + is stable; coordinate template updates in a follow-up. +* **Published user guide / migration guide updates** — deferred for this spike (README, + user guide, Intersphinx links remain until a documentation pass). +* **HTTP/2** — not implemented now; avoid choices that would permanently prevent enabling + ``http2`` on a future ``httpx`` client. +* **Async** — session creation and public API for this release remain **synchronous** + (``httpx.Client``), aligned with current usage. + +Agreed direction (decided so far) +--------------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 28 72 + + * - Topic + - Decision + * - Sync vs async + - Stay **sync** for session and ``ApiClient`` transport for this major release. + * - HTTP/2 + - **Do not implement** in the spike; do not hard-code decisions that forbid turning + HTTP/2 on later. + * - Versioning + - **Major** release (breaking change for types and session objects). + * - Configuration API + - Introduce **``TransportConfiguration``** and **``get_transport_configuration()``** + (or equivalent) for settings used to build the HTTP client—document that this is + client/transport configuration, not necessarily a bare ``httpx.HTTPTransport`` + subclass unless we expose one explicitly. + * - Retries + - Preserve **400** in the set of status codes that trigger retry (intermittent + server behavior). + * - Retry implementation + - Prefer a **subclass of ``httpx.HTTPTransport``** (analogous to the current + ``HTTPAdapter`` + timeout wrapper). **Document in code** which **urllib3** + ``Retry`` semantics are mirrored (status retries, backoff, connection vs read + errors, etc.)—full parity with urllib3 is not assumed unless we implement it. + * - Headers + - Prefer **``httpx.Headers``** where it simplifies code. **``WWW-Authenticate``** + remains parsed by **``parse_authenticate``**; use **``Headers.get_list("www-authenticate")``** + (or equivalent) when multiple header field lines exist, then merge parsed schemes + as needed for scheme selection. + * - NTLM + - Use an **httpx-native** NTLM auth path (e.g. **``httpx-ntlm``** / pyspnego-based), + analogous to **``requests-ntlm``**, for password NTLM when required. + * - Negotiate / Kerberos / SSPI + - **Locked:** **Windows** — ``httpx-negotiate-sspi`` (``HttpSspiAuth`` from + ``httpx_negotiate_sspi``), pinned for ``sys_platform == 'win32'``. **Linux** — + ``httpx-gssapi`` (``HTTPSPNEGOAuth``) for integrated Negotiate on the ``httpx`` client. + The ``[linux-kerberos]`` extra declares ``httpx-gssapi`` for Negotiate on Linux. + Application code uses a single ``with_autologon()`` path; + ``_session.py`` selects the backend by platform. Validation strategy: + see negotiate-validation-strategy_. + * - OIDC / OAuth2 + - Target **``httpx-auth``** for OAuth2/OIDC-style flows instead of **``requests_auth``**, + pending a **spike** that proves parity for PKCE, client/session wiring, and edge + cases (e.g. Auth0 ``audience`` / refresh behavior). + * - Tests + - Adopt **``pytest-httpx``** for HTTP mocking in place of **``requests-mock``** for + the majority of tests. Legacy urllib3 / **``HTTPAdapter``**-oriented tests are + rewritten against **``httpx``** responses or **``RetryingHTTPTransport``** behaviour + (see implementation status below). + +Implementation status (snapshot) +-------------------------------- + +This section tracks **what is already merged** versus **what remains** (cleanup). +Updated periodically while the migration branch evolves. + +**Implemented in the current tree** + +* **Configuration:** ``TransportConfiguration``, ``SessionConfiguration.get_transport_configuration()``, + ``httpx_client_init_kwargs()``, and ``create_httpx_client_from_session_configuration()``. +* **Factory client:** ``ApiClientFactory`` constructs ``httpx.Client`` with + ``RetryingHTTPTransport`` (default timeout from + ``SessionConfiguration``, retries including **400**, exponential backoff on transport errors—see + module docstring in ``_retry_transport.py``). +* **ApiClient:** Requires ``httpx.Client`` as ``rest_client`` (the factory path); legacy + ``requests.Session`` support has been removed for this major release. +* **Case-insensitive mapping:** ``SessionConfiguration.headers`` and exception header snapshots use + ``CaseInsensitiveDict``, **vendored** from Requests ``structures.py`` in + ``_case_insensitive_dict.py`` (Apache-2.0 attributed in-file)—no dependency on ``requests`` for this type. + + **Why vendoring (not ``httpx.Headers``):** ``httpx.Headers`` is built for HTTP header semantics + (combining, normalization); ``SessionConfiguration`` needs a general-purpose **mutable** + case-insensitive **mapping** for arbitrary configuration keys. Vendoring preserves the same + behaviour as the historical Requests type without pulling in ``requests`` at runtime. + +* **WWW-Authenticate:** Multiple header field lines are gathered via ``httpx.Headers.get_list``; + each challenge string is passed to ``parse_authenticate`` and merged for scheme detection + (``_session.py``). +* **Credential auth:** Basic, NTLM (``httpx-ntlm``, Windows), Negotiate / SSPI (``httpx-negotiate-sspi``), + Linux Negotiate (``httpx-gssapi``) on the shared ``httpx`` client. +* **OIDC:** ``OIDCSessionFactory`` builds **``httpx.Client``** instances (API + IdP) via + ``create_httpx_client_from_session_configuration``. OAuth uses **``httpx-auth``** + ``OAuth2AuthorizationCodePKCE`` with ``client=`` (shared IdP client). ``WWW-Authenticate`` + for Bearer challenges uses the same multi-line header collection as the factory path + (``collect_www_authenticate_raw_values``). Builder API (**``OIDCSessionBuilder``**) unchanged. +* **Tests:** Session flows largely mock HTTP with ``pytest-httpx``. Timeout and retry wiring are + covered by ``tests/test_session_configuration.py::TestHttpxClientTransportFromSessionConfiguration`` + and ``tests/test_retry_transport.py``. The old **``_RequestsTimeoutAdapter``** helper has been + removed. + +**Still outstanding** + +* **OIDC hardening:** Confirm **``httpx-auth``** PKCE flows match production expectations end-to-end + (Auth0 ``audience``, refresh rotation, interactive browser timeout)—parity was preserved in unit tests, + not a full IdP matrix. +* **Phase 6 (tests):** ``requests-mock`` removed from dev dependencies; ``pytest-httpx`` is used for HTTP + mocking (including ``tests/test_api_client.py``). +* **Phase 7 (dependencies):** Runtime ``requests`` / legacy ``requests-*`` packages and ``types-requests`` + removed from ``pyproject.toml`` (library and tests use ``httpx`` only). + +Technical outline +----------------- + +#. **Dependencies** + + * Runtime: ``httpx``; remove or narrow ``requests`` once migration is complete. + * Typing: rely on ``httpx``'s inline types; drop ``types-requests`` when unused. + * Optional auth: replace ``requests-*`` extras with httpx-oriented packages per + platform and flow (NTLM, Negotiate/SSPI, Kerberos/GSSAPI, OIDC). + +#. **Client construction** + + * Replace ``requests.Session()`` with ``httpx.Client(...)`` built from + ``TransportConfiguration`` / ``get_transport_configuration()`` instead of mutating + session ``__dict__`` (``set_session_kwargs`` pattern). + +#. **Retries and timeouts** + + * Implement retry + default timeout in the **custom ``HTTPTransport``** subclass, + matching agreed urllib-inspired semantics and preserving **400** in the retryable + status set. + +#. **API surface** + + * ``ApiClient`` and factories accept an **httpx client** (and types/docs updated for + ``httpx.Response`` where applicable, e.g. ``reason_phrase`` vs ``reason``). + +#. **WWW-Authenticate** + + * Keep **``parse_authenticate``**; call sites merge **one parsed dict per header field line** + (``httpx.Headers.get_list("www-authenticate")``). Implemented in ``_session.py``. + +#. **Testing** + + * **``pytest-httpx``** for URL-level mocking; custom POST/body checks use callbacks or + captured request assertions. + * **``test_api_client.py``** uses **synthetic ``httpx.Response``** helpers (no ``requests`` or + urllib3 response fixtures). + * **Done:** former **timeout adapter** tests are replaced by factory **``httpx.Client``** + assertions plus ``tests/test_retry_transport.py``. + +Implementation order (phased) +----------------------------- + +This section is the suggested **sequence of work** so each stage stays testable in CI +before layering complexity. **Authentication is deliberately narrowed first**: Basic +(and anonymous) flows use only ``httpx``'s built-in auth and simple header logic, so the +HTTP stack, response model, and configuration plumbing can be validated **before** NTLM, +Negotiate/Kerberos, SSPI, or OIDC packages are introduced. + +Why Basic first +~~~~~~~~~~~~~~~ + +* **Fewer moving parts**: ``httpx.BasicAuth`` maps directly from today’s password + flows when the server advertises **Basic** or the caller forces **Basic**. +* **Same ``WWW-Authenticate`` parsing**: ``parse_authenticate`` already produces scheme + keys; Basic-only branch exercises **``httpx`` responses + headers** without pyspnego, + SSPI, or OAuth libraries. +* **Early CI signal**: Core ``ApiClient`` / serialization / exception paths run green + while heavier auth is still ported. + +Phases (do roughly in this order) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Each phase should end with **tests passing** (full suite or an agreed subset marked +with ``pytest`` markers until later phases land). + +.. list-table:: + :header-rows: 1 + :widths: 8 42 50 + + * - Step + - Scope + - How to verify + * - **1** + - **Dependencies + configuration API.** Add ``httpx``. Introduce + ``TransportConfiguration`` and ``get_transport_configuration()`` (and retire + ``RequestsConfiguration`` / ``get_configuration_for_requests()`` as part of the + major bump). Replace ``set_session_kwargs`` with **explicit** + ``httpx.Client(...)`` construction from that mapping. + - Unit tests only: configuration round-trip, no live HTTP. Types and public names + compile. + * - **2** + - **Anonymous HTTP + Basic credentials only.** Wire ``ApiClient`` to use + ``httpx`` verb helpers; switch exceptions and deserialization to + ``httpx.Response`` (``reason_phrase``, headers). **In the current branch** the factory + client already uses **``RetryingHTTPTransport``** (step 3) rather than a bare default + transport—steps 2 and 3 are combined for production code. Implement **Basic** auth first: + ``AuthenticationScheme.BASIC``, and **AUTO** only when + ``parse_authenticate`` yields **Basic** (other schemes: document as “not yet + available” or skip with a clear error until step 5). + - ``pytest`` on anonymous + Basic-only paths; **``pytest-httpx``** (or synthetic + ``httpx.Response`` builders) for ``ApiClient`` behavior. Replace urllib3-based + **response fixtures** in tests as soon as this step touches them. + * - **3** + - **Custom ``HTTPTransport``** (timeout defaults, retries incl. **400**, backoff). + Document urllib3 **Retry** semantics you mirror in a file/class comment (**implemented** + in ``_retry_transport.py``). + - Dedicated transport unit tests (**``tests/test_retry_transport.py``**) and + **``TestHttpxClientTransportFromSessionConfiguration``** (session configuration module) + replacing legacy urllib3 ``HTTPAdapter`` timeout patching. + * - **4** + - **NTLM** (e.g. ``httpx-ntlm``) and **Negotiate / Kerberos / SSPI** using chosen + platform packages. Expand ``with_credentials`` **AUTO** for real servers. + - Existing session-creation tests ported to ``httpx`` + optional extras; manual or + integration tests against representative hosts if available. + * - **5** + - **OIDC / OAuth2** via ``httpx-auth`` (or confirmed alternative), rewriting + ``_oidc.py`` and optional extras. + - OIDC unit/integration tests; spike checklist closed (PKCE, refresh, Auth0 edge + cases). + * - **6** + - **Test suite consolidation.** Migrate remaining **``requests-mock``** usage to + **``pytest-httpx``**; align POST body matchers; drop **``requests``**-only dev + deps when unused. + - Full ``pytest`` green; coverage comparable to pre-migration baseline. + * - **7** + - **Dependency cleanup.** Remove ``requests``, ``types-requests``, and obsolete + ``requests-*`` packages from runtime where replaced; refresh ``pyproject.toml`` / + lockfile. + - Clean install + CI; no stray imports. + +What to do first (short answer) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +#. **Configuration + ``httpx.Client`` skeleton** (steps 1–2): types and anonymous requests, + then Basic auth on ``ApiClient`` / factory paths. +#. **Retry transport** (step 3): restore production resilience before SSO debugging. +#. **NTLM / Negotiate / OIDC** (steps 4–5): unlock full **AUTO** and enterprise flows. +#. **Tests + dependency purge** (steps 6–7). + +Parallelism +~~~~~~~~~~~ + +* **Docs** (user-facing migration guide) and **generator templates** remain deferred; + this planning page can still be updated as decisions land. +* **pytest-httpx** adoption can start in **step 2** for new/changed tests; a full sweep + fits **step 6**. +* **Step 3 before steps 4–5 is recommended** so retry behavior is stable before debugging + SSO stacks. + +Expected test suite status by phase +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use this as a **checkpoint list** when running ``uv run pytest``. Exact counts drift as +tests are added; the authoritative inventory is ``uv run pytest --collect-only -q``. + +.. note:: + + Baseline (current tree): **355** tests collected. Some tests are **skipped** today + (e.g. ``test_invalid_header_malformed`` in ``test_parse_authenticate.py``). **NTLM** + session tests use ``skipif`` (non-Windows or missing ``httpx_ntlm``), not blanket skips. + ``tests/integration/test_negotiate.py`` is marked ``kerberos`` and only runs with ``--with-kerberos``. + +Phase 1 — configuration API only +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Goal:** Green suite for modules that do **not** open HTTP connections and do **not** +depend on ``requests.Session`` / adapters. + +.. list-table:: + :header-rows: 1 + :widths: 38 62 + + * - Scope (expect pass) + - Notes + * - ``tests/test_parse_authenticate.py`` + - Parser unit tests only (no HTTP stack). + * - ``tests/test_utils_misc.py`` + - ``CaseInsensitiveOrderedDict``. + * - ``tests/test_unset.py`` + - Single test. + * - ``tests/test_model_methods.py`` + - Model serialization helpers. + * - ``tests/test_session_configuration.py`` (partial) + - **Include:** ``test_defaults`` through ``test_redirects``, all of + ``TestDeserialization`` **except** ``test_assign_all_values``. **Exclude:** + ``test_cookies`` (uses ``httpx.Client.build_request`` to assert serialized cookies). Timeout/retry + tests that once targeted ``HTTPAdapter`` now live under Phase 3 class names (see below). + +**Rough count:** ~55–60 tests (four full modules + partial ``test_session_configuration``). + +Phase 2 — ApiClient + anonymous + Basic +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Goal:** Everything that exercises **``httpx.Client``**, **``ApiClient``**, **Basic** +auth, and **anonymous** flows, without requiring NTLM, Negotiate, or OIDC. (The factory +client includes **``RetryingHTTPTransport``**; retry behaviour is validated under Phase 3 +tests.) + +.. list-table:: + :header-rows: 1 + :widths: 38 62 + + * - Scope (expect pass) + - Notes + * - ``tests/test_exceptions.py`` + - Switch mocks to ``httpx`` responses. + * - ``tests/test_api_client.py`` + - Full file (fixtures move from ``requests.Session`` to ``httpx.Client``; + ``TestResponseParsing`` uses synthetic ``httpx.Response`` instead of urllib3). + * - ``tests/integration/test_anonymous.py`` + - Live FastAPI harness. + * - ``tests/integration/test_basic.py`` + - Basic-auth integration (AUTO + BASIC modes against real server). + * - ``tests/test_session_configuration.py`` (remainder from Phase 1) + - ``test_cookies``; ``TestDeserialization::test_assign_all_values``. + * - ``tests/test_session_creation.py`` (subset) + - **Expect pass:** ``test_anonymous``; ``test_other_status_codes_throw``; + ``test_missing_www_authenticate_throws``; ``test_unconfigured_builder_throws``; + ``test_can_connect_with_basic`` and the three related Basic variants; + ``test_only_called_once_with_basic_when_anonymous_is_ok`` for + ``AuthenticationScheme.AUTO`` and ``.BASIC`` only; + ``test_throws_with_invalid_credentials`` (**AUTO** / **BASIC** always; **NTLM** + Windows-only with ``httpx_ntlm`` / pyspnego — see Phase 4); + ``test_with_credentials_throws_with_invalid_auth_method``; + ``test_self_signed_throws``; ``test_invalid_initial_response_raises_exception``. + * - Still **not** Phase 2 + - Any test whose name implies **NTLM**, **Negotiate**, **autologon**, or **OIDC**; + parametrized cases **NTLM** on Basic flows; ``test_neither_basic_nor_ntlm_throws``; + ``test_no_autologon_throws``; ``test_no_oidc_throws``. + +Phase 3 — custom ``HTTPTransport`` (timeouts + retries) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Goal:** Document and test **``RetryingHTTPTransport``** (timeouts from ``SessionConfiguration``, +retries, backoff). Legacy ``_RequestsTimeoutAdapter`` is **removed**. + +.. list-table:: + :header-rows: 1 + :widths: 38 62 + + * - Scope (expect pass) + - Notes + * - ``tests/test_retry_transport.py`` + - Status retries, transport-error retries, max attempts, disallowed methods. + * - ``tests/test_session_configuration.py::TestHttpxClientTransportFromSessionConfiguration`` + - Client default/custom timeout and ``retry_count`` → transport ``max_attempts``. + * - ``tests/test_session_configuration.py::TestWwwAuthenticateHeaderMerging`` + - Multiple ``WWW-Authenticate`` header lines merged for scheme detection. + +Phase 4 — NTLM + Negotiate / Kerberos / SSPI +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Goal:** Complete credential flows beyond Basic. + +.. list-table:: + :header-rows: 1 + :widths: 38 62 + + * - Scope (expect pass) + - Notes + * - ``tests/test_session_creation.py`` (remaining auth) + - NTLM handshake tests (**``test_can_connect_with_ntlm``**, **``test_throws_with_invalid_credentials``** + for **NTLM**); Negotiate / autologon paths; parametrized **NTLM** cases on shared Basic + tests; ``test_neither_basic_nor_ntlm_throws``; ``test_no_autologon_throws``. + * - ``tests/integration/test_negotiate.py`` + - Runs only with ``--with-kerberos`` (Linux-oriented). + * - ``tests/test_missing_imports.py::test_create_autologon_on_linux_with_no_extra_throws`` + - Linux only; update **blocked import** name if Kerberos extra package changes. + +.. _negotiate-validation-strategy: + +Negotiate validation strategy (locked packages) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Packages are declared in ``pyproject.toml`` (base deps on Windows; ``[linux-kerberos]`` +on Linux). **Automated CI** does **not** reproduce a domain-attached Windows SSPI client +or a full internal KDC for ``httpx`` Negotiate—those environments are costly and +environment-specific. + +* **Linux:** Optional integration coverage via ``tests/integration/test_negotiate.py``, + run only with ``pytest --with-kerberos`` when a Kerberos test harness (e.g. ``asgi_gssapi`` + + KDC) is available—see ``pytest.mark.kerberos`` in ``tests/conftest.py``. + +* **Windows (SSPI):** No portable Negotiate integration test in public CI. Periodically + validate **manually** against an **internal or staging HTTP API** that returns + ``401`` with ``WWW-Authenticate: Negotiate``—for example call + ``ApiClientFactory(api_url).with_autologon().connect()`` (and an authenticated request) + from a domain-joined or suitably configured workstation. Record regressions if behaviour + diverges after dependency bumps (``httpx-negotiate-sspi``, ``httpx``, etc.). + +Phase 5 — OIDC / OAuth2 +^^^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 38 62 + + * - Scope (expect pass) + - Notes + * - ``tests/test_oidc.py`` + - Entire file (currently ``requests`` / ``requests_auth`` oriented). + * - ``tests/test_session_creation.py`` (OIDC tests) + - ``test_can_connect_with_oidc`` and related ``with_oidc`` / token variants; + ``test_only_called_once_with_oidc_when_anonymous_is_ok``; + ``test_no_oidc_throws``. + * - ``tests/test_missing_imports.py::test_create_oidc_with_no_extra_throws`` + - Adjust **extra** / import name when OIDC dependency changes. + +Phases 6–7 — test tooling + dependency purge +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Goal:** Full **355**-test (or successor) suite under ``uv run pytest`` with +**``pytest-httpx``**, no **``requests-mock``** where migrated, and runtime **without** +``requests``. Treat prior phases as included; CI should match **pre-migration** +coverage expectations. + +Suggested pytest shortcuts (optional) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Use **deselect** / node IDs until later phases land, e.g. exclude OIDC module entirely: + +.. code-block:: shell + + uv run pytest tests/ --ignore=tests/test_oidc.py + +(or narrow paths explicitly). Prefer **documented markers** if you introduce +``@pytest.mark.httpx_phaseN`` during the spike. + +Deferred (explicit) +------------------- + +* **Downstream OpenAPI templates** — update generated clients after this library stabilizes. +* **End-user documentation** — migration notes, user guide, Intersphinx, README refresh. + +Outstanding decisions / follow-ups +------------------------------------ + +These remain open or need validation work. Items marked **locked** are decided; follow bullets +are maintenance and validation notes, not open package selection. + +#. **OIDC spike** + + * Confirm **``httpx-auth``** exposes flows equivalent to **``OAuth2AuthorizationCodePKCE``** + (session/client injection, PKCE, token refresh, **``InvalidGrantRequest``**-style + errors, Auth0 ``audience`` workaround). + +#. **Negotiate / Kerberos (locked; maintenance)** + + * **Linux:** ``httpx-gssapi`` for Negotiate in ``with_autologon()``; the ``[linux-kerberos]`` + extra installs ``httpx-gssapi``. + Integration tests are optional (``--with-kerberos``); CI alignment is **not** required + for every PR unless that job is enabled. + + * **Windows:** ``httpx-negotiate-sspi`` (SSPI). **CI does not** exercise live Negotiate; + validate against an **internal server** or staging API as described in + negotiate-validation-strategy_. + + * **Ongoing:** Keep pins compatible with ``python-gssapi`` / OS krb5 on supported Linux + distros; bump ``httpx-negotiate-sspi`` as needed after smoke tests on Windows. + +#. **Retry semantics** (**resolved in code**) + + * **``RetryingHTTPTransport``** retries selected **HTTP statuses** (including **400**) for + configured methods, and retries **transport-level failures** (timeouts, ``NetworkError``, + ``ProxyError``, ``RemoteProtocolError``) with exponential backoff—see the class/module + docstring in ``_retry_transport.py``. This is **not** full urllib3 ``Retry`` parity. + +#. **Idempotency / POST + retries** (**documented in transport**) + + * POST (and other methods) may be retried on retryable statuses; the transport docstring + notes the duplicate side-effect risk. Promote to user-facing guidance only if product + communications require it. + +#. **``SessionConfiguration.headers`` type** (**resolved**) + + * **``CaseInsensitiveDict``** is **vendored** (Requests-derived implementation in + ``_case_insensitive_dict.py``), exported from the package public API. Using **``httpx.Headers``** + for arbitrary configuration keys was rejected as semantically wrong. + +#. **POST body matching in tests** (**done for ``test_api_client.py``**) + + * **``requests-mock``** ``additional_matcher`` patterns were ported to **``pytest-httpx``** + callbacks / assertions on captured requests. + +References +---------- + +* `httpx documentation `_ +* `pytest-httpx `_ From fedbc68f2a1abfadd16a619cffbb7d4f17d1dd87 Mon Sep 17 00:00:00 2001 From: Doug Addy Date: Fri, 8 May 2026 10:14:18 +0100 Subject: [PATCH 08/18] Tidy up docs (draft) --- README.rst | 2 +- doc/source/conf.py | 2 +- doc/source/index.rst | 1 - doc/source/planning/httpx-migration.rst | 520 ------------------------ doc/source/user_guide/index.rst | 101 ++--- pyproject.toml | 2 +- 6 files changed, 54 insertions(+), 574 deletions(-) delete mode 100644 doc/source/planning/httpx-migration.rst diff --git a/README.rst b/README.rst index b3dd670b..54eb5496 100644 --- a/README.rst +++ b/README.rst @@ -51,7 +51,7 @@ APIs, this Python library provides a common client to consume HTTP APIs, minimizing overhead and reducing code duplication. OpenAPI-Common supports authentication with Basic, Negotiate, NTLM, -and OpenID Connect. Most features of the underlying requests session +and OpenID Connect. Most features of the underlying :mod:`httpx` client are exposed for use. Some basic configuration is also provided by default. Dependencies diff --git a/doc/source/conf.py b/doc/source/conf.py index 09c62140..164a8398 100755 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -46,7 +46,7 @@ # sphinx.ext.intersphinx intersphinx_mapping = { "python": ("https://docs.python.org/3.11", None), - "requests": ("https://requests.readthedocs.io/en/latest", None), + "httpx": ("https://www.python-httpx.org", None), "sphinx": ("https://www.sphinx-doc.org/en/master/", None), } diff --git a/doc/source/index.rst b/doc/source/index.rst index 766c455b..68257577 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -46,4 +46,3 @@ effort to facilitate the use of Ansys technologies directly from Python. api/index contributing changelog - planning/httpx-migration diff --git a/doc/source/planning/httpx-migration.rst b/doc/source/planning/httpx-migration.rst deleted file mode 100644 index bc31f291..00000000 --- a/doc/source/planning/httpx-migration.rst +++ /dev/null @@ -1,520 +0,0 @@ -.. - Planning note: this page tracks the library migration from ``requests`` to ``httpx``. - User-facing migration guides may be added later; see "Deferred" below. - -HTTP client migration: ``requests`` → ``httpx`` -================================================= - -.. note:: - - This document is an engineering plan and decision log for maintainers. It is not - end-user documentation. - -Goals ------ - -* Replace ``requests`` with ``httpx`` for synchronous HTTP from OpenAPI-Common while - preserving behavior that callers rely on (including retries on selected status codes, - authentication flows, and session configuration). -* Ship the change as a **major** semantic-version release. -* Keep the door open for optional **HTTP/2** and **async** APIs later without designing - the current spike around blocking assumptions. - -Non-goals (this spike) ----------------------- - -* **Downstream OpenAPI generator templates** — deferred until the library implementation - is stable; coordinate template updates in a follow-up. -* **Published user guide / migration guide updates** — deferred for this spike (README, - user guide, Intersphinx links remain until a documentation pass). -* **HTTP/2** — not implemented now; avoid choices that would permanently prevent enabling - ``http2`` on a future ``httpx`` client. -* **Async** — session creation and public API for this release remain **synchronous** - (``httpx.Client``), aligned with current usage. - -Agreed direction (decided so far) ---------------------------------- - -.. list-table:: - :header-rows: 1 - :widths: 28 72 - - * - Topic - - Decision - * - Sync vs async - - Stay **sync** for session and ``ApiClient`` transport for this major release. - * - HTTP/2 - - **Do not implement** in the spike; do not hard-code decisions that forbid turning - HTTP/2 on later. - * - Versioning - - **Major** release (breaking change for types and session objects). - * - Configuration API - - Introduce **``TransportConfiguration``** and **``get_transport_configuration()``** - (or equivalent) for settings used to build the HTTP client—document that this is - client/transport configuration, not necessarily a bare ``httpx.HTTPTransport`` - subclass unless we expose one explicitly. - * - Retries - - Preserve **400** in the set of status codes that trigger retry (intermittent - server behavior). - * - Retry implementation - - Prefer a **subclass of ``httpx.HTTPTransport``** (analogous to the current - ``HTTPAdapter`` + timeout wrapper). **Document in code** which **urllib3** - ``Retry`` semantics are mirrored (status retries, backoff, connection vs read - errors, etc.)—full parity with urllib3 is not assumed unless we implement it. - * - Headers - - Prefer **``httpx.Headers``** where it simplifies code. **``WWW-Authenticate``** - remains parsed by **``parse_authenticate``**; use **``Headers.get_list("www-authenticate")``** - (or equivalent) when multiple header field lines exist, then merge parsed schemes - as needed for scheme selection. - * - NTLM - - Use an **httpx-native** NTLM auth path (e.g. **``httpx-ntlm``** / pyspnego-based), - analogous to **``requests-ntlm``**, for password NTLM when required. - * - Negotiate / Kerberos / SSPI - - **Locked:** **Windows** — ``httpx-negotiate-sspi`` (``HttpSspiAuth`` from - ``httpx_negotiate_sspi``), pinned for ``sys_platform == 'win32'``. **Linux** — - ``httpx-gssapi`` (``HTTPSPNEGOAuth``) for integrated Negotiate on the ``httpx`` client. - The ``[linux-kerberos]`` extra declares ``httpx-gssapi`` for Negotiate on Linux. - Application code uses a single ``with_autologon()`` path; - ``_session.py`` selects the backend by platform. Validation strategy: - see negotiate-validation-strategy_. - * - OIDC / OAuth2 - - Target **``httpx-auth``** for OAuth2/OIDC-style flows instead of **``requests_auth``**, - pending a **spike** that proves parity for PKCE, client/session wiring, and edge - cases (e.g. Auth0 ``audience`` / refresh behavior). - * - Tests - - Adopt **``pytest-httpx``** for HTTP mocking in place of **``requests-mock``** for - the majority of tests. Legacy urllib3 / **``HTTPAdapter``**-oriented tests are - rewritten against **``httpx``** responses or **``RetryingHTTPTransport``** behaviour - (see implementation status below). - -Implementation status (snapshot) --------------------------------- - -This section tracks **what is already merged** versus **what remains** (cleanup). -Updated periodically while the migration branch evolves. - -**Implemented in the current tree** - -* **Configuration:** ``TransportConfiguration``, ``SessionConfiguration.get_transport_configuration()``, - ``httpx_client_init_kwargs()``, and ``create_httpx_client_from_session_configuration()``. -* **Factory client:** ``ApiClientFactory`` constructs ``httpx.Client`` with - ``RetryingHTTPTransport`` (default timeout from - ``SessionConfiguration``, retries including **400**, exponential backoff on transport errors—see - module docstring in ``_retry_transport.py``). -* **ApiClient:** Requires ``httpx.Client`` as ``rest_client`` (the factory path); legacy - ``requests.Session`` support has been removed for this major release. -* **Case-insensitive mapping:** ``SessionConfiguration.headers`` and exception header snapshots use - ``CaseInsensitiveDict``, **vendored** from Requests ``structures.py`` in - ``_case_insensitive_dict.py`` (Apache-2.0 attributed in-file)—no dependency on ``requests`` for this type. - - **Why vendoring (not ``httpx.Headers``):** ``httpx.Headers`` is built for HTTP header semantics - (combining, normalization); ``SessionConfiguration`` needs a general-purpose **mutable** - case-insensitive **mapping** for arbitrary configuration keys. Vendoring preserves the same - behaviour as the historical Requests type without pulling in ``requests`` at runtime. - -* **WWW-Authenticate:** Multiple header field lines are gathered via ``httpx.Headers.get_list``; - each challenge string is passed to ``parse_authenticate`` and merged for scheme detection - (``_session.py``). -* **Credential auth:** Basic, NTLM (``httpx-ntlm``, Windows), Negotiate / SSPI (``httpx-negotiate-sspi``), - Linux Negotiate (``httpx-gssapi``) on the shared ``httpx`` client. -* **OIDC:** ``OIDCSessionFactory`` builds **``httpx.Client``** instances (API + IdP) via - ``create_httpx_client_from_session_configuration``. OAuth uses **``httpx-auth``** - ``OAuth2AuthorizationCodePKCE`` with ``client=`` (shared IdP client). ``WWW-Authenticate`` - for Bearer challenges uses the same multi-line header collection as the factory path - (``collect_www_authenticate_raw_values``). Builder API (**``OIDCSessionBuilder``**) unchanged. -* **Tests:** Session flows largely mock HTTP with ``pytest-httpx``. Timeout and retry wiring are - covered by ``tests/test_session_configuration.py::TestHttpxClientTransportFromSessionConfiguration`` - and ``tests/test_retry_transport.py``. The old **``_RequestsTimeoutAdapter``** helper has been - removed. - -**Still outstanding** - -* **OIDC hardening:** Confirm **``httpx-auth``** PKCE flows match production expectations end-to-end - (Auth0 ``audience``, refresh rotation, interactive browser timeout)—parity was preserved in unit tests, - not a full IdP matrix. -* **Phase 6 (tests):** ``requests-mock`` removed from dev dependencies; ``pytest-httpx`` is used for HTTP - mocking (including ``tests/test_api_client.py``). -* **Phase 7 (dependencies):** Runtime ``requests`` / legacy ``requests-*`` packages and ``types-requests`` - removed from ``pyproject.toml`` (library and tests use ``httpx`` only). - -Technical outline ------------------ - -#. **Dependencies** - - * Runtime: ``httpx``; remove or narrow ``requests`` once migration is complete. - * Typing: rely on ``httpx``'s inline types; drop ``types-requests`` when unused. - * Optional auth: replace ``requests-*`` extras with httpx-oriented packages per - platform and flow (NTLM, Negotiate/SSPI, Kerberos/GSSAPI, OIDC). - -#. **Client construction** - - * Replace ``requests.Session()`` with ``httpx.Client(...)`` built from - ``TransportConfiguration`` / ``get_transport_configuration()`` instead of mutating - session ``__dict__`` (``set_session_kwargs`` pattern). - -#. **Retries and timeouts** - - * Implement retry + default timeout in the **custom ``HTTPTransport``** subclass, - matching agreed urllib-inspired semantics and preserving **400** in the retryable - status set. - -#. **API surface** - - * ``ApiClient`` and factories accept an **httpx client** (and types/docs updated for - ``httpx.Response`` where applicable, e.g. ``reason_phrase`` vs ``reason``). - -#. **WWW-Authenticate** - - * Keep **``parse_authenticate``**; call sites merge **one parsed dict per header field line** - (``httpx.Headers.get_list("www-authenticate")``). Implemented in ``_session.py``. - -#. **Testing** - - * **``pytest-httpx``** for URL-level mocking; custom POST/body checks use callbacks or - captured request assertions. - * **``test_api_client.py``** uses **synthetic ``httpx.Response``** helpers (no ``requests`` or - urllib3 response fixtures). - * **Done:** former **timeout adapter** tests are replaced by factory **``httpx.Client``** - assertions plus ``tests/test_retry_transport.py``. - -Implementation order (phased) ------------------------------ - -This section is the suggested **sequence of work** so each stage stays testable in CI -before layering complexity. **Authentication is deliberately narrowed first**: Basic -(and anonymous) flows use only ``httpx``'s built-in auth and simple header logic, so the -HTTP stack, response model, and configuration plumbing can be validated **before** NTLM, -Negotiate/Kerberos, SSPI, or OIDC packages are introduced. - -Why Basic first -~~~~~~~~~~~~~~~ - -* **Fewer moving parts**: ``httpx.BasicAuth`` maps directly from today’s password - flows when the server advertises **Basic** or the caller forces **Basic**. -* **Same ``WWW-Authenticate`` parsing**: ``parse_authenticate`` already produces scheme - keys; Basic-only branch exercises **``httpx`` responses + headers** without pyspnego, - SSPI, or OAuth libraries. -* **Early CI signal**: Core ``ApiClient`` / serialization / exception paths run green - while heavier auth is still ported. - -Phases (do roughly in this order) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Each phase should end with **tests passing** (full suite or an agreed subset marked -with ``pytest`` markers until later phases land). - -.. list-table:: - :header-rows: 1 - :widths: 8 42 50 - - * - Step - - Scope - - How to verify - * - **1** - - **Dependencies + configuration API.** Add ``httpx``. Introduce - ``TransportConfiguration`` and ``get_transport_configuration()`` (and retire - ``RequestsConfiguration`` / ``get_configuration_for_requests()`` as part of the - major bump). Replace ``set_session_kwargs`` with **explicit** - ``httpx.Client(...)`` construction from that mapping. - - Unit tests only: configuration round-trip, no live HTTP. Types and public names - compile. - * - **2** - - **Anonymous HTTP + Basic credentials only.** Wire ``ApiClient`` to use - ``httpx`` verb helpers; switch exceptions and deserialization to - ``httpx.Response`` (``reason_phrase``, headers). **In the current branch** the factory - client already uses **``RetryingHTTPTransport``** (step 3) rather than a bare default - transport—steps 2 and 3 are combined for production code. Implement **Basic** auth first: - ``AuthenticationScheme.BASIC``, and **AUTO** only when - ``parse_authenticate`` yields **Basic** (other schemes: document as “not yet - available” or skip with a clear error until step 5). - - ``pytest`` on anonymous + Basic-only paths; **``pytest-httpx``** (or synthetic - ``httpx.Response`` builders) for ``ApiClient`` behavior. Replace urllib3-based - **response fixtures** in tests as soon as this step touches them. - * - **3** - - **Custom ``HTTPTransport``** (timeout defaults, retries incl. **400**, backoff). - Document urllib3 **Retry** semantics you mirror in a file/class comment (**implemented** - in ``_retry_transport.py``). - - Dedicated transport unit tests (**``tests/test_retry_transport.py``**) and - **``TestHttpxClientTransportFromSessionConfiguration``** (session configuration module) - replacing legacy urllib3 ``HTTPAdapter`` timeout patching. - * - **4** - - **NTLM** (e.g. ``httpx-ntlm``) and **Negotiate / Kerberos / SSPI** using chosen - platform packages. Expand ``with_credentials`` **AUTO** for real servers. - - Existing session-creation tests ported to ``httpx`` + optional extras; manual or - integration tests against representative hosts if available. - * - **5** - - **OIDC / OAuth2** via ``httpx-auth`` (or confirmed alternative), rewriting - ``_oidc.py`` and optional extras. - - OIDC unit/integration tests; spike checklist closed (PKCE, refresh, Auth0 edge - cases). - * - **6** - - **Test suite consolidation.** Migrate remaining **``requests-mock``** usage to - **``pytest-httpx``**; align POST body matchers; drop **``requests``**-only dev - deps when unused. - - Full ``pytest`` green; coverage comparable to pre-migration baseline. - * - **7** - - **Dependency cleanup.** Remove ``requests``, ``types-requests``, and obsolete - ``requests-*`` packages from runtime where replaced; refresh ``pyproject.toml`` / - lockfile. - - Clean install + CI; no stray imports. - -What to do first (short answer) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -#. **Configuration + ``httpx.Client`` skeleton** (steps 1–2): types and anonymous requests, - then Basic auth on ``ApiClient`` / factory paths. -#. **Retry transport** (step 3): restore production resilience before SSO debugging. -#. **NTLM / Negotiate / OIDC** (steps 4–5): unlock full **AUTO** and enterprise flows. -#. **Tests + dependency purge** (steps 6–7). - -Parallelism -~~~~~~~~~~~ - -* **Docs** (user-facing migration guide) and **generator templates** remain deferred; - this planning page can still be updated as decisions land. -* **pytest-httpx** adoption can start in **step 2** for new/changed tests; a full sweep - fits **step 6**. -* **Step 3 before steps 4–5 is recommended** so retry behavior is stable before debugging - SSO stacks. - -Expected test suite status by phase -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Use this as a **checkpoint list** when running ``uv run pytest``. Exact counts drift as -tests are added; the authoritative inventory is ``uv run pytest --collect-only -q``. - -.. note:: - - Baseline (current tree): **355** tests collected. Some tests are **skipped** today - (e.g. ``test_invalid_header_malformed`` in ``test_parse_authenticate.py``). **NTLM** - session tests use ``skipif`` (non-Windows or missing ``httpx_ntlm``), not blanket skips. - ``tests/integration/test_negotiate.py`` is marked ``kerberos`` and only runs with ``--with-kerberos``. - -Phase 1 — configuration API only -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -**Goal:** Green suite for modules that do **not** open HTTP connections and do **not** -depend on ``requests.Session`` / adapters. - -.. list-table:: - :header-rows: 1 - :widths: 38 62 - - * - Scope (expect pass) - - Notes - * - ``tests/test_parse_authenticate.py`` - - Parser unit tests only (no HTTP stack). - * - ``tests/test_utils_misc.py`` - - ``CaseInsensitiveOrderedDict``. - * - ``tests/test_unset.py`` - - Single test. - * - ``tests/test_model_methods.py`` - - Model serialization helpers. - * - ``tests/test_session_configuration.py`` (partial) - - **Include:** ``test_defaults`` through ``test_redirects``, all of - ``TestDeserialization`` **except** ``test_assign_all_values``. **Exclude:** - ``test_cookies`` (uses ``httpx.Client.build_request`` to assert serialized cookies). Timeout/retry - tests that once targeted ``HTTPAdapter`` now live under Phase 3 class names (see below). - -**Rough count:** ~55–60 tests (four full modules + partial ``test_session_configuration``). - -Phase 2 — ApiClient + anonymous + Basic -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -**Goal:** Everything that exercises **``httpx.Client``**, **``ApiClient``**, **Basic** -auth, and **anonymous** flows, without requiring NTLM, Negotiate, or OIDC. (The factory -client includes **``RetryingHTTPTransport``**; retry behaviour is validated under Phase 3 -tests.) - -.. list-table:: - :header-rows: 1 - :widths: 38 62 - - * - Scope (expect pass) - - Notes - * - ``tests/test_exceptions.py`` - - Switch mocks to ``httpx`` responses. - * - ``tests/test_api_client.py`` - - Full file (fixtures move from ``requests.Session`` to ``httpx.Client``; - ``TestResponseParsing`` uses synthetic ``httpx.Response`` instead of urllib3). - * - ``tests/integration/test_anonymous.py`` - - Live FastAPI harness. - * - ``tests/integration/test_basic.py`` - - Basic-auth integration (AUTO + BASIC modes against real server). - * - ``tests/test_session_configuration.py`` (remainder from Phase 1) - - ``test_cookies``; ``TestDeserialization::test_assign_all_values``. - * - ``tests/test_session_creation.py`` (subset) - - **Expect pass:** ``test_anonymous``; ``test_other_status_codes_throw``; - ``test_missing_www_authenticate_throws``; ``test_unconfigured_builder_throws``; - ``test_can_connect_with_basic`` and the three related Basic variants; - ``test_only_called_once_with_basic_when_anonymous_is_ok`` for - ``AuthenticationScheme.AUTO`` and ``.BASIC`` only; - ``test_throws_with_invalid_credentials`` (**AUTO** / **BASIC** always; **NTLM** - Windows-only with ``httpx_ntlm`` / pyspnego — see Phase 4); - ``test_with_credentials_throws_with_invalid_auth_method``; - ``test_self_signed_throws``; ``test_invalid_initial_response_raises_exception``. - * - Still **not** Phase 2 - - Any test whose name implies **NTLM**, **Negotiate**, **autologon**, or **OIDC**; - parametrized cases **NTLM** on Basic flows; ``test_neither_basic_nor_ntlm_throws``; - ``test_no_autologon_throws``; ``test_no_oidc_throws``. - -Phase 3 — custom ``HTTPTransport`` (timeouts + retries) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -**Goal:** Document and test **``RetryingHTTPTransport``** (timeouts from ``SessionConfiguration``, -retries, backoff). Legacy ``_RequestsTimeoutAdapter`` is **removed**. - -.. list-table:: - :header-rows: 1 - :widths: 38 62 - - * - Scope (expect pass) - - Notes - * - ``tests/test_retry_transport.py`` - - Status retries, transport-error retries, max attempts, disallowed methods. - * - ``tests/test_session_configuration.py::TestHttpxClientTransportFromSessionConfiguration`` - - Client default/custom timeout and ``retry_count`` → transport ``max_attempts``. - * - ``tests/test_session_configuration.py::TestWwwAuthenticateHeaderMerging`` - - Multiple ``WWW-Authenticate`` header lines merged for scheme detection. - -Phase 4 — NTLM + Negotiate / Kerberos / SSPI -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -**Goal:** Complete credential flows beyond Basic. - -.. list-table:: - :header-rows: 1 - :widths: 38 62 - - * - Scope (expect pass) - - Notes - * - ``tests/test_session_creation.py`` (remaining auth) - - NTLM handshake tests (**``test_can_connect_with_ntlm``**, **``test_throws_with_invalid_credentials``** - for **NTLM**); Negotiate / autologon paths; parametrized **NTLM** cases on shared Basic - tests; ``test_neither_basic_nor_ntlm_throws``; ``test_no_autologon_throws``. - * - ``tests/integration/test_negotiate.py`` - - Runs only with ``--with-kerberos`` (Linux-oriented). - * - ``tests/test_missing_imports.py::test_create_autologon_on_linux_with_no_extra_throws`` - - Linux only; update **blocked import** name if Kerberos extra package changes. - -.. _negotiate-validation-strategy: - -Negotiate validation strategy (locked packages) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Packages are declared in ``pyproject.toml`` (base deps on Windows; ``[linux-kerberos]`` -on Linux). **Automated CI** does **not** reproduce a domain-attached Windows SSPI client -or a full internal KDC for ``httpx`` Negotiate—those environments are costly and -environment-specific. - -* **Linux:** Optional integration coverage via ``tests/integration/test_negotiate.py``, - run only with ``pytest --with-kerberos`` when a Kerberos test harness (e.g. ``asgi_gssapi`` - + KDC) is available—see ``pytest.mark.kerberos`` in ``tests/conftest.py``. - -* **Windows (SSPI):** No portable Negotiate integration test in public CI. Periodically - validate **manually** against an **internal or staging HTTP API** that returns - ``401`` with ``WWW-Authenticate: Negotiate``—for example call - ``ApiClientFactory(api_url).with_autologon().connect()`` (and an authenticated request) - from a domain-joined or suitably configured workstation. Record regressions if behaviour - diverges after dependency bumps (``httpx-negotiate-sspi``, ``httpx``, etc.). - -Phase 5 — OIDC / OAuth2 -^^^^^^^^^^^^^^^^^^^^^^^ - -.. list-table:: - :header-rows: 1 - :widths: 38 62 - - * - Scope (expect pass) - - Notes - * - ``tests/test_oidc.py`` - - Entire file (currently ``requests`` / ``requests_auth`` oriented). - * - ``tests/test_session_creation.py`` (OIDC tests) - - ``test_can_connect_with_oidc`` and related ``with_oidc`` / token variants; - ``test_only_called_once_with_oidc_when_anonymous_is_ok``; - ``test_no_oidc_throws``. - * - ``tests/test_missing_imports.py::test_create_oidc_with_no_extra_throws`` - - Adjust **extra** / import name when OIDC dependency changes. - -Phases 6–7 — test tooling + dependency purge -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -**Goal:** Full **355**-test (or successor) suite under ``uv run pytest`` with -**``pytest-httpx``**, no **``requests-mock``** where migrated, and runtime **without** -``requests``. Treat prior phases as included; CI should match **pre-migration** -coverage expectations. - -Suggested pytest shortcuts (optional) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Use **deselect** / node IDs until later phases land, e.g. exclude OIDC module entirely: - -.. code-block:: shell - - uv run pytest tests/ --ignore=tests/test_oidc.py - -(or narrow paths explicitly). Prefer **documented markers** if you introduce -``@pytest.mark.httpx_phaseN`` during the spike. - -Deferred (explicit) -------------------- - -* **Downstream OpenAPI templates** — update generated clients after this library stabilizes. -* **End-user documentation** — migration notes, user guide, Intersphinx, README refresh. - -Outstanding decisions / follow-ups ------------------------------------- - -These remain open or need validation work. Items marked **locked** are decided; follow bullets -are maintenance and validation notes, not open package selection. - -#. **OIDC spike** - - * Confirm **``httpx-auth``** exposes flows equivalent to **``OAuth2AuthorizationCodePKCE``** - (session/client injection, PKCE, token refresh, **``InvalidGrantRequest``**-style - errors, Auth0 ``audience`` workaround). - -#. **Negotiate / Kerberos (locked; maintenance)** - - * **Linux:** ``httpx-gssapi`` for Negotiate in ``with_autologon()``; the ``[linux-kerberos]`` - extra installs ``httpx-gssapi``. - Integration tests are optional (``--with-kerberos``); CI alignment is **not** required - for every PR unless that job is enabled. - - * **Windows:** ``httpx-negotiate-sspi`` (SSPI). **CI does not** exercise live Negotiate; - validate against an **internal server** or staging API as described in - negotiate-validation-strategy_. - - * **Ongoing:** Keep pins compatible with ``python-gssapi`` / OS krb5 on supported Linux - distros; bump ``httpx-negotiate-sspi`` as needed after smoke tests on Windows. - -#. **Retry semantics** (**resolved in code**) - - * **``RetryingHTTPTransport``** retries selected **HTTP statuses** (including **400**) for - configured methods, and retries **transport-level failures** (timeouts, ``NetworkError``, - ``ProxyError``, ``RemoteProtocolError``) with exponential backoff—see the class/module - docstring in ``_retry_transport.py``. This is **not** full urllib3 ``Retry`` parity. - -#. **Idempotency / POST + retries** (**documented in transport**) - - * POST (and other methods) may be retried on retryable statuses; the transport docstring - notes the duplicate side-effect risk. Promote to user-facing guidance only if product - communications require it. - -#. **``SessionConfiguration.headers`` type** (**resolved**) - - * **``CaseInsensitiveDict``** is **vendored** (Requests-derived implementation in - ``_case_insensitive_dict.py``), exported from the package public API. Using **``httpx.Headers``** - for arbitrary configuration keys was rejected as semantically wrong. - -#. **POST body matching in tests** (**done for ``test_api_client.py``**) - - * **``requests-mock``** ``additional_matcher`` patterns were ported to **``pytest-httpx``** - callbacks / assertions on captured requests. - -References ----------- - -* `httpx documentation `_ -* `pytest-httpx `_ diff --git a/doc/source/user_guide/index.rst b/doc/source/user_guide/index.rst index 192313dd..9e4a7f4b 100644 --- a/doc/source/user_guide/index.rst +++ b/doc/source/user_guide/index.rst @@ -5,6 +5,7 @@ User guide ########## + Basic usage ----------- @@ -77,88 +78,88 @@ Currently only the Authorization Code authentication flow is supported. Session configuration --------------------- -You can set all options that are available in Python library *requests* through -the client with the :class:`~.SessionConfiguration` object. This enables you to -configure custom SSL certificate validation, send client certificates if your API -server requires them, and configure many other options. +The :class:`~.SessionConfiguration` class holds TLS settings, proxies, headers, redirects, and +timeouts. :class:`.ApiClientFactory` turns this into an :class:`httpx.Client` (with retries and +timeouts applied in OpenAPI-Common’s transport layer), which backs each :class:`.ApiClient`. + +Use it to configure custom certificate validation, send client certificates if your API +server requires them, and adjust other transport options. For example, to send a client certificate with every request: .. code:: python - >>> from ansys.openapi.common import SessionConfiguration + >>> from ansys.openapi.common import ApiClientFactory, SessionConfiguration >>> configuration = SessionConfiguration( ... client_cert_path='./my-client-cert.pem', - ... client_cert_key='secret-key' + ... client_cert_key='secret-key', ... ) - >>> client.configuration = configuration + >>> client = ApiClientFactory( + ... 'https://my-api.com/v1.svc', + ... session_configuration=configuration, + ... ).with_anonymous().connect() + HTTPS certificates ------------------ -It is common to use a private CA in an organization to generate TLS certificates for internal resources. The -``requests`` library uses the ``certifi`` package which contains public CA certificates only, which means ``requests`` -cannot verify private TLS certificates in its default configuration. The following error message is typically displayed -if a private TLS certificate is validated against the ``certifi`` public CAs: +It is common to use a private CA in an organization to generate TLS certificates for internal resources. By +default, **httpx** verifies server certificates using the same **certifi** CA bundle that many Python HTTP +stacks use: it contains public roots only, so a server certificate issued by a private CA is not trusted unless +you add trust material (private CA file, merged bundle, or system-store integration). + +If verification fails, Python typically surfaces ``ssl.SSLCertVerificationError``, sometimes wrapped by +**httpx** in a ``httpx.ConnectError`` when opening the TLS connection. For example: .. code:: text - requests.exceptions.SSLError: HTTPSConnectionPool(host='example.com', port=443): Max retries exceeded with url: / - (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable - to get local issuer certificate (_ssl.c:1028)')))`` + ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: + unable to get local issuer certificate (_ssl.c:1000) -If you encounter this error message, you should provide ``requests`` with the CA used to generate your private TLS -certificate. There are three recommended approaches to doing this, listed below in the order of simplicity. +If you see this, point OpenAPI-Common at the CA that signed the server certificate (or a bundle that includes +it), using one of the options below. 1. `pip-system-certs`_ ~~~~~~~~~~~~~~~~~~~~~~ -The ``pip-system-certs`` library patches the certificate loading mechanism for ``requests`` to use the system -certificate store instead of the ``certifi`` store. Assuming the system certificate store includes the private CA, no -further action is required beyond installing ``pip-system-certs`` in the same virtual environment as this package. +The ``pip-system-certs`` package patches **certifi** so the default CA bundle reflects your operating system’s +certificate store instead of the bundled Mozilla list alone. Because **httpx** uses that default bundle for +verification unless you override it, installing ``pip-system-certs`` in the same environment as this library +often resolves trust for corporate CAs that are already in the system store. .. warning:: - The change to ``requests`` affects every package in your environment, including pip. You **must** use a virtual - environment when using ``pip-system-certs`` to avoid unexpected side-effects in other Python scripts. - -This is recommended approach for Windows and Linux users. However, there are some situations in which -``pip-system-certs`` cannot be used: - -* Your platform is not supported by ``pip-system-certs``. -* The private CA certificate has not been added to the system certificate store. -* The OpenSSL deployment used by Python is not configured to use the system certificate store (common when using - conda-provided Python). + Changing **certifi**’s behaviour affects other libraries in that environment that rely on the same process-wide + patching, including package installers. Use a **virtual environment** when enabling ``pip-system-certs`` to + avoid unintended side effects outside your project. -In these cases, the ``SSLCertVerificationError`` is still raised. Instead, provide the appropriate CA certificate to -``requests`` directly. +This is the recommended approach for Windows and Linux when ``pip-system-certs`` is supported. It does **not** help +when the private CA is not in the system store, or when your Python build does not load the system store for +OpenSSL (common with some conda layouts). In those cases, pass a CA file or bundle explicitly (sections 2 and 3). 2. System CA certificate bundle (Linux only) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The :class:`~.SessionConfiguration` object allows you to provide a path to a file containing one or more CA -certificates. The custom CA certificate file is used instead of the ``certifi`` package to verify the service's TLS -certificate. +certificates. The file is used for TLS verification instead of the default **certifi** bundle. -If you need to authenticate both internally- and publicly signed TLS certificates within the same environment, you must -use a CA bundle which contains both the internal and public CAs used to sign the TLS certificates. +If you need to validate both internal and public TLS endpoints in the same process, use a single bundle that +concatenates the internal CA(s) and the public roots you care about. .. note:: - OIDC authentication often requires validating internally- and publicly signed TLS certificates, since both internal - and public resources are used to authenticate the resource. + OIDC flows often touch both internal and public endpoints, so a merged bundle may be required. -CA bundles are often provided by Linux environments which include all trusted public CAs and any internal CAs added to -the system certificate store. These are available in the following locations: +CA bundles are often available on Linux machines that combine public and locally trusted anchors, for example: * Ubuntu: ``/etc/ssl/certs/ca-certificates.crt`` * SLES: ``/var/lib/ca-certificates/ca-bundle.pem`` * RHEL/Rocky Linux: ``/etc/pki/tls/cert.pem`` -For example, to use the system CA bundle in Ubuntu, use the following: +For example, on Ubuntu: .. code:: python @@ -166,30 +167,30 @@ For example, to use the system CA bundle in Ubuntu, use the following: config = SessionConfiguration(cert_store_path="/etc/ssl/certs/ca-certificates.crt") -This allows ``requests`` to correctly validate both internally and publicly signed TLS certificates, as long as the -internal CA certificate has been added to the system certificate store. If the internal CA certificate has not been -added to the system certificate store, then a ``SSLCertVerificationError`` is still raised, and you should proceed to -the next section. +This lets the **httpx** client validate chains signed by CAs present in that bundle, provided the issuing CA for +your service is included. If your internal CA is not in the system bundle, continue to section 3. 3. Single CA certificate ~~~~~~~~~~~~~~~~~~~~~~~~ -If you only need to authenticate internal TLS certificates, you can provide a path to the specific internal CA -certificate to be used for verification: +If you only need to trust a dedicated internal issuing CA, pass its certificate (PEM) as ``cert_store_path``: .. code:: python from ansys.openapi.common import SessionConfiguration - config = SessionConfiguration(cert_store_path=/home/username/my_private_ca_certificate.pem) + config = SessionConfiguration( + cert_store_path="/home/username/my_private_ca_certificate.pem" + ) -Where ``/home/username/my_private_ca_certificate.pem`` is the path to the CA certificate file. +where ``/home/username/my_private_ca_certificate.pem`` is the path to the PEM file. .. note:: - The ``cert_store_path`` argument overrides the ``certifi`` CA certificates. Providing a single private CA certificate - causes ``requests`` to fail to validate publicly signed TLS certificates. + When ``cert_store_path`` is set, that file **replaces** the default **certifi** bundle for verification. A PEM + that contains only your private CA will **not** validate publicly issued sites unless those roots are also + included in the same file or you use one of the other strategies above. .. _pip-system-certs: https://gitlab.com/alelec/pip-system-certs diff --git a/pyproject.toml b/pyproject.toml index 7b9ec86a..181233a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,7 @@ oidc = [ ] # GSSAPI/Kerberos via python-gssapi is Linux-oriented. Windows Negotiate uses SSPI: # ``httpx-negotiate-sspi``; do not add ``httpx-gssapi`` for win32. -# Validation / CI notes: doc/source/planning/httpx-migration.rst (negotiate-validation-strategy). +# Validation: optional Negotiate integration tests (Linux) and manual SSPI checks (Windows). linux-kerberos = [ "httpx-gssapi>=0.6,<0.7; sys_platform == 'linux'" ] From 7fb7e11e4c76a7f0a947d5f774ccfc7a83b0f00a Mon Sep 17 00:00:00 2001 From: Doug Addy Date: Fri, 8 May 2026 12:14:09 +0100 Subject: [PATCH 09/18] docs: clarify IdP session header handling in OIDC and add test for factory session headers Updated documentation to specify that the headers in `idp_session_configuration` are not fully respected for IdP HTTP clients, particularly regarding the `Accept` and `Content-Type` headers. Added a test to ensure that the initial probe to the API uses only the factory's session headers, confirming the expected behavior of the `ApiClientFactory`. --- src/ansys/openapi/common/_oidc.py | 16 ++++++---- src/ansys/openapi/common/_session.py | 2 ++ tests/test_session_creation.py | 45 ++++++++++++++++++++++++++-- 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/ansys/openapi/common/_oidc.py b/src/ansys/openapi/common/_oidc.py index 3fa9c872..0e0410c1 100644 --- a/src/ansys/openapi/common/_oidc.py +++ b/src/ansys/openapi/common/_oidc.py @@ -68,8 +68,12 @@ class OIDCSessionFactory: Notes ----- - The ``headers`` field in ``idp_session_configuration`` is not fully respected. The ``Accept`` and - ``Content-Type`` headers will be overridden. Other settings are respected. + The ``headers`` field in ``idp_session_configuration`` is not fully respected on IdP HTTP + clients: ``Accept`` and ``Content-Type`` are overridden by + ``OIDCSessionFactory._override_idp_header``. Other settings apply to well-known discovery + and token endpoint traffic. The initial ``GET`` to the resource server is performed with + the factory's main client (constructor ``session_configuration`` only); it does not use + this IdP mapping for that request. OAuth2 / token flows use :class:`httpx.Client` with ``httpx-auth`` (PKCE), aligned with the rest of the migration to ``httpx``. @@ -96,7 +100,9 @@ def __init__( idp_sc = idp_session_configuration or SessionConfiguration() self._api_session_configuration = api_sc.get_transport_configuration() - idp_transport = OIDCSessionFactory._override_idp_header(idp_sc.get_transport_configuration()) + idp_transport = OIDCSessionFactory._override_idp_header( + idp_sc.get_transport_configuration() + ) self._idp_session_configuration = idp_transport discovery_sc = SessionConfiguration.from_dict(idp_transport) @@ -301,9 +307,7 @@ def _parse_unauthorized_header( return bearer_parameters @staticmethod - def _fetch_and_parse_well_known( - client: httpx.Client, url: str - ) -> CaseInsensitiveOrderedDict: + def _fetch_and_parse_well_known(client: httpx.Client, url: str) -> CaseInsensitiveOrderedDict: """Fetch and process the required parameters from identity provider's the well-known endpoint. Perform a GET request to the endpoint and verify that the required parameters are returned. diff --git a/src/ansys/openapi/common/_session.py b/src/ansys/openapi/common/_session.py index 7236774d..e617106c 100644 --- a/src/ansys/openapi/common/_session.py +++ b/src/ansys/openapi/common/_session.py @@ -372,6 +372,8 @@ def with_oidc( ---------- idp_session_configuration : ~ansys.openapi.common.SessionConfiguration, optional Additional configuration settings for the HTTP client when connected to the OpenID identity provider. + The initial anonymous probe of the **API** uses only the factory's ``session_configuration`` (see + ``ApiClientFactory`` constructor); put resource-server headers such as application identity there. Returns ------- diff --git a/tests/test_session_creation.py b/tests/test_session_creation.py index 5ed7dacd..d41dbfaa 100644 --- a/tests/test_session_creation.py +++ b/tests/test_session_creation.py @@ -51,7 +51,9 @@ # NTLM pytest-httpx shared handshake (pyspnego via httpx-ntlm; regenerate if deps change — versions in test_can_connect_with_ntlm). _NTLM_PATCHED_URANDOM = b"\xde\xad\xbe\xef\xde\xad\xbe\xef" -_NTLM_CANNED_EXPECT1 = {"Authorization": "NTLM TlRMTVNTUAABAAAAN4II4gAAAAAoAAAAAAAAACgAAAAADAAAAAAADw=="} +_NTLM_CANNED_EXPECT1 = { + "Authorization": "NTLM TlRMTVNTUAABAAAAN4II4gAAAAAoAAAAAAAAACgAAAAADAAAAAAADw==" +} _NTLM_CANNED_CHALLENGE_WWW = ( "NTLM TlRMTVNTUAACAAAAHgAeADgAAAA1gori1CEifyE0ovkAAAAAAAAAAJgAmABWAAAAC" "gBhSgAAAA9UAEUAUwBUAFcATwBSAEsAUwBUAEEAVABJAE8ATgACAB4AVABFAFMAVABXAE8AUgBLAFMAVABBAFQASQB" @@ -86,7 +88,9 @@ def _response_reason(response): def test_anonymous(httpx_mock): - httpx_mock.add_response(url=SERVICELAYER_URL, method="GET", status_code=200, text="Connection OK") + httpx_mock.add_response( + url=SERVICELAYER_URL, method="GET", status_code=200, text="Connection OK" + ) _ = ApiClientFactory(SERVICELAYER_URL).with_anonymous() @@ -134,7 +138,9 @@ def test_can_connect_with_basic(httpx_mock): status_code=200, match_headers={"Authorization": "Basic VEVTVF9VU0VSOlBBU1NXT1JE"}, ) - _ = ApiClientFactory(SERVICELAYER_URL).with_credentials(username="TEST_USER", password="PASSWORD") + _ = ApiClientFactory(SERVICELAYER_URL).with_credentials( + username="TEST_USER", password="PASSWORD" + ) def test_can_connect_with_pre_emptive_basic(httpx_mock): @@ -384,6 +390,39 @@ def test_can_connect_with_oidc(): pass +def test_oidc_probe_uses_factory_session_headers_only(httpx_mock): + """Application headers on the resource server must come from the factory SessionConfiguration.""" + redirect_uri = "https://www.example.com/login/" + authority_url = "https://www.example.com/authority/" + authenticate_header = ( + f'Bearer redirecturi="{redirect_uri}", authority="{authority_url}", ' + 'clientid="b4e44bfa-6b73-4d6a-9df6-8055216a5836"' + ) + seen: list[str | None] = [] + + def probe(request: httpx.Request) -> httpx.Response: + seen.append(request.headers.get("X-GrantaApplicationName")) + return httpx.Response(401, headers={"www-authenticate": authenticate_header}) + + httpx_mock.add_callback(probe, url=SECURE_SERVICELAYER_URL, method="GET") + httpx_mock.add_response( + url=f"{authority_url}.well-known/openid-configuration", + method="GET", + json={ + "token_endpoint": f"{authority_url}token", + "authorization_endpoint": f"{authority_url}authorization", + }, + ) + + main_cfg = SessionConfiguration(headers={"X-GrantaApplicationName": "FromApiSession"}) + idp_cfg = SessionConfiguration(headers={"X-GrantaApplicationName": "FromIdpSession"}) + _ = ApiClientFactory( + SECURE_SERVICELAYER_URL, + session_configuration=main_cfg, + ).with_oidc(idp_session_configuration=idp_cfg) + assert seen == ["FromApiSession"] + + def test_only_called_once_with_oidc_when_anonymous_is_ok(httpx_mock): httpx_mock.add_response(url=SERVICELAYER_URL, method="GET", status_code=200) with pytest.warns(AuthenticationWarning, match="Continuing without credentials"): From 85cf7c018565047e752885a35812fcbd477b1fc1 Mon Sep 17 00:00:00 2001 From: Doug Addy Date: Fri, 8 May 2026 12:54:10 +0100 Subject: [PATCH 10/18] Remove _preload_content from ApiClient call_api path Generated clients always used the default; the flag was wired incorrectly to streaming for requests. call_api now always deserializes and raises ApiException with a parsed exception model on error. request() no longer accepts _preload_content. Add brief docstrings on context manager methods for pydocstyle. Co-authored-by: Cursor --- src/ansys/openapi/common/_api_client.py | 38 ++++-------- src/ansys/openapi/common/_base/_types.py | 3 +- tests/test_api_client.py | 75 ++++-------------------- 3 files changed, 24 insertions(+), 92 deletions(-) diff --git a/src/ansys/openapi/common/_api_client.py b/src/ansys/openapi/common/_api_client.py index 332393de..f46d61bd 100644 --- a/src/ansys/openapi/common/_api_client.py +++ b/src/ansys/openapi/common/_api_client.py @@ -163,6 +163,7 @@ def close(self) -> None: rc.close() def __enter__(self) -> "ApiClient": + """Enter a context manager; returns this client.""" return self def __exit__( @@ -171,6 +172,7 @@ def __exit__( exc_val: Optional[BaseException], exc_tb: Optional[TracebackType], ) -> None: + """Exit the context manager and close the underlying HTTP client.""" self.close() def __repr__(self) -> str: @@ -213,10 +215,9 @@ def __call_api( response_type: Optional[str] = None, _return_http_data_only: Optional[bool] = None, collection_formats: Optional[Dict[str, str]] = None, - _preload_content: bool = True, _request_timeout: Union[float, Tuple[float, float], None] = None, response_type_map: Optional[Mapping[int, Union[str, None]]] = None, - ) -> Union[httpx.Response, DeserializedType, None]: + ) -> Union[DeserializedType, Tuple[DeserializedType, int, httpx.Headers], None]: # header parameters header_params = header_params or {} if header_params: @@ -260,26 +261,20 @@ def __call_api( headers=header_params, post_params=post_params, body=body, - _preload_content=_preload_content, _request_timeout=_request_timeout, ) self.last_response = response_data logger.debug(f"response body: {response_data.text}") - return_data: Union[httpx.Response, DeserializedType, None] = response_data - if _preload_content: - _response_type = response_type - if response_type_map is not None: - _response_type = response_type_map.get(response_data.status_code, None) + _response_type = response_type + if response_type_map is not None: + _response_type = response_type_map.get(response_data.status_code, None) - deserialized_response = self.deserialize(response_data, _response_type) - if not 200 <= response_data.status_code <= 299: - raise ApiException.from_response(response_data, deserialized_response) - return_data = deserialized_response - else: - if not 200 <= response_data.status_code <= 299: - raise ApiException.from_response(response_data) + deserialized_response = self.deserialize(response_data, _response_type) + if not 200 <= response_data.status_code <= 299: + raise ApiException.from_response(response_data, deserialized_response) + return_data = deserialized_response if _return_http_data_only: return return_data @@ -514,10 +509,9 @@ def call_api( response_type: Optional[str] = None, _return_http_data_only: Optional[bool] = None, collection_formats: Optional[Dict[str, str]] = None, - _preload_content: bool = True, _request_timeout: Union[float, Tuple[float, float], None] = None, response_type_map: Optional[Mapping[int, Union[str, None]]] = None, - ) -> Union[httpx.Response, DeserializedType, None]: + ) -> Union[DeserializedType, Tuple[DeserializedType, int, httpx.Headers], None]: """Make the HTTP request and return the deserialized data. Parameters @@ -546,10 +540,6 @@ def call_api( collection_formats : Dict[str, str] Collection format name for path, query, header, and post parameters. This parameter maps the parameter name to the collection type. - _preload_content : bool, optional - Whether to return the underlying response without reading or decoding response data. The default - is ``True``, in which case response data is read or decoded. If ``False``, response data is not - read or decoded. _request_timeout : Union[float, Tuple[float, float], None] Timeout setting for the request. If only one number is provided, it is used as a total request timeout. It can also be a pair (tuple) of (connection, read) timeouts. This parameter overrides the session-level @@ -570,7 +560,6 @@ def call_api( response_type, _return_http_data_only, collection_formats, - _preload_content, _request_timeout, response_type_map, ) @@ -591,7 +580,6 @@ def request( Iterable[Tuple[str, Union[str, bytes, Tuple[str, Union[str, bytes], str]]]] ] = None, body: Optional[Any] = None, - _preload_content: bool = True, _request_timeout: Union[float, Tuple[float, float], None] = None, ) -> httpx.Response: """Make the HTTP request and return it directly. @@ -610,10 +598,6 @@ def request( Request post form parameters for ``multipart/form-data``. body : :obj:`.SerializedType` Request body. - _preload_content : bool, optional - Whether to return the underlying response without reading or decoding response data. The default - is ``True``, in which case the response data is read or decoded. If ``False``, the response - data is not read or decoded. _request_timeout : Union[float, Tuple[float, float], None] Timeout setting for the request. If only one number is provided, it is used as a total request timeout. It can also be a pair (tuple) of (connection, read) timeouts. This parameter overrides the session-level diff --git a/src/ansys/openapi/common/_base/_types.py b/src/ansys/openapi/common/_base/_types.py index 95733d91..1c9e19b4 100644 --- a/src/ansys/openapi/common/_base/_types.py +++ b/src/ansys/openapi/common/_base/_types.py @@ -136,10 +136,9 @@ def call_api( response_type: Optional[str] = None, _return_http_data_only: Optional[bool] = None, collection_formats: Optional[Dict[str, str]] = None, - _preload_content: bool = True, _request_timeout: Union[float, Tuple[float, float], None] = None, response_type_map: Optional[Mapping[int, Union[str, None]]] = None, - ) -> Union[httpx.Response, DeserializedType, None]: + ) -> Union[DeserializedType, Tuple[DeserializedType, int, httpx.Headers], None]: """Provide method signature for calling the API.""" diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 3afa1c24..27069d3b 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -714,7 +714,6 @@ class TestRequestDispatch: query_params = "foo=bar&baz=qux" post_params = [("clientId", secrets.token_hex(32))] header_params = {"Accept": "application/json"} - stream = False body = { "str": "foo", "int": 12, @@ -736,7 +735,6 @@ def send_request(self, verb: str): headers=self.header_params, post_params=self.post_params, body=self.body, - _preload_content=self.stream, _request_timeout=self.timeout, ) @@ -759,9 +757,7 @@ def assert_responses(self, verb, request_mock): elif verb == "HEAD": request_mock.assert_called_once_with(expected_url, **base_kw) elif verb == "OPTIONS": - request_mock.assert_called_once_with( - "OPTIONS", expected_url, **base_kw, **body_kw - ) + request_mock.assert_called_once_with("OPTIONS", expected_url, **base_kw, **body_kw) elif verb == "POST": request_mock.assert_called_once_with(expected_url, **base_kw, **body_kw) elif verb == "PATCH": @@ -769,9 +765,7 @@ def assert_responses(self, verb, request_mock): elif verb == "PUT": request_mock.assert_called_once_with(expected_url, **base_kw, **body_kw) elif verb == "DELETE": - request_mock.assert_called_once_with( - "DELETE", expected_url, **base_kw, **body_kw - ) + request_mock.assert_called_once_with("DELETE", expected_url, **base_kw, **body_kw) else: raise AssertionError(verb) @@ -1064,9 +1058,8 @@ def test_get_model_raises_exception_with_no_deserialized_response(self, httpx_mo assert "Content-Type" in e.value.headers assert e.value.headers["Content-Type"] == "application/json" - def test_get_object_with_preload_false_returns_raw_response(self, httpx_mock): - """This test represents getting an object from a server where we do not want to deserialize the response - immediately""" + def test_get_object_returns_deserialized_model_when_return_http_data_only(self, httpx_mock): + """GET with response_type_map returns a model when ``_return_http_data_only`` is True.""" resource_path = "/items/1" method = "GET" @@ -1088,64 +1081,20 @@ def test_get_object_with_preload_false_returns_raw_response(self, httpx_mock): json=api_response, headers={"Content-Type": "application/json"}, ) - response = self._client.call_api( + from .models import ExampleModel + + result = self._client.call_api( resource_path, method, response_type_map=response_type_map, - _preload_content=False, _return_http_data_only=True, ) - assert isinstance(response, httpx.Response) - assert response.status_code == 200 - assert response.json() == api_response - - def test_get_object_with_preload_false_raises_exception(self, httpx_mock): - """This test represents getting an object from a server where we do not want to deserialize the response - immediately, but an exception is returned.""" - - resource_path = "/items/1" - method = "GET" - - expected_url = TEST_URL + resource_path - - exception_text = "Item not found" - exception_code = 1 - stack_trace = [ - "Source lines", - "101: if id_ not in items:", - "102: raise ItemNotFound(id_)", - ] - - api_response = { - "ExceptionText": exception_text, - "ExceptionCode": exception_code, - "StackTrace": stack_trace, - } - - response_type_map = {200: "ExampleModel", 404: "ExampleException"} - - def respond_404(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 404, - json=api_response, - headers={"Content-Type": "application/json"}, - extensions={"reason_phrase": b"Not Found"}, - ) - - httpx_mock.add_callback(respond_404, url=expected_url, method="GET") - with pytest.raises(ApiException) as e: - _ = self._client.call_api( - resource_path, - method, - response_type_map=response_type_map, - _preload_content=False, - _return_http_data_only=True, - ) - - assert e.value.status_code == 404 - assert e.value.reason_phrase == "Not Found" - assert json.loads(e.value.body) == api_response + assert isinstance(result, ExampleModel) + assert result.string_property == "new_model" + assert result.int_property == 1 + assert result.list_property == ["red", "yellow", "green"] + assert result.bool_property is False def test_patch_object(self, httpx_mock): """This test represents updating a value on an existing record using a custom json payload. The new object From bb58284cfa2e5c75abf14f5579d1bea5c9357a13 Mon Sep 17 00:00:00 2001 From: Doug Addy Date: Fri, 8 May 2026 12:54:46 +0100 Subject: [PATCH 11/18] Replace SessionConfiguration proxies dict with proxy_url Use a single optional outbound proxy URL and route it through one httpx mount keyed by the API (or IdP) scheme from mount_scheme_url. ApiClientFactory passes api_url; OIDC passes authority or resource URL. Remove per-scheme dict handling and NotImplementedError paths from client init kwargs. Update tests and user guide. Co-authored-by: Cursor --- doc/source/user_guide/index.rst | 2 +- src/ansys/openapi/common/_oidc.py | 16 +++-- src/ansys/openapi/common/_session.py | 5 +- src/ansys/openapi/common/_util.py | 92 +++++++++++++++++++--------- tests/test_oidc.py | 2 +- tests/test_session_configuration.py | 42 +++++++++---- 6 files changed, 111 insertions(+), 48 deletions(-) diff --git a/doc/source/user_guide/index.rst b/doc/source/user_guide/index.rst index 9e4a7f4b..f258400e 100644 --- a/doc/source/user_guide/index.rst +++ b/doc/source/user_guide/index.rst @@ -78,7 +78,7 @@ Currently only the Authorization Code authentication flow is supported. Session configuration --------------------- -The :class:`~.SessionConfiguration` class holds TLS settings, proxies, headers, redirects, and +The :class:`~.SessionConfiguration` class holds TLS settings, an optional outbound ``proxy_url``, headers, redirects, and timeouts. :class:`.ApiClientFactory` turns this into an :class:`httpx.Client` (with retries and timeouts applied in OpenAPI-Common’s transport layer), which backs each :class:`.ApiClient`. diff --git a/src/ansys/openapi/common/_oidc.py b/src/ansys/openapi/common/_oidc.py index 0e0410c1..1777aec9 100644 --- a/src/ansys/openapi/common/_oidc.py +++ b/src/ansys/openapi/common/_oidc.py @@ -108,10 +108,14 @@ def __init__( discovery_sc = SessionConfiguration.from_dict(idp_transport) discovery_sc.retry_count = idp_sc.retry_count discovery_sc.request_timeout = idp_sc.request_timeout - with create_httpx_client_from_session_configuration(discovery_sc) as discovery_client: + authority = self._authenticate_parameters["authority"] + with create_httpx_client_from_session_configuration( + discovery_sc, + mount_scheme_url=authority, + ) as discovery_client: self._well_known_parameters = OIDCSessionFactory._fetch_and_parse_well_known( discovery_client, - self._authenticate_parameters["authority"], + authority, ) self._add_api_audience_if_set() @@ -119,7 +123,10 @@ def __init__( oauth_sc = SessionConfiguration.from_dict(idp_transport) oauth_sc.retry_count = idp_sc.retry_count oauth_sc.request_timeout = idp_sc.request_timeout - self._oauth_httpx_client = create_httpx_client_from_session_configuration(oauth_sc) + self._oauth_httpx_client = create_httpx_client_from_session_configuration( + oauth_sc, + mount_scheme_url=authority, + ) logger.info("Configuring session...") scopes_raw = self._authenticate_parameters.get("scope") @@ -156,7 +163,8 @@ def __init__( api_client_sc.retry_count = api_sc.retry_count api_client_sc.request_timeout = api_sc.request_timeout self._authorized_httpx_client = create_httpx_client_from_session_configuration( - api_client_sc + api_client_sc, + mount_scheme_url=self._api_url, ) logger.info("Configuration complete.") diff --git a/src/ansys/openapi/common/_session.py b/src/ansys/openapi/common/_session.py index e617106c..296501aa 100644 --- a/src/ansys/openapi/common/_session.py +++ b/src/ansys/openapi/common/_session.py @@ -139,7 +139,10 @@ def __init__( session_configuration.headers["User-Agent"] = user_agent self._session_configuration = session_configuration - self._session = create_httpx_client_from_session_configuration(session_configuration) + self._session = create_httpx_client_from_session_configuration( + session_configuration, + mount_scheme_url=api_url, + ) logger.debug( f"Configured httpx client default timeout " diff --git a/src/ansys/openapi/common/_util.py b/src/ansys/openapi/common/_util.py index 913b6e3d..ce20e3fb 100644 --- a/src/ansys/openapi/common/_util.py +++ b/src/ansys/openapi/common/_util.py @@ -24,6 +24,7 @@ import http.cookiejar from itertools import chain import tempfile +import urllib.parse from typing import ( Any, Collection, @@ -204,6 +205,15 @@ def parse_authenticate(value: str) -> CaseInsensitiveOrderedDict: return parser.parse_header(value) +def _scheme_mount_prefix(url: str) -> str: + scheme = urllib.parse.urlparse(url).scheme.lower() + if scheme not in ("http", "https"): + raise ValueError( + f"mount_scheme_url must use http or https scheme (got {scheme!r} from {url!r})." + ) + return f"{scheme}://" + + def collect_www_authenticate_raw_values(response: httpx.Response) -> list[str]: """Return each raw ``WWW-Authenticate`` challenge line from ``response``. @@ -222,7 +232,7 @@ class TransportConfiguration(TypedDict): cert: Union[None, str, Tuple[str, str]] verify: Union[None, str, bool] cookies: http.cookiejar.CookieJar - proxies: Dict[str, str] + proxy_url: Optional[str] headers: CaseInsensitiveDict max_redirects: int @@ -230,10 +240,9 @@ class TransportConfiguration(TypedDict): def httpx_client_init_kwargs(configuration: TransportConfiguration) -> dict[str, Any]: """Build keyword arguments for :class:`httpx.Client` from transport configuration. - ``requests`` accepts a per-scheme ``proxies`` mapping on the session. ``httpx`` uses a - single ``proxy`` URL for the default transport in the common case. When exactly one - proxy URL is configured, it is passed through; otherwise a :class:`NotImplementedError` - is raised until full per-scheme routing is implemented. + ``proxy_url`` is not applied here; it is handled in + :func:`create_httpx_client_from_session_configuration` using a single ``httpx`` mount + derived from ``mount_scheme_url``. Parameters ---------- @@ -255,15 +264,6 @@ def httpx_client_init_kwargs(configuration: TransportConfiguration) -> dict[str, # requests follows redirects by default; match that for future Client wiring. "follow_redirects": True, } - proxies = configuration["proxies"] - if proxies: - if len(proxies) == 1: - kwargs["proxy"] = next(iter(proxies.values())) - else: - raise NotImplementedError( - "Multiple proxy mappings are not yet mapped to httpx.Client mounts; " - "configure a single proxy URL or extend httpx_client_init_kwargs." - ) return kwargs @@ -284,9 +284,11 @@ class SessionConfiguration: case-insensitive. The default is ``None``, in which case only required headers will be included. max_redirects : int, optional Maximum number of redirects to allow before halting. The default is ``10``. - proxies : dict, optional - Proxy server URLs, indexed by resource URLs. The default is ``None``, in which case - no proxies are registered for use. + proxy_url : str, optional + Outbound HTTP(S) proxy URL (e.g. ``http://proxy.corp:8080``). When set, pass + ``mount_scheme_url`` to :func:`create_httpx_client_from_session_configuration` + (for example the API base URL) so the correct ``http(s)://`` transport mount is used. + The default is ``None`` (no proxy). verify_ssl : bool, optional Whether to verify the SSL certificate of the remote host. The default is ``True``. cert_store_path : str, optional @@ -314,7 +316,7 @@ def __init__( cookies: Optional[http.cookiejar.CookieJar] = None, headers: Optional[CaseInsensitiveDict] = None, max_redirects: int = 10, - proxies: Optional[Dict[str, str]] = None, + proxy_url: Optional[str] = None, verify_ssl: bool = True, cert_store_path: Optional[str] = None, temp_folder_path: Optional[str] = None, @@ -328,7 +330,7 @@ def __init__( self.cookies = cookies or http.cookiejar.CookieJar() self.headers = headers or CaseInsensitiveDict() self.max_redirects = max_redirects - self.proxies = proxies or {} + self.proxy_url = (proxy_url.strip() if proxy_url else None) or None self.verify_ssl = verify_ssl self.cert_store_path = cert_store_path self.temp_folder_path = temp_folder_path or tempfile.gettempdir() @@ -361,7 +363,7 @@ def get_transport_configuration( "cert": self._cert, "verify": self._verify, "cookies": self.cookies, - "proxies": self.proxies, + "proxy_url": self.proxy_url, "headers": self.headers, "max_redirects": self.max_redirects, } @@ -402,8 +404,11 @@ def from_dict(cls, configuration_dict: TransportConfiguration) -> "SessionConfig ) if configuration_dict["cookies"] is not None: new.cookies = configuration_dict["cookies"] - if configuration_dict["proxies"] is not None: - new.proxies = configuration_dict["proxies"] + if configuration_dict["proxy_url"] is not None: + pu = configuration_dict["proxy_url"] + if not isinstance(pu, str): + raise ValueError(f"Invalid 'proxy_url' field. Must be str, not '{type(pu)}'.") + new.proxy_url = pu.strip() or None if configuration_dict["headers"] is not None: new.headers = configuration_dict["headers"] if configuration_dict["max_redirects"] is not None: @@ -413,6 +418,8 @@ def from_dict(cls, configuration_dict: TransportConfiguration) -> "SessionConfig def create_httpx_client_from_session_configuration( session_configuration: SessionConfiguration, + *, + mount_scheme_url: Optional[str] = None, ) -> httpx.Client: """Create a synchronous :class:`httpx.Client` from a :class:`SessionConfiguration`. @@ -420,10 +427,18 @@ def create_httpx_client_from_session_configuration( failures, timeouts, and selected HTTP status codes are retried according to ``session_configuration.retry_count`` (maximum total attempts per request). + When ``SessionConfiguration.proxy_url`` is set, ``mount_scheme_url`` (for example the + API base URL) is **required**. Its scheme selects a single ``httpx`` mount + (``http://`` or ``https://``) that uses the proxy; the default transport handles other + schemes without a proxy (for example redirects). + Parameters ---------- session_configuration : SessionConfiguration - Source configuration for TLS, cookies, headers, redirects, timeout, proxies, and retries. + Source configuration for TLS, cookies, headers, redirects, timeout, proxy, and retries. + mount_scheme_url : + URL whose scheme determines the proxy mount when ``proxy_url`` is set. Ignored when + ``proxy_url`` is unset. Returns ------- @@ -435,15 +450,36 @@ def create_httpx_client_from_session_configuration( verify = kwargs.pop("verify", True) cert = kwargs.pop("cert", None) - proxy = kwargs.pop("proxy", None) - kwargs["transport"] = RetryingHTTPTransport( + proxy_url = session_configuration.proxy_url + attempts = max(1, session_configuration.retry_count) + backoff = 0.3 + + default_transport = RetryingHTTPTransport( verify=verify, cert=cert, - proxy=proxy, - max_attempts=max(1, session_configuration.retry_count), - backoff_factor=0.3, + proxy=None, + max_attempts=attempts, + backoff_factor=backoff, ) + kwargs["transport"] = default_transport + + if proxy_url is not None: + if mount_scheme_url is None: + raise ValueError( + "mount_scheme_url is required when SessionConfiguration.proxy_url is set " + "(for example the API base URL)." + ) + mount_prefix = _scheme_mount_prefix(mount_scheme_url) + proxied_transport = RetryingHTTPTransport( + verify=verify, + cert=cert, + proxy=proxy_url, + max_attempts=attempts, + backoff_factor=backoff, + ) + kwargs["mounts"] = {mount_prefix: proxied_transport} + return httpx.Client(**kwargs) diff --git a/tests/test_oidc.py b/tests/test_oidc.py index 911feded..80ce6fde 100644 --- a/tests/test_oidc.py +++ b/tests/test_oidc.py @@ -177,7 +177,7 @@ def test_override_idp_configuration_with_no_headers_does_nothing(): configuration = { "headers": None, "verify": False, - "proxies": {"www.example.com", "proxy.example.com"}, + "proxy_url": None, } response = OIDCSessionFactory._override_idp_header(configuration) assert response == configuration diff --git a/tests/test_session_configuration.py b/tests/test_session_configuration.py index b00a1014..cf5003a5 100644 --- a/tests/test_session_configuration.py +++ b/tests/test_session_configuration.py @@ -39,7 +39,7 @@ CLIENT_CERT_PATH = "./client-cert.pem" CLIENT_CERT_KEY = "5up3rS3c43t!" CA_CERT_PATH = "./ca-certs.pem" -PROXY_CONFIG = {"https://www.google.com:80": "https://proxy.mycompany.com:8080"} +PROXY_URL = "https://proxy.mycompany.com:8080" def test_defaults(): @@ -47,15 +47,13 @@ def test_defaults(): assert output["cert"] is None assert output["verify"] assert len(output["cookies"]) == 0 - assert output["proxies"] == {} + assert output["proxy_url"] is None assert output["headers"] == {} assert output["max_redirects"] == 10 def test_cert_path_returns_str(): - output = SessionConfiguration( - client_cert_path=CLIENT_CERT_PATH - ).get_transport_configuration() + output = SessionConfiguration(client_cert_path=CLIENT_CERT_PATH).get_transport_configuration() assert output["cert"] == CLIENT_CERT_PATH @@ -105,9 +103,9 @@ def test_update_headers_indistinct(header_test_fixture): assert not header_test_fixture["lower_case"] -def test_proxies(): - output = SessionConfiguration(proxies=PROXY_CONFIG).get_transport_configuration() - assert output["proxies"] == PROXY_CONFIG +def test_proxy_url(): + output = SessionConfiguration(proxy_url=PROXY_URL).get_transport_configuration() + assert output["proxy_url"] == PROXY_URL def test_cookies(): @@ -154,7 +152,7 @@ def _test_input_dict(self): "cert": None, "verify": None, "cookies": None, - "proxies": None, + "proxy_url": None, "headers": None, "max_redirects": None, } @@ -169,7 +167,7 @@ def test_blank_input_returns_default_object(self): assert isinstance(configuration_obj.cookies, http.cookiejar.CookieJar) assert configuration_obj.cookies._cookies == {} # noqa assert configuration_obj.headers == CaseInsensitiveDict() - assert configuration_obj.proxies == {} + assert configuration_obj.proxy_url is None assert configuration_obj.max_redirects == 10 assert configuration_obj.temp_folder_path == tempfile.gettempdir() @@ -258,7 +256,7 @@ def test_assign_all_values(self): test_input["cookies"] = cookie_jar proxy_url = "http://10.10.1.10:3128" - test_input["proxies"] = {"http": proxy_url} + test_input["proxy_url"] = proxy_url header_name = "X-TestHeader" header_value = "Foo" test_input["headers"] = CaseInsensitiveDict({header_name: header_value}) @@ -268,8 +266,7 @@ def test_assign_all_values(self): assert config_object.verify_ssl assert config_object.cert_store_path == CA_CERT_PATH - assert "http" in config_object.proxies - assert config_object.proxies["http"] == proxy_url + assert config_object.proxy_url == proxy_url assert header_name in config_object.headers assert config_object.headers[header_name] == header_value assert config_object.max_redirects == 30 @@ -301,6 +298,25 @@ def test_retry_count_maps_to_transport_max_attempts(self): assert isinstance(client._transport, RetryingHTTPTransport) assert client._transport._max_attempts == 7 + def test_proxy_url_requires_mount_scheme_url(self): + proxy_u = "http://127.0.0.1:8888" + config = SessionConfiguration(proxy_url=proxy_u) + with pytest.raises(ValueError, match="mount_scheme_url"): + create_httpx_client_from_session_configuration(config) + + def test_proxy_url_mount_matches_api_scheme(self): + proxy_u = "http://127.0.0.1:8888" + config = SessionConfiguration(proxy_url=proxy_u) + with create_httpx_client_from_session_configuration( + config, + mount_scheme_url="https://api.example/v1/", + ) as client: + picked = client._transport_for_url(httpx.URL("https://api.example/resource")) + assert isinstance(picked, RetryingHTTPTransport) + assert picked is not client._transport + plain = client._transport_for_url(httpx.URL("http://other.example/")) + assert plain is client._transport + class TestWwwAuthenticateHeaderMerging: def test_multiple_header_lines_merged(self): From 7c03d33ade4e39a6ab071c6967eac618d19944c6 Mon Sep 17 00:00:00 2001 From: Doug Addy Date: Fri, 8 May 2026 13:12:03 +0100 Subject: [PATCH 12/18] docs: satisfy Vale and pydocstyle tooling - Add .valeignore for Sphinx _build and api/_autosummary output. - Extend ANSYS Vale accept list (httpx, certifi, conda, OpenSSL). - Reword user guide HTTPS/session section for Google rules and spacing. - Fix RetryingHTTPTransport docstrings (numpy style, handle_request). - Split pydocstyle pre-commit: skip D105/D102 on vendored CaseInsensitiveDict. Co-authored-by: Cursor --- .pre-commit-config.yaml | 6 ++++++ .valeignore | 3 +++ doc/source/user_guide/index.rst | 10 +++++----- doc/styles/config/vocabularies/ANSYS/accept.txt | 4 ++++ src/ansys/openapi/common/_retry_transport.py | 4 +++- 5 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 .valeignore diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 590ecdd0..97292aa6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,12 @@ repos: - id: pydocstyle additional_dependencies: [tomli] files: "^(src/)" + exclude: "_case_insensitive_dict\\.py$" + - id: pydocstyle + name: pydocstyle (vendored CaseInsensitiveDict) + additional_dependencies: [tomli] + files: "^src/ansys/openapi/common/_case_insensitive_dict\\.py$" + args: ["--convention=numpy", "--add-ignore=D105,D102"] - repo: https://github.com/ansys/pre-commit-hooks rev: v0.7.2 diff --git a/.valeignore b/.valeignore new file mode 100644 index 00000000..e76687af --- /dev/null +++ b/.valeignore @@ -0,0 +1,3 @@ +# Generated paths — not hand-edited documentation prose. +doc/_build/ +doc/source/api/_autosummary/ diff --git a/doc/source/user_guide/index.rst b/doc/source/user_guide/index.rst index f258400e..b956d878 100644 --- a/doc/source/user_guide/index.rst +++ b/doc/source/user_guide/index.rst @@ -79,8 +79,8 @@ Currently only the Authorization Code authentication flow is supported. Session configuration --------------------- The :class:`~.SessionConfiguration` class holds TLS settings, an optional outbound ``proxy_url``, headers, redirects, and -timeouts. :class:`.ApiClientFactory` turns this into an :class:`httpx.Client` (with retries and -timeouts applied in OpenAPI-Common’s transport layer), which backs each :class:`.ApiClient`. +timeouts. The :class:`.ApiClientFactory` turns it into a synchronous HTTP client using ``httpx`` (retries and +timeouts are applied in OpenAPI-Common's transport layer), which backs each :class:`.ApiClient`. Use it to configure custom certificate validation, send client certificates if your API server requires them, and adjust other transport options. @@ -151,7 +151,7 @@ concatenates the internal CA(s) and the public roots you care about. .. note:: - OIDC flows often touch both internal and public endpoints, so a merged bundle may be required. + OIDC flows often involve both internal and public endpoints, so a merged bundle may be required. CA bundles are often available on Linux machines that combine public and locally trusted anchors, for example: @@ -189,8 +189,8 @@ where ``/home/username/my_private_ca_certificate.pem`` is the path to the PEM fi .. note:: When ``cert_store_path`` is set, that file **replaces** the default **certifi** bundle for verification. A PEM - that contains only your private CA will **not** validate publicly issued sites unless those roots are also - included in the same file or you use one of the other strategies above. + that contains only your private CA **does** not validate publicly issued sites unless those roots are also + included in the same file or you use one of the other strategies described earlier in this section. .. _pip-system-certs: https://gitlab.com/alelec/pip-system-certs diff --git a/doc/styles/config/vocabularies/ANSYS/accept.txt b/doc/styles/config/vocabularies/ANSYS/accept.txt index 8931d974..0aca11dc 100644 --- a/doc/styles/config/vocabularies/ANSYS/accept.txt +++ b/doc/styles/config/vocabularies/ANSYS/accept.txt @@ -14,3 +14,7 @@ pip\-system\-certs HTTPS CA CAs +certifi +conda +httpx +OpenSSL diff --git a/src/ansys/openapi/common/_retry_transport.py b/src/ansys/openapi/common/_retry_transport.py index a03680d1..bfd8c1c0 100644 --- a/src/ansys/openapi/common/_retry_transport.py +++ b/src/ansys/openapi/common/_retry_transport.py @@ -81,7 +81,8 @@ def __init__( retry_http_methods: Collection[str] | None = None, **transport_kwargs: Any, ) -> None: - """ + """Create a retrying transport. + Parameters ---------- max_attempts @@ -105,6 +106,7 @@ def __init__( self._retry_exceptions = _retryable_transport_exceptions() def handle_request(self, request: httpx.Request) -> httpx.Response: + """Dispatch ``request`` with retries for transport errors and configured statuses.""" method_upper = request.method.upper() for attempt in range(self._max_attempts): try: From ab0957b3fbb380bf1ffb0d48d4b1ff514e4c5879 Mon Sep 17 00:00:00 2001 From: Doug Addy Date: Fri, 8 May 2026 13:16:25 +0100 Subject: [PATCH 13/18] docs: drop httpx intersphinx; fix ApiClient numpydoc order; fix ruff F401 - Remove httpx from intersphinx_mapping (no Sphinx objects.inv on MkDocs site). - Reorder ApiClient class docstring to Parameters, Notes, Examples (GL07). - Remove unused typing.Any import in _exceptions.py. Co-authored-by: Cursor --- doc/source/conf.py | 2 +- src/ansys/openapi/common/_api_client.py | 10 +++++----- src/ansys/openapi/common/_exceptions.py | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/doc/source/conf.py b/doc/source/conf.py index 164a8398..0ab64818 100755 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -46,7 +46,7 @@ # sphinx.ext.intersphinx intersphinx_mapping = { "python": ("https://docs.python.org/3.11", None), - "httpx": ("https://www.python-httpx.org", None), + # httpx docs are MkDocs-based; they do not publish a Sphinx objects.inv. "sphinx": ("https://www.sphinx-doc.org/en/master/", None), } diff --git a/src/ansys/openapi/common/_api_client.py b/src/ansys/openapi/common/_api_client.py index f46d61bd..0fa5d19c 100644 --- a/src/ansys/openapi/common/_api_client.py +++ b/src/ansys/openapi/common/_api_client.py @@ -98,6 +98,11 @@ class ApiClient(ApiClientBase): configuration : SessionConfiguration Configuration options for the API client. + Notes + ----- + Call :meth:`close` when finished, or use ``with ApiClient(...) as client:``, so the + underlying HTTP client releases its connection pool. + Examples -------- >>> transport = httpx.MockTransport(lambda request: httpx.Response(200)) @@ -117,11 +122,6 @@ class ApiClient(ApiClientBase): ... session_config) ... ssl_client - - Notes - ----- - Call :meth:`close` when finished, or use ``with ApiClient(...) as client:``, so the - underlying HTTP client releases its connection pool. """ PRIMITIVE_TYPES = (float, bool, bytes, str, int) diff --git a/src/ansys/openapi/common/_exceptions.py b/src/ansys/openapi/common/_exceptions.py index 2addd477..1cc9fb24 100644 --- a/src/ansys/openapi/common/_exceptions.py +++ b/src/ansys/openapi/common/_exceptions.py @@ -20,7 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Optional from ._case_insensitive_dict import CaseInsensitiveDict From 154321ff6b1115bd7f38fb2b50f2e79411cb6d18 Mon Sep 17 00:00:00 2001 From: Doug Addy Date: Fri, 8 May 2026 13:33:24 +0100 Subject: [PATCH 14/18] Remove module link from README --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 54eb5496..a7220a09 100644 --- a/README.rst +++ b/README.rst @@ -51,7 +51,7 @@ APIs, this Python library provides a common client to consume HTTP APIs, minimizing overhead and reducing code duplication. OpenAPI-Common supports authentication with Basic, Negotiate, NTLM, -and OpenID Connect. Most features of the underlying :mod:`httpx` client +and OpenID Connect. Most features of the underlying ``httpx`` client are exposed for use. Some basic configuration is also provided by default. Dependencies From c4a8933ec245f8c3e1a7a9546ea5c120af06a3b8 Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Fri, 8 May 2026 12:34:28 +0000 Subject: [PATCH 15/18] chore: adding changelog file 1046.added.md [dependabot-skip] --- doc/changelog.d/1046.added.md | 1 + pyproject.toml | 30 ++++++++++++++++++------------ 2 files changed, 19 insertions(+), 12 deletions(-) create mode 100644 doc/changelog.d/1046.added.md diff --git a/doc/changelog.d/1046.added.md b/doc/changelog.d/1046.added.md new file mode 100644 index 00000000..78b556b3 --- /dev/null +++ b/doc/changelog.d/1046.added.md @@ -0,0 +1 @@ +DRAFT - Use httpx as the transport diff --git a/pyproject.toml b/pyproject.toml index 181233a6..842b2ba7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -154,13 +154,13 @@ title_format = "`{version} Date: Fri, 8 May 2026 15:38:36 +0100 Subject: [PATCH 16/18] feat(httpx): add AsyncApiClient and extend retry transport Expose AsyncApiClient from the public package, wire async request paths through the existing client stack, and align retry transport and session utilities with async-capable httpx usage. Add unit tests for the async client surface and updated retry behavior. Co-authored-by: Cursor --- src/ansys/openapi/common/__init__.py | 11 +- src/ansys/openapi/common/_api_client.py | 390 ++++++++++++++++--- src/ansys/openapi/common/_retry_transport.py | 78 +++- src/ansys/openapi/common/_util.py | 80 +++- tests/test_async_api_client.py | 113 ++++++ tests/test_retry_transport.py | 26 +- 6 files changed, 645 insertions(+), 53 deletions(-) create mode 100644 tests/test_async_api_client.py diff --git a/src/ansys/openapi/common/__init__.py b/src/ansys/openapi/common/__init__.py index d360c277..e3f7092c 100644 --- a/src/ansys/openapi/common/__init__.py +++ b/src/ansys/openapi/common/__init__.py @@ -26,7 +26,7 @@ __version__ = metadata.version("ansys-openapi-common") -from ._api_client import ApiClient +from ._api_client import ApiClient, AsyncApiClient from ._base import ApiBase, ApiClientBase, ModelBase, Unset, Unset_Type from ._case_insensitive_dict import CaseInsensitiveDict from ._exceptions import ( @@ -36,15 +36,22 @@ UndefinedObjectWarning, ) from ._session import ApiClientFactory, AuthenticationScheme, OIDCSessionBuilder -from ._util import SessionConfiguration, TransportConfiguration, generate_user_agent +from ._util import ( + SessionConfiguration, + TransportConfiguration, + create_async_httpx_client_from_session_configuration, + generate_user_agent, +) __all__ = [ "ApiClient", + "AsyncApiClient", "ApiClientFactory", "AuthenticationScheme", "CaseInsensitiveDict", "SessionConfiguration", "TransportConfiguration", + "create_async_httpx_client_from_session_configuration", "ApiException", "ApiConnectionException", "AuthenticationWarning", diff --git a/src/ansys/openapi/common/_api_client.py b/src/ansys/openapi/common/_api_client.py index 0fa5d19c..f694d8e2 100644 --- a/src/ansys/openapi/common/_api_client.py +++ b/src/ansys/openapi/common/_api_client.py @@ -36,6 +36,8 @@ Iterable, List, Mapping, + NamedTuple, + NoReturn, Optional, Tuple, Type, @@ -80,6 +82,44 @@ def _close_distinct_httpx_auth_clients(rest_client: httpx.Client) -> None: token_client.close() +class _CallRequestParts(NamedTuple): + method: str + url: str + query_params_str: str + header_params: Dict[str, Any] + post_params: Optional[List[Tuple[Any, Any]]] + body: Optional[Any] + request_timeout: Union[float, Tuple[float, float], None] + + +async def _aclose_distinct_httpx_auth_clients(rest_client: httpx.AsyncClient) -> None: + """Close distinct clients held by auth handlers (sync or async), excluding ``rest_client``.""" + auth = getattr(rest_client, "auth", None) + if auth is None: + return + modes = getattr(auth, "authentication_modes", None) + modes_list = list(modes) if modes is not None else [auth] + seen: set[int] = set() + for mode in modes_list: + token_client = getattr(mode, "client", None) + if isinstance(token_client, httpx.AsyncClient): + if token_client is rest_client: + continue + tid = id(token_client) + if tid in seen: + continue + seen.add(tid) + await token_client.aclose() + elif isinstance(token_client, httpx.Client): + if token_client is rest_client: + continue + tid = id(token_client) + if tid in seen: + continue + seen.add(tid) + token_client.close() + + # noinspection DuplicatedCode class ApiClient(ApiClientBase): """Provides a generic API client for OpenAPI client library builds. @@ -200,7 +240,7 @@ def setup_client(self, models: ModuleType) -> None: """ self.models = models.__dict__ - def __call_api( + def _build_call_request_parts( self, resource_path: str, method: str, @@ -212,13 +252,9 @@ def __call_api( files: Optional[ Mapping[str, Union[str, bytes, IO, Iterable[Union[str, bytes, IO]]]] ] = None, - response_type: Optional[str] = None, - _return_http_data_only: Optional[bool] = None, collection_formats: Optional[Dict[str, str]] = None, _request_timeout: Union[float, Tuple[float, float], None] = None, - response_type_map: Optional[Mapping[int, Union[str, None]]] = None, - ) -> Union[DeserializedType, Tuple[DeserializedType, int, httpx.Headers], None]: - # header parameters + ) -> _CallRequestParts: header_params = header_params or {} if header_params: header_params_sanitized = self.sanitize_for_serialization(header_params) @@ -226,44 +262,44 @@ def __call_api( self.parameters_to_tuples(header_params_sanitized, collection_formats) ) - # path parameters if path_params: resource_path = self.__handle_path_params( resource_path, path_params, collection_formats ) - # query parameters query_params_str = "" if query_params: query_params_str = self.__handle_query_params(query_params, collection_formats) - # post parameters if post_params or files: post_param_tuples = self.prepare_post_parameters(post_params, files) sanitized_post_params = self.sanitize_for_serialization(post_param_tuples) post_params = self.parameters_to_tuples(sanitized_post_params, collection_formats) - # body if body: body = self.sanitize_for_serialization(body) if isinstance(body, (list, dict)): body = json.dumps(body).encode("utf8") header_params.setdefault("Content-Type", "application/json") - # request url url = self.api_url + resource_path - - # perform request and return response - response_data = self.request( - method, - url, - query_params=query_params_str, - headers=header_params, + return _CallRequestParts( + method=method, + url=url, + query_params_str=query_params_str, + header_params=header_params, post_params=post_params, body=body, - _request_timeout=_request_timeout, + request_timeout=_request_timeout, ) + def _finish_call_api( + self, + response_data: httpx.Response, + response_type: Optional[str], + _return_http_data_only: Optional[bool], + response_type_map: Optional[Mapping[int, Union[str, None]]], + ) -> Union[DeserializedType, Tuple[DeserializedType, int, httpx.Headers], None]: self.last_response = response_data logger.debug(f"response body: {response_data.text}") @@ -281,6 +317,49 @@ def __call_api( else: return return_data, response_data.status_code, response_data.headers + def __call_api( + self, + resource_path: str, + method: str, + path_params: Union[Dict[str, Union[str, int]], List[Tuple], None] = None, + query_params: Union[Dict[str, Union[str, int]], List[Tuple], None] = None, + header_params: Union[Dict[str, Union[str, int]], None] = None, + body: Optional[Any] = None, + post_params: Optional[List[Tuple[str, Union[str, bytes]]]] = None, + files: Optional[ + Mapping[str, Union[str, bytes, IO, Iterable[Union[str, bytes, IO]]]] + ] = None, + response_type: Optional[str] = None, + _return_http_data_only: Optional[bool] = None, + collection_formats: Optional[Dict[str, str]] = None, + _request_timeout: Union[float, Tuple[float, float], None] = None, + response_type_map: Optional[Mapping[int, Union[str, None]]] = None, + ) -> Union[DeserializedType, Tuple[DeserializedType, int, httpx.Headers], None]: + parts = self._build_call_request_parts( + resource_path, + method, + path_params, + query_params, + header_params, + body, + post_params, + files, + collection_formats, + _request_timeout, + ) + response_data = self.request( + parts.method, + parts.url, + query_params=parts.query_params_str, + headers=parts.header_params, + post_params=parts.post_params, + body=parts.body, + _request_timeout=parts.request_timeout, + ) + return self._finish_call_api( + response_data, response_type, _return_http_data_only, response_type_map + ) + def __handle_path_params( self, resource_path: str, @@ -570,6 +649,41 @@ def _url_with_query_string(url: str, query_params: Optional[str]) -> str: return url return f"{url}&{query_params}" if "?" in url else f"{url}?{query_params}" + @staticmethod + def _prepare_httpx_request_args( + url: str, + query_params: Optional[str], + headers: Optional[Dict], + post_params: Optional[ + Iterable[Tuple[str, Union[str, bytes, Tuple[str, Union[str, bytes], str]]]] + ], + body: Optional[Any], + _request_timeout: Union[float, Tuple[float, float], None], + ) -> Tuple[str, Dict[str, Any], Dict[str, Any]]: + url_effective = ApiClient._url_with_query_string(url, query_params) + kw: Dict[str, Any] = { + "headers": headers, + "timeout": _request_timeout, + } + body_kw: Dict[str, Any] = {} + if post_params is not None: + body_kw["files"] = post_params + if body is not None: + if post_params is not None: + if isinstance(body, str): + body_kw["content"] = body.encode("utf-8") + elif isinstance(body, bytes): + body_kw["content"] = body + else: + body_kw["data"] = body + elif isinstance(body, bytes): + body_kw["content"] = body + elif isinstance(body, str): + body_kw["content"] = body.encode("utf-8") + else: + body_kw["data"] = body + return url_effective, kw, body_kw + def request( self, method: str, @@ -606,32 +720,9 @@ def request( rc = self.rest_client if not isinstance(rc, httpx.Client): raise TypeError("ApiClient requires an httpx.Client instance.") - url_effective = ApiClient._url_with_query_string(url, query_params) - # httpx 0.28+ no longer accepts ``stream=`` on high-level methods; use - # ``Client.stream()`` only if true incremental reads are required. - kw: Dict[str, Any] = { - "headers": headers, - "timeout": _request_timeout, - } - body_kw: Dict[str, Any] = {} - if post_params is not None: - body_kw["files"] = post_params - if body is not None: - if post_params is not None: - # Multipart: mapping/list uses ``data``; raw text/bytes must use ``content`` - # (httpx deprecates ``data=`` for raw bodies). - if isinstance(body, str): - body_kw["content"] = body.encode("utf-8") - elif isinstance(body, bytes): - body_kw["content"] = body - else: - body_kw["data"] = body - elif isinstance(body, bytes): - body_kw["content"] = body - elif isinstance(body, str): - body_kw["content"] = body.encode("utf-8") - else: - body_kw["data"] = body + url_effective, kw, body_kw = self._prepare_httpx_request_args( + url, query_params, headers, post_params, body, _request_timeout + ) if method == "GET": return rc.get(url_effective, **kw) if method == "HEAD": @@ -942,3 +1033,212 @@ def __deserialize_model( pass return instance + + +class AsyncApiClient(ApiClient): + """OpenAPI API client that performs HTTP I/O with :class:`httpx.AsyncClient`. + + Build an async client with :func:`~.create_async_httpx_client_from_session_configuration` + (optionally passing a finalized sync client to reuse headers, cookies, and auth). + + Notes + ----- + Use :meth:`acall_api` / :meth:`arequest` and ``await aclose()``, or the asynchronous + context manager. Synchronous :meth:`~ApiClient.call_api`, :meth:`~ApiClient.request`, + and :meth:`~ApiClient.close` are disabled and raise :class:`TypeError`. + """ + + def close(self) -> None: + """Raise :class:`TypeError`; use :meth:`aclose` instead.""" + raise TypeError( + "AsyncApiClient must be closed with await aclose() or 'async with AsyncApiClient(...)'." + ) + + def __enter__(self) -> NoReturn: + """Disallow synchronous ``with``; use ``async with``.""" + raise TypeError("Use 'async with AsyncApiClient(...)' instead of synchronous 'with'.") + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + """Disallow synchronous ``with``; use ``async with``.""" + raise TypeError("Use 'async with AsyncApiClient(...)' instead of synchronous 'with'.") + + async def __aenter__(self) -> "AsyncApiClient": + """Return this client for use in ``async with``.""" + return self + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + """Close the HTTP client when leaving ``async with``.""" + await self.aclose() + + async def aclose(self) -> None: + """Close the underlying async HTTP client and any distinct auth helper clients.""" + if self._closed: + return + self._closed = True + rc = self.rest_client + if not isinstance(rc, httpx.AsyncClient): + raise TypeError("AsyncApiClient requires an httpx.AsyncClient instance.") + await _aclose_distinct_httpx_auth_clients(rc) + await rc.aclose() + + def call_api( + self, + resource_path: str, + method: str, + path_params: Union[Dict[str, Union[str, int]], List[Tuple], None] = None, + query_params: Union[Dict[str, Union[str, int]], List[Tuple], None] = None, + header_params: Union[Dict[str, Union[str, int]], None] = None, + body: Optional[DeserializedType] = None, + post_params: Optional[List[Tuple[str, Union[str, bytes]]]] = None, + files: Optional[Mapping[str, Union[str, bytes, IO]]] = None, + response_type: Optional[str] = None, + _return_http_data_only: Optional[bool] = None, + collection_formats: Optional[Dict[str, str]] = None, + _request_timeout: Union[float, Tuple[float, float], None] = None, + response_type_map: Optional[Mapping[int, Union[str, None]]] = None, + ) -> Union[DeserializedType, Tuple[DeserializedType, int, httpx.Headers], None]: + """Raise :class:`TypeError`; use :meth:`acall_api` instead.""" + raise TypeError("Use await acall_api(...) for async OpenAPI calls.") + + def request( + self, + method: str, + url: str, + query_params: Optional[str] = None, + headers: Optional[Dict] = None, + post_params: Optional[ + Iterable[Tuple[str, Union[str, bytes, Tuple[str, Union[str, bytes], str]]]] + ] = None, + body: Optional[Any] = None, + _request_timeout: Union[float, Tuple[float, float], None] = None, + ) -> httpx.Response: + """Raise :class:`TypeError`; use :meth:`arequest` instead.""" + raise TypeError("Use await arequest(...) for async HTTP.") + + async def arequest( + self, + method: str, + url: str, + query_params: Optional[str] = None, + headers: Optional[Dict] = None, + post_params: Optional[ + Iterable[Tuple[str, Union[str, bytes, Tuple[str, Union[str, bytes], str]]]] + ] = None, + body: Optional[Any] = None, + _request_timeout: Union[float, Tuple[float, float], None] = None, + ) -> httpx.Response: + """Make an asynchronous HTTP request and return the response.""" + rc = self.rest_client + if not isinstance(rc, httpx.AsyncClient): + raise TypeError("AsyncApiClient requires an httpx.AsyncClient instance.") + url_effective, kw, body_kw = self._prepare_httpx_request_args( + url, query_params, headers, post_params, body, _request_timeout + ) + if method == "GET": + return await rc.get(url_effective, **kw) + if method == "HEAD": + return await rc.head(url_effective, **kw) + if method == "OPTIONS": + return await rc.request( + "OPTIONS", + url_effective, + **kw, + **body_kw, + ) + if method == "POST": + return await rc.post(url_effective, **kw, **body_kw) + if method == "PUT": + return await rc.put(url_effective, **kw, **body_kw) + if method == "PATCH": + return await rc.patch(url_effective, **kw, **body_kw) + if method == "DELETE": + return await rc.request("DELETE", url_effective, **kw, **body_kw) + raise ValueError( + "http method must be `GET`, `HEAD`, `OPTIONS`, `POST`, `PATCH`, `PUT`, or `DELETE`." + ) + + async def acall_api( + self, + resource_path: str, + method: str, + path_params: Union[Dict[str, Union[str, int]], List[Tuple], None] = None, + query_params: Union[Dict[str, Union[str, int]], List[Tuple], None] = None, + header_params: Union[Dict[str, Union[str, int]], None] = None, + body: Optional[DeserializedType] = None, + post_params: Optional[List[Tuple[str, Union[str, bytes]]]] = None, + files: Optional[Mapping[str, Union[str, bytes, IO]]] = None, + response_type: Optional[str] = None, + _return_http_data_only: Optional[bool] = None, + collection_formats: Optional[Dict[str, str]] = None, + _request_timeout: Union[float, Tuple[float, float], None] = None, + response_type_map: Optional[Mapping[int, Union[str, None]]] = None, + ) -> Union[DeserializedType, Tuple[DeserializedType, int, httpx.Headers], None]: + """Async counterpart of :meth:`ApiClient.call_api`.""" + return await self.__acall_api( + resource_path, + method, + path_params, + query_params, + header_params, + body, + post_params, + files, + response_type, + _return_http_data_only, + collection_formats, + _request_timeout, + response_type_map, + ) + + async def __acall_api( + self, + resource_path: str, + method: str, + path_params: Union[Dict[str, Union[str, int]], List[Tuple], None] = None, + query_params: Union[Dict[str, Union[str, int]], List[Tuple], None] = None, + header_params: Union[Dict[str, Union[str, int]], None] = None, + body: Optional[Any] = None, + post_params: Optional[List[Tuple[str, Union[str, bytes]]]] = None, + files: Optional[ + Mapping[str, Union[str, bytes, IO, Iterable[Union[str, bytes, IO]]]] + ] = None, + response_type: Optional[str] = None, + _return_http_data_only: Optional[bool] = None, + collection_formats: Optional[Dict[str, str]] = None, + _request_timeout: Union[float, Tuple[float, float], None] = None, + response_type_map: Optional[Mapping[int, Union[str, None]]] = None, + ) -> Union[DeserializedType, Tuple[DeserializedType, int, httpx.Headers], None]: + parts = self._build_call_request_parts( + resource_path, + method, + path_params, + query_params, + header_params, + body, + post_params, + files, + collection_formats, + _request_timeout, + ) + response_data = await self.arequest( + parts.method, + parts.url, + query_params=parts.query_params_str, + headers=parts.header_params, + post_params=parts.post_params, + body=parts.body, + _request_timeout=parts.request_timeout, + ) + return self._finish_call_api( + response_data, response_type, _return_http_data_only, response_type_map + ) diff --git a/src/ansys/openapi/common/_retry_transport.py b/src/ansys/openapi/common/_retry_transport.py index bfd8c1c0..33a4dda6 100644 --- a/src/ansys/openapi/common/_retry_transport.py +++ b/src/ansys/openapi/common/_retry_transport.py @@ -20,10 +20,11 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -"""Synchronous :class:`httpx.HTTPTransport` with retries. +"""Synchronous and asynchronous :class:`httpx` transports with retries. -This mirrors historical resilience from ``urllib3.Retry`` + ``requests.HTTPAdapter`` -while staying inside ``httpx``'s transport layer. +The synchronous :class:`RetryingHTTPTransport` mirrors historical resilience from +``urllib3.Retry`` + ``requests.HTTPAdapter`` while staying inside ``httpx``'s transport layer. +:class:`RetryingAsyncHTTPTransport` applies the same policy for :class:`httpx.AsyncClient`. **Semantics** @@ -46,6 +47,7 @@ from __future__ import annotations +import asyncio import time from typing import Any, Collection, FrozenSet @@ -148,3 +150,73 @@ def _drain_response(response: httpx.Response) -> None: response.read() finally: response.close() + + +class RetryingAsyncHTTPTransport(httpx.AsyncHTTPTransport): + """Async HTTP transport that retries failed requests up to ``max_attempts`` times.""" + + def __init__( + self, + *, + max_attempts: int = 3, + backoff_factor: float = 0.3, + retry_status_codes: Collection[int] | None = None, + retry_http_methods: Collection[str] | None = None, + **transport_kwargs: Any, + ) -> None: + """Create a retrying async transport. + + Parameters match :class:`RetryingHTTPTransport`. + """ + super().__init__(retries=0, **transport_kwargs) + self._max_attempts = max(1, max_attempts) + self._backoff_factor = backoff_factor + self._retry_status_codes = frozenset(retry_status_codes or _DEFAULT_RETRY_STATUSES) + self._retry_http_methods = frozenset( + m.upper() for m in (retry_http_methods or _DEFAULT_RETRY_METHODS) + ) + self._retry_exceptions = _retryable_transport_exceptions() + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + """Dispatch ``request`` with retries for transport errors and configured statuses.""" + method_upper = request.method.upper() + for attempt in range(self._max_attempts): + try: + response = await super().handle_async_request(request) + except self._retry_exceptions: + if attempt >= self._max_attempts - 1: + raise + await self._sleep_backoff(attempt) + logger.debug( + "Retrying HTTP request after transport error " + f"(attempt {attempt + 2}/{self._max_attempts})" + ) + continue + + if ( + response.status_code in self._retry_status_codes + and method_upper in self._retry_http_methods + and attempt < self._max_attempts - 1 + ): + await self._adrain_response(response) + await self._sleep_backoff(attempt) + logger.debug( + "Retrying HTTP request after status " + f"{response.status_code} (attempt {attempt + 2}/{self._max_attempts})" + ) + continue + + return response + + raise AssertionError("retry loop fell through") # pragma: no cover + + async def _sleep_backoff(self, attempt_index: int) -> None: + delay = self._backoff_factor * (2**attempt_index) + await asyncio.sleep(delay) + + @staticmethod + async def _adrain_response(response: httpx.Response) -> None: + try: + await response.aread() + finally: + await response.aclose() diff --git a/src/ansys/openapi/common/_util.py b/src/ansys/openapi/common/_util.py index ce20e3fb..2ec9f35b 100644 --- a/src/ansys/openapi/common/_util.py +++ b/src/ansys/openapi/common/_util.py @@ -41,7 +41,7 @@ import pyparsing as pp from ._case_insensitive_dict import CaseInsensitiveDict -from ._retry_transport import RetryingHTTPTransport +from ._retry_transport import RetryingAsyncHTTPTransport, RetryingHTTPTransport class CaseInsensitiveOrderedDict(OrderedDict): @@ -252,7 +252,7 @@ def httpx_client_init_kwargs(configuration: TransportConfiguration) -> dict[str, Returns ------- dict[str, Any] - Keyword arguments suitable for ``httpx.Client(**kwargs)``. + Keyword arguments suitable for ``httpx.Client(**kwargs)`` or ``httpx.AsyncClient(**kwargs)``. """ headers = configuration["headers"] kwargs: dict[str, Any] = { @@ -483,6 +483,82 @@ def create_httpx_client_from_session_configuration( return httpx.Client(**kwargs) +def create_async_httpx_client_from_session_configuration( + session_configuration: SessionConfiguration, + *, + mount_scheme_url: Optional[str] = None, + sync_client: Optional[httpx.Client] = None, +) -> httpx.AsyncClient: + """Create an asynchronous :class:`httpx.AsyncClient` from a :class:`SessionConfiguration`. + + Uses :class:`~ansys.openapi.common._retry_transport.RetryingAsyncHTTPTransport` with the + same retry semantics as :func:`create_httpx_client_from_session_configuration`. + + When ``sync_client`` is provided (for example after synchronous authentication), its + ``headers``, ``cookies``, and ``auth`` are applied on top of the configuration-derived + defaults so the async client reuses the finalized session state. + + Parameters + ---------- + session_configuration : SessionConfiguration + Source configuration for TLS, cookies, headers, redirects, timeout, proxy, and retries. + mount_scheme_url : + URL whose scheme determines the proxy mount when ``proxy_url`` is set. Ignored when + ``proxy_url`` is unset. + sync_client : httpx.Client, optional + Optional sync client whose headers, cookies, and auth are merged into the async client. + + Returns + ------- + httpx.AsyncClient + Configured async HTTP client (call ``await client.aclose()`` when done). + """ + kwargs = httpx_client_init_kwargs(session_configuration.get_transport_configuration()) + kwargs["timeout"] = session_configuration.request_timeout + + verify = kwargs.pop("verify", True) + cert = kwargs.pop("cert", None) + + proxy_url = session_configuration.proxy_url + attempts = max(1, session_configuration.retry_count) + backoff = 0.3 + + default_transport = RetryingAsyncHTTPTransport( + verify=verify, + cert=cert, + proxy=None, + max_attempts=attempts, + backoff_factor=backoff, + ) + kwargs["transport"] = default_transport + + if proxy_url is not None: + if mount_scheme_url is None: + raise ValueError( + "mount_scheme_url is required when SessionConfiguration.proxy_url is set " + "(for example the API base URL)." + ) + mount_prefix = _scheme_mount_prefix(mount_scheme_url) + proxied_transport = RetryingAsyncHTTPTransport( + verify=verify, + cert=cert, + proxy=proxy_url, + max_attempts=attempts, + backoff_factor=backoff, + ) + kwargs["mounts"] = {mount_prefix: proxied_transport} + + if sync_client is not None: + merged_headers = dict(kwargs.get("headers") or {}) + merged_headers.update(sync_client.headers) + kwargs["headers"] = merged_headers + kwargs["cookies"] = sync_client.cookies + if sync_client.auth is not None: + kwargs["auth"] = sync_client.auth + + return httpx.AsyncClient(**kwargs) + + def generate_user_agent(package_name: str, package_version: str) -> str: """Generate a user-agent string in the form * *. diff --git a/tests/test_async_api_client.py b/tests/test_async_api_client.py new file mode 100644 index 00000000..1cc4dad8 --- /dev/null +++ b/tests/test_async_api_client.py @@ -0,0 +1,113 @@ +# Copyright (C) 2022 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Tests for :class:`~ansys.openapi.common.AsyncApiClient` and async client factory.""" + +from __future__ import annotations + +import asyncio +import json + +import httpx +import pytest + +from ansys.openapi.common import ( + AsyncApiClient, + SessionConfiguration, + create_async_httpx_client_from_session_configuration, +) +from ansys.openapi.common._util import create_httpx_client_from_session_configuration + +TEST_URL = "http://localhost/api/v1.svc" + + +class _JsonOkTransport(httpx.AsyncBaseTransport): + """Return JSON 200 for any request.""" + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: # noqa: ARG002 + return httpx.Response(200, content=json.dumps({"message": "hello"}).encode()) + + +def test_async_api_client_acall_api(): + async def run() -> None: + async with httpx.AsyncClient(transport=_JsonOkTransport()) as session: + client = AsyncApiClient(session, TEST_URL, SessionConfiguration()) + out = await client.acall_api( + "/ping", + "GET", + response_type="dict(str, str)", + _return_http_data_only=True, + ) + assert out == {"message": "hello"} + + asyncio.run(run()) + + +def test_async_api_client_rejects_sync_call_api(): + async def run() -> None: + async with httpx.AsyncClient(transport=_JsonOkTransport()) as session: + client = AsyncApiClient(session, TEST_URL, SessionConfiguration()) + with pytest.raises(TypeError, match="acall_api"): + client.call_api("/x", "GET") + + asyncio.run(run()) + + +def test_async_api_client_rejects_sync_context_manager(): + async def run() -> None: + async with httpx.AsyncClient(transport=_JsonOkTransport()) as session: + client = AsyncApiClient(session, TEST_URL, SessionConfiguration()) + with pytest.raises(TypeError, match="async with"): + with client: + pass + + asyncio.run(run()) + + +def test_async_api_client_context_manager_aclose(): + async def run() -> None: + transport = _JsonOkTransport() + session = httpx.AsyncClient(transport=transport) + async with AsyncApiClient(session, TEST_URL, SessionConfiguration()) as client: + assert client.rest_client is session + assert session.is_closed + + asyncio.run(run()) + + +def test_create_async_client_copies_sync_state(): + sync = create_httpx_client_from_session_configuration( + SessionConfiguration(headers={"X-T": "1"}), + ) + try: + sync.headers["X-Extra"] = "2" + async_client = create_async_httpx_client_from_session_configuration( + SessionConfiguration(), + sync_client=sync, + ) + try: + assert async_client.headers["X-T"] == "1" + assert async_client.headers["X-Extra"] == "2" + finally: + asyncio.run(async_client.aclose()) + finally: + sync.close() diff --git a/tests/test_retry_transport.py b/tests/test_retry_transport.py index faf315d9..575fb0b4 100644 --- a/tests/test_retry_transport.py +++ b/tests/test_retry_transport.py @@ -20,11 +20,16 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +import asyncio + import httpx from ansys.openapi.common import SessionConfiguration from ansys.openapi.common._retry_transport import RetryingHTTPTransport -from ansys.openapi.common._util import create_httpx_client_from_session_configuration +from ansys.openapi.common._util import ( + create_async_httpx_client_from_session_configuration, + create_httpx_client_from_session_configuration, +) _URL = "https://example.test/resource" @@ -41,6 +46,25 @@ def test_retries_http_503_then_ok(httpx_mock): assert len(httpx_mock.get_requests(url=_URL)) == 2 +def test_retries_http_503_then_ok_async(httpx_mock): + httpx_mock.add_response(url=_URL, method="GET", status_code=503) + httpx_mock.add_response(url=_URL, method="GET", status_code=200, text="ok") + + async def main(): + client = create_async_httpx_client_from_session_configuration( + SessionConfiguration(retry_count=3) + ) + try: + r = await client.get(_URL) + assert r.status_code == 200 + assert r.text == "ok" + finally: + await client.aclose() + + asyncio.run(main()) + assert len(httpx_mock.get_requests(url=_URL)) == 2 + + def test_retries_stop_after_max_attempts(httpx_mock): httpx_mock.add_response(url=_URL, method="GET", status_code=503) httpx_mock.add_response(url=_URL, method="GET", status_code=503) From 864e3d23f5209ecc6459e92a4053a888b22cf4a9 Mon Sep 17 00:00:00 2001 From: Doug Addy Date: Fri, 8 May 2026 15:40:07 +0100 Subject: [PATCH 17/18] test(integration): shared FastAPI fixtures and HTTP verb coverage Factor Uvicorn apps into fixture_apps with shared /models routes, add server_utils and async_integration helpers, and extend basic, anonymous, and negotiate suites with parametrized GET/POST/PUT/DELETE/HEAD/OPTIONS checks alongside existing PATCH coverage. Co-authored-by: Cursor --- tests/integration/async_integration.py | 71 +++++++ tests/integration/common.py | 84 +++++++- tests/integration/fixture_apps.py | 271 +++++++++++++++++++++++++ tests/integration/server_utils.py | 58 ++++++ tests/integration/test_anonymous.py | 198 +++++++++++++----- tests/integration/test_basic.py | 255 +++++++++++++++-------- tests/integration/test_negotiate.py | 227 ++++++++++++--------- 7 files changed, 927 insertions(+), 237 deletions(-) create mode 100644 tests/integration/async_integration.py create mode 100644 tests/integration/fixture_apps.py create mode 100644 tests/integration/server_utils.py diff --git a/tests/integration/async_integration.py b/tests/integration/async_integration.py new file mode 100644 index 00000000..c3cf1000 --- /dev/null +++ b/tests/integration/async_integration.py @@ -0,0 +1,71 @@ +# Copyright (C) 2022 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Shared helpers for async :class:`~ansys.openapi.common.AsyncApiClient` integration tests.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable + +import httpx + +from ansys.openapi.common import ( + ApiClient, + ApiClientFactory, + AsyncApiClient, + SessionConfiguration, + create_async_httpx_client_from_session_configuration, +) + + +def async_clients_from_sync(sync_api: ApiClient) -> tuple[httpx.AsyncClient, AsyncApiClient]: + """Build ``httpx.AsyncClient`` + :class:`AsyncApiClient` from a connected sync :class:`ApiClient`.""" + http = create_async_httpx_client_from_session_configuration( + sync_api.configuration, + mount_scheme_url=sync_api.api_url, + sync_client=sync_api.rest_client, + ) + async_api = AsyncApiClient(http, sync_api.api_url, sync_api.configuration) + return http, async_api + + +def run_with_factory_and_async_client( + base_url: str, + connect: Callable[[ApiClientFactory], ApiClient], + body: Callable[[AsyncApiClient], Awaitable[None]], +) -> None: + """Connect with ``ApiClientFactory``, build async stack from the sync client, run ``await body(api)``.""" + + async def main() -> None: + factory = ApiClientFactory(base_url, SessionConfiguration()) + try: + sync = connect(factory) + http, api = async_clients_from_sync(sync) + try: + await body(api) + finally: + await http.aclose() + finally: + factory.close() + + asyncio.run(main()) diff --git a/tests/integration/common.py b/tests/integration/common.py index c8d0ffd3..12ddb4fb 100644 --- a/tests/integration/common.py +++ b/tests/integration/common.py @@ -22,7 +22,7 @@ import os import secrets -from typing import List, Optional +from typing import Any, Dict, List, Optional from fastapi import HTTPException, Response, status from fastapi.security import HTTPBasicCredentials @@ -127,3 +127,85 @@ def modify_response_headers(cls, response: Response) -> None: del response.headers[header_name.lower()] else: response.headers[header_name.lower()] = value + + +def patch_model_integration_expectations() -> Dict[str, Any]: + """Return call kwargs and the expected :class:`~.models.ExampleModel` for PATCH integration tests.""" + ctx = model_endpoint_integration_expectations("PATCH") + ctx["upload_data"] = ctx["body"] + return ctx + + +def model_endpoint_integration_expectations(http_method: str) -> Dict[str, Any]: + """Return ``call_api`` / ``acall_api`` kwargs and expected deserialized value for ``/models`` routes. + + Covers GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS. HEAD expects no JSON body + (``response_type`` is ``None``); OPTIONS returns a small JSON object. + """ + from .. import models + + method = http_method.upper() + upload = {"ListOfStrings": ["red", "yellow", "green"]} + + example_expected = models.ExampleModel( + string_property="new_model", + int_property=1, + list_property=["red", "yellow", "green"], + bool_property=False, + ) + + if method == "GET": + return { + "resource_path": "/models/{ID}", + "http_method": method, + "path_params": {"ID": TEST_MODEL_ID}, + "body": None, + "response_type": "ExampleModel", + "expected": example_expected, + } + if method == "POST": + return { + "resource_path": "/models", + "http_method": method, + "path_params": None, + "body": upload, + "response_type": "ExampleModel", + "expected": example_expected, + } + if method in ("PUT", "PATCH"): + return { + "resource_path": "/models/{ID}", + "http_method": method, + "path_params": {"ID": TEST_MODEL_ID}, + "body": upload, + "response_type": "ExampleModel", + "expected": example_expected, + } + if method == "DELETE": + return { + "resource_path": "/models/{ID}", + "http_method": method, + "path_params": {"ID": TEST_MODEL_ID}, + "body": None, + "response_type": "ExampleModel", + "expected": example_expected, + } + if method == "HEAD": + return { + "resource_path": "/models/{ID}", + "http_method": method, + "path_params": {"ID": TEST_MODEL_ID}, + "body": None, + "response_type": None, + "expected": None, + } + if method == "OPTIONS": + return { + "resource_path": "/models/{ID}", + "http_method": method, + "path_params": {"ID": TEST_MODEL_ID}, + "body": None, + "response_type": "dict(str, str)", + "expected": {"allowed_methods": "GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS"}, + } + raise ValueError(f"unsupported HTTP method for integration: {method!r}") diff --git a/tests/integration/fixture_apps.py b/tests/integration/fixture_apps.py new file mode 100644 index 00000000..ea2319a6 --- /dev/null +++ b/tests/integration/fixture_apps.py @@ -0,0 +1,271 @@ +# Copyright (C) 2022 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""FastAPI apps and uvicorn targets shared by integration tests (picklable for ``multiprocessing``).""" + +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from starlette.responses import Response +import uvicorn + +from tests.integration.common import ( + TEST_MODEL_ID, + TEST_PORT, + CustomResponseHeaders, + ExampleModelPyd, + return_model, + validate_user_basic, + validate_user_principal, +) + +# --- Basic auth (tests.integration.test_basic*) --- + +BASIC_AUTH_APP = FastAPI() +_basic_security = HTTPBasic() + + +@BASIC_AUTH_APP.middleware("http") +async def _basic_modify_response_headers(request: Request, call_next): + response = await call_next(request) + if response.status_code == 401: + CustomResponseHeaders.modify_response_headers(response) + return response + + +@BASIC_AUTH_APP.patch("/models/{model_id}") +async def _basic_patch_model( + model_id: str, + example_model: ExampleModelPyd, + credentials: HTTPBasicCredentials = Depends(_basic_security), +): + validate_user_basic(credentials) + return return_model(model_id, example_model) + + +@BASIC_AUTH_APP.get("/models/{model_id}") +async def _basic_get_model( + model_id: str, credentials: HTTPBasicCredentials = Depends(_basic_security) +): + validate_user_basic(credentials) + return return_model(model_id, ExampleModelPyd()) + + +@BASIC_AUTH_APP.post("/models") +async def _basic_post_model( + example_model: ExampleModelPyd, + credentials: HTTPBasicCredentials = Depends(_basic_security), +): + validate_user_basic(credentials) + return return_model(TEST_MODEL_ID, example_model) + + +@BASIC_AUTH_APP.put("/models/{model_id}") +async def _basic_put_model( + model_id: str, + example_model: ExampleModelPyd, + credentials: HTTPBasicCredentials = Depends(_basic_security), +): + validate_user_basic(credentials) + return return_model(model_id, example_model) + + +@BASIC_AUTH_APP.delete("/models/{model_id}") +async def _basic_delete_model( + model_id: str, credentials: HTTPBasicCredentials = Depends(_basic_security) +): + validate_user_basic(credentials) + return return_model(model_id, ExampleModelPyd()) + + +@BASIC_AUTH_APP.head("/models/{model_id}") +async def _basic_head_model( + model_id: str, credentials: HTTPBasicCredentials = Depends(_basic_security) +): + validate_user_basic(credentials) + if model_id != TEST_MODEL_ID: + raise HTTPException(status_code=404, detail="Model not found") + return Response(status_code=200) + + +@BASIC_AUTH_APP.options("/models/{model_id}") +async def _basic_options_model( + model_id: str, credentials: HTTPBasicCredentials = Depends(_basic_security) +): + validate_user_basic(credentials) + if model_id != TEST_MODEL_ID: + raise HTTPException(status_code=404, detail="Model not found") + return {"allowed_methods": "GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS"} + + +@BASIC_AUTH_APP.get("/test_api") +async def _basic_get_test_api(credentials: HTTPBasicCredentials = Depends(_basic_security)): + validate_user_basic(credentials) + return {"msg": "OK"} + + +@BASIC_AUTH_APP.get("/") +async def _basic_get_none(credentials: HTTPBasicCredentials = Depends(_basic_security)): + validate_user_basic(credentials) + return None + + +def run_basic_auth_server() -> None: + uvicorn.run(BASIC_AUTH_APP, port=TEST_PORT) + + +# --- Anonymous (tests.integration.test_anonymous*) --- + +ANONYMOUS_APP = FastAPI() + + +@ANONYMOUS_APP.patch("/models/{model_id}") +async def _anon_patch_model(model_id: str, example_model: ExampleModelPyd): + return return_model(model_id, example_model) + + +@ANONYMOUS_APP.get("/models/{model_id}") +async def _anon_get_model(model_id: str): + return return_model(model_id, ExampleModelPyd()) + + +@ANONYMOUS_APP.post("/models") +async def _anon_post_model(example_model: ExampleModelPyd): + return return_model(TEST_MODEL_ID, example_model) + + +@ANONYMOUS_APP.put("/models/{model_id}") +async def _anon_put_model(model_id: str, example_model: ExampleModelPyd): + return return_model(model_id, example_model) + + +@ANONYMOUS_APP.delete("/models/{model_id}") +async def _anon_delete_model(model_id: str): + return return_model(model_id, ExampleModelPyd()) + + +@ANONYMOUS_APP.head("/models/{model_id}") +async def _anon_head_model(model_id: str): + if model_id != TEST_MODEL_ID: + raise HTTPException(status_code=404, detail="Model not found") + return Response(status_code=200) + + +@ANONYMOUS_APP.options("/models/{model_id}") +async def _anon_options_model(model_id: str): + if model_id != TEST_MODEL_ID: + raise HTTPException(status_code=404, detail="Model not found") + return {"allowed_methods": "GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS"} + + +@ANONYMOUS_APP.get("/test_api") +async def _anon_get_test_api(): + return {"msg": "OK"} + + +@ANONYMOUS_APP.get("/") +async def _anon_get_none(): + return None + + +def run_anonymous_server() -> None: + uvicorn.run(ANONYMOUS_APP, port=TEST_PORT) + + +# --- Negotiate / Kerberos (tests.integration.test_negotiate*) --- + +NEGOTIATE_TEST_URL = f"http://test-server:{TEST_PORT}" +NEGOTIATE_PRINCIPAL = "httpuser@EXAMPLE.COM" + +NEGOTIATE_APP = FastAPI() + + +@NEGOTIATE_APP.middleware("http") +async def _nego_modify_response_headers(request: Request, call_next): + response = await call_next(request) + if response.status_code == 401: + CustomResponseHeaders.modify_response_headers(response) + return response + + +@NEGOTIATE_APP.patch("/models/{model_id}") +async def _nego_patch_model(model_id: str, example_model: ExampleModelPyd, request: Request): + validate_user_principal(request, NEGOTIATE_PRINCIPAL) + return return_model(model_id, example_model) + + +@NEGOTIATE_APP.get("/models/{model_id}") +async def _nego_get_model(model_id: str, request: Request): + validate_user_principal(request, NEGOTIATE_PRINCIPAL) + return return_model(model_id, ExampleModelPyd()) + + +@NEGOTIATE_APP.post("/models") +async def _nego_post_model(example_model: ExampleModelPyd, request: Request): + validate_user_principal(request, NEGOTIATE_PRINCIPAL) + return return_model(TEST_MODEL_ID, example_model) + + +@NEGOTIATE_APP.put("/models/{model_id}") +async def _nego_put_model(model_id: str, example_model: ExampleModelPyd, request: Request): + validate_user_principal(request, NEGOTIATE_PRINCIPAL) + return return_model(model_id, example_model) + + +@NEGOTIATE_APP.delete("/models/{model_id}") +async def _nego_delete_model(model_id: str, request: Request): + validate_user_principal(request, NEGOTIATE_PRINCIPAL) + return return_model(model_id, ExampleModelPyd()) + + +@NEGOTIATE_APP.head("/models/{model_id}") +async def _nego_head_model(model_id: str, request: Request): + validate_user_principal(request, NEGOTIATE_PRINCIPAL) + if model_id != TEST_MODEL_ID: + raise HTTPException(status_code=404, detail="Model not found") + return Response(status_code=200) + + +@NEGOTIATE_APP.options("/models/{model_id}") +async def _nego_options_model(model_id: str, request: Request): + validate_user_principal(request, NEGOTIATE_PRINCIPAL) + if model_id != TEST_MODEL_ID: + raise HTTPException(status_code=404, detail="Model not found") + return {"allowed_methods": "GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS"} + + +@NEGOTIATE_APP.get("/test_api") +async def _nego_get_test_api(request: Request): + validate_user_principal(request, NEGOTIATE_PRINCIPAL) + return {"msg": "OK"} + + +@NEGOTIATE_APP.get("/") +async def _nego_get_none(request: Request): + validate_user_principal(request, NEGOTIATE_PRINCIPAL) + return None + + +def run_negotiate_server() -> None: + from asgi_gssapi import SPNEGOAuthMiddleware + + authenticated_app = SPNEGOAuthMiddleware(NEGOTIATE_APP, hostname="test-server") + uvicorn.run(authenticated_app, port=TEST_PORT) diff --git a/tests/integration/server_utils.py b/tests/integration/server_utils.py new file mode 100644 index 00000000..0e01bb87 --- /dev/null +++ b/tests/integration/server_utils.py @@ -0,0 +1,58 @@ +# Copyright (C) 2022 - 2026 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Process-spawn helpers for integration tests that need a real uvicorn server.""" + +from __future__ import annotations + +from collections.abc import Callable, Generator +from contextlib import AbstractContextManager, contextmanager +from multiprocessing import Process +from time import sleep + + +@contextmanager +def spawn_uvicorn_subprocess(target: Callable[[], None]) -> Generator[None, None, None]: + """Run ``target`` (a no-arg entrypoint that calls ``uvicorn.run``) in a daemon process.""" + proc = Process(target=target, daemon=True) + proc.start() + try: + yield + finally: + proc.terminate() + while proc.is_alive(): + sleep(1) + + +@contextmanager +def spawn_uvicorn_with_optional_context( + target: Callable[[], None], + outer: AbstractContextManager[None] | None = None, +) -> Generator[None, None, None]: + """Like :func:`spawn_uvicorn_subprocess`, optionally wrapped in another context (e.g. header env).""" + if outer is None: + with spawn_uvicorn_subprocess(target): + yield + else: + with outer: + with spawn_uvicorn_subprocess(target): + yield diff --git a/tests/integration/test_anonymous.py b/tests/integration/test_anonymous.py index 5ee819e9..ec3dab37 100644 --- a/tests/integration/test_anonymous.py +++ b/tests/integration/test_anonymous.py @@ -20,53 +20,27 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from multiprocessing import Process -from time import sleep +import asyncio -from fastapi import FastAPI import pytest -import uvicorn from ansys.openapi.common import ApiClientFactory, AuthenticationWarning, SessionConfiguration -from tests.integration.common import ( - TEST_MODEL_ID, - TEST_PORT, + +from .async_integration import async_clients_from_sync, run_with_factory_and_async_client +from .common import ( TEST_URL, - ExampleModelPyd, - return_model, + model_endpoint_integration_expectations, + patch_model_integration_expectations, ) - -fastapi_test_app = FastAPI() - - -@fastapi_test_app.patch("/models/{model_id}") -async def patch_model(model_id: str, example_model: ExampleModelPyd): - return return_model(model_id, example_model) - - -@fastapi_test_app.get("/test_api") -async def get_test_api(): - return {"msg": "OK"} - - -@fastapi_test_app.get("/") -async def get_none(): - return None - - -def run_server(): - uvicorn.run(fastapi_test_app, port=TEST_PORT) +from .fixture_apps import run_anonymous_server +from .server_utils import spawn_uvicorn_subprocess class TestAnonymous: @pytest.fixture(autouse=True) def server(self): - proc = Process(target=run_server, args=(), daemon=True) - proc.start() - yield - proc.terminate() - while proc.is_alive(): - sleep(1) + with spawn_uvicorn_subprocess(run_anonymous_server): + yield def test_can_connect(self): client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) @@ -79,7 +53,6 @@ def test_get_health_returns_200_ok(self): client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) try: client = client_factory.with_anonymous().connect() - resp = client.request("GET", TEST_URL + "/test_api") assert resp.status_code == 200 assert "OK" in resp.text @@ -91,7 +64,6 @@ def test_basic_credentials_raises_warning(self): try: with pytest.warns(AuthenticationWarning, match="anonymous"): client = client_factory.with_credentials("TEST_USER", "TEST_PASS").connect() - resp = client.request("GET", TEST_URL + "/test_api") assert resp.status_code == 200 assert "OK" in resp.text @@ -101,34 +73,148 @@ def test_basic_credentials_raises_warning(self): def test_patch_model(self): from .. import models - deserialized_response = models.ExampleModel( - string_property="new_model", - int_property=1, - list_property=["red", "yellow", "green"], - bool_property=False, - ) + ctx = patch_model_integration_expectations() + expected = ctx["expected"] - resource_path = "/models/{ID}" - method = "PATCH" - path_params = {"ID": TEST_MODEL_ID} + client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) + try: + client = client_factory.with_anonymous().connect() + client.setup_client(models) + response = client.call_api( + ctx["resource_path"], + ctx["http_method"], + path_params=ctx["path_params"], + body=ctx["upload_data"], + response_type=ctx["response_type"], + _return_http_data_only=True, + ) + assert response == expected + finally: + client_factory.close() - response_type = "ExampleModel" + @pytest.mark.parametrize( + "http_method", + ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"], + ) + def test_model_resource_http_verbs(self, http_method): + from .. import models - upload_data = {"ListOfStrings": ["red", "yellow", "green"]} + ctx = model_endpoint_integration_expectations(http_method) + expected = ctx["expected"] client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) try: client = client_factory.with_anonymous().connect() client.setup_client(models) - response = client.call_api( - resource_path, - method, - path_params=path_params, - body=upload_data, - response_type=response_type, + ctx["resource_path"], + ctx["http_method"], + path_params=ctx["path_params"], + body=ctx["body"], + response_type=ctx["response_type"], _return_http_data_only=True, ) - assert response == deserialized_response + assert response == expected finally: client_factory.close() + + +class TestAnonymousAsync: + @pytest.fixture(autouse=True) + def server(self): + with spawn_uvicorn_subprocess(run_anonymous_server): + yield + + def test_can_connect(self): + async def body(api): + resp = await api.arequest("GET", TEST_URL + "/test_api") + assert resp.status_code == 200 + + run_with_factory_and_async_client( + TEST_URL, + lambda f: f.with_anonymous().connect(), + body, + ) + + def test_get_health_returns_200_ok(self): + async def body(api): + resp = await api.arequest("GET", TEST_URL + "/test_api") + assert resp.status_code == 200 + assert "OK" in resp.text + + run_with_factory_and_async_client( + TEST_URL, + lambda f: f.with_anonymous().connect(), + body, + ) + + def test_basic_credentials_raises_warning(self): + client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) + try: + with pytest.warns(AuthenticationWarning, match="anonymous"): + sync = client_factory.with_credentials("TEST_USER", "TEST_PASS").connect() + + async def http_part(): + http, api = async_clients_from_sync(sync) + try: + resp = await api.arequest("GET", TEST_URL + "/test_api") + assert resp.status_code == 200 + assert "OK" in resp.text + finally: + await http.aclose() + + asyncio.run(http_part()) + finally: + client_factory.close() + + def test_patch_model(self): + from .. import models + + ctx = patch_model_integration_expectations() + expected = ctx["expected"] + + async def body(api): + api.setup_client(models) + response = await api.acall_api( + ctx["resource_path"], + ctx["http_method"], + path_params=ctx["path_params"], + body=ctx["upload_data"], + response_type=ctx["response_type"], + _return_http_data_only=True, + ) + assert response == expected + + run_with_factory_and_async_client( + TEST_URL, + lambda f: f.with_anonymous().connect(), + body, + ) + + @pytest.mark.parametrize( + "http_method", + ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"], + ) + def test_model_resource_http_verbs(self, http_method): + from .. import models + + ctx = model_endpoint_integration_expectations(http_method) + expected = ctx["expected"] + + async def body(api): + api.setup_client(models) + response = await api.acall_api( + ctx["resource_path"], + ctx["http_method"], + path_params=ctx["path_params"], + body=ctx["body"], + response_type=ctx["response_type"], + _return_http_data_only=True, + ) + assert response == expected + + run_with_factory_and_async_client( + TEST_URL, + lambda f: f.with_anonymous().connect(), + body, + ) diff --git a/tests/integration/test_basic.py b/tests/integration/test_basic.py index 65de42ce..d0b83cc4 100644 --- a/tests/integration/test_basic.py +++ b/tests/integration/test_basic.py @@ -20,13 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from multiprocessing import Process -from time import sleep - -from fastapi import Depends, FastAPI, Request -from fastapi.security import HTTPBasic, HTTPBasicCredentials import pytest -import uvicorn from ansys.openapi.common import ( ApiClientFactory, @@ -34,54 +28,18 @@ AuthenticationScheme, SessionConfiguration, ) -from tests.integration.common import ( - TEST_MODEL_ID, + +from .async_integration import run_with_factory_and_async_client +from .common import ( + CustomResponseHeaders, TEST_PASS, - TEST_PORT, TEST_URL, TEST_USER, - CustomResponseHeaders, - ExampleModelPyd, - return_model, - validate_user_basic, + model_endpoint_integration_expectations, + patch_model_integration_expectations, ) - -custom_test_app = FastAPI() -security = HTTPBasic() - - -@custom_test_app.middleware("http") -async def modify_response_headers(request: Request, call_next): - response = await call_next(request) - if response.status_code == 401: - CustomResponseHeaders.modify_response_headers(response) - return response - - -@custom_test_app.patch("/models/{model_id}") -async def patch_model( - model_id: str, - example_model: ExampleModelPyd, - credentials: HTTPBasicCredentials = Depends(security), -): - validate_user_basic(credentials) - return return_model(model_id, example_model) - - -@custom_test_app.get("/test_api") -async def get_test_api(credentials: HTTPBasicCredentials = Depends(security)): - validate_user_basic(credentials) - return {"msg": "OK"} - - -@custom_test_app.get("/") -async def get_none(credentials: HTTPBasicCredentials = Depends(security)): - validate_user_basic(credentials) - return None - - -def run_server(): - uvicorn.run(custom_test_app, port=TEST_PORT) +from .fixture_apps import run_basic_auth_server +from .server_utils import spawn_uvicorn_subprocess, spawn_uvicorn_with_optional_context class BasicTestCases: @@ -114,7 +72,6 @@ def test_get_health_returns_200_ok(self, auth_mode): client = client_factory.with_credentials( TEST_USER, TEST_PASS, authentication_scheme=auth_mode ).connect() - resp = client.request("GET", TEST_URL + "/test_api") assert resp.status_code == 200 assert "OK" in resp.text @@ -124,20 +81,36 @@ def test_get_health_returns_200_ok(self, auth_mode): def test_patch_model(self, auth_mode): from .. import models - deserialized_response = models.ExampleModel( - string_property="new_model", - int_property=1, - list_property=["red", "yellow", "green"], - bool_property=False, - ) + ctx = patch_model_integration_expectations() + expected = ctx["expected"] - resource_path = "/models/{ID}" - http_method = "PATCH" - path_params = {"ID": TEST_MODEL_ID} + client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) + try: + client = client_factory.with_credentials( + TEST_USER, TEST_PASS, authentication_scheme=auth_mode + ).connect() + client.setup_client(models) + response = client.call_api( + ctx["resource_path"], + ctx["http_method"], + path_params=ctx["path_params"], + body=ctx["upload_data"], + response_type=ctx["response_type"], + _return_http_data_only=True, + ) + assert response == expected + finally: + client_factory.close() - response_type = "ExampleModel" + @pytest.mark.parametrize( + "http_method", + ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"], + ) + def test_model_resource_http_verbs(self, auth_mode, http_method): + from .. import models - upload_data = {"ListOfStrings": ["red", "yellow", "green"]} + ctx = model_endpoint_integration_expectations(http_method) + expected = ctx["expected"] client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) try: @@ -145,53 +118,161 @@ def test_patch_model(self, auth_mode): TEST_USER, TEST_PASS, authentication_scheme=auth_mode ).connect() client.setup_client(models) - response = client.call_api( - resource_path, - http_method, - path_params=path_params, - body=upload_data, - response_type=response_type, + ctx["resource_path"], + ctx["http_method"], + path_params=ctx["path_params"], + body=ctx["body"], + response_type=ctx["response_type"], _return_http_data_only=True, ) - assert response == deserialized_response + assert response == expected finally: client_factory.close() +class AsyncBasicTestCases: + """HTTP via :class:`~ansys.openapi.common.AsyncApiClient` after sync :meth:`ApiClientFactory.connect`.""" + + def test_can_connect(self, auth_mode): + async def body(api): + resp = await api.arequest("GET", TEST_URL + "/test_api") + assert resp.status_code == 200 + + run_with_factory_and_async_client( + TEST_URL, + lambda f: f.with_credentials( + TEST_USER, TEST_PASS, authentication_scheme=auth_mode + ).connect(), + body, + ) + + def test_get_health_returns_200_ok(self, auth_mode): + async def body(api): + resp = await api.arequest("GET", TEST_URL + "/test_api") + assert resp.status_code == 200 + assert "OK" in resp.text + + run_with_factory_and_async_client( + TEST_URL, + lambda f: f.with_credentials( + TEST_USER, TEST_PASS, authentication_scheme=auth_mode + ).connect(), + body, + ) + + def test_patch_model(self, auth_mode): + from .. import models + + ctx = patch_model_integration_expectations() + expected = ctx["expected"] + + async def body(api): + api.setup_client(models) + response = await api.acall_api( + ctx["resource_path"], + ctx["http_method"], + path_params=ctx["path_params"], + body=ctx["upload_data"], + response_type=ctx["response_type"], + _return_http_data_only=True, + ) + assert response == expected + + run_with_factory_and_async_client( + TEST_URL, + lambda f: f.with_credentials( + TEST_USER, TEST_PASS, authentication_scheme=auth_mode + ).connect(), + body, + ) + + @pytest.mark.parametrize( + "http_method", + ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"], + ) + def test_model_resource_http_verbs(self, auth_mode, http_method): + from .. import models + + ctx = model_endpoint_integration_expectations(http_method) + expected = ctx["expected"] + + async def body(api): + api.setup_client(models) + response = await api.acall_api( + ctx["resource_path"], + ctx["http_method"], + path_params=ctx["path_params"], + body=ctx["body"], + response_type=ctx["response_type"], + _return_http_data_only=True, + ) + assert response == expected + + run_with_factory_and_async_client( + TEST_URL, + lambda f: f.with_credentials( + TEST_USER, TEST_PASS, authentication_scheme=auth_mode + ).connect(), + body, + ) + + @pytest.mark.parametrize("auth_mode", [AuthenticationScheme.AUTO, AuthenticationScheme.BASIC]) class TestBasic(BasicTestCases): @pytest.fixture(autouse=True) def server(self): - proc = Process(target=run_server, args=(), daemon=True) - proc.start() - yield - proc.terminate() - while proc.is_alive(): - sleep(1) + with spawn_uvicorn_subprocess(run_basic_auth_server): + yield + + +@pytest.mark.parametrize("auth_mode", [AuthenticationScheme.AUTO, AuthenticationScheme.BASIC]) +class TestBasicAsync(AsyncBasicTestCases): + @pytest.fixture(autouse=True) + def server(self): + with spawn_uvicorn_subprocess(run_basic_auth_server): + yield @pytest.mark.parametrize("auth_mode", [AuthenticationScheme.BASIC]) class TestBasicWrongHeader(BasicTestCases): @pytest.fixture(autouse=True) def server(self): - with CustomResponseHeaders("www-authenticate", 'Bearer realm="example"'): - proc = Process(target=run_server, args=(), daemon=True) - proc.start() + with spawn_uvicorn_with_optional_context( + run_basic_auth_server, + CustomResponseHeaders("www-authenticate", 'Bearer realm="example"'), + ): + yield + + +@pytest.mark.parametrize("auth_mode", [AuthenticationScheme.BASIC]) +class TestBasicWrongHeaderAsync(AsyncBasicTestCases): + @pytest.fixture(autouse=True) + def server(self): + with spawn_uvicorn_with_optional_context( + run_basic_auth_server, + CustomResponseHeaders("www-authenticate", 'Bearer realm="example"'), + ): yield - proc.terminate() - while proc.is_alive(): - sleep(1) @pytest.mark.parametrize("auth_mode", [AuthenticationScheme.BASIC]) class TestBasicMissingHeader(BasicTestCases): @pytest.fixture(autouse=True) def server(self): - with CustomResponseHeaders("www-authenticate", None): - proc = Process(target=run_server, args=(), daemon=True) - proc.start() + with spawn_uvicorn_with_optional_context( + run_basic_auth_server, + CustomResponseHeaders("www-authenticate", None), + ): + yield + + +@pytest.mark.parametrize("auth_mode", [AuthenticationScheme.BASIC]) +class TestBasicMissingHeaderAsync(AsyncBasicTestCases): + @pytest.fixture(autouse=True) + def server(self): + with spawn_uvicorn_with_optional_context( + run_basic_auth_server, + CustomResponseHeaders("www-authenticate", None), + ): yield - proc.terminate() - while proc.is_alive(): - sleep(1) diff --git a/tests/integration/test_negotiate.py b/tests/integration/test_negotiate.py index 69ab562d..40fbe96f 100644 --- a/tests/integration/test_negotiate.py +++ b/tests/integration/test_negotiate.py @@ -20,91 +20,44 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from multiprocessing import Process import sys -from time import sleep -from fastapi import FastAPI import pytest from starlette.requests import Request -import uvicorn from ansys.openapi.common import ApiClientFactory, ApiConnectionException, SessionConfiguration -from tests.integration.common import ( - TEST_MODEL_ID, - TEST_PORT, - CustomResponseHeaders, - ExampleModelPyd, - return_model, + +from .async_integration import run_with_factory_and_async_client +from .common import ( + model_endpoint_integration_expectations, + patch_model_integration_expectations, validate_user_principal, ) +from .fixture_apps import NEGOTIATE_APP, NEGOTIATE_TEST_URL, run_negotiate_server +from .server_utils import spawn_uvicorn_subprocess pytestmark = pytest.mark.kerberos -TEST_URL = f"http://test-server:{TEST_PORT}" -TEST_PRINCIPAL = "httpuser@EXAMPLE.COM" - -custom_test_app = FastAPI() - - -@custom_test_app.middleware("http") -async def modify_response_headers(request: Request, call_next): - response = await call_next(request) - if response.status_code == 401: - CustomResponseHeaders.modify_response_headers(response) - return response - - -@custom_test_app.patch("/models/{model_id}") -async def patch_model(model_id: str, example_model: ExampleModelPyd, request: Request): - validate_user_principal(request, TEST_PRINCIPAL) - return return_model(model_id, example_model) - - -@custom_test_app.get("/test_api") -async def get_test_api(request: Request): - validate_user_principal(request, TEST_PRINCIPAL) - return {"msg": "OK"} - - -@custom_test_app.get("/") -async def get_none(request: Request): - validate_user_principal(request, TEST_PRINCIPAL) - return None - - -def run_server(): - # Function is only executed if testing in Linux - from asgi_gssapi import SPNEGOAuthMiddleware - - authenticated_app = SPNEGOAuthMiddleware(custom_test_app, hostname="test-server") - uvicorn.run(authenticated_app, port=TEST_PORT) - @pytest.mark.skipif(sys.platform == "win32", reason="No portable KDC is available at present") class TestNegotiate: @pytest.fixture(autouse=True) def server(self): - proc = Process(target=run_server, args=(), daemon=True) - proc.start() - yield - proc.terminate() - while proc.is_alive(): - sleep(1) + with spawn_uvicorn_subprocess(run_negotiate_server): + yield def test_can_connect(self): - client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) + client_factory = ApiClientFactory(NEGOTIATE_TEST_URL, SessionConfiguration()) try: _ = client_factory.with_autologon().connect() finally: client_factory.close() def test_get_health_returns_200_ok(self): - client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) + client_factory = ApiClientFactory(NEGOTIATE_TEST_URL, SessionConfiguration()) try: client = client_factory.with_autologon().connect() - - resp = client.request("GET", TEST_URL + "/test_api") + resp = client.request("GET", NEGOTIATE_TEST_URL + "/test_api") assert resp.status_code == 200 assert "OK" in resp.text finally: @@ -113,70 +66,158 @@ def test_get_health_returns_200_ok(self): def test_patch_model(self): from .. import models - deserialized_response = models.ExampleModel( - string_property="new_model", - int_property=1, - list_property=["red", "yellow", "green"], - bool_property=False, - ) + ctx = patch_model_integration_expectations() + expected = ctx["expected"] - resource_path = "/models/{ID}" - method = "PATCH" - path_params = {"ID": TEST_MODEL_ID} + client_factory = ApiClientFactory(NEGOTIATE_TEST_URL, SessionConfiguration()) + try: + client = client_factory.with_autologon().connect() + client.setup_client(models) + response = client.call_api( + ctx["resource_path"], + ctx["http_method"], + path_params=ctx["path_params"], + body=ctx["upload_data"], + response_type=ctx["response_type"], + _return_http_data_only=True, + ) + assert response == expected + finally: + client_factory.close() - response_type = "ExampleModel" + @pytest.mark.parametrize( + "http_method", + ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"], + ) + def test_model_resource_http_verbs(self, http_method): + from .. import models - upload_data = {"ListOfStrings": ["red", "yellow", "green"]} + ctx = model_endpoint_integration_expectations(http_method) + expected = ctx["expected"] - client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) + client_factory = ApiClientFactory(NEGOTIATE_TEST_URL, SessionConfiguration()) try: client = client_factory.with_autologon().connect() client.setup_client(models) - response = client.call_api( - resource_path, - method, - path_params=path_params, - body=upload_data, - response_type=response_type, + ctx["resource_path"], + ctx["http_method"], + path_params=ctx["path_params"], + body=ctx["body"], + response_type=ctx["response_type"], _return_http_data_only=True, ) - assert response == deserialized_response + assert response == expected finally: client_factory.close() @pytest.mark.skipif(sys.platform == "win32", reason="No portable KDC is available at present") -class TestNegotiateFailures: +class TestNegotiateAsync: @pytest.fixture(autouse=True) def server(self): - # Stash the original routes - original_routes = custom_test_app.router.routes + with spawn_uvicorn_subprocess(run_negotiate_server): + yield + + def test_can_connect(self): + async def body(api): + resp = await api.arequest("GET", NEGOTIATE_TEST_URL + "/test_api") + assert resp.status_code == 200 + + run_with_factory_and_async_client( + NEGOTIATE_TEST_URL, + lambda f: f.with_autologon().connect(), + body, + ) + + def test_get_health_returns_200_ok(self): + async def body(api): + resp = await api.arequest("GET", NEGOTIATE_TEST_URL + "/test_api") + assert resp.status_code == 200 + assert "OK" in resp.text + + run_with_factory_and_async_client( + NEGOTIATE_TEST_URL, + lambda f: f.with_autologon().connect(), + body, + ) + + def test_patch_model(self): + from .. import models + + ctx = patch_model_integration_expectations() + expected = ctx["expected"] + + async def body(api): + api.setup_client(models) + response = await api.acall_api( + ctx["resource_path"], + ctx["http_method"], + path_params=ctx["path_params"], + body=ctx["upload_data"], + response_type=ctx["response_type"], + _return_http_data_only=True, + ) + assert response == expected - # Remove all the routes (a bit drastic) - custom_test_app.router.routes = [] + run_with_factory_and_async_client( + NEGOTIATE_TEST_URL, + lambda f: f.with_autologon().connect(), + body, + ) + + @pytest.mark.parametrize( + "http_method", + ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"], + ) + def test_model_resource_http_verbs(self, http_method): + from .. import models + + ctx = model_endpoint_integration_expectations(http_method) + expected = ctx["expected"] + + async def body(api): + api.setup_client(models) + response = await api.acall_api( + ctx["resource_path"], + ctx["http_method"], + path_params=ctx["path_params"], + body=ctx["body"], + response_type=ctx["response_type"], + _return_http_data_only=True, + ) + assert response == expected + + run_with_factory_and_async_client( + NEGOTIATE_TEST_URL, + lambda f: f.with_autologon().connect(), + body, + ) + + +@pytest.mark.skipif(sys.platform == "win32", reason="No portable KDC is available at present") +class TestNegotiateFailures: + @pytest.fixture(autouse=True) + def server(self): + original_routes = NEGOTIATE_APP.router.routes + NEGOTIATE_APP.router.routes = [] - @custom_test_app.get("/") + @NEGOTIATE_APP.get("/") async def get_forbidden(request: Request): validate_user_principal(request, "otheruser@EXAMPLE.COM") return None - proc = Process(target=run_server, args=(), daemon=True) - proc.start() - yield - proc.terminate() - while proc.is_alive(): - sleep(1) + with spawn_uvicorn_subprocess(run_negotiate_server): + yield - # Restore the original routes - custom_test_app.router.routes = original_routes + NEGOTIATE_APP.router.routes = original_routes @pytest.mark.xfail( sys.version_info[:2] == (3, 14), reason="Unexpectedly returns 200 with unauthorized user on Python 3.14", ) def test_bad_principal_returns_403(self): - client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) + client_factory = ApiClientFactory(NEGOTIATE_TEST_URL, SessionConfiguration()) try: with pytest.raises(ApiConnectionException) as excinfo: _ = client_factory.with_autologon().connect() From 6637380f3d0b6b44ebbc36adc93ebf21af2f50f8 Mon Sep 17 00:00:00 2001 From: Doug Addy Date: Fri, 8 May 2026 16:13:02 +0100 Subject: [PATCH 18/18] test(api): enhance tests for AsyncApiClient and OIDCSessionFactory - Added tests to ensure AsyncApiClient correctly rejects sync clients and handles async client closure. - Implemented tests for OIDCSessionFactory to verify audience header handling in API and IDP configurations. - Included checks for proper error handling when invalid HTTP clients are used. Co-authored-by: Cursor --- tests/test_api_client.py | 13 +++ tests/test_async_api_client.py | 191 +++++++++++++++++++++++++++++++++ tests/test_oidc.py | 27 ++++- 3 files changed, 230 insertions(+), 1 deletion(-) diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 27069d3b..032a9079 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -23,6 +23,7 @@ import datetime import json import os +import asyncio from pathlib import Path import secrets import sys @@ -101,6 +102,18 @@ def sync_auth_flow(self, request: httpx.Request): assert outer.is_closed +def test_request_requires_sync_httpx_client(): + transport = httpx.MockTransport(lambda request: httpx.Response(200)) + # ApiClient is typed for httpx.Client; an AsyncClient must be rejected at request time. + bad_session = httpx.AsyncClient(transport=transport) # type: ignore[assignment] + try: + client = ApiClient(bad_session, TEST_URL, SessionConfiguration()) # type: ignore[arg-type] + with pytest.raises(TypeError, match="httpx.Client"): + client.request("GET", TEST_URL + "/x") + finally: + asyncio.run(bad_session.aclose()) + + class TestParameterHandling: @pytest.fixture(autouse=True) def _blank_client(self, blank_client): diff --git a/tests/test_async_api_client.py b/tests/test_async_api_client.py index 1cc4dad8..2f21f106 100644 --- a/tests/test_async_api_client.py +++ b/tests/test_async_api_client.py @@ -26,6 +26,7 @@ import asyncio import json +from types import SimpleNamespace import httpx import pytest @@ -35,6 +36,7 @@ SessionConfiguration, create_async_httpx_client_from_session_configuration, ) +from ansys.openapi.common._api_client import _aclose_distinct_httpx_auth_clients from ansys.openapi.common._util import create_httpx_client_from_session_configuration TEST_URL = "http://localhost/api/v1.svc" @@ -111,3 +113,192 @@ def test_create_async_client_copies_sync_state(): asyncio.run(async_client.aclose()) finally: sync.close() + + +class _DummyAsyncAuth(httpx.Auth): + """Separate token client on auth, matching patterns used by ``httpx-auth`` OAuth.""" + + def __init__(self, token_client: httpx.AsyncClient) -> None: + self.client = token_client + + async def async_auth_flow(self, request: httpx.Request): # noqa: ARG002 + yield request + + +def test_async_api_client_aclose_disposes_distinct_auth_token_client(): + async def run() -> None: + transport = httpx.MockTransport(lambda r: httpx.Response(200)) + inner = httpx.AsyncClient(transport=transport) + outer = httpx.AsyncClient(transport=transport, auth=_DummyAsyncAuth(inner)) + client = AsyncApiClient(outer, TEST_URL, SessionConfiguration()) + await client.aclose() + assert inner.is_closed + assert outer.is_closed + + asyncio.run(run()) + + +def test_aclose_distinct_skips_when_auth_is_none(): + async def run() -> None: + rest = SimpleNamespace(auth=None) + await _aclose_distinct_httpx_auth_clients(rest) # type: ignore[arg-type] + + asyncio.run(run()) + + +def test_aclose_distinct_skips_non_httpx_token_clients(): + async def run() -> None: + rest = SimpleNamespace( + auth=SimpleNamespace(authentication_modes=[SimpleNamespace(client="not-a-client")]) + ) + await _aclose_distinct_httpx_auth_clients(rest) # type: ignore[arg-type] + + asyncio.run(run()) + + +def test_aclose_distinct_closes_distinct_async_token_client(): + async def run() -> None: + transport = httpx.MockTransport(lambda r: httpx.Response(200)) + tok = httpx.AsyncClient(transport=transport) + rest = SimpleNamespace( + auth=SimpleNamespace(authentication_modes=[SimpleNamespace(client=tok)]) + ) + await _aclose_distinct_httpx_auth_clients(rest) # type: ignore[arg-type] + assert tok.is_closed + + asyncio.run(run()) + + +def test_aclose_distinct_closes_distinct_sync_token_client(): + async def run() -> None: + transport = httpx.MockTransport(lambda r: httpx.Response(200)) + tok = httpx.Client(transport=transport) + rest = SimpleNamespace( + auth=SimpleNamespace(authentication_modes=[SimpleNamespace(client=tok)]) + ) + await _aclose_distinct_httpx_auth_clients(rest) # type: ignore[arg-type] + assert tok.is_closed + + asyncio.run(run()) + + +def test_aclose_distinct_skips_nested_client_when_same_as_rest_client(): + async def run() -> None: + transport = httpx.MockTransport(lambda r: httpx.Response(200)) + shared = httpx.AsyncClient(transport=transport) + mode = SimpleNamespace(client=shared) + # Bypass httpx auth validation; production stacks attach richer auth objects. + object.__setattr__( + shared, + "_auth", + SimpleNamespace(authentication_modes=[mode]), + ) + await _aclose_distinct_httpx_auth_clients(shared) + assert not shared.is_closed + await shared.aclose() + assert shared.is_closed + + asyncio.run(run()) + + +def test_aclose_distinct_deduplicates_same_token_client(): + class _CountingAsyncClient(httpx.AsyncClient): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.aclose_calls = 0 + + async def aclose(self) -> None: + self.aclose_calls += 1 + await super().aclose() + + async def run() -> None: + transport = httpx.MockTransport(lambda r: httpx.Response(200)) + tok = _CountingAsyncClient(transport=transport) + modes = [SimpleNamespace(client=tok), SimpleNamespace(client=tok)] + rest = SimpleNamespace(auth=SimpleNamespace(authentication_modes=modes)) + await _aclose_distinct_httpx_auth_clients(rest) # type: ignore[arg-type] + assert tok.aclose_calls == 1 + assert tok.is_closed + + asyncio.run(run()) + + +def test_aclose_distinct_single_auth_object_without_modes_list(): + async def run() -> None: + transport = httpx.MockTransport(lambda r: httpx.Response(200)) + tok = httpx.AsyncClient(transport=transport) + rest = SimpleNamespace(auth=SimpleNamespace(client=tok)) + await _aclose_distinct_httpx_auth_clients(rest) # type: ignore[arg-type] + assert tok.is_closed + + asyncio.run(run()) + + +def test_async_api_client_sync_close_raises_type_error(): + async def run() -> None: + async with httpx.AsyncClient(transport=_JsonOkTransport()) as session: + client = AsyncApiClient(session, TEST_URL, SessionConfiguration()) + with pytest.raises(TypeError, match="await aclose"): + client.close() + + asyncio.run(run()) + + +def test_async_api_client_aclose_idempotent(): + async def run() -> None: + transport = _JsonOkTransport() + session = httpx.AsyncClient(transport=transport) + client = AsyncApiClient(session, TEST_URL, SessionConfiguration()) + await client.aclose() + assert session.is_closed + await client.aclose() + + asyncio.run(run()) + + +def test_async_api_client_aclose_requires_async_httpx_client(): + async def run() -> None: + transport = httpx.MockTransport(lambda r: httpx.Response(200)) + sync_session = httpx.Client(transport=transport) + try: + client = AsyncApiClient(sync_session, TEST_URL, SessionConfiguration()) + with pytest.raises(TypeError, match="AsyncApiClient requires an httpx.AsyncClient"): + await client.aclose() + finally: + sync_session.close() + + asyncio.run(run()) + + +def test_arequest_requires_async_httpx_client(): + async def run() -> None: + transport = httpx.MockTransport(lambda r: httpx.Response(200)) + sync_session = httpx.Client(transport=transport) + try: + client = AsyncApiClient(sync_session, TEST_URL, SessionConfiguration()) + with pytest.raises(TypeError, match="AsyncApiClient requires an httpx.AsyncClient"): + await client.arequest("GET", TEST_URL + "/x") + finally: + sync_session.close() + + asyncio.run(run()) + + +def test_arequest_invalid_http_verb(): + async def run() -> None: + async with httpx.AsyncClient(transport=_JsonOkTransport()) as session: + client = AsyncApiClient(session, TEST_URL, SessionConfiguration()) + with pytest.raises(ValueError, match="http method must be"): + await client.arequest("TEAPOT", TEST_URL + "/x") + + asyncio.run(run()) + + +def test_acall_api_invalid_http_verb(): + async def run() -> None: + async with httpx.AsyncClient(transport=_JsonOkTransport()) as session: + client = AsyncApiClient(session, TEST_URL, SessionConfiguration()) + with pytest.raises(ValueError, match="http method must be"): + await client.acall_api("/x", "WABBAJACK", response_type="dict(str, str)") + + asyncio.run(run()) diff --git a/tests/test_oidc.py b/tests/test_oidc.py index 80ce6fde..2bdeb5ea 100644 --- a/tests/test_oidc.py +++ b/tests/test_oidc.py @@ -29,7 +29,7 @@ import pytest from httpx_auth import OAuth2 -from ansys.openapi.common import ApiClientFactory +from ansys.openapi.common import ApiClientFactory, SessionConfiguration from ansys.openapi.common._oidc import OIDCSessionFactory REQUIRED_HEADERS = { @@ -183,6 +183,31 @@ def test_override_idp_configuration_with_no_headers_does_nothing(): assert response == configuration +def test_add_api_audience_if_set_no_op_when_not_in_authenticate_parameters(): + factory = OIDCSessionFactory.__new__(OIDCSessionFactory) + factory._authenticate_parameters = dict(REQUIRED_HEADERS) + api_tc = SessionConfiguration().get_transport_configuration() + idp_tc = SessionConfiguration().get_transport_configuration() + factory._api_session_configuration = api_tc + factory._idp_session_configuration = idp_tc + OIDCSessionFactory._add_api_audience_if_set(factory) + assert "audience" not in api_tc["headers"] + assert "audience" not in idp_tc["headers"] + + +def test_add_api_audience_if_set_writes_audience_to_api_and_idp_headers(): + factory = OIDCSessionFactory.__new__(OIDCSessionFactory) + audience = "https://my-api.example.com" + factory._authenticate_parameters = {**REQUIRED_HEADERS, "apiAudience": audience} + api_tc = SessionConfiguration().get_transport_configuration() + idp_tc = SessionConfiguration().get_transport_configuration() + factory._api_session_configuration = api_tc + factory._idp_session_configuration = idp_tc + OIDCSessionFactory._add_api_audience_if_set(factory) + assert api_tc["headers"]["audience"] == audience + assert idp_tc["headers"]["audience"] == audience + + def test_setting_access_token_with_no_token_throws(): mock_factory = Mock() with pytest.raises(ValueError):