diff --git a/datamasque/client/base.py b/datamasque/client/base.py index 442e867..ffd726a 100644 --- a/datamasque/client/base.py +++ b/datamasque/client/base.py @@ -1,7 +1,10 @@ import logging +import platform +import sys import warnings from contextlib import contextmanager from dataclasses import dataclass +from importlib.metadata import PackageNotFoundError, version from io import BufferedIOBase, BytesIO, TextIOBase from pathlib import Path from typing import Any, Callable, Iterator, Optional, Type, TypeVar, Union @@ -25,16 +28,39 @@ _T = TypeVar("_T", bound=BaseModel) +def _build_user_agent() -> str: + """ + Identify ourselves to the DataMasque server in access logs and audit trails. + + Default `python-requests/x.y.z` is anonymous; this surfaces the SDK name + + version, Python interpreter, and OS so operators can correlate API traffic + with a specific SDK release (e.g. when triaging a bug report). + """ + + try: + sdk_version = version("datamasque-python") + except PackageNotFoundError: + # Source checkouts without installed metadata. + sdk_version = "dev" + py = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + return f"datamasque-python/{sdk_version} (Python/{py}; {platform.system()}/{platform.release()})" + + +USER_AGENT = _build_user_agent() + + def _build_session(verify_ssl: bool) -> requests.Session: """ Build a configured `requests.Session` for one client's lifetime. - Centralises the `verify` default so every call site inherits it - automatically — keeping the per-call code free of boilerplate and removing - the risk of forgetting the flag on a new endpoint. + Centralises the `User-Agent` and `verify` defaults so every call site + inherits them automatically — keeping the per-call code free of + boilerplate and removing the risk of forgetting either flag on a new + endpoint. """ session = requests.Session() + session.headers["User-Agent"] = USER_AGENT session.verify = verify_ssl return session @@ -90,8 +116,8 @@ class BaseClient: Uses a single `requests.Session` for the lifetime of the client so that per-host TCP / TLS connections are pooled across calls (paginated list endpoints and tight polling loops benefit most). Session-wide defaults - (`verify`) are set once on construction; per-call headers like - `Authorization` are merged at request time. + (`User-Agent`, `verify`) are set once on construction; per-call headers + like `Authorization` are merged at request time. `requests.Session` is not thread-safe; do not share a client between threads. Construct one per worker. diff --git a/tests/test_base.py b/tests/test_base.py index 6d1d4b0..041880c 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -10,6 +10,7 @@ from urllib3.exceptions import InsecureRequestWarning from datamasque.client import DataMasqueClient, RunId +from datamasque.client.base import USER_AGENT from datamasque.client.exceptions import ( DataMasqueApiError, DataMasqueNotReadyError, @@ -280,6 +281,37 @@ def test_token_source_called_again_on_401_retry(): assert client.token == "Token t2" +def test_user_agent_identifies_the_sdk(client): + """ + Every outgoing request must carry an SDK-identifying User-Agent header. + + This lets operators attribute API traffic to a specific SDK release rather + than the generic `python-requests/x.y.z` default. + """ + assert USER_AGENT.startswith("datamasque-python/") + + with requests_mock.Mocker() as m: + m.get("http://test-server/api/healthcheck/", json={}) + client.healthcheck() + assert m.request_history[0].headers["User-Agent"] == USER_AGENT + + +def test_user_agent_sent_on_authenticated_requests(client): + """ + The User-Agent must be present on authenticated calls, alongside the auth token. + + It is not limited to anonymous requests. + """ + client.token = "Token test-token" + + with requests_mock.Mocker() as m: + m.get("http://test-server/api/runs/", json={"results": [], "next": None}) + client.make_request("GET", "/api/runs/") + headers = m.request_history[0].headers + assert headers["User-Agent"] == USER_AGENT + assert headers["Authorization"] == "Token test-token" + + def test_401_does_not_retry_when_requires_authorization_is_false(client): """ A 401 on an anonymous request must surface as-is, not trigger a re-auth retry. diff --git a/tests/test_ifm.py b/tests/test_ifm.py index 11f3186..c1a5929 100644 --- a/tests/test_ifm.py +++ b/tests/test_ifm.py @@ -12,6 +12,7 @@ RulesetPlanPartialUpdateRequest, RulesetPlanUpdateRequest, ) +from datamasque.client.base import USER_AGENT from datamasque.client.exceptions import DataMasqueApiError, DataMasqueUserError ADMIN = "http://admin.test" @@ -63,11 +64,24 @@ def test_authenticate_via_jwt_login(ifm_config): status_code=200, ) client.authenticate() + assert m.request_history[0].headers["User-Agent"] == USER_AGENT assert client.access_token == "ACC" assert client.refresh_token == "REF" +def test_ifm_request_carries_user_agent(authed_ifm_client): + """ + Every IFM call must identify the SDK in the User-Agent header. + + Covers login, refresh, and authenticated requests. + """ + with requests_mock.Mocker() as m: + m.get(f"{IFM}/health/", json={"status": "ok"}) + authed_ifm_client._make_request("GET", "/health/") + assert m.request_history[0].headers["User-Agent"] == USER_AGENT + + def test_authenticate_failure_raises_ifm_auth_error(ifm_config): client = DataMasqueIfmClient(ifm_config)