From 834900f4a9c1afe2459dd5ee9908c4dccfe1e830 Mon Sep 17 00:00:00 2001 From: Datata1 Date: Fri, 10 Jul 2026 20:33:17 +0200 Subject: [PATCH] refactor(core): replace __getattribute__ op magic with typed explicit dispatch Implements plans/refactor/02-typed-operation-dispatch.md: - APIOperation/StreamOperation become frozen generic dataclasses; response_model drives both validation and the static return type (types.NoneType for void endpoints, replacing type(None) sentinels) - New execute_operation(client, op, *, data, params, path_params) frees the dispatch from executor state: endpoint templates are formatted from explicit path params instead of scraping executor __dict__/model_dump - input_model is now enforced: dict payloads are validated before the request is sent (previously declared but silently ignored) - Fix falsy-body bug: data={} is now sent as a JSON body (previously dropped by a truthiness check) - ResourceBase and new BoundModel (pydantic entity base carrying the bound HTTP client) each expose a typed generic _execute; entities (Team, Workspace, Domain) migrate from the executor mixin - Delete _APIOperationExecutor, APIRequestHandler, AsyncCallable and all Field(default=) wiring; domain ops are defined once with their final response models instead of model_copy overrides; Domain.verify_status return type fixed to DomainVerificationStatus - Remove src/codesphere/core and src/codesphere/resources from the ty ratchet; uv run ty check passes over both - CONTRIBUTING.md: new-resource recipe and public-API policy Public user-facing API is unchanged; codesphere.core internals are declared private. --- CONTRIBUTING.md | 56 +++ pyproject.toml | 2 - src/codesphere/core/__init__.py | 12 +- src/codesphere/core/base.py | 53 ++- src/codesphere/core/handler.py | 228 +++++------- src/codesphere/core/operations.py | 27 +- .../resources/metadata/operations.py | 3 - .../resources/metadata/resources.py | 21 +- .../resources/team/domain/manager.py | 49 +-- .../resources/team/domain/operations.py | 22 +- .../resources/team/domain/resources.py | 41 +-- src/codesphere/resources/team/operations.py | 10 +- src/codesphere/resources/team/resources.py | 19 +- src/codesphere/resources/team/schemas.py | 26 +- .../resources/team/usage/manager.py | 37 +- .../resources/team/usage/schemas.py | 17 +- .../resources/workspace/envVars/models.py | 15 +- .../resources/workspace/envVars/operations.py | 6 +- .../resources/workspace/git/models.py | 19 +- .../resources/workspace/git/operations.py | 8 +- .../resources/workspace/landscape/models.py | 32 +- .../workspace/landscape/operations.py | 16 +- .../resources/workspace/landscape/schemas.py | 6 +- .../resources/workspace/logs/models.py | 3 + .../resources/workspace/operations.py | 6 +- .../resources/workspace/resources.py | 18 +- src/codesphere/resources/workspace/schemas.py | 25 +- tests/core/test_base.py | 8 +- tests/core/test_handler.py | 337 ++++++++++-------- tests/core/test_operations.py | 20 +- .../workspace/landscape/test_pipeline.py | 34 +- 31 files changed, 608 insertions(+), 568 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 100c0ce..ad85397 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 0f14536..ea35def 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/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) diff --git a/src/codesphere/core/__init__.py b/src/codesphere/core/__init__.py index 542d066..1b9935b 100644 --- a/src/codesphere/core/__init__.py +++ b/src/codesphere/core/__init__.py @@ -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", ] diff --git a/src/codesphere/core/base.py b/src/codesphere/core/base.py index 21c8566..dee3728 100644 --- a/src/codesphere/core/base.py +++ b/src/codesphere/core/base.py @@ -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( @@ -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] diff --git a/src/codesphere/core/handler.py b/src/codesphere/core/handler.py index 5315edf..9c59a4e 100644 --- a/src/codesphere/core/handler.py +++ b/src/codesphere/core/handler.py @@ -1,141 +1,107 @@ import logging -from functools import partial -from typing import Any, get_args, get_origin +from collections.abc import Mapping, Sequence +from types import NoneType +from typing import Any, TypeVar, cast import httpx -from pydantic import BaseModel, PrivateAttr, RootModel, ValidationError -from pydantic.fields import FieldInfo +from pydantic import BaseModel, RootModel, ValidationError from ..http_client import APIHttpClient from .operations import APIOperation log = logging.getLogger(__name__) - -class _APIOperationExecutor: - _http_client: APIHttpClient | None = PrivateAttr(default=None) - - def __getattribute__(self, name: str) -> Any: - attr = super().__getattribute__(name) - operation = None - - if isinstance(attr, FieldInfo): - if isinstance(attr.default, APIOperation): - operation = attr.default - elif isinstance(attr, APIOperation): - operation = attr - - if operation: - return partial(self._execute_operation, operation=operation) - - return attr - - async def _execute_operation(self, operation: APIOperation, **kwargs: Any) -> Any: - handler = APIRequestHandler(executor=self, operation=operation, kwargs=kwargs) - return await handler.execute() - - def validate_http_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 - - -class APIRequestHandler: - def __init__( - self, executor: _APIOperationExecutor, operation: APIOperation, kwargs: dict - ): - self.executor = executor - self.operation = operation - self.kwargs = kwargs - self.http_client = getattr(executor, "_http_client", None) - - async def execute(self) -> Any: - endpoint, request_kwargs = self._prepare_request_args() - - response = await self._make_request( - self.operation.method, endpoint, **request_kwargs +ResponseT = TypeVar("ResponseT") + +type RequestData = BaseModel | Mapping[str, Any] | Sequence[Any] + + +async def execute_operation( + client: APIHttpClient, + op: APIOperation[ResponseT], + *, + data: RequestData | None = None, + params: Mapping[str, Any] | None = None, + path_params: Mapping[str, Any] | None = None, +) -> ResponseT: + """Execute a declarative :class:`APIOperation` against the API. + + Path parameters are passed explicitly and formatted into + ``op.endpoint_template``. When ``op.input_model`` is set, ``data`` is + validated against it before the request is sent. A non-``None`` ``data`` + is always sent as the JSON body, even when empty. + """ + endpoint = _format_endpoint(op.endpoint_template, path_params or {}) + + request_kwargs: dict[str, Any] = {} + if params is not None: + request_kwargs["params"] = params + if data is not None: + request_kwargs["json"] = _serialize_payload(data, op.input_model) + + response = await client.request( + method=op.method, endpoint=endpoint, **request_kwargs + ) + return _parse_response(response, op.response_model, client, endpoint) + + +def _format_endpoint(template: str, path_params: Mapping[str, Any]) -> str: + try: + return template.format(**path_params) + except KeyError as e: + raise ValueError( + f"Missing path parameter {e.args[0]!r} for endpoint template {template!r}" + ) from e + + +def _serialize_payload(data: RequestData, input_model: type[BaseModel] | None) -> Any: + if input_model is not None: + model = ( + data if isinstance(data, input_model) else input_model.model_validate(data) ) - - return await self._parse_and_validate_response( - response, self.operation.response_model, endpoint - ) - - def _prepare_request_args(self) -> tuple[str, dict]: - format_args = {} - format_args.update(self.kwargs) - format_args.update(self.executor.__dict__) - - if isinstance(self.executor, BaseModel): - format_args.update(self.executor.model_dump()) - - format_args.update(self.kwargs) - - endpoint = self.operation.endpoint_template.format(**format_args) - - payload = None - if json_data_obj := self.kwargs.get("data"): - if isinstance(json_data_obj, BaseModel): - payload = json_data_obj.model_dump(exclude_none=True) - else: - payload = json_data_obj - - request_kwargs = {"params": self.kwargs.get("params"), "json": payload} - return endpoint, {k: v for k, v in request_kwargs.items() if v is not None} - - async def _make_request( - self, method: str, endpoint: str, **kwargs: Any - ) -> httpx.Response: - client = self.executor.validate_http_client() - return await client.request(method=method, endpoint=endpoint, **kwargs) - - def _inject_client_into_model(self, model_instance: BaseModel) -> BaseModel: - if hasattr(model_instance, "_http_client"): - model_instance._http_client = self.http_client - - if isinstance(model_instance, RootModel) and isinstance( - model_instance.root, list - ): - for item in model_instance.root: - if hasattr(item, "_http_client"): - item._http_client = self.http_client - - return model_instance - - async def _parse_and_validate_response( - self, - response: httpx.Response, - response_model: type[BaseModel] | type[list[BaseModel]] | None, - endpoint_for_logging: str, - ) -> Any: - if response_model in (None, type(None)): - return None - - try: - json_response = response.json() - log.debug(f"Validating JSON response for endpoint: {endpoint_for_logging}") - except httpx.ResponseNotRead: - log.warning(f"No JSON response body for {endpoint_for_logging}") - return None - - try: - origin = get_origin(response_model) - if origin is list or origin is list: - item_model = get_args(response_model)[0] - instances = [item_model.model_validate(item) for item in json_response] - for instance in instances: - self._inject_client_into_model(instance) - log.debug("Successfully validated response into list of models.") - return instances - else: - instance = response_model.model_validate(json_response) - self._inject_client_into_model(instance) - log.debug("Successfully validated response into single model.") - return instance - except ValidationError as e: - log.error( - f"Pydantic validation failed for {endpoint_for_logging}. Error: {e}" - ) - log.error(f"Failing JSON data: {json_response}") - raise e + return model.model_dump(exclude_none=True) + if isinstance(data, BaseModel): + return data.model_dump(exclude_none=True) + return data + + +def _parse_response( + response: httpx.Response, + response_model: type[ResponseT], + client: APIHttpClient | None, + endpoint_for_logging: str, +) -> ResponseT: + if response_model is NoneType: + return cast(ResponseT, None) + + try: + json_response = response.json() + log.debug(f"Validating JSON response for endpoint: {endpoint_for_logging}") + except httpx.ResponseNotRead: + log.warning(f"No JSON response body for {endpoint_for_logging}") + return cast(ResponseT, None) + + model_cls = cast(type[BaseModel], response_model) + try: + instance = model_cls.model_validate(json_response) + except ValidationError as e: + log.error(f"Pydantic validation failed for {endpoint_for_logging}. Error: {e}") + log.error(f"Failing JSON data: {json_response}") + raise + + _bind_client(instance, client) + log.debug("Successfully validated response for %s.", endpoint_for_logging) + return cast(ResponseT, instance) + + +def _bind_client(instance: BaseModel, client: APIHttpClient | None) -> None: + """Attach the HTTP client to returned models so they can make calls.""" + from .base import BoundModel + + if isinstance(instance, BoundModel): + instance._http_client = client + + if isinstance(instance, RootModel) and isinstance(instance.root, list): + for item in instance.root: + if isinstance(item, BoundModel): + item._http_client = client diff --git a/src/codesphere/core/operations.py b/src/codesphere/core/operations.py index 4b072a2..6cc6c1b 100644 --- a/src/codesphere/core/operations.py +++ b/src/codesphere/core/operations.py @@ -1,25 +1,32 @@ -from collections.abc import Awaitable, Callable +from dataclasses import dataclass from typing import Generic, TypeVar -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel ResponseT = TypeVar("ResponseT") -InputT = TypeVar("InputT") -EntryT = TypeVar("EntryT") +EntryT = TypeVar("EntryT", bound=BaseModel) -type AsyncCallable[_T] = Callable[[], Awaitable[_T]] +@dataclass(frozen=True, slots=True) +class APIOperation(Generic[ResponseT]): + """Declarative description of a single API endpoint. -class APIOperation(BaseModel, Generic[ResponseT, InputT]): - model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) + ``response_model`` drives both response validation and the static return + type of ``_execute``: use a pydantic model class (``ResourceList[Team]`` + included) or ``types.NoneType`` for endpoints without a response body. + ``input_model``, when set, validates the ``data`` payload before the + request is sent. + """ method: str endpoint_template: str response_model: type[ResponseT] - input_model: type[InputT] | None = None + input_model: type[BaseModel] | None = None -class StreamOperation(BaseModel, Generic[EntryT]): - model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) +@dataclass(frozen=True, slots=True) +class StreamOperation(Generic[EntryT]): + """Declarative description of an SSE streaming endpoint.""" + endpoint_template: str entry_model: type[EntryT] diff --git a/src/codesphere/resources/metadata/operations.py b/src/codesphere/resources/metadata/operations.py index a31383b..cd1e414 100644 --- a/src/codesphere/resources/metadata/operations.py +++ b/src/codesphere/resources/metadata/operations.py @@ -5,20 +5,17 @@ _LIST_DC_OP = APIOperation( method="GET", endpoint_template="/metadata/datacenters", - input_model=type(None), response_model=ResourceList[Datacenter], ) _LIST_PLANS_OP = APIOperation( method="GET", endpoint_template="/metadata/workspace-plans", - input_model=type(None), response_model=ResourceList[WsPlan], ) _LIST_IMAGES_OP = APIOperation( method="GET", endpoint_template="/metadata/workspace-base-images", - input_model=type(None), response_model=ResourceList[Image], ) diff --git a/src/codesphere/resources/metadata/resources.py b/src/codesphere/resources/metadata/resources.py index ebca89e..ee4969d 100644 --- a/src/codesphere/resources/metadata/resources.py +++ b/src/codesphere/resources/metadata/resources.py @@ -1,30 +1,17 @@ -from pydantic import Field - -from ...core import AsyncCallable, ResourceBase -from ...core.base import ResourceList +from ...core import ResourceBase from .operations import _LIST_DC_OP, _LIST_IMAGES_OP, _LIST_PLANS_OP from .schemas import Datacenter, Image, WsPlan class MetadataResource(ResourceBase): - list_datacenters_op: AsyncCallable[ResourceList[Datacenter]] = Field( - default=_LIST_DC_OP, exclude=True - ) - list_plans_op: AsyncCallable[ResourceList[WsPlan]] = Field( - default=_LIST_PLANS_OP, exclude=True - ) - list_images_op: AsyncCallable[ResourceList[Image]] = Field( - default=_LIST_IMAGES_OP, exclude=True - ) - async def list_datacenters(self) -> list[Datacenter]: - result = await self.list_datacenters_op() + result = await self._execute(_LIST_DC_OP) return result.root async def list_plans(self) -> list[WsPlan]: - result = await self.list_plans_op() + result = await self._execute(_LIST_PLANS_OP) return result.root async def list_images(self) -> list[Image]: - result = await self.list_images_op() + result = await self._execute(_LIST_IMAGES_OP) return result.root diff --git a/src/codesphere/resources/team/domain/manager.py b/src/codesphere/resources/team/domain/manager.py index 711b3a3..19f53cd 100644 --- a/src/codesphere/resources/team/domain/manager.py +++ b/src/codesphere/resources/team/domain/manager.py @@ -1,56 +1,29 @@ -from pydantic import Field - -from ....core.base import ResourceList -from ....core.handler import _APIOperationExecutor -from ....core.operations import AsyncCallable +from ....core.base import ResourceBase from ....http_client import APIHttpClient from .operations import _CREATE_OP, _GET_OP, _LIST_OP, _UPDATE_OP, _UPDATE_WS_OP from .resources import Domain from .schemas import CustomDomainConfig, DomainRouting, RoutingMap -class TeamDomainManager(_APIOperationExecutor): +class TeamDomainManager(ResourceBase): def __init__(self, http_client: APIHttpClient, team_id: int): - self._http_client = http_client + super().__init__(http_client) self.team_id = team_id - list_op: AsyncCallable[ResourceList[Domain]] = Field( - default=_LIST_OP.model_copy(update={"response_model": ResourceList[Domain]}), - exclude=True, - ) - async def list(self) -> list[Domain]: - result = await self.list_op() + result = await self._execute(_LIST_OP, team_id=self.team_id) return result.root - get_op: AsyncCallable[Domain] = Field( - default=_GET_OP.model_copy(update={"response_model": Domain}), - exclude=True, - ) - async def get(self, name: str) -> Domain: - return await self.get_op(name=name) - - create_op: AsyncCallable[Domain] = Field( - default=_CREATE_OP.model_copy(update={"response_model": Domain}), - exclude=True, - ) + return await self._execute(_GET_OP, team_id=self.team_id, name=name) async def create(self, name: str) -> Domain: - return await self.create_op(name=name) - - update_op: AsyncCallable[Domain] = Field( - default=_UPDATE_OP.model_copy(update={"response_model": Domain}), - exclude=True, - ) + return await self._execute(_CREATE_OP, team_id=self.team_id, name=name) async def update(self, name: str, config: CustomDomainConfig) -> Domain: - return await self.update_op(name=name, data=config) - - update_ws_op: AsyncCallable[Domain] = Field( - default=_UPDATE_WS_OP.model_copy(update={"response_model": Domain}), - exclude=True, - ) + return await self._execute( + _UPDATE_OP, team_id=self.team_id, name=name, data=config + ) async def update_workspace_connections( self, name: str, connections: DomainRouting | RoutingMap @@ -58,4 +31,6 @@ async def update_workspace_connections( payload = ( connections.root if isinstance(connections, DomainRouting) else connections ) - return await self.update_ws_op(name=name, data=payload) + return await self._execute( + _UPDATE_WS_OP, team_id=self.team_id, name=name, data=payload + ) diff --git a/src/codesphere/resources/team/domain/operations.py b/src/codesphere/resources/team/domain/operations.py index 2f438dc..efcab50 100644 --- a/src/codesphere/resources/team/domain/operations.py +++ b/src/codesphere/resources/team/domain/operations.py @@ -1,41 +1,39 @@ +from types import NoneType + from ....core.base import ResourceList from ....core.operations import APIOperation -from .schemas import ( - CustomDomainConfig, - DomainBase, - DomainVerificationStatus, -) +from .resources import Domain +from .schemas import CustomDomainConfig, DomainVerificationStatus _LIST_OP = APIOperation( method="GET", endpoint_template="/domains/team/{team_id}", - response_model=ResourceList[DomainBase], + response_model=ResourceList[Domain], ) _GET_OP = APIOperation( method="GET", endpoint_template="/domains/team/{team_id}/domain/{name}", - response_model=DomainBase, + response_model=Domain, ) _CREATE_OP = APIOperation( method="POST", endpoint_template="/domains/team/{team_id}/domain/{name}", - response_model=DomainBase, + response_model=Domain, ) _UPDATE_OP = APIOperation( method="PATCH", endpoint_template="/domains/team/{team_id}/domain/{name}", input_model=CustomDomainConfig, - response_model=DomainBase, + response_model=Domain, ) _UPDATE_WS_OP = APIOperation( method="PUT", endpoint_template="/domains/team/{team_id}/domain/{name}/workspace-connections", - input_model=None, - response_model=DomainBase, + response_model=Domain, ) _VERIFY_OP = APIOperation( @@ -47,5 +45,5 @@ _DELETE_OP = APIOperation( method="DELETE", endpoint_template="/domains/team/{team_id}/domain/{name}", - response_model=type(None), + response_model=NoneType, ) diff --git a/src/codesphere/resources/team/domain/resources.py b/src/codesphere/resources/team/domain/resources.py index da544d5..adff622 100644 --- a/src/codesphere/resources/team/domain/resources.py +++ b/src/codesphere/resources/team/domain/resources.py @@ -2,17 +2,8 @@ import logging -from pydantic import Field - -from ....core.handler import _APIOperationExecutor -from ....core.operations import AsyncCallable +from ....core.base import BoundModel from ....utils import update_model_fields -from .operations import ( - _DELETE_OP, - _UPDATE_OP, - _UPDATE_WS_OP, - _VERIFY_OP, -) from .schemas import ( CustomDomainConfig, DomainBase, @@ -24,34 +15,38 @@ log = logging.getLogger(__name__) -class Domain(DomainBase, _APIOperationExecutor): - update_op: AsyncCallable[None] = Field(default=_UPDATE_OP, exclude=True) - update_workspace_connections_op: AsyncCallable[None] = Field( - default=_UPDATE_WS_OP, exclude=True - ) - verify_domain_op: AsyncCallable[None] = Field(default=_VERIFY_OP, exclude=True) - delete_domain_op: AsyncCallable[None] = Field(default=_DELETE_OP, exclude=True) - +class Domain(DomainBase, BoundModel): async def update(self, data: CustomDomainConfig) -> Domain: - payload = data.model_dump(exclude_unset=True, by_alias=True) - response = await self.update_op(data=payload) + from .operations import _UPDATE_OP + + response = await self._execute( + _UPDATE_OP, team_id=self.team_id, name=self.name, data=data + ) update_model_fields(target=self, source=response) return response async def update_workspace_connections( self, connections: DomainRouting | RoutingMap ) -> Domain: + from .operations import _UPDATE_WS_OP + payload = ( connections.root if isinstance(connections, DomainRouting) else connections ) - response = await self.update_workspace_connections_op(data=payload) + response = await self._execute( + _UPDATE_WS_OP, team_id=self.team_id, name=self.name, data=payload + ) update_model_fields(target=self, source=response) return response async def verify_status(self) -> DomainVerificationStatus: - response = await self.verify_domain_op() + from .operations import _VERIFY_OP + + response = await self._execute(_VERIFY_OP, team_id=self.team_id, name=self.name) update_model_fields(target=self.domain_verification_status, source=response) return response async def delete(self) -> None: - await self.delete_domain_op() + from .operations import _DELETE_OP + + await self._execute(_DELETE_OP, team_id=self.team_id, name=self.name) diff --git a/src/codesphere/resources/team/operations.py b/src/codesphere/resources/team/operations.py index 83e6455..489da74 100644 --- a/src/codesphere/resources/team/operations.py +++ b/src/codesphere/resources/team/operations.py @@ -1,3 +1,5 @@ +from types import NoneType + from ...core.base import ResourceList from ...core.operations import APIOperation from .schemas import Team, TeamCreate @@ -5,14 +7,12 @@ _LIST_TEAMS_OP = APIOperation( method="GET", endpoint_template="/teams", - input_model=type(None), response_model=ResourceList[Team], ) _GET_TEAM_OP = APIOperation( method="GET", endpoint_template="/teams/{team_id}", - input_model=type(None), response_model=Team, ) @@ -22,3 +22,9 @@ input_model=TeamCreate, response_model=Team, ) + +_DELETE_TEAM_OP = APIOperation( + method="DELETE", + endpoint_template="/teams/{team_id}", + response_model=NoneType, +) diff --git a/src/codesphere/resources/team/resources.py b/src/codesphere/resources/team/resources.py index 2bc8097..ef69221 100644 --- a/src/codesphere/resources/team/resources.py +++ b/src/codesphere/resources/team/resources.py @@ -1,7 +1,4 @@ -from pydantic import Field - -from ...core import AsyncCallable, ResourceBase -from ...core.base import ResourceList +from ...core import ResourceBase from .operations import ( _CREATE_TEAM_OP, _GET_TEAM_OP, @@ -11,20 +8,12 @@ class TeamsResource(ResourceBase): - list_team_op: AsyncCallable[ResourceList[Team]] = Field( - default=_LIST_TEAMS_OP, exclude=True - ) - async def list(self) -> list[Team]: - result = await self.list_team_op() + result = await self._execute(_LIST_TEAMS_OP) return result.root - get_team_op: AsyncCallable[Team] = Field(default=_GET_TEAM_OP, exclude=True) - async def get(self, team_id: int) -> Team: - return await self.get_team_op(team_id=team_id) - - create_team_op: AsyncCallable[Team] = Field(default=_CREATE_TEAM_OP, exclude=True) + return await self._execute(_GET_TEAM_OP, team_id=team_id) async def create(self, payload: TeamCreate) -> Team: - return await self.create_team_op(data=payload) + return await self._execute(_CREATE_TEAM_OP, data=payload) diff --git a/src/codesphere/resources/team/schemas.py b/src/codesphere/resources/team/schemas.py index 977b55b..5c3100e 100644 --- a/src/codesphere/resources/team/schemas.py +++ b/src/codesphere/resources/team/schemas.py @@ -2,10 +2,7 @@ from functools import cached_property -from pydantic import Field - -from ...core import APIOperation, AsyncCallable, _APIOperationExecutor -from ...core.base import CamelModel +from ...core.base import BoundModel, CamelModel from .domain.manager import TeamDomainManager from .usage.manager import TeamUsageManager @@ -26,23 +23,16 @@ class TeamBase(CamelModel): role: int | None = None -class Team(TeamBase, _APIOperationExecutor): - delete: AsyncCallable[None] - delete = Field( - default=APIOperation( - method="DELETE", - endpoint_template="/teams/{id}", - response_model=type(None), - ), - exclude=True, - ) +class Team(TeamBase, BoundModel): + async def delete(self) -> None: + from .operations import _DELETE_TEAM_OP + + await self._execute(_DELETE_TEAM_OP, team_id=self.id) @cached_property def domains(self) -> TeamDomainManager: - http_client = self.validate_http_client() - return TeamDomainManager(http_client, team_id=self.id) + return TeamDomainManager(self._client(), team_id=self.id) @cached_property def usage(self) -> TeamUsageManager: - http_client = self.validate_http_client() - return TeamUsageManager(http_client, team_id=self.id) + return TeamUsageManager(self._client(), team_id=self.id) diff --git a/src/codesphere/resources/team/usage/manager.py b/src/codesphere/resources/team/usage/manager.py index e01d7e9..e46b5ce 100644 --- a/src/codesphere/resources/team/usage/manager.py +++ b/src/codesphere/resources/team/usage/manager.py @@ -4,10 +4,7 @@ from datetime import datetime from functools import partial -from pydantic import Field - -from ....core.handler import _APIOperationExecutor -from ....core.operations import AsyncCallable +from ....core.base import ResourceBase from ....http_client import APIHttpClient from .operations import _GET_LANDSCAPE_EVENTS_OP, _GET_LANDSCAPE_SUMMARY_OP from .schemas import ( @@ -18,7 +15,7 @@ ) -class TeamUsageManager(_APIOperationExecutor): +class TeamUsageManager(ResourceBase): """Manager for team usage history operations. Provides access to landscape service usage summaries and events with @@ -48,13 +45,9 @@ class TeamUsageManager(_APIOperationExecutor): """ def __init__(self, http_client: APIHttpClient, team_id: int): - self._http_client = http_client + super().__init__(http_client) self.team_id = team_id - get_landscape_summary_op: AsyncCallable[UsageSummaryResponse] = Field( - default=_GET_LANDSCAPE_SUMMARY_OP, exclude=True - ) - async def get_landscape_summary( self, begin_date: datetime | str, @@ -72,11 +65,13 @@ async def get_landscape_summary( "limit": min(max(1, limit), 100), "offset": max(0, offset), } - result: UsageSummaryResponse = await self.get_landscape_summary_op( - params=params + result = await self._execute( + _GET_LANDSCAPE_SUMMARY_OP, team_id=self.team_id, params=params ) - result._refresh_op = partial(self.get_landscape_summary_op) + result._refresh_op = partial( + self._execute, _GET_LANDSCAPE_SUMMARY_OP, team_id=self.team_id + ) result._team_id = self.team_id return result @@ -106,10 +101,6 @@ async def iter_all_landscape_summary( offset += page_size - get_landscape_events_op: AsyncCallable[UsageEventsResponse] = Field( - default=_GET_LANDSCAPE_EVENTS_OP, exclude=True - ) - async def get_landscape_events( self, resource_id: str, @@ -128,12 +119,18 @@ async def get_landscape_events( "limit": min(max(1, limit), 100), "offset": max(0, offset), } - result: UsageEventsResponse = await self.get_landscape_events_op( - resource_id=resource_id, params=params + result = await self._execute( + _GET_LANDSCAPE_EVENTS_OP, + team_id=self.team_id, + resource_id=resource_id, + params=params, ) result._refresh_op = partial( - self.get_landscape_events_op, resource_id=resource_id + self._execute, + _GET_LANDSCAPE_EVENTS_OP, + team_id=self.team_id, + resource_id=resource_id, ) result._team_id = self.team_id result._resource_id = resource_id diff --git a/src/codesphere/resources/team/usage/schemas.py b/src/codesphere/resources/team/usage/schemas.py index 0bbbf4d..76b491a 100644 --- a/src/codesphere/resources/team/usage/schemas.py +++ b/src/codesphere/resources/team/usage/schemas.py @@ -1,14 +1,13 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from datetime import datetime from enum import Enum -from typing import Generic, TypeVar +from typing import Any, Generic, TypeVar from pydantic import Field from ....core.base import CamelModel -from ....core.handler import _APIOperationExecutor -from ....core.operations import AsyncCallable class ServiceAction(str, Enum): @@ -72,11 +71,9 @@ def total_pages(self) -> int: return (self.total_items + current_limit - 1) // current_limit -class UsageSummaryResponse( - PaginatedResponse[LandscapeServiceSummary], _APIOperationExecutor -): +class UsageSummaryResponse(PaginatedResponse[LandscapeServiceSummary]): summary: list[LandscapeServiceSummary] = Field(default_factory=list) - _refresh_op: AsyncCallable[UsageSummaryResponse] | None = None + _refresh_op: Callable[..., Awaitable[Any]] | None = None _team_id: int | None = None @property @@ -102,12 +99,10 @@ async def refresh(self) -> UsageSummaryResponse: return self -class UsageEventsResponse( - PaginatedResponse[LandscapeServiceEvent], _APIOperationExecutor -): +class UsageEventsResponse(PaginatedResponse[LandscapeServiceEvent]): events: list[LandscapeServiceEvent] = Field(default_factory=list) - _refresh_op: AsyncCallable[UsageEventsResponse] | None = None + _refresh_op: Callable[..., Awaitable[Any]] | None = None _team_id: int | None = None _resource_id: str | None = None diff --git a/src/codesphere/resources/workspace/envVars/models.py b/src/codesphere/resources/workspace/envVars/models.py index 36d90a1..1166c9e 100644 --- a/src/codesphere/resources/workspace/envVars/models.py +++ b/src/codesphere/resources/workspace/envVars/models.py @@ -2,8 +2,7 @@ import logging -from ....core.base import ResourceList -from ....core.handler import _APIOperationExecutor +from ....core.base import ResourceBase, ResourceList from ....http_client import APIHttpClient from .operations import _BULK_DELETE_OP, _BULK_SET_OP, _GET_OP from .schemas import EnvVar @@ -11,18 +10,18 @@ log = logging.getLogger(__name__) -class WorkspaceEnvVarManager(_APIOperationExecutor): +class WorkspaceEnvVarManager(ResourceBase): def __init__(self, http_client: APIHttpClient, workspace_id: int): - self._http_client = http_client + super().__init__(http_client) self._workspace_id = workspace_id self.id = workspace_id - async def get(self) -> list[EnvVar]: - return await self._execute_operation(_GET_OP) + async def get(self) -> ResourceList[EnvVar]: + return await self._execute(_GET_OP, id=self.id) async def set(self, env_vars: ResourceList[EnvVar] | list[dict[str, str]]) -> None: payload = ResourceList[EnvVar].model_validate(env_vars) - await self._execute_operation(_BULK_SET_OP, data=payload.model_dump()) + await self._execute(_BULK_SET_OP, id=self.id, data=payload) async def delete(self, items: list[str] | ResourceList[EnvVar]) -> None: if not items: @@ -38,4 +37,4 @@ async def delete(self, items: list[str] | ResourceList[EnvVar]) -> None: payload.append(item["name"]) if payload: - await self._execute_operation(_BULK_DELETE_OP, data=payload) + await self._execute(_BULK_DELETE_OP, id=self.id, data=payload) diff --git a/src/codesphere/resources/workspace/envVars/operations.py b/src/codesphere/resources/workspace/envVars/operations.py index 35eef50..b4063e6 100644 --- a/src/codesphere/resources/workspace/envVars/operations.py +++ b/src/codesphere/resources/workspace/envVars/operations.py @@ -1,3 +1,5 @@ +from types import NoneType + from ....core.base import ResourceList from ....core.operations import APIOperation from .schemas import EnvVar @@ -11,11 +13,11 @@ _BULK_SET_OP = APIOperation( method="PUT", endpoint_template="/workspaces/{id}/env-vars", - response_model=type(None), + response_model=NoneType, ) _BULK_DELETE_OP = APIOperation( method="DELETE", endpoint_template="/workspaces/{id}/env-vars", - response_model=type(None), + response_model=NoneType, ) diff --git a/src/codesphere/resources/workspace/git/models.py b/src/codesphere/resources/workspace/git/models.py index 4c34879..97a5c0c 100644 --- a/src/codesphere/resources/workspace/git/models.py +++ b/src/codesphere/resources/workspace/git/models.py @@ -2,7 +2,7 @@ import logging -from ....core.handler import _APIOperationExecutor +from ....core.base import ResourceBase from ....http_client import APIHttpClient from .operations import ( _GET_HEAD_OP, @@ -15,16 +15,16 @@ log = logging.getLogger(__name__) -class WorkspaceGitManager(_APIOperationExecutor): +class WorkspaceGitManager(ResourceBase): """Manager for git operations on a workspace.""" def __init__(self, http_client: APIHttpClient, workspace_id: int): - self._http_client = http_client + super().__init__(http_client) self._workspace_id = workspace_id self.id = workspace_id async def get_head(self) -> GitHead: - return await self._execute_operation(_GET_HEAD_OP) + return await self._execute(_GET_HEAD_OP, id=self.id) async def pull( self, @@ -32,10 +32,13 @@ async def pull( branch: str | None = None, ) -> None: if remote is not None and branch is not None: - await self._execute_operation( - _PULL_WITH_REMOTE_AND_BRANCH_OP, remote=remote, branch=branch + await self._execute( + _PULL_WITH_REMOTE_AND_BRANCH_OP, + id=self.id, + remote=remote, + branch=branch, ) elif remote is not None: - await self._execute_operation(_PULL_WITH_REMOTE_OP, remote=remote) + await self._execute(_PULL_WITH_REMOTE_OP, id=self.id, remote=remote) else: - await self._execute_operation(_PULL_OP) + await self._execute(_PULL_OP, id=self.id) diff --git a/src/codesphere/resources/workspace/git/operations.py b/src/codesphere/resources/workspace/git/operations.py index be12694..34dc25e 100644 --- a/src/codesphere/resources/workspace/git/operations.py +++ b/src/codesphere/resources/workspace/git/operations.py @@ -1,5 +1,7 @@ from __future__ import annotations +from types import NoneType + from ....core.operations import APIOperation from .schema import GitHead @@ -12,17 +14,17 @@ _PULL_OP = APIOperation( method="POST", endpoint_template="/workspaces/{id}/git/pull", - response_model=type(None), + response_model=NoneType, ) _PULL_WITH_REMOTE_OP = APIOperation( method="POST", endpoint_template="/workspaces/{id}/git/pull/{remote}", - response_model=type(None), + response_model=NoneType, ) _PULL_WITH_REMOTE_AND_BRANCH_OP = APIOperation( method="POST", endpoint_template="/workspaces/{id}/git/pull/{remote}/{branch}", - response_model=type(None), + response_model=NoneType, ) diff --git a/src/codesphere/resources/workspace/landscape/models.py b/src/codesphere/resources/workspace/landscape/models.py index c29fadf..8b2b556 100644 --- a/src/codesphere/resources/workspace/landscape/models.py +++ b/src/codesphere/resources/workspace/landscape/models.py @@ -5,8 +5,7 @@ import re from typing import TYPE_CHECKING -from ....core.base import ResourceList -from ....core.handler import _APIOperationExecutor +from ....core.base import ResourceBase, ResourceList from ....http_client import APIHttpClient from .operations import ( _DEPLOY_OP, @@ -49,9 +48,9 @@ def _profile_filename(name: str) -> str: return f"ci.{name}.yml" -class WorkspaceLandscapeManager(_APIOperationExecutor): +class WorkspaceLandscapeManager(ResourceBase): def __init__(self, http_client: APIHttpClient, workspace_id: int): - self._http_client = http_client + super().__init__(http_client) self._workspace_id = workspace_id self.id = workspace_id @@ -59,8 +58,8 @@ async def _run_command(self, command: str) -> CommandOutput: from ..operations import _EXECUTE_COMMAND_OP from ..schemas import CommandInput - return await self._execute_operation( - _EXECUTE_COMMAND_OP, data=CommandInput(command=command) + return await self._execute( + _EXECUTE_COMMAND_OP, id=self.id, data=CommandInput(command=command) ) async def list_profiles(self) -> ResourceList[Profile]: @@ -94,15 +93,15 @@ async def delete_profile(self, name: str) -> None: async def deploy(self, profile: str | None = None) -> None: if profile is not None: _validate_profile_name(profile) - await self._execute_operation(_DEPLOY_WITH_PROFILE_OP, profile=profile) + await self._execute(_DEPLOY_WITH_PROFILE_OP, id=self.id, profile=profile) else: - await self._execute_operation(_DEPLOY_OP) + await self._execute(_DEPLOY_OP, id=self.id) async def teardown(self) -> None: - await self._execute_operation(_TEARDOWN_OP) + await self._execute(_TEARDOWN_OP, id=self.id) async def scale(self, services: dict[str, int]) -> None: - await self._execute_operation(_SCALE_OP, data=services) + await self._execute(_SCALE_OP, id=self.id, data=services) async def start_stage( self, @@ -114,23 +113,26 @@ async def start_stage( if profile is not None: _validate_profile_name(profile) - await self._execute_operation( - _START_PIPELINE_STAGE_WITH_PROFILE_OP, stage=stage, profile=profile + await self._execute( + _START_PIPELINE_STAGE_WITH_PROFILE_OP, + id=self.id, + stage=stage, + profile=profile, ) else: - await self._execute_operation(_START_PIPELINE_STAGE_OP, stage=stage) + await self._execute(_START_PIPELINE_STAGE_OP, id=self.id, stage=stage) async def stop_stage(self, stage: PipelineStage | str) -> None: if isinstance(stage, PipelineStage): stage = stage.value - await self._execute_operation(_STOP_PIPELINE_STAGE_OP, stage=stage) + await self._execute(_STOP_PIPELINE_STAGE_OP, id=self.id, stage=stage) async def get_stage_status(self, stage: PipelineStage | str) -> PipelineStatusList: if isinstance(stage, PipelineStage): stage = stage.value - return await self._execute_operation(_GET_PIPELINE_STATUS_OP, stage=stage) + return await self._execute(_GET_PIPELINE_STATUS_OP, id=self.id, stage=stage) async def wait_for_stage( self, diff --git a/src/codesphere/resources/workspace/landscape/operations.py b/src/codesphere/resources/workspace/landscape/operations.py index 9d2a53a..858b818 100644 --- a/src/codesphere/resources/workspace/landscape/operations.py +++ b/src/codesphere/resources/workspace/landscape/operations.py @@ -1,46 +1,48 @@ +from types import NoneType + from ....core.operations import APIOperation from .schemas import PipelineStatusList _DEPLOY_OP = APIOperation( method="POST", endpoint_template="/workspaces/{id}/landscape/deploy", - response_model=type(None), + response_model=NoneType, ) _DEPLOY_WITH_PROFILE_OP = APIOperation( method="POST", endpoint_template="/workspaces/{id}/landscape/deploy/{profile}", - response_model=type(None), + response_model=NoneType, ) _TEARDOWN_OP = APIOperation( method="DELETE", endpoint_template="/workspaces/{id}/landscape/teardown", - response_model=type(None), + response_model=NoneType, ) _SCALE_OP = APIOperation( method="PATCH", endpoint_template="/workspaces/{id}/landscape/scale", - response_model=type(None), + response_model=NoneType, ) _START_PIPELINE_STAGE_OP = APIOperation( method="POST", endpoint_template="/workspaces/{id}/pipeline/{stage}/start", - response_model=type(None), + response_model=NoneType, ) _START_PIPELINE_STAGE_WITH_PROFILE_OP = APIOperation( method="POST", endpoint_template="/workspaces/{id}/pipeline/{stage}/start/{profile}", - response_model=type(None), + response_model=NoneType, ) _STOP_PIPELINE_STAGE_OP = APIOperation( method="POST", endpoint_template="/workspaces/{id}/pipeline/{stage}/stop", - response_model=type(None), + response_model=NoneType, ) _GET_PIPELINE_STATUS_OP = APIOperation( diff --git a/src/codesphere/resources/workspace/landscape/schemas.py b/src/codesphere/resources/workspace/landscape/schemas.py index 42b1789..9f9653d 100644 --- a/src/codesphere/resources/workspace/landscape/schemas.py +++ b/src/codesphere/resources/workspace/landscape/schemas.py @@ -108,8 +108,10 @@ class ProfileConfig(CamelModel): default_factory=dict ) - def to_yaml(self, *, exclude_none: bool = True) -> str: - data = self.model_dump(by_alias=True, exclude_none=exclude_none, mode="json") + def to_yaml(self, *, by_alias: bool = True, exclude_none: bool = True) -> str: + data = self.model_dump( + by_alias=by_alias, exclude_none=exclude_none, mode="json" + ) return yaml.safe_dump( data, default_flow_style=False, allow_unicode=True, sort_keys=False ) diff --git a/src/codesphere/resources/workspace/logs/models.py b/src/codesphere/resources/workspace/logs/models.py index 1837570..fae0c8f 100644 --- a/src/codesphere/resources/workspace/logs/models.py +++ b/src/codesphere/resources/workspace/logs/models.py @@ -73,6 +73,9 @@ async def _iterate(self) -> AsyncIterator[LogEntry]: yield entry async def _parse_sse_stream(self) -> AsyncIterator[LogEntry]: + if self._response is None: + raise RuntimeError("LogStream must be used as async context manager") + event_type: str | None = None data_buffer: list[str] = [] diff --git a/src/codesphere/resources/workspace/operations.py b/src/codesphere/resources/workspace/operations.py index 181b268..42c57b1 100644 --- a/src/codesphere/resources/workspace/operations.py +++ b/src/codesphere/resources/workspace/operations.py @@ -1,5 +1,7 @@ from __future__ import annotations +from types import NoneType + from ...core.base import ResourceList from ...core.operations import APIOperation from .schemas import ( @@ -32,13 +34,13 @@ _UPDATE_OP = APIOperation( method="PATCH", endpoint_template="/workspaces/{id}", - response_model=type(None), + response_model=NoneType, ) _DELETE_OP = APIOperation( method="DELETE", endpoint_template="/workspaces/{id}", - response_model=type(None), + response_model=NoneType, ) _GET_STATUS_OP = APIOperation( diff --git a/src/codesphere/resources/workspace/resources.py b/src/codesphere/resources/workspace/resources.py index 6a4658a..4847c41 100644 --- a/src/codesphere/resources/workspace/resources.py +++ b/src/codesphere/resources/workspace/resources.py @@ -1,8 +1,4 @@ -from pydantic import Field - from ...core import ResourceBase -from ...core.base import ResourceList -from ...core.operations import AsyncCallable from ...exceptions import ValidationError from .operations import ( _CREATE_OP, @@ -13,24 +9,16 @@ class WorkspacesResource(ResourceBase): - list_by_team_op: AsyncCallable[ResourceList[Workspace]] = Field( - default=_LIST_BY_TEAM_OP, exclude=True - ) - async def list(self, team_id: int) -> list[Workspace]: if team_id <= 0: raise ValidationError("team_id must be a positive integer") - result = await self.list_by_team_op(team_id=team_id) + result = await self._execute(_LIST_BY_TEAM_OP, team_id=team_id) return result.root - get_op: AsyncCallable[Workspace] = Field(default=_GET_OP, exclude=True) - async def get(self, workspace_id: int) -> Workspace: if workspace_id <= 0: raise ValidationError("workspace_id must be a positive integer") - return await self.get_op(workspace_id=workspace_id) - - create_op: AsyncCallable[Workspace] = Field(default=_CREATE_OP, exclude=True) + return await self._execute(_GET_OP, workspace_id=workspace_id) async def create(self, payload: WorkspaceCreate) -> Workspace: - return await self.create_op(data=payload) + return await self._execute(_CREATE_OP, data=payload) diff --git a/src/codesphere/resources/workspace/schemas.py b/src/codesphere/resources/workspace/schemas.py index 7c61642..67fcb9e 100644 --- a/src/codesphere/resources/workspace/schemas.py +++ b/src/codesphere/resources/workspace/schemas.py @@ -4,8 +4,7 @@ import logging from functools import cached_property -from ...core import _APIOperationExecutor -from ...core.base import CamelModel +from ...core.base import BoundModel, CamelModel from ...utils import update_model_fields from .envVars import EnvVar, WorkspaceEnvVarManager from .git import WorkspaceGitManager @@ -75,22 +74,22 @@ class WorkspaceStatus(CamelModel): is_running: bool -class Workspace(WorkspaceBase, _APIOperationExecutor): +class Workspace(WorkspaceBase, BoundModel): async def update(self, data: WorkspaceUpdate) -> None: from .operations import _UPDATE_OP - await self._execute_operation(_UPDATE_OP, data=data) + await self._execute(_UPDATE_OP, id=self.id, data=data) update_model_fields(target=self, source=data) async def delete(self) -> None: from .operations import _DELETE_OP - await self._execute_operation(_DELETE_OP) + await self._execute(_DELETE_OP, id=self.id) async def get_status(self) -> WorkspaceStatus: from .operations import _GET_STATUS_OP - return await self._execute_operation(_GET_STATUS_OP) + return await self._execute(_GET_STATUS_OP, id=self.id) async def wait_until_running( self, @@ -126,24 +125,21 @@ async def execute_command( from .operations import _EXECUTE_COMMAND_OP command_data = CommandInput(command=command, env=env) - return await self._execute_operation(_EXECUTE_COMMAND_OP, data=command_data) + return await self._execute(_EXECUTE_COMMAND_OP, id=self.id, data=command_data) @cached_property def env_vars(self) -> WorkspaceEnvVarManager: - http_client = self.validate_http_client() - return WorkspaceEnvVarManager(http_client, workspace_id=self.id) + return WorkspaceEnvVarManager(self._client(), workspace_id=self.id) @cached_property def landscape(self) -> WorkspaceLandscapeManager: """Manager for landscape operations (Multi Server Deployments).""" - http_client = self.validate_http_client() - return WorkspaceLandscapeManager(http_client, workspace_id=self.id) + return WorkspaceLandscapeManager(self._client(), workspace_id=self.id) @cached_property def git(self) -> WorkspaceGitManager: """Manager for git operations (head, pull).""" - http_client = self.validate_http_client() - return WorkspaceGitManager(http_client, workspace_id=self.id) + return WorkspaceGitManager(self._client(), workspace_id=self.id) @cached_property def logs(self) -> WorkspaceLogManager: @@ -162,5 +158,4 @@ def logs(self) -> WorkspaceLogManager: entries = await workspace.logs.collect(stage="test", step=1) ``` """ - http_client = self.validate_http_client() - return WorkspaceLogManager(http_client, workspace_id=self.id) + return WorkspaceLogManager(self._client(), workspace_id=self.id) diff --git a/tests/core/test_base.py b/tests/core/test_base.py index a2dc6da..b36fdac 100644 --- a/tests/core/test_base.py +++ b/tests/core/test_base.py @@ -372,8 +372,6 @@ def test_initialization_with_http_client(self): assert resource._http_client is mock_client - def test_inherits_from_api_operation_executor(self): - """ResourceBase should inherit from _APIOperationExecutor.""" - from codesphere.core.handler import _APIOperationExecutor - - assert issubclass(ResourceBase, _APIOperationExecutor) + def test_has_execute_method(self): + """ResourceBase should expose the typed _execute dispatch method.""" + assert callable(getattr(ResourceBase, "_execute", None)) diff --git a/tests/core/test_handler.py b/tests/core/test_handler.py index 79c8f0c..3a60dd5 100644 --- a/tests/core/test_handler.py +++ b/tests/core/test_handler.py @@ -1,10 +1,12 @@ -from unittest.mock import MagicMock +from types import NoneType +from unittest.mock import AsyncMock, MagicMock import pytest -from pydantic import BaseModel, Field, PrivateAttr, RootModel +from pydantic import BaseModel, ValidationError -from codesphere.core.handler import APIRequestHandler, _APIOperationExecutor -from codesphere.core.operations import APIOperation, AsyncCallable +from codesphere.core.base import BoundModel, ResourceBase, ResourceList +from codesphere.core.handler import execute_operation +from codesphere.core.operations import APIOperation class SampleResponseModel(BaseModel): @@ -17,164 +19,199 @@ class SampleInputModel(BaseModel): count: int -class ConcreteExecutor(_APIOperationExecutor, BaseModel): - id: int = 100 - _http_client: MagicMock | None = PrivateAttr(default=None) - - -class TestAPIOperationExecutor: - def test_http_client_private_attribute_exists(self): - executor = ConcreteExecutor() - assert hasattr(executor, "_http_client") - executor._http_client = MagicMock() - assert executor._http_client is not None - - def test_getattribute_returns_partial_for_operation(self): - class ExecutorWithOp(_APIOperationExecutor, BaseModel): - id: int = 123 - _http_client: MagicMock | None = PrivateAttr(default=None) - test_op: AsyncCallable[SampleResponseModel] = Field( - default=APIOperation( - method="GET", - endpoint_template="/test/{id}", - response_model=SampleResponseModel, - ), - exclude=True, - ) - - executor = ExecutorWithOp() - attr = executor.test_op - assert callable(attr) - - def test_getattribute_returns_normal_values(self): - class SampleExecutor(_APIOperationExecutor, BaseModel): - id: int = 456 - name: str = "test" - _http_client: MagicMock | None = PrivateAttr(default=None) - - executor = SampleExecutor() - assert executor.id == 456 - assert executor.name == "test" - - -class TestAPIRequestHandler: - @pytest.fixture - def mock_executor(self): - executor = ConcreteExecutor() - mock_client = MagicMock() - mock_client.request = MagicMock() - executor._http_client = mock_client - return executor - - @pytest.fixture - def sample_operation(self): - return APIOperation( - method="GET", +class SampleBoundModel(BoundModel): + id: int + + +def make_client(json_data=None): + """Mock APIHttpClient whose request returns a response with json_data.""" + response = MagicMock() + response.json.return_value = json_data if json_data is not None else {} + client = MagicMock() + client.request = AsyncMock(return_value=response) + return client + + +GET_OP = APIOperation( + method="GET", + endpoint_template="/resources/{id}", + response_model=SampleResponseModel, +) + +CREATE_OP = APIOperation( + method="POST", + endpoint_template="/resources", + input_model=SampleInputModel, + response_model=SampleResponseModel, +) + +DELETE_OP = APIOperation( + method="DELETE", + endpoint_template="/resources/{id}", + response_model=NoneType, +) + + +class TestExecuteOperation: + async def test_formats_endpoint_from_path_params(self): + client = make_client({"id": 1, "name": "x"}) + await execute_operation(client, GET_OP, path_params={"id": 100}) + + call = client.request.call_args + assert call.kwargs["method"] == "GET" + assert call.kwargs["endpoint"] == "/resources/100" + + async def test_missing_path_param_raises_before_request(self): + client = make_client() + with pytest.raises(ValueError, match="Missing path parameter 'id'"): + await execute_operation(client, GET_OP) + client.request.assert_not_awaited() + + async def test_model_payload_is_dumped(self): + client = make_client({"id": 1, "name": "x"}) + payload = SampleInputModel(title="Test", count=10) + await execute_operation(client, CREATE_OP, data=payload) + + call = client.request.call_args + assert call.kwargs["json"] == {"title": "Test", "count": 10} + + async def test_dict_payload_is_validated_against_input_model(self): + client = make_client({"id": 1, "name": "x"}) + await execute_operation(client, CREATE_OP, data={"title": "Test", "count": 10}) + + call = client.request.call_args + assert call.kwargs["json"] == {"title": "Test", "count": 10} + + async def test_invalid_payload_rejected_before_request(self): + client = make_client() + with pytest.raises(ValidationError): + await execute_operation(client, CREATE_OP, data={"title": "no count"}) + client.request.assert_not_awaited() + + async def test_empty_dict_body_is_sent(self): + """Regression: falsy payloads must still be sent as the JSON body.""" + op = APIOperation( + method="PATCH", endpoint_template="/resources/{id}", - response_model=SampleResponseModel, + response_model=NoneType, ) + client = make_client() + await execute_operation(client, op, data={}, path_params={"id": 1}) - def test_handler_initialization(self, mock_executor, sample_operation): - kwargs = {"param": "value"} - handler = APIRequestHandler( - executor=mock_executor, - operation=sample_operation, - kwargs=kwargs, - ) - assert handler.executor is mock_executor - assert handler.operation is sample_operation - assert handler.kwargs == kwargs - assert handler.http_client is mock_executor._http_client - - def test_prepare_request_args_formats_endpoint( - self, mock_executor, sample_operation - ): - handler = APIRequestHandler( - executor=mock_executor, - operation=sample_operation, - kwargs={}, - ) - endpoint, request_kwargs = handler._prepare_request_args() - assert endpoint == "/resources/100" - - def test_prepare_request_args_with_data_payload(self, mock_executor): - operation = APIOperation( - method="POST", - endpoint_template="/resources", - response_model=SampleResponseModel, - input_model=SampleInputModel, + call = client.request.call_args + assert call.kwargs["json"] == {} + + async def test_no_data_sends_no_json_kwarg(self): + client = make_client({"id": 1, "name": "x"}) + await execute_operation(client, GET_OP, path_params={"id": 1}) + + call = client.request.call_args + assert "json" not in call.kwargs + + async def test_params_are_passed_through(self): + client = make_client({"id": 1, "name": "x"}) + await execute_operation( + client, GET_OP, params={"limit": 5}, path_params={"id": 1} ) - input_model = SampleInputModel(title="Test", count=10) - handler = APIRequestHandler( - executor=mock_executor, - operation=operation, - kwargs={"data": input_model}, + + call = client.request.call_args + assert call.kwargs["params"] == {"limit": 5} + + async def test_none_response_model_returns_none(self): + client = make_client() + result = await execute_operation(client, DELETE_OP, path_params={"id": 1}) + assert result is None + client.request.return_value.json.assert_not_called() + + async def test_response_is_validated_into_model(self): + client = make_client({"id": 7, "name": "seven"}) + result = await execute_operation(client, GET_OP, path_params={"id": 7}) + + assert isinstance(result, SampleResponseModel) + assert result.id == 7 + assert result.name == "seven" + + async def test_invalid_response_raises_validation_error(self): + client = make_client({"unexpected": "shape"}) + with pytest.raises(ValidationError): + await execute_operation(client, GET_OP, path_params={"id": 1}) + + async def test_client_is_bound_to_returned_model(self): + op = APIOperation( + method="GET", + endpoint_template="/bound/{id}", + response_model=SampleBoundModel, ) - endpoint, request_kwargs = handler._prepare_request_args() - assert "json" in request_kwargs - assert request_kwargs["json"] == {"title": "Test", "count": 10} - - @pytest.mark.asyncio - async def test_execute_raises_without_http_client(self, sample_operation): - executor = ConcreteExecutor() - handler = APIRequestHandler( - executor=executor, - operation=sample_operation, - kwargs={}, + client = make_client({"id": 1}) + result = await execute_operation(client, op, path_params={"id": 1}) + + assert result._http_client is client + + async def test_client_is_bound_to_resource_list_items(self): + op = APIOperation( + method="GET", + endpoint_template="/bound", + response_model=ResourceList[SampleBoundModel], ) + client = make_client([{"id": 1}, {"id": 2}]) + result = await execute_operation(client, op) + + assert len(result) == 2 + for item in result: + assert item._http_client is client + + +class TestResourceBaseExecute: + async def test_execute_uses_stored_client_and_kwargs_as_path_params(self): + client = make_client({"id": 1, "name": "x"}) + resource = ResourceBase(http_client=client) + + result = await resource._execute(GET_OP, id=42) + + assert isinstance(result, SampleResponseModel) + assert client.request.call_args.kwargs["endpoint"] == "/resources/42" + + +class TestBoundModelExecute: + async def test_detached_model_raises(self): + model = SampleBoundModel(id=1) with pytest.raises( RuntimeError, match="Cannot access resource on a detached model" ): - await handler.execute() - - @pytest.mark.asyncio - async def test_inject_client_into_model(self, mock_executor, sample_operation): - mock_client = MagicMock() - mock_client.request = MagicMock() - mock_executor._http_client = mock_client - - handler = APIRequestHandler( - executor=mock_executor, - operation=sample_operation, - kwargs={}, - ) + await model._execute(DELETE_OP, id=1) - class ModelWithClient(BaseModel): - id: int - _http_client: MagicMock | None = PrivateAttr(default=None) - - instance = ModelWithClient(id=1) - handler._inject_client_into_model(instance) - assert instance._http_client is mock_executor._http_client - - @pytest.mark.asyncio - async def test_inject_client_into_root_model_items( - self, mock_executor, sample_operation - ): - """RootModel items in .root should have _http_client injected.""" - mock_client = MagicMock() - mock_client.request = MagicMock() - mock_executor._http_client = mock_client - - handler = APIRequestHandler( - executor=mock_executor, - operation=sample_operation, - kwargs={}, - ) + async def test_bound_model_executes_with_attached_client(self): + client = make_client() + model = SampleBoundModel(id=1) + model._http_client = client + + await model._execute(DELETE_OP, id=model.id) - class ItemWithClient(BaseModel): - id: int - _http_client: MagicMock | None = PrivateAttr(default=None) + assert client.request.call_args.kwargs["endpoint"] == "/resources/1" - class ResourceList(RootModel[list[ItemWithClient]]): - _http_client: MagicMock | None = PrivateAttr(default=None) - item1 = ItemWithClient(id=1) - item2 = ItemWithClient(id=2) - resource_list = ResourceList(root=[item1, item2]) +class TestClientBindingHelpers: + def test_bind_client_sets_bound_model(self): + from codesphere.core.handler import _bind_client - handler._inject_client_into_model(resource_list) + client = MagicMock() + instance = SampleBoundModel(id=1) + _bind_client(instance, client) + assert instance._http_client is client - assert resource_list._http_client is mock_executor._http_client - for item in resource_list.root: - assert item._http_client is mock_executor._http_client + def test_bind_client_ignores_plain_models(self): + from codesphere.core.handler import _bind_client + + instance = SampleResponseModel(id=1, name="x") + _bind_client(instance, MagicMock()) # must not raise + + def test_bind_client_sets_root_model_items(self): + from codesphere.core.handler import _bind_client + + client = MagicMock() + items = ResourceList[SampleBoundModel]( + root=[SampleBoundModel(id=1), SampleBoundModel(id=2)] + ) + _bind_client(items, client) + for item in items: + assert item._http_client is client diff --git a/tests/core/test_operations.py b/tests/core/test_operations.py index 4f53c9e..14304cf 100644 --- a/tests/core/test_operations.py +++ b/tests/core/test_operations.py @@ -1,7 +1,8 @@ +import dataclasses from dataclasses import dataclass import pytest -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel from codesphere.core.operations import APIOperation, StreamOperation from codesphere.resources.workspace.logs import LogEntry @@ -82,19 +83,20 @@ def test_create_operation(self, case: APIOperationTestCase): assert operation.response_model == case.response_model assert operation.input_model == case.input_model - def test_operation_is_pydantic_model(self): - """APIOperation should be a Pydantic BaseModel.""" - assert issubclass(APIOperation, BaseModel) + def test_operation_is_frozen_dataclass(self): + """APIOperation should be a frozen dataclass, not a pydantic model.""" + assert dataclasses.is_dataclass(APIOperation) + assert not issubclass(APIOperation, BaseModel) - def test_operation_model_copy(self): - """APIOperation should support model_copy for creating variants.""" + def test_operation_replace(self): + """APIOperation should support dataclasses.replace for variants.""" original = APIOperation( method="GET", endpoint_template="/test", response_model=SampleResponseModel, ) - copied = original.model_copy(update={"method": "POST"}) + copied = dataclasses.replace(original, method="POST") assert copied.method == "POST" assert copied.endpoint_template == original.endpoint_template @@ -138,7 +140,7 @@ def test_api_operation_is_frozen(self): endpoint_template="/test", response_model=LogEntry, ) - with pytest.raises(ValidationError): + with pytest.raises(dataclasses.FrozenInstanceError): op.method = "POST" @@ -156,5 +158,5 @@ def test_stream_operation_is_frozen(self): endpoint_template="/logs/{id}", entry_model=LogEntry, ) - with pytest.raises(ValidationError): + with pytest.raises(dataclasses.FrozenInstanceError): op.endpoint_template = "/other" diff --git a/tests/resources/workspace/landscape/test_pipeline.py b/tests/resources/workspace/landscape/test_pipeline.py index e7b5239..c8aa74b 100644 --- a/tests/resources/workspace/landscape/test_pipeline.py +++ b/tests/resources/workspace/landscape/test_pipeline.py @@ -91,34 +91,34 @@ def landscape_manager(self, mock_http_client): @pytest.mark.asyncio async def test_start_stage_with_enum(self, landscape_manager): """Test start_stage with PipelineStage enum.""" - landscape_manager._execute_operation = AsyncMock() + landscape_manager._execute = AsyncMock() await landscape_manager.start_stage(PipelineStage.PREPARE) - landscape_manager._execute_operation.assert_called_once() - call_args = landscape_manager._execute_operation.call_args + landscape_manager._execute.assert_called_once() + call_args = landscape_manager._execute.call_args assert call_args.kwargs["stage"] == "prepare" @pytest.mark.asyncio async def test_start_stage_with_string(self, landscape_manager): """Test start_stage with string stage.""" - landscape_manager._execute_operation = AsyncMock() + landscape_manager._execute = AsyncMock() await landscape_manager.start_stage("run") - landscape_manager._execute_operation.assert_called_once() - call_args = landscape_manager._execute_operation.call_args + landscape_manager._execute.assert_called_once() + call_args = landscape_manager._execute.call_args assert call_args.kwargs["stage"] == "run" @pytest.mark.asyncio async def test_start_stage_with_profile(self, landscape_manager): """Test start_stage with a profile name.""" - landscape_manager._execute_operation = AsyncMock() + landscape_manager._execute = AsyncMock() await landscape_manager.start_stage(PipelineStage.RUN, profile="production") - landscape_manager._execute_operation.assert_called_once() - call_args = landscape_manager._execute_operation.call_args + landscape_manager._execute.assert_called_once() + call_args = landscape_manager._execute.call_args assert call_args.kwargs["stage"] == "run" assert call_args.kwargs["profile"] == "production" @@ -133,12 +133,12 @@ async def test_start_stage_invalid_profile_name(self, landscape_manager): @pytest.mark.asyncio async def test_stop_stage(self, landscape_manager): """Test stop_stage.""" - landscape_manager._execute_operation = AsyncMock() + landscape_manager._execute = AsyncMock() await landscape_manager.stop_stage(PipelineStage.RUN) - landscape_manager._execute_operation.assert_called_once() - call_args = landscape_manager._execute_operation.call_args + landscape_manager._execute.assert_called_once() + call_args = landscape_manager._execute.call_args assert call_args.kwargs["stage"] == "run" @pytest.mark.asyncio @@ -154,7 +154,7 @@ async def test_get_stage_status(self, landscape_manager): ) ] ) - landscape_manager._execute_operation = AsyncMock(return_value=mock_status) + landscape_manager._execute = AsyncMock(return_value=mock_status) result = await landscape_manager.get_stage_status(PipelineStage.RUN) @@ -174,7 +174,7 @@ async def test_wait_for_stage_completes_immediately(self, landscape_manager): ) ] ) - landscape_manager._execute_operation = AsyncMock(return_value=mock_status) + landscape_manager._execute = AsyncMock(return_value=mock_status) result = await landscape_manager.wait_for_stage(PipelineStage.PREPARE) @@ -205,7 +205,7 @@ async def test_wait_for_stage_polls_until_complete(self, landscape_manager): ] ) - landscape_manager._execute_operation = AsyncMock( + landscape_manager._execute = AsyncMock( side_effect=[running_status, running_status, success_status] ) @@ -214,7 +214,7 @@ async def test_wait_for_stage_polls_until_complete(self, landscape_manager): ) assert result[0].state == PipelineState.SUCCESS - assert landscape_manager._execute_operation.call_count == 3 + assert landscape_manager._execute.call_count == 3 @pytest.mark.asyncio async def test_wait_for_stage_timeout(self, landscape_manager): @@ -229,7 +229,7 @@ async def test_wait_for_stage_timeout(self, landscape_manager): ) ] ) - landscape_manager._execute_operation = AsyncMock(return_value=running_status) + landscape_manager._execute = AsyncMock(return_value=running_status) with pytest.raises(TimeoutError, match="did not complete"): await landscape_manager.wait_for_stage(