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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions glpi_python_client/auth/_v1_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
GlpiServerError,
GlpiValidationError,
)
from glpi_python_client.clients.commons._config import build_http_session
from glpi_python_client.clients.commons._http import (
ensure_response_status,
finalize_request_response,
Expand Down Expand Up @@ -95,8 +96,10 @@ def __init__(
seconds=session_refresh_interval_seconds
)

self._http = requests.Session()
self._http.verify = verify_ssl
# Built through the shared factory so the SSL policy is applied at
# construction: httpx reads ``verify`` only in ``Client.__init__``
# and silently ignores a later assignment.
self._http = build_http_session(verify_ssl=verify_ssl)

self._session_token: str | None = None
self._session_started_at: datetime | None = None
Expand Down Expand Up @@ -236,22 +239,19 @@ def _authenticated_request(
"""

request_headers = {**self._headers(), **(headers or {})}
request_method = getattr(self._http, method.lower())
response = cast(
requests.Response,
request_method(url, headers=request_headers, **kwargs),
)
# Dispatch through ``request(method, ...)`` rather than looking up a
# per-verb attribute: it is the one call shape both transports share,
# and it keeps the verb a value instead of an attribute name.
verb = method.upper()
response = self._http.request(verb, url, headers=request_headers, **kwargs)
if _is_auth_failure_response(response):
logger.warning(
"GLPI v1 session token was rejected; refreshing session and "
"retrying request once."
)
self._renew_session()
request_headers = {**self._headers(), **(headers or {})}
response = cast(
requests.Response,
request_method(url, headers=request_headers, **kwargs),
)
response = self._http.request(verb, url, headers=request_headers, **kwargs)
return finalize_request_response(
response,
method=method,
Expand Down
28 changes: 28 additions & 0 deletions glpi_python_client/auth/tests/test_v1_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,34 @@ def __init__(self, responses: dict[str, list[FakeResponse]]) -> None:
def _next(self, key: str) -> FakeResponse:
return self._responses[key].pop(0)

def request(
self,
method: str,
url: str,
headers: dict[str, str],
timeout: int = 30,
**kwargs: Any,
) -> FakeResponse:
"""Verb-agnostic entry point mirroring the real dispatch.

``GLPIV1Session`` routes authenticated calls through
``session.request(method, ...)`` rather than a per-verb attribute,
because that is the one call shape ``requests`` and ``httpx`` share.
This dispatches back to the per-verb handlers so their recorded
call shapes stay identical.
"""

verb = method.upper()
handler = {
"GET": self.get,
"POST": self.post,
"PUT": self.put,
"DELETE": self.delete,
}.get(verb)
if handler is None:
raise AssertionError(f"unexpected verb {verb!r} in fake v1 session")
return handler(url, headers=headers, timeout=timeout, **kwargs)

def get(
self,
url: str,
Expand Down
61 changes: 58 additions & 3 deletions glpi_python_client/clients/commons/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from collections.abc import Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Protocol

import requests
import urllib3
Expand All @@ -22,6 +22,19 @@
from glpi_python_client.auth.auth import GLPITokenManager


class SessionFactory(Protocol):
"""Callable that builds the transport session for a client.

Declared as a ``Protocol`` rather than a bare ``Callable`` alias so the
keyword-only ``verify_ssl`` argument is part of the contract: the whole
point of this seam is that the SSL policy is supplied *at construction*,
and a positional-argument factory would let that guarantee slip.
"""

def __call__(self, *, verify_ssl: bool) -> requests.Session:
"""Return a session configured for ``verify_ssl``."""


@dataclass(frozen=True)
class ClientResources:
"""Runtime resources owned by one async ``GlpiClient`` instance.
Expand Down Expand Up @@ -49,6 +62,39 @@ def configure_ssl_warning_policy(*, verify_ssl: bool) -> None:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)


def build_http_session(*, verify_ssl: bool) -> requests.Session:
"""Construct the HTTP session used for every GLPI call.

This is the single place the library instantiates a transport session,
which makes it the seam the transport swap turns on. Two properties
matter and both are the reason this is a function rather than two inline
lines:

* ``verify`` is applied **as part of construction**. ``requests``
tolerates assigning it afterwards; ``httpx`` does not — it reads
``verify`` only in ``Client.__init__`` and a later assignment is
accepted and silently ignored, leaving certificate verification on
when the caller asked for it off.
* Callers that need to intercept traffic (tests, and anything wanting
``httpx.MockTransport``) can substitute this factory instead of
monkey-patching a session after the fact.

Parameters
----------
verify_ssl : bool
Whether TLS certificates are verified.

Returns
-------
requests.Session
A session configured for the requested SSL policy.
"""

session = requests.Session()
session.verify = verify_ssl
return session


def build_client_resources(
*,
glpi_api_url: object,
Expand All @@ -62,12 +108,21 @@ def build_client_resources(
v1_base_url: str | None,
v1_user_token: str | None,
v1_app_token: str | None,
session_factory: SessionFactory | None = None,
) -> ClientResources:
"""Build the shared resources required by one async client instance.

The helper validates the API URL, configures SSL behaviour, builds the
OAuth token manager, and optionally instantiates the legacy v1 session
used solely by the document upload mixin.

Parameters
----------
session_factory : SessionFactory | None, optional
Override for :func:`build_http_session`, called with the resolved
``verify_ssl`` policy. This is the transport-injection seam: it lets
a caller supply a session wired to a stub transport without patching
module globals. ``None`` uses the default factory.
"""

from glpi_python_client.auth._v1_session import GLPIV1Session
Expand All @@ -83,8 +138,8 @@ def build_client_resources(
)
configure_ssl_warning_policy(verify_ssl=verify_ssl)

session = requests.Session()
session.verify = verify_ssl
factory = session_factory or build_http_session
session = factory(verify_ssl=verify_ssl)
try:
auth = GLPITokenManager(
token_url=f"{normalized_api_url}/token",
Expand Down
28 changes: 27 additions & 1 deletion glpi_python_client/clients/commons/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,32 @@
from glpi_python_client.clients.commons._constants import RequestParamValue


def response_reason(response: requests.Response) -> str:
"""Return one response's HTTP reason phrase, whatever the transport.

``requests`` spells this ``Response.reason``; ``httpx`` spells it
``Response.reason_phrase`` and has no ``reason`` attribute at all. Every
read of the phrase goes through this helper so swapping the transport
touches one function instead of every message that quotes it.

Parameters
----------
response : requests.Response
Response to read the reason phrase from. Typed against the current
transport; any object exposing either attribute works at runtime.

Returns
-------
str
The reason phrase, or ``""`` when the transport supplies none.
"""

reason = getattr(response, "reason", None)
if reason is None:
reason = getattr(response, "reason_phrase", None)
return str(reason) if reason else ""


def request_params(
params: dict[str, object] | None,
) -> dict[str, RequestParamValue] | None:
Expand Down Expand Up @@ -133,7 +159,7 @@ def finalize_request_response(
if 500 <= response.status_code < 600:
message = (
f"GLPI {method_name} {url} failed with "
f"{response.status_code} {response.reason}"
f"{response.status_code} {response_reason(response)}"
)
logger.warning(message)
raise GlpiServerError(
Expand Down
15 changes: 10 additions & 5 deletions glpi_python_client/clients/commons/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
import logging
import threading
from collections.abc import Callable
from typing import TYPE_CHECKING, TypeVar, cast
from typing import TYPE_CHECKING, Any, TypeVar

import requests
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
Expand Down Expand Up @@ -150,16 +150,21 @@ def _send_request(
self,
method: str,
url: str,
**kwargs: object,
**kwargs: Any,
) -> requests.Response:
"""Dispatch one blocking ``requests`` call.
"""Dispatch one blocking HTTP call.

The helper exists as an indirection seam so tests can stub HTTP
dispatch without monkey-patching the session attribute directly.

Dispatch goes through ``session.request(method, url, ...)`` rather
than looking up a per-verb attribute. ``request`` is the one call
shape ``requests`` and ``httpx`` agree on, and keeping the verb a
value rather than an attribute name means the transport swap does
not have to reason about dynamic attribute lookup.
"""

request_method = getattr(self._session, method)
return cast(requests.Response, request_method(url, **kwargs))
return self._session.request(method.upper(), url, **kwargs)

def _execute_request(
self,
Expand Down
51 changes: 47 additions & 4 deletions glpi_python_client/clients/commons/tests/test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,59 @@ def test_ensure_token_calls_auth_manager(monkeypatch: pytest.MonkeyPatch) -> Non
c.close()


def test_send_request_dispatches_to_matching_session_method(
def test_send_request_dispatches_through_session_request(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``_send_request`` calls the method matching the verb on the session."""
"""``_send_request`` routes through ``session.request`` with an upper verb.

The verb is passed as a *value* to ``request`` rather than being looked
up as a per-verb attribute: that is the one call shape ``requests`` and
``httpx`` share, so the transport swap does not have to reason about
dynamic attribute lookup.
"""

c = make_client()
fake = FakeResponse(status_code=200, payload={})
monkeypatch.setattr(c._session, "get", lambda url, **kw: fake)
result = c._send_request("get", "https://glpi.example.test/api.php/v2/test")
seen: dict[str, object] = {}

def _request(method: str, url: str, **kw: object) -> FakeResponse:
seen.update({"method": method, "url": url, "kw": kw})
return fake

monkeypatch.setattr(c._session, "request", _request)
result = c._send_request(
"get", "https://glpi.example.test/api.php/v2/test", timeout=30
)
assert result is fake
assert seen["method"] == "GET"
assert seen["url"] == "https://glpi.example.test/api.php/v2/test"
assert seen["kw"] == {"timeout": 30}
c.close()


def test_send_request_does_not_use_per_verb_attributes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Stubbing only ``session.get`` must no longer intercept a GET.

This pins the seam: if dispatch ever regresses to
``getattr(session, verb)`` this test fails, because the per-verb
attribute would be used again.
"""

c = make_client()
sentinel = FakeResponse(status_code=418, payload={})
monkeypatch.setattr(c._session, "get", lambda url, **kw: sentinel)
captured: dict[str, object] = {}

def _request(method: str, url: str, **kw: object) -> FakeResponse:
captured["used"] = True
return FakeResponse(status_code=200, payload={})

monkeypatch.setattr(c._session, "request", _request)
result = c._send_request("get", "https://glpi.example.test/api.php/v2/test")
assert captured.get("used") is True
assert result is not sentinel
c.close()


Expand Down
Loading
Loading