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
56 changes: 56 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,62 @@ You are now ready to start developing!

---

## Adding a New Resource

Every API resource follows the same three-part pattern. To add one:

1. **Schemas** (`schemas.py`): pydantic models for the API's request/response
shapes. Derive from `CamelModel` (handles snake_case ↔ camelCase aliasing),
or from `BoundModel` if the returned entity should be able to make further
API calls itself (e.g. `workspace.delete()`).

2. **Operations** (`operations.py`): one `APIOperation` constant per endpoint.
`response_model` drives response validation *and* the static return type;
use `types.NoneType` for endpoints without a response body. Set
`input_model` to have the request payload validated before sending.

```python
from types import NoneType
from ...core.operations import APIOperation

_GET_THING_OP = APIOperation(
method="GET",
endpoint_template="/things/{thing_id}",
response_model=Thing,
)
_DELETE_THING_OP = APIOperation(
method="DELETE",
endpoint_template="/things/{thing_id}",
response_model=NoneType,
)
```

3. **Resource class** (`resources.py`): subclass `ResourceBase` and write one
typed public method per operation. Path parameters are passed explicitly
as keyword arguments; `data=` is the JSON body, `params=` the query string.

```python
from ...core import ResourceBase

class ThingsResource(ResourceBase):
async def get(self, thing_id: int) -> Thing:
return await self._execute(_GET_THING_OP, thing_id=thing_id)

async def delete(self, thing_id: int) -> None:
await self._execute(_DELETE_THING_OP, thing_id=thing_id)
```

Finally, re-export the public names in the package's `__init__.py` and, for
top-level resources, register the class in `client.py`. `uv run ty check`
must pass — the `_execute` generics give you full return-type inference.

**Public API note:** only symbols exported from the top-level `codesphere`
package (plus documented `codesphere.resources.*` re-exports) are public API.
Everything in `codesphere.core` is internal and may change between minor
versions.

---

## Testing Guidelines

We maintain two types of tests: **unit tests** and **integration tests**. When contributing, please ensure appropriate test coverage for your changes.
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/core/", # ticket 02 (typed operation dispatch)
"src/codesphere/resources/", # tickets 02 + 04 (dispatch, normalization)
"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)
Expand Down
12 changes: 6 additions & 6 deletions src/codesphere/core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from .base import CamelModel, ResourceBase
from .handler import APIRequestHandler, _APIOperationExecutor
from .operations import APIOperation, AsyncCallable, StreamOperation
from .base import BoundModel, CamelModel, ResourceBase, ResourceList
from .handler import execute_operation
from .operations import APIOperation, StreamOperation

__all__ = [
"APIOperation",
"APIRequestHandler",
"AsyncCallable",
"BoundModel",
"CamelModel",
"ResourceBase",
"ResourceList",
"StreamOperation",
"_APIOperationExecutor",
"execute_operation",
]
53 changes: 50 additions & 3 deletions src/codesphere/core/base.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,36 @@
from collections.abc import Mapping
from typing import Any, Generic, Literal, TypeVar

import yaml
from pydantic import BaseModel, ConfigDict, RootModel
from pydantic import BaseModel, ConfigDict, PrivateAttr, RootModel
from pydantic.alias_generators import to_camel

from ..http_client import APIHttpClient
from .handler import _APIOperationExecutor
from .handler import RequestData, execute_operation
from .operations import APIOperation

ModelT = TypeVar("ModelT", bound=BaseModel)
ResponseT = TypeVar("ResponseT")


class ResourceBase(_APIOperationExecutor):
class ResourceBase:
"""Base for resource and manager classes bound to an HTTP client."""

def __init__(self, http_client: APIHttpClient):
self._http_client = http_client

async def _execute(
self,
op: APIOperation[ResponseT],
*,
data: RequestData | None = None,
params: Mapping[str, Any] | None = None,
**path_params: Any,
) -> ResponseT:
return await execute_operation(
self._http_client, op, data=data, params=params, path_params=path_params
)


class CamelModel(BaseModel):
model_config = ConfigDict(
Expand Down Expand Up @@ -75,6 +92,36 @@ def to_yaml(self, *, by_alias: bool = True, exclude_none: bool = False) -> str:
)


class BoundModel(CamelModel):
"""API entity that carries the HTTP client it was fetched with.

Instances returned by the SDK get their client attached automatically,
which lets entity methods (e.g. ``workspace.delete()``) make further
API calls.
"""

_http_client: APIHttpClient | None = PrivateAttr(default=None)

def _client(self) -> APIHttpClient:
if self._http_client is None or not hasattr(self._http_client, "request"):
raise RuntimeError(
"Cannot access resource on a detached model. HTTP client missing."
)
return self._http_client

async def _execute(
self,
op: APIOperation[ResponseT],
*,
data: RequestData | None = None,
params: Mapping[str, Any] | None = None,
**path_params: Any,
) -> ResponseT:
return await execute_operation(
self._client(), op, data=data, params=params, path_params=path_params
)


class ResourceList(RootModel[list[ModelT]], Generic[ModelT]):
root: list[ModelT]

Expand Down
Loading
Loading