From 132f2306c2c013bbca18918e20669c0d0fddcc6e Mon Sep 17 00:00:00 2001 From: Datata1 Date: Sun, 12 Jul 2026 13:29:44 +0200 Subject: [PATCH] refactor(client): constructor-injectable config, lazy settings, single close path Implements plans/refactor/03-client-config-lifecycle.md: - config.py no longer instantiates Settings() at import time; token is optional on the Settings model. 'import codesphere' now succeeds without any environment variables - CodesphereSDK(token=..., base_url=..., timeout=..., http_client=...) with resolution order: explicit argument > CS_* env (.env honored) > built-in default. A missing token raises AuthenticationError at construction (previously a raw pydantic ValidationError at import; the purpose-built exception was never raised) - Fix dead CS_BASE_URL: the transport no longer defaults its own base_url, so the configured value actually applies; two SDK instances with different tokens/URLs can now coexist in one process - APIHttpClient becomes a dumb transport: full config passed in, no settings access, dynamic get/post/... setattr loop removed, BaseModel re-dump removed (serialization owned solely by execute_operation) - One close path: __aexit__ delegates to close(exc_type, exc_val, exc_tb) on both SDK and transport - New tests/test_sdk_config.py: import-without-env (empty-env subprocess), token/base_url/timeout resolution order, two-client isolation via httpx.MockTransport; conftest fixtures no longer patch a settings singleton - config.py and http_client.py removed from the ty ratchet - README documents constructor configuration Behavior change (fix): startup failure moves from import time to CodesphereSDK() construction and is now AuthenticationError. --- README.md | 17 ++++- pyproject.toml | 2 - src/codesphere/client.py | 78 +++++++++++++++++-- src/codesphere/config.py | 15 ++-- src/codesphere/http_client.py | 48 +++++++----- tests/conftest.py | 32 +++----- tests/test_client.py | 43 ++++------- tests/test_sdk_config.py | 138 ++++++++++++++++++++++++++++++++++ 8 files changed, 288 insertions(+), 85 deletions(-) create mode 100644 tests/test_sdk_config.py diff --git a/README.md b/README.md index 9512224..4c239cd 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,28 @@ uv add codesphere ## Configuration -Create a `.env` file in your project root: +Every option can be passed directly to the constructor: + +```python +from codesphere import CodesphereSDK + +sdk = CodesphereSDK(token="your-api-token") +# or, e.g. against a different deployment: +sdk = CodesphereSDK(token="your-api-token", base_url="https://my-instance.example.com/api") +``` + +Alternatively, configure via environment variables (or a `.env` file in your +project root). Explicit constructor arguments always win over the environment: | Variable | Description | Default | |----------|-------------|---------| | `CS_TOKEN` | API token (required) | – | | `CS_BASE_URL` | API base URL | `https://codesphere.com/api` | +If no token is provided at all, `CodesphereSDK()` raises an +`AuthenticationError` at construction (importing the package never requires +any environment variable). + ## Quick Start ```python diff --git a/pyproject.toml b/pyproject.toml index ea35def..ac1e55a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,8 +89,6 @@ exclude = [ # Each exclusion below is temporary and owned by a refactor ticket in # plans/refactor/. When a ticket lands, its paths MUST be removed here. # Do not add new exclusions without a ticket reference. - "src/codesphere/http_client.py", # ticket 03 (client config & lifecycle) - "src/codesphere/config.py", # ticket 03 (client config & lifecycle) "tests/", # ticket 06 (respx-based test suite) # --- Permanent exclusions ----------------------------------------------- # Examples have their own dependencies (e.g. fastapi) not installed in diff --git a/src/codesphere/client.py b/src/codesphere/client.py index d46b68f..1da6d7f 100644 --- a/src/codesphere/client.py +++ b/src/codesphere/client.py @@ -1,3 +1,10 @@ +from types import TracebackType + +import httpx +from pydantic import SecretStr + +from .config import Settings +from .exceptions import AuthenticationError from .http_client import APIHttpClient from .resources.metadata import MetadataResource from .resources.team import TeamsResource @@ -5,25 +12,80 @@ class CodesphereSDK: + """Entry point to the Codesphere API. + + Configuration is resolved per argument: explicit constructor argument, + then the corresponding ``CS_*`` environment variable (``CS_TOKEN``, + ``CS_BASE_URL``, ``CS_CLIENT_TIMEOUT_CONNECT``/``_READ``; a local + ``.env`` file is honored), then the built-in default. A missing token + raises :class:`~codesphere.AuthenticationError` at construction. + """ + teams: TeamsResource workspaces: WorkspacesResource metadata: MetadataResource - def __init__(self): - self._http_client = APIHttpClient() + def __init__( + self, + token: str | SecretStr | None = None, + base_url: str | None = None, + timeout: httpx.Timeout | None = None, + http_client: APIHttpClient | None = None, + ): + if http_client is not None: + self._http_client = http_client + else: + settings = Settings() + + resolved_token = self._resolve_token(token, settings) + resolved_base_url = ( + base_url if base_url is not None else str(settings.base_url) + ) + resolved_timeout = ( + timeout + if timeout is not None + else httpx.Timeout( + settings.client_timeout_connect, + read=settings.client_timeout_read, + ) + ) + self._http_client = APIHttpClient( + token=resolved_token, + base_url=resolved_base_url, + timeout=resolved_timeout, + ) + self.teams = TeamsResource(self._http_client) self.workspaces = WorkspacesResource(self._http_client) self.metadata = MetadataResource(self._http_client) - async def open(self): + @staticmethod + def _resolve_token(token: str | SecretStr | None, settings: Settings) -> SecretStr: + if token is not None: + return token if isinstance(token, SecretStr) else SecretStr(token) + if settings.token is not None: + return settings.token + raise AuthenticationError() + + async def open(self) -> None: await self._http_client.open() - async def close(self): - await self._http_client.close() + async def close( + self, + exc_type: type[BaseException] | None = None, + exc_val: BaseException | None = None, + exc_tb: TracebackType | None = None, + ) -> None: + await self._http_client.close(exc_type, exc_val, exc_tb) - async def __aenter__(self): + async def __aenter__(self) -> "CodesphereSDK": await self.open() return self - async def __aexit__(self, exc_type, exc_val, exc_tb): - await self._http_client.close(exc_type, exc_val, exc_tb) + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.close(exc_type, exc_val, exc_tb) diff --git a/src/codesphere/config.py b/src/codesphere/config.py index a5aed65..250e788 100644 --- a/src/codesphere/config.py +++ b/src/codesphere/config.py @@ -1,8 +1,16 @@ from pydantic import HttpUrl, SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict +DEFAULT_BASE_URL = "https://codesphere.com/api" + class Settings(BaseSettings): + """Environment-driven defaults (``CS_*`` variables, optional ``.env``). + + Constructed lazily by :class:`codesphere.CodesphereSDK` — importing the + package never requires any environment variable to be set. + """ + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", @@ -10,11 +18,8 @@ class Settings(BaseSettings): extra="ignore", ) - token: SecretStr - base_url: HttpUrl = "https://codesphere.com/api" + token: SecretStr | None = None + base_url: HttpUrl = HttpUrl(DEFAULT_BASE_URL) client_timeout_connect: float = 10.0 client_timeout_read: float = 30.0 - - -settings = Settings() diff --git a/src/codesphere/http_client.py b/src/codesphere/http_client.py index aaaa764..b8f85ff 100644 --- a/src/codesphere/http_client.py +++ b/src/codesphere/http_client.py @@ -1,34 +1,35 @@ import logging -from functools import partial +from types import TracebackType from typing import Any import httpx -from pydantic import BaseModel +from pydantic import SecretStr -from .config import settings from .exceptions import NetworkError, TimeoutError, raise_for_status log = logging.getLogger(__name__) class APIHttpClient: - def __init__(self, base_url: str = "https://codesphere.com/api"): - self._token = settings.token.get_secret_value() - self._base_url = base_url or str(settings.base_url) + """Thin async transport around httpx. + + Receives its full configuration from the caller (the SDK resolves + arguments, environment variables, and defaults) and owns nothing but + the connection lifecycle and error mapping. + """ + + def __init__(self, *, token: SecretStr, base_url: str, timeout: httpx.Timeout): + self._token = token.get_secret_value() + self._base_url = base_url self._client: httpx.AsyncClient | None = None - self._timeout_config = httpx.Timeout( - settings.client_timeout_connect, read=settings.client_timeout_read - ) - self._client_config = { + self._timeout_config = timeout + self._client_config: dict[str, Any] = { "base_url": self._base_url, "headers": {"Authorization": f"Bearer {self._token}"}, "timeout": self._timeout_config, } - for method in ["get", "post", "put", "patch", "delete"]: - setattr(self, method, partial(self.request, method.upper())) - def _get_client(self) -> httpx.AsyncClient: if not self._client: raise RuntimeError( @@ -37,21 +38,31 @@ def _get_client(self) -> httpx.AsyncClient: ) return self._client - async def open(self): + async def open(self) -> None: if not self._client: self._client = httpx.AsyncClient(**self._client_config) await self._client.__aenter__() - async def close(self, exc_type=None, exc_val=None, exc_tb=None): + async def close( + self, + exc_type: type[BaseException] | None = None, + exc_val: BaseException | None = None, + exc_tb: TracebackType | None = None, + ) -> None: if self._client: await self._client.__aexit__(exc_type, exc_val, exc_tb) self._client = None - async def __aenter__(self): + async def __aenter__(self) -> "APIHttpClient": await self.open() return self - async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any): + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: await self.close(exc_type, exc_val, exc_tb) async def request( @@ -59,9 +70,6 @@ async def request( ) -> httpx.Response: client = self._get_client() - if "json" in kwargs and isinstance(kwargs["json"], BaseModel): - kwargs["json"] = kwargs["json"].model_dump(exclude_none=True) - log.debug(f"Request: {method} {endpoint}") log.debug(f"Request kwargs: {kwargs}") diff --git a/tests/conftest.py b/tests/conftest.py index 75623ac..6adcf01 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,5 @@ from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import httpx import pytest @@ -88,33 +88,23 @@ def mock_token(): @pytest.fixture -def mock_settings(mock_token): +def api_http_client(mock_token): from pydantic import SecretStr - mock = MagicMock() - mock.token = SecretStr(mock_token) - mock.base_url = "https://codesphere.com/api" - mock.client_timeout_connect = 10.0 - mock.client_timeout_read = 30.0 - return mock + from codesphere.http_client import APIHttpClient - -@pytest.fixture -def api_http_client(mock_settings): - with patch("codesphere.http_client.settings", mock_settings): - from codesphere.http_client import APIHttpClient - - client = APIHttpClient() - yield client + return APIHttpClient( + token=SecretStr(mock_token), + base_url="https://codesphere.com/api", + timeout=httpx.Timeout(10.0, read=30.0), + ) @pytest.fixture -def sdk_client(mock_settings): - with patch("codesphere.http_client.settings", mock_settings): - from codesphere.client import CodesphereSDK +def sdk_client(mock_token): + from codesphere.client import CodesphereSDK - sdk = CodesphereSDK() - yield sdk + return CodesphereSDK(token=mock_token) @pytest.fixture diff --git a/tests/test_client.py b/tests/test_client.py index e6bfa90..418db38 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,18 +4,10 @@ import httpx import pytest -from pydantic import BaseModel from codesphere.exceptions import APIError, NotFoundError -class DummyModel(BaseModel): - """A simple Pydantic model for testing.""" - - name: str - value: int - - @dataclass class RequestTestCase: """Test case for HTTP request methods.""" @@ -31,37 +23,37 @@ class RequestTestCase: request_test_cases = [ RequestTestCase( name="GET request successful", - method="get", + method="GET", use_context_manager=True, ), RequestTestCase( - name="POST request with Pydantic model successful", - method="post", + name="POST request with dictionary successful", + method="POST", use_context_manager=True, - payload=DummyModel(name="test", value=123), + payload={"name": "test", "value": 123}, ), RequestTestCase( name="PUT request with dictionary successful", - method="put", + method="PUT", use_context_manager=True, payload={"key": "value"}, ), RequestTestCase( name="Request fails without context manager", - method="get", + method="GET", use_context_manager=False, expected_exception=RuntimeError, ), RequestTestCase( name="Request with 404 error raises NotFoundError", - method="get", + method="GET", use_context_manager=True, mock_status_code=404, expected_exception=NotFoundError, ), RequestTestCase( name="Request with 500 error raises APIError", - method="post", + method="POST", use_context_manager=True, mock_status_code=500, expected_exception=APIError, @@ -120,27 +112,22 @@ async def test_client_requests( ): if case.use_context_manager: async with api_http_client: - await getattr(api_http_client, case.method)("/test-endpoint") + await api_http_client.request(case.method, "/test-endpoint") else: - await getattr(api_http_client, case.method)("/test-endpoint") + await api_http_client.request(case.method, "/test-endpoint") return with patch("httpx.AsyncClient", return_value=mock_http_client): async with api_http_client: - request_func = getattr(api_http_client, case.method) - response = await request_func("/test-endpoint", json=case.payload) + response = await api_http_client.request( + case.method, "/test-endpoint", json=case.payload + ) mock_http_client.request.assert_awaited_once() call_args = mock_http_client.request.call_args - assert call_args.args[0] == case.method.upper() + assert call_args.args[0] == case.method assert call_args.args[1] == "/test-endpoint" - - if isinstance(case.payload, BaseModel): - assert call_args.kwargs["json"] == case.payload.model_dump( - exclude_none=True - ) - else: - assert call_args.kwargs["json"] == case.payload + assert call_args.kwargs["json"] == case.payload assert response.status_code == case.mock_status_code diff --git a/tests/test_sdk_config.py b/tests/test_sdk_config.py new file mode 100644 index 0000000..908a68d --- /dev/null +++ b/tests/test_sdk_config.py @@ -0,0 +1,138 @@ +"""Configuration resolution tests for CodesphereSDK (refactor ticket 03).""" + +import subprocess +import sys + +import httpx +import pytest + +from codesphere import AuthenticationError, CodesphereSDK + + +@pytest.fixture +def clean_env(monkeypatch, tmp_path): + """No CS_* env vars and no .env file in cwd.""" + for var in ( + "CS_TOKEN", + "CS_BASE_URL", + "CS_CLIENT_TIMEOUT_CONNECT", + "CS_CLIENT_TIMEOUT_READ", + ): + monkeypatch.delenv(var, raising=False) + monkeypatch.chdir(tmp_path) + return monkeypatch + + +def make_transport(seen: list) -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, json=[]) + + return httpx.MockTransport(handler) + + +async def fire_request(sdk: CodesphereSDK, seen: list) -> httpx.Request: + """Issue one real request through the SDK against a mock transport.""" + sdk._http_client._client_config["transport"] = make_transport(seen) + async with sdk: + await sdk.teams.list() + return seen[-1] + + +class TestImportWithoutEnvironment: + def test_import_succeeds_without_any_env(self): + """`import codesphere` must not require CS_TOKEN (ran in a subprocess + with an empty environment so no .env or exported vars leak in).""" + result = subprocess.run( + [sys.executable, "-c", "import codesphere"], + capture_output=True, + text=True, + env={}, + cwd="/tmp", + ) + assert result.returncode == 0, result.stderr + + +class TestTokenResolution: + def test_missing_token_raises_authentication_error(self, clean_env): + with pytest.raises(AuthenticationError, match="CS_TOKEN"): + CodesphereSDK() + + def test_explicit_token_argument(self, clean_env): + sdk = CodesphereSDK(token="arg-token") + assert sdk._http_client._token == "arg-token" + + def test_token_from_environment(self, clean_env): + clean_env.setenv("CS_TOKEN", "env-token") + sdk = CodesphereSDK() + assert sdk._http_client._token == "env-token" + + def test_argument_wins_over_environment(self, clean_env): + clean_env.setenv("CS_TOKEN", "env-token") + sdk = CodesphereSDK(token="arg-token") + assert sdk._http_client._token == "arg-token" + + async def test_two_clients_send_different_auth_headers(self, clean_env): + seen_a: list = [] + seen_b: list = [] + request_a = await fire_request(CodesphereSDK(token="token-a"), seen_a) + request_b = await fire_request(CodesphereSDK(token="token-b"), seen_b) + + assert request_a.headers["authorization"] == "Bearer token-a" + assert request_b.headers["authorization"] == "Bearer token-b" + + +class TestBaseUrlResolution: + async def test_explicit_base_url_argument(self, clean_env): + seen: list = [] + sdk = CodesphereSDK(token="t", base_url="http://localhost:8000/api") + request = await fire_request(sdk, seen) + assert str(request.url) == "http://localhost:8000/api/teams" + + async def test_base_url_from_environment(self, clean_env): + clean_env.setenv("CS_BASE_URL", "https://staging.example.com/api") + seen: list = [] + request = await fire_request(CodesphereSDK(token="t"), seen) + assert request.url.host == "staging.example.com" + + async def test_default_base_url(self, clean_env): + seen: list = [] + request = await fire_request(CodesphereSDK(token="t"), seen) + assert str(request.url) == "https://codesphere.com/api/teams" + + +class TestTimeoutResolution: + def test_default_timeouts(self, clean_env): + sdk = CodesphereSDK(token="t") + timeout = sdk._http_client._timeout_config + assert timeout.connect == 10.0 + assert timeout.read == 30.0 + + def test_explicit_timeout_argument(self, clean_env): + sdk = CodesphereSDK(token="t", timeout=httpx.Timeout(1.0, read=2.0)) + timeout = sdk._http_client._timeout_config + assert timeout.connect == 1.0 + assert timeout.read == 2.0 + + def test_timeouts_from_environment(self, clean_env): + clean_env.setenv("CS_CLIENT_TIMEOUT_CONNECT", "3.5") + clean_env.setenv("CS_CLIENT_TIMEOUT_READ", "7.5") + sdk = CodesphereSDK(token="t") + timeout = sdk._http_client._timeout_config + assert timeout.connect == 3.5 + assert timeout.read == 7.5 + + +class TestInjectedHttpClient: + def test_injected_client_is_used_as_is(self, clean_env): + from pydantic import SecretStr + + from codesphere.http_client import APIHttpClient + + transport = APIHttpClient( + token=SecretStr("t"), + base_url="https://example.com", + timeout=httpx.Timeout(1.0), + ) + sdk = CodesphereSDK(http_client=transport) + assert sdk._http_client is transport