Skip to content

Commit 132f230

Browse files
committed
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.
1 parent 5ed602d commit 132f230

8 files changed

Lines changed: 288 additions & 85 deletions

File tree

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,28 @@ uv add codesphere
3636

3737
## Configuration
3838

39-
Create a `.env` file in your project root:
39+
Every option can be passed directly to the constructor:
40+
41+
```python
42+
from codesphere import CodesphereSDK
43+
44+
sdk = CodesphereSDK(token="your-api-token")
45+
# or, e.g. against a different deployment:
46+
sdk = CodesphereSDK(token="your-api-token", base_url="https://my-instance.example.com/api")
47+
```
48+
49+
Alternatively, configure via environment variables (or a `.env` file in your
50+
project root). Explicit constructor arguments always win over the environment:
4051

4152
| Variable | Description | Default |
4253
|----------|-------------|---------|
4354
| `CS_TOKEN` | API token (required) ||
4455
| `CS_BASE_URL` | API base URL | `https://codesphere.com/api` |
4556

57+
If no token is provided at all, `CodesphereSDK()` raises an
58+
`AuthenticationError` at construction (importing the package never requires
59+
any environment variable).
60+
4661
## Quick Start
4762

4863
```python

pyproject.toml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,6 @@ exclude = [
8989
# Each exclusion below is temporary and owned by a refactor ticket in
9090
# plans/refactor/. When a ticket lands, its paths MUST be removed here.
9191
# Do not add new exclusions without a ticket reference.
92-
"src/codesphere/http_client.py", # ticket 03 (client config & lifecycle)
93-
"src/codesphere/config.py", # ticket 03 (client config & lifecycle)
9492
"tests/", # ticket 06 (respx-based test suite)
9593
# --- Permanent exclusions -----------------------------------------------
9694
# Examples have their own dependencies (e.g. fastapi) not installed in

src/codesphere/client.py

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,91 @@
1+
from types import TracebackType
2+
3+
import httpx
4+
from pydantic import SecretStr
5+
6+
from .config import Settings
7+
from .exceptions import AuthenticationError
18
from .http_client import APIHttpClient
29
from .resources.metadata import MetadataResource
310
from .resources.team import TeamsResource
411
from .resources.workspace import WorkspacesResource
512

613

714
class CodesphereSDK:
15+
"""Entry point to the Codesphere API.
16+
17+
Configuration is resolved per argument: explicit constructor argument,
18+
then the corresponding ``CS_*`` environment variable (``CS_TOKEN``,
19+
``CS_BASE_URL``, ``CS_CLIENT_TIMEOUT_CONNECT``/``_READ``; a local
20+
``.env`` file is honored), then the built-in default. A missing token
21+
raises :class:`~codesphere.AuthenticationError` at construction.
22+
"""
23+
824
teams: TeamsResource
925
workspaces: WorkspacesResource
1026
metadata: MetadataResource
1127

12-
def __init__(self):
13-
self._http_client = APIHttpClient()
28+
def __init__(
29+
self,
30+
token: str | SecretStr | None = None,
31+
base_url: str | None = None,
32+
timeout: httpx.Timeout | None = None,
33+
http_client: APIHttpClient | None = None,
34+
):
35+
if http_client is not None:
36+
self._http_client = http_client
37+
else:
38+
settings = Settings()
39+
40+
resolved_token = self._resolve_token(token, settings)
41+
resolved_base_url = (
42+
base_url if base_url is not None else str(settings.base_url)
43+
)
44+
resolved_timeout = (
45+
timeout
46+
if timeout is not None
47+
else httpx.Timeout(
48+
settings.client_timeout_connect,
49+
read=settings.client_timeout_read,
50+
)
51+
)
52+
self._http_client = APIHttpClient(
53+
token=resolved_token,
54+
base_url=resolved_base_url,
55+
timeout=resolved_timeout,
56+
)
57+
1458
self.teams = TeamsResource(self._http_client)
1559
self.workspaces = WorkspacesResource(self._http_client)
1660
self.metadata = MetadataResource(self._http_client)
1761

18-
async def open(self):
62+
@staticmethod
63+
def _resolve_token(token: str | SecretStr | None, settings: Settings) -> SecretStr:
64+
if token is not None:
65+
return token if isinstance(token, SecretStr) else SecretStr(token)
66+
if settings.token is not None:
67+
return settings.token
68+
raise AuthenticationError()
69+
70+
async def open(self) -> None:
1971
await self._http_client.open()
2072

21-
async def close(self):
22-
await self._http_client.close()
73+
async def close(
74+
self,
75+
exc_type: type[BaseException] | None = None,
76+
exc_val: BaseException | None = None,
77+
exc_tb: TracebackType | None = None,
78+
) -> None:
79+
await self._http_client.close(exc_type, exc_val, exc_tb)
2380

24-
async def __aenter__(self):
81+
async def __aenter__(self) -> "CodesphereSDK":
2582
await self.open()
2683
return self
2784

28-
async def __aexit__(self, exc_type, exc_val, exc_tb):
29-
await self._http_client.close(exc_type, exc_val, exc_tb)
85+
async def __aexit__(
86+
self,
87+
exc_type: type[BaseException] | None,
88+
exc_val: BaseException | None,
89+
exc_tb: TracebackType | None,
90+
) -> None:
91+
await self.close(exc_type, exc_val, exc_tb)

src/codesphere/config.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
11
from pydantic import HttpUrl, SecretStr
22
from pydantic_settings import BaseSettings, SettingsConfigDict
33

4+
DEFAULT_BASE_URL = "https://codesphere.com/api"
5+
46

57
class Settings(BaseSettings):
8+
"""Environment-driven defaults (``CS_*`` variables, optional ``.env``).
9+
10+
Constructed lazily by :class:`codesphere.CodesphereSDK` — importing the
11+
package never requires any environment variable to be set.
12+
"""
13+
614
model_config = SettingsConfigDict(
715
env_file=".env",
816
env_file_encoding="utf-8",
917
env_prefix="CS_",
1018
extra="ignore",
1119
)
1220

13-
token: SecretStr
14-
base_url: HttpUrl = "https://codesphere.com/api"
21+
token: SecretStr | None = None
22+
base_url: HttpUrl = HttpUrl(DEFAULT_BASE_URL)
1523

1624
client_timeout_connect: float = 10.0
1725
client_timeout_read: float = 30.0
18-
19-
20-
settings = Settings()

src/codesphere/http_client.py

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,35 @@
11
import logging
2-
from functools import partial
2+
from types import TracebackType
33
from typing import Any
44

55
import httpx
6-
from pydantic import BaseModel
6+
from pydantic import SecretStr
77

8-
from .config import settings
98
from .exceptions import NetworkError, TimeoutError, raise_for_status
109

1110
log = logging.getLogger(__name__)
1211

1312

1413
class APIHttpClient:
15-
def __init__(self, base_url: str = "https://codesphere.com/api"):
16-
self._token = settings.token.get_secret_value()
17-
self._base_url = base_url or str(settings.base_url)
14+
"""Thin async transport around httpx.
15+
16+
Receives its full configuration from the caller (the SDK resolves
17+
arguments, environment variables, and defaults) and owns nothing but
18+
the connection lifecycle and error mapping.
19+
"""
20+
21+
def __init__(self, *, token: SecretStr, base_url: str, timeout: httpx.Timeout):
22+
self._token = token.get_secret_value()
23+
self._base_url = base_url
1824
self._client: httpx.AsyncClient | None = None
1925

20-
self._timeout_config = httpx.Timeout(
21-
settings.client_timeout_connect, read=settings.client_timeout_read
22-
)
23-
self._client_config = {
26+
self._timeout_config = timeout
27+
self._client_config: dict[str, Any] = {
2428
"base_url": self._base_url,
2529
"headers": {"Authorization": f"Bearer {self._token}"},
2630
"timeout": self._timeout_config,
2731
}
2832

29-
for method in ["get", "post", "put", "patch", "delete"]:
30-
setattr(self, method, partial(self.request, method.upper()))
31-
3233
def _get_client(self) -> httpx.AsyncClient:
3334
if not self._client:
3435
raise RuntimeError(
@@ -37,31 +38,38 @@ def _get_client(self) -> httpx.AsyncClient:
3738
)
3839
return self._client
3940

40-
async def open(self):
41+
async def open(self) -> None:
4142
if not self._client:
4243
self._client = httpx.AsyncClient(**self._client_config)
4344
await self._client.__aenter__()
4445

45-
async def close(self, exc_type=None, exc_val=None, exc_tb=None):
46+
async def close(
47+
self,
48+
exc_type: type[BaseException] | None = None,
49+
exc_val: BaseException | None = None,
50+
exc_tb: TracebackType | None = None,
51+
) -> None:
4652
if self._client:
4753
await self._client.__aexit__(exc_type, exc_val, exc_tb)
4854
self._client = None
4955

50-
async def __aenter__(self):
56+
async def __aenter__(self) -> "APIHttpClient":
5157
await self.open()
5258
return self
5359

54-
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any):
60+
async def __aexit__(
61+
self,
62+
exc_type: type[BaseException] | None,
63+
exc_val: BaseException | None,
64+
exc_tb: TracebackType | None,
65+
) -> None:
5566
await self.close(exc_type, exc_val, exc_tb)
5667

5768
async def request(
5869
self, method: str, endpoint: str, **kwargs: Any
5970
) -> httpx.Response:
6071
client = self._get_client()
6172

62-
if "json" in kwargs and isinstance(kwargs["json"], BaseModel):
63-
kwargs["json"] = kwargs["json"].model_dump(exclude_none=True)
64-
6573
log.debug(f"Request: {method} {endpoint}")
6674
log.debug(f"Request kwargs: {kwargs}")
6775

tests/conftest.py

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from typing import Any
2-
from unittest.mock import AsyncMock, MagicMock, patch
2+
from unittest.mock import AsyncMock, MagicMock
33

44
import httpx
55
import pytest
@@ -88,33 +88,23 @@ def mock_token():
8888

8989

9090
@pytest.fixture
91-
def mock_settings(mock_token):
91+
def api_http_client(mock_token):
9292
from pydantic import SecretStr
9393

94-
mock = MagicMock()
95-
mock.token = SecretStr(mock_token)
96-
mock.base_url = "https://codesphere.com/api"
97-
mock.client_timeout_connect = 10.0
98-
mock.client_timeout_read = 30.0
99-
return mock
94+
from codesphere.http_client import APIHttpClient
10095

101-
102-
@pytest.fixture
103-
def api_http_client(mock_settings):
104-
with patch("codesphere.http_client.settings", mock_settings):
105-
from codesphere.http_client import APIHttpClient
106-
107-
client = APIHttpClient()
108-
yield client
96+
return APIHttpClient(
97+
token=SecretStr(mock_token),
98+
base_url="https://codesphere.com/api",
99+
timeout=httpx.Timeout(10.0, read=30.0),
100+
)
109101

110102

111103
@pytest.fixture
112-
def sdk_client(mock_settings):
113-
with patch("codesphere.http_client.settings", mock_settings):
114-
from codesphere.client import CodesphereSDK
104+
def sdk_client(mock_token):
105+
from codesphere.client import CodesphereSDK
115106

116-
sdk = CodesphereSDK()
117-
yield sdk
107+
return CodesphereSDK(token=mock_token)
118108

119109

120110
@pytest.fixture

0 commit comments

Comments
 (0)