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
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 70 additions & 8 deletions src/codesphere/client.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,91 @@
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
from .resources.workspace import WorkspacesResource


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)
15 changes: 10 additions & 5 deletions src/codesphere/config.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
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",
env_prefix="CS_",
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()
48 changes: 28 additions & 20 deletions src/codesphere/http_client.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -37,31 +38,38 @@ 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(
self, method: str, endpoint: str, **kwargs: Any
) -> 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}")

Expand Down
32 changes: 11 additions & 21 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading