Skip to content
Open
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
36 changes: 31 additions & 5 deletions datamasque/client/base.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions tests/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions tests/test_ifm.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
RulesetPlanPartialUpdateRequest,
RulesetPlanUpdateRequest,
)
from datamasque.client.base import USER_AGENT
from datamasque.client.exceptions import DataMasqueApiError, DataMasqueUserError

ADMIN = "http://admin.test"
Expand Down Expand Up @@ -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)

Expand Down