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 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/README.rst b/README.rst index b3dd670b..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 requests session +and OpenID Connect. Most features of the underlying ``httpx`` client are exposed for use. Some basic configuration is also provided by default. Dependencies 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/doc/source/conf.py b/doc/source/conf.py index 09c62140..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), - "requests": ("https://requests.readthedocs.io/en/latest", 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/doc/source/user_guide/index.rst b/doc/source/user_guide/index.rst index 192313dd..b956d878 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, an optional outbound ``proxy_url``, headers, redirects, and +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. 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 involve 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 **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/pyproject.toml b/pyproject.toml index 41f634ae..842b2ba7 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: optional Negotiate integration tests (Linux) and manual SSPI checks (Windows). 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" @@ -137,13 +154,13 @@ title_format = "`{version} 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() + + +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. @@ -65,16 +131,22 @@ 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 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 -------- - >>> 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,7 +157,7 @@ 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 @@ -107,7 +179,7 @@ class ApiClient(ApiClientBase): def __init__( self, - session: requests.Session, + session: httpx.Client, api_url: str, configuration: SessionConfiguration, ): @@ -115,6 +187,33 @@ 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": + """Enter a context manager; returns this client.""" + return self + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + 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: """Printable representation of the object.""" @@ -132,7 +231,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 @@ -140,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, @@ -152,14 +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, - _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]: - # header parameters + ) -> _CallRequestParts: header_params = header_params or {} if header_params: header_params_sanitized = self.sanitize_for_serialization(header_params) @@ -167,67 +262,104 @@ 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, - _preload_content=_preload_content, - _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}") - return_data: Union[requests.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 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, @@ -273,13 +405,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 +444,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 +461,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) """ @@ -454,10 +588,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[requests.Response, DeserializedType, None]: + ) -> Union[DeserializedType, Tuple[DeserializedType, int, httpx.Headers], None]: """Make the HTTP request and return the deserialized data. Parameters @@ -486,10 +619,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 @@ -510,11 +639,51 @@ def call_api( response_type, _return_http_data_only, collection_formats, - _preload_content, _request_timeout, 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}" + + @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, @@ -525,9 +694,8 @@ 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, - ) -> requests.Response: + ) -> httpx.Response: """Make the HTTP request and return it directly. Parameters @@ -544,84 +712,39 @@ 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 timeout setting. """ + rc = self.rest_client + if not isinstance(rc, httpx.Client): + raise TypeError("ApiClient requires an httpx.Client instance.") + url_effective, kw, body_kw = self._prepare_httpx_request_args( + url, query_params, headers, post_params, body, _request_timeout + ) 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 +888,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 +896,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) @@ -910,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/_base/_types.py b/src/ansys/openapi/common/_base/_types.py index 040b1e78..1c9e19b4 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[ @@ -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[requests.Response, DeserializedType, None]: + ) -> Union[DeserializedType, Tuple[DeserializedType, int, httpx.Headers], 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..1cc9fb24 100644 --- a/src/ansys/openapi/common/_exceptions.py +++ b/src/ansys/openapi/common/_exceptions.py @@ -22,14 +22,27 @@ from typing import TYPE_CHECKING, 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 diff --git a/src/ansys/openapi/common/_oidc.py b/src/ansys/openapi/common/_oidc.py index 8dc94d74..1777aec9 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. @@ -64,78 +68,112 @@ 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``. + + 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() + api_sc = api_session_configuration or SessionConfiguration() + idp_sc = idp_session_configuration or 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() + 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 + + discovery_sc = SessionConfiguration.from_dict(idp_transport) + discovery_sc.retry_count = idp_sc.retry_count + discovery_sc.request_timeout = idp_sc.request_timeout + 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, + authority, + ) - self._oauth_requests_session = initial_session - set_session_kwargs(self._oauth_requests_session, self._idp_session_configuration) + self._add_api_audience_if_set() - self._well_known_parameters = self._fetch_and_parse_well_known( - self._authenticate_parameters["authority"] + 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, + mount_scheme_url=authority, ) - self._add_api_audience_if_set() - 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, + mount_scheme_url=self._api_url, + ) + 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 +183,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 +205,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 +239,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 +248,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 +286,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 +314,32 @@ 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 +367,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 +391,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/_retry_transport.py b/src/ansys/openapi/common/_retry_transport.py new file mode 100644 index 00000000..33a4dda6 --- /dev/null +++ b/src/ansys/openapi/common/_retry_transport.py @@ -0,0 +1,222 @@ +# 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 and asynchronous :class:`httpx` transports with retries. + +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** + +* ``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 asyncio +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: + """Create a retrying transport. + + 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: + """Dispatch ``request`` with retries for transport errors and configured statuses.""" + 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() + + +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/_session.py b/src/ansys/openapi/common/_session.py index 1ef41ecb..296501aa 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,39 @@ def __init__( session_configuration.headers["User-Agent"] = user_agent self._session_configuration = session_configuration - logger.debug( - f"Setting requests session parameter 'max_retries' " - f"with value '{self._session_configuration.retry_count}'" + self._session = create_httpx_client_from_session_configuration( + session_configuration, + mount_scheme_url=api_url, ) + 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 +194,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 +275,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 +293,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 +302,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 +340,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 +348,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 +374,9 @@ 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. + 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 ------- @@ -374,12 +391,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 +408,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 +421,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 +438,7 @@ def __handle_initial_response( Parameters ---------- - initial_response : requests.Response + initial_response : httpx.Response Response from querying the API server. Raises @@ -453,13 +468,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 +482,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 +634,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) diff --git a/src/ansys/openapi/common/_util.py b/src/ansys/openapi/common/_util.py index 38dc17b3..2ec9f35b 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, @@ -36,9 +37,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 RetryingAsyncHTTPTransport, RetryingHTTPTransport class CaseInsensitiveOrderedDict(OrderedDict): @@ -202,31 +205,68 @@ 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 _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}://" - Parameters - ---------- - session : :obj:`requests.Session` - Session object to configure. - property_dict : dict - Mapping from requests session parameter to value. + +def collect_www_authenticate_raw_values(response: httpx.Response) -> list[str]: + """Return each raw ``WWW-Authenticate`` challenge line from ``response``. + + 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] cookies: http.cookiejar.CookieJar - proxies: Dict[str, str] + proxy_url: Optional[str] headers: CaseInsensitiveDict max_redirects: int +def httpx_client_init_kwargs(configuration: TransportConfiguration) -> dict[str, Any]: + """Build keyword arguments for :class:`httpx.Client` from transport configuration. + + ``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 + ---------- + configuration : TransportConfiguration + Output of :meth:`SessionConfiguration.get_transport_configuration`. + + Returns + ------- + dict[str, Any] + Keyword arguments suitable for ``httpx.Client(**kwargs)`` or ``httpx.AsyncClient(**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, + } + return kwargs + + class SessionConfiguration: """Provides configuration for the API client session. @@ -244,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 @@ -274,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, @@ -288,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() @@ -313,26 +355,26 @@ 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, - "proxies": self.proxies, + "proxy_url": self.proxy_url, "headers": self.headers, "max_redirects": self.max_redirects, } 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 ---------- @@ -362,8 +404,11 @@ def from_dict(cls, configuration_dict: "RequestsConfiguration") -> "SessionConfi ) 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: @@ -371,6 +416,149 @@ def from_dict(cls, configuration_dict: "RequestsConfiguration") -> "SessionConfi return new +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`. + + 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). + + 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, 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 + ------- + 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_url = session_configuration.proxy_url + attempts = max(1, session_configuration.retry_count) + backoff = 0.3 + + default_transport = RetryingHTTPTransport( + 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 = RetryingHTTPTransport( + verify=verify, + cert=cert, + proxy=proxy_url, + max_attempts=attempts, + backoff_factor=backoff, + ) + kwargs["mounts"] = {mount_prefix: proxied_transport} + + 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/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 51c0f3b6..ec3dab37 100644 --- a/tests/integration/test_anonymous.py +++ b/tests/integration/test_anonymous.py @@ -20,103 +20,201 @@ # 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() +from .fixture_apps import run_anonymous_server +from .server_utils import spawn_uvicorn_subprocess -@fastapi_test_app.patch("/models/{model_id}") -async def patch_model(model_id: str, example_model: ExampleModelPyd): - return return_model(model_id, example_model) +class TestAnonymous: + @pytest.fixture(autouse=True) + def server(self): + with spawn_uvicorn_subprocess(run_anonymous_server): + yield + def test_can_connect(self): + client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) + try: + _ = client_factory.with_anonymous().connect() + finally: + client_factory.close() -@fastapi_test_app.get("/test_api") -async def get_test_api(): - return {"msg": "OK"} + 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 + finally: + client_factory.close() + def test_basic_credentials_raises_warning(self): + client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) + 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 + finally: + client_factory.close() -@fastapi_test_app.get("/") -async def get_none(): - return None + def test_patch_model(self): + from .. import models + ctx = patch_model_integration_expectations() + expected = ctx["expected"] -def run_server(): - uvicorn.run(fastapi_test_app, port=TEST_PORT) + 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() + + @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"] -class TestAnonymous: + 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["body"], + response_type=ctx["response_type"], + _return_http_data_only=True, + ) + assert response == expected + finally: + client_factory.close() + + +class TestAnonymousAsync: @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()) - _ = client_factory.with_anonymous().connect() + 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): - client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) - client = client_factory.with_anonymous().connect() - - resp = client.request("GET", TEST_URL + "/test_api") - assert resp.status_code == 200 - assert "OK" in resp.text + 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()) - 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 + 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 - 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"] + + 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, ) - resource_path = "/models/{ID}" - method = "PATCH" - path_params = {"ID": TEST_MODEL_ID} - - response_type = "ExampleModel" - - upload_data = {"ListOfStrings": ["red", "yellow", "green"]} + @pytest.mark.parametrize( + "http_method", + ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"], + ) + def test_model_resource_http_verbs(self, http_method): + from .. import models - 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, + 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, ) - assert response == deserialized_response diff --git a/tests/integration/test_basic.py b/tests/integration/test_basic.py index aa4573b4..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,150 +28,251 @@ 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: 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() - - resp = client.request("GET", TEST_URL + "/test_api") - assert resp.status_code == 200 - assert "OK" in resp.text + 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 + finally: + client_factory.close() 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"] + + 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() + + @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"] + + 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["body"], + response_type=ctx["response_type"], + _return_http_data_only=True, + ) + 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, ) - resource_path = "/models/{ID}" - http_method = "PATCH" - path_params = {"ID": TEST_MODEL_ID} + 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, + ) - response_type = "ExampleModel" + def test_patch_model(self, auth_mode): + from .. import models - upload_data = {"ListOfStrings": ["red", "yellow", "green"]} + 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, + ) - 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, + @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, ) - assert response == deserialized_response @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 0c11dcfc..40fbe96f 100644 --- a/tests/integration/test_negotiate.py +++ b/tests/integration/test_negotiate.py @@ -20,155 +20,210 @@ # 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 +@pytest.mark.skipif(sys.platform == "win32", reason="No portable KDC is available at present") +class TestNegotiate: + @pytest.fixture(autouse=True) + def server(self): + with spawn_uvicorn_subprocess(run_negotiate_server): + yield -@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"} - + def test_can_connect(self): + client_factory = ApiClientFactory(NEGOTIATE_TEST_URL, SessionConfiguration()) + try: + _ = client_factory.with_autologon().connect() + finally: + client_factory.close() -@custom_test_app.get("/") -async def get_none(request: Request): - validate_user_principal(request, TEST_PRINCIPAL) - return None + def test_get_health_returns_200_ok(self): + client_factory = ApiClientFactory(NEGOTIATE_TEST_URL, SessionConfiguration()) + try: + client = client_factory.with_autologon().connect() + resp = client.request("GET", NEGOTIATE_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 -def run_server(): - # Function is only executed if testing in Linux - from asgi_gssapi import SPNEGOAuthMiddleware + ctx = patch_model_integration_expectations() + expected = ctx["expected"] + + 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() + + @pytest.mark.parametrize( + "http_method", + ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"], + ) + def test_model_resource_http_verbs(self, http_method): + from .. import models - authenticated_app = SPNEGOAuthMiddleware(custom_test_app, hostname="test-server") - uvicorn.run(authenticated_app, port=TEST_PORT) + ctx = model_endpoint_integration_expectations(http_method) + expected = ctx["expected"] + + 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["body"], + response_type=ctx["response_type"], + _return_http_data_only=True, + ) + assert response == expected + finally: + client_factory.close() @pytest.mark.skipif(sys.platform == "win32", reason="No portable KDC is available at present") -class TestNegotiate: +class TestNegotiateAsync: @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.with_autologon().connect() + 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): - client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) - client = client_factory.with_autologon().connect() - - resp = client.request("GET", TEST_URL + "/test_api") - assert resp.status_code == 200 - assert "OK" in resp.text + 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 - 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"] + + 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( + NEGOTIATE_TEST_URL, + lambda f: f.with_autologon().connect(), + body, ) - resource_path = "/models/{ID}" - method = "PATCH" - path_params = {"ID": TEST_MODEL_ID} - - response_type = "ExampleModel" - - upload_data = {"ListOfStrings": ["red", "yellow", "green"]} - - client_factory = ApiClientFactory(TEST_URL, SessionConfiguration()) - client = client_factory.with_autologon().connect() - client.setup_client(models) + @pytest.mark.parametrize( + "http_method", + ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"], + ) + def test_model_resource_http_verbs(self, http_method): + from .. import models - response = client.call_api( - resource_path, - method, - path_params=path_params, - body=upload_data, - response_type=response_type, - _return_http_data_only=True, + 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, ) - assert response == deserialized_response @pytest.mark.skipif(sys.platform == "win32", reason="No portable KDC is available at present") class TestNegotiateFailures: @pytest.fixture(autouse=True) def server(self): - # Stash the original routes - original_routes = custom_test_app.router.routes - - # Remove all the routes (a bit drastic) - custom_test_app.router.routes = [] + 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()) - with pytest.raises(ApiConnectionException) as excinfo: - _ = client_factory.with_autologon().connect() - assert excinfo.value.response.status_code == 403 - assert excinfo.value.response.reason == "Forbidden" + client_factory = ApiClientFactory(NEGOTIATE_TEST_URL, SessionConfiguration()) + 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..032a9079 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -23,19 +23,16 @@ import datetime import json import os +import asyncio from pathlib import Path 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 +52,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 +64,56 @@ 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 + + +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): @@ -527,11 +576,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 +584,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} @@ -698,13 +727,14 @@ 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, "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") @@ -718,36 +748,56 @@ 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, ) 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 +813,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 +827,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 +843,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 +854,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 +871,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 +907,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 +933,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 +956,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 +969,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 +1008,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 +1031,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 +1055,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,9 +1071,8 @@ 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): - """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" @@ -1056,72 +1087,29 @@ 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, - ) - - assert isinstance(response, requests.Response) - assert response.status_code == 200 - assert response.text == json.dumps(api_response) - - def test_get_object_with_preload_false_raises_exception(self): - """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"} + httpx_mock.add_response( + url=expected_url, + method="GET", + status_code=200, + json=api_response, + headers={"Content-Type": "application/json"}, + ) + from .models import ExampleModel - with requests_mock.Mocker() as m: - m.get( - expected_url, - status_code=404, - json=api_response, - reason="Not Found", - headers={"Content-Type": "application/json"}, - ) - 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, - ) + result = self._client.call_api( + resource_path, + method, + response_type_map=response_type_map, + _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 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): + 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 +1134,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 +1145,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 +1177,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 +1222,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 +1234,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 +1267,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 +1299,7 @@ def _blank_client(self): def test_response_type_handling( self, mocker, + httpx_mock, response_code: int, response_type_map, response_type, @@ -1330,17 +1310,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 +1348,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 +1359,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_async_api_client.py b/tests/test_async_api_client.py new file mode 100644 index 00000000..2f21f106 --- /dev/null +++ b/tests/test_async_api_client.py @@ -0,0 +1,304 @@ +# 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 +from types import SimpleNamespace + +import httpx +import pytest + +from ansys.openapi.common import ( + AsyncApiClient, + 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" + + +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() + + +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_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..2bdeb5ea 100644 --- a/tests/test_oidc.py +++ b/tests/test_oidc.py @@ -25,12 +25,11 @@ 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 import ApiClientFactory, SessionConfiguration from ansys.openapi.common._oidc import OIDCSessionFactory REQUIRED_HEADERS = { @@ -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( @@ -191,12 +177,37 @@ 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 +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): @@ -207,7 +218,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 +234,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 +258,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 +308,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..575fb0b4 --- /dev/null +++ b/tests/test_retry_transport.py @@ -0,0 +1,116 @@ +# 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 asyncio + +import httpx + +from ansys.openapi.common import SessionConfiguration +from ansys.openapi.common._retry_transport import RetryingHTTPTransport +from ansys.openapi.common._util import ( + create_async_httpx_client_from_session_configuration, + 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_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) + 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..cf5003a5 100644 --- a/tests/test_session_configuration.py +++ b/tests/test_session_configuration.py @@ -24,54 +24,54 @@ 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!" 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(): - output = SessionConfiguration().get_configuration_for_requests() + output = SessionConfiguration().get_transport_configuration() 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_configuration_for_requests() + output = SessionConfiguration(client_cert_path=CLIENT_CERT_PATH).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 +79,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"] @@ -103,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_configuration_for_requests() - 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(): @@ -131,21 +131,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 @@ -156,7 +152,7 @@ def _test_input_dict(self): "cert": None, "verify": None, "cookies": None, - "proxies": None, + "proxy_url": None, "headers": None, "max_redirects": None, } @@ -171,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() @@ -233,7 +229,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() @@ -260,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}) @@ -270,74 +266,67 @@ 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 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 + + 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): + 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..d41dbfaa 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,76 @@ ) 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 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 _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(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 +125,71 @@ 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 +206,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 +231,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 +320,117 @@ 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_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"): + _ = 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 +444,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 +507,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 +571,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 +647,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) 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"