From 8c9f102e321daf521c70e8b31931109f98f6d038 Mon Sep 17 00:00:00 2001 From: Datata1 Date: Sun, 12 Jul 2026 13:44:57 +0200 Subject: [PATCH] refactor(resources): one canonical layout, scoped bases, dedup schemas Implements plans/refactor/04-resource-layer-normalization.md: - Canonical per-resource layout everywhere: operations.py / schemas.py / resources.py. Renamed domain+usage manager.py and envVars/git/ landscape/logs models.py to resources.py; git schema.py -> schemas.py; deleted the zero-byte landscape/resources.py placeholder - envVars/ package renamed to env_vars/; the old camelCase import path remains as a deprecation shim module (warns, re-exports) - Domain entity moves to domain/schemas.py alongside its base (matching Team/Workspace); TeamDomainManager lives in domain/resources.py; domain/__init__.py gains proper re-exports - New TeamScopedResource / WorkspaceScopedResource bases in core/base.py replace six copy-pasted manager __init__s; managers expose team_id/workspace_id consistently - EnvVar derives from CamelModel like every other schema (gains to_dict/to_json/to_yaml) - PipelineStatusList is now an alias of ResourceList[PipelineStatus] instead of a reimplementation - PaginatedResponse: refresh() hoisted from the two response subclasses, limit/offset are non-Optional with defaults (drops the repeated 'or 25' re-defaulting); single _clamp_page_size helper replaces four inline min(max(...)) clamps - Deleted dead utils.dict_to_model_list Only internal module paths moved; all public re-exports (top-level codesphere, package __init__s) are unchanged, plus the envVars shim. --- examples/dashboard/app.py | 2 +- src/codesphere/__init__.py | 2 +- src/codesphere/core/__init__.py | 11 +- src/codesphere/core/base.py | 16 ++ src/codesphere/resources/team/__init__.py | 4 +- .../resources/team/domain/__init__.py | 19 ++ .../resources/team/domain/manager.py | 36 ---- .../resources/team/domain/operations.py | 3 +- .../resources/team/domain/resources.py | 56 ++--- .../resources/team/domain/schemas.py | 40 +++- src/codesphere/resources/team/schemas.py | 4 +- .../resources/team/usage/__init__.py | 2 +- .../team/usage/{manager.py => resources.py} | 22 +- .../resources/team/usage/schemas.py | 65 ++---- src/codesphere/resources/workspace/envVars.py | 14 ++ .../{envVars => env_vars}/__init__.py | 2 +- .../{envVars => env_vars}/operations.py | 0 .../models.py => env_vars/resources.py} | 16 +- .../{envVars => env_vars}/schemas.py | 4 +- .../resources/workspace/git/__init__.py | 4 +- .../resources/workspace/git/operations.py | 2 +- .../workspace/git/{models.py => resources.py} | 22 +- .../workspace/git/{schema.py => schemas.py} | 0 .../resources/workspace/landscape/__init__.py | 2 +- .../resources/workspace/landscape/models.py | 194 ----------------- .../workspace/landscape/operations.py | 5 +- .../workspace/landscape/resources.py | 196 ++++++++++++++++++ .../resources/workspace/landscape/schemas.py | 17 +- .../resources/workspace/logs/__init__.py | 2 +- .../logs/{models.py => resources.py} | 11 +- src/codesphere/resources/workspace/schemas.py | 2 +- src/codesphere/utils.py | 31 --- tests/resources/team/domain/test_domain.py | 2 +- tests/resources/team/usage/test_usage.py | 2 +- .../workspace/env_vars/test_env_vars.py | 25 ++- .../workspace/landscape/test_pipeline.py | 15 +- tests/resources/workspace/logs/test_logs.py | 3 +- 37 files changed, 426 insertions(+), 427 deletions(-) delete mode 100644 src/codesphere/resources/team/domain/manager.py rename src/codesphere/resources/team/usage/{manager.py => resources.py} (90%) create mode 100644 src/codesphere/resources/workspace/envVars.py rename src/codesphere/resources/workspace/{envVars => env_vars}/__init__.py (62%) rename src/codesphere/resources/workspace/{envVars => env_vars}/operations.py (100%) rename src/codesphere/resources/workspace/{envVars/models.py => env_vars/resources.py} (61%) rename src/codesphere/resources/workspace/{envVars => env_vars}/schemas.py (52%) rename src/codesphere/resources/workspace/git/{models.py => resources.py} (55%) rename src/codesphere/resources/workspace/git/{schema.py => schemas.py} (100%) delete mode 100644 src/codesphere/resources/workspace/landscape/models.py rename src/codesphere/resources/workspace/logs/{models.py => resources.py} (96%) diff --git a/examples/dashboard/app.py b/examples/dashboard/app.py index fbd9787..3c53de5 100644 --- a/examples/dashboard/app.py +++ b/examples/dashboard/app.py @@ -35,7 +35,7 @@ WorkspaceCreate, WorkspaceUpdate, ) -from codesphere.resources.workspace.envVars import EnvVar # noqa: E402 +from codesphere.resources.workspace.env_vars import EnvVar # noqa: E402 from codesphere.resources.workspace.landscape import ( # noqa: E402 PipelineStage, ProfileBuilder, diff --git a/src/codesphere/__init__.py b/src/codesphere/__init__.py index 8d3532b..a312e4b 100644 --- a/src/codesphere/__init__.py +++ b/src/codesphere/__init__.py @@ -51,7 +51,7 @@ WorkspaceStatus, WorkspaceUpdate, ) -from .resources.workspace.envVars import EnvVar +from .resources.workspace.env_vars import EnvVar logging.getLogger("codesphere").addHandler(logging.NullHandler()) diff --git a/src/codesphere/core/__init__.py b/src/codesphere/core/__init__.py index 1b9935b..f14aa0f 100644 --- a/src/codesphere/core/__init__.py +++ b/src/codesphere/core/__init__.py @@ -1,4 +1,11 @@ -from .base import BoundModel, CamelModel, ResourceBase, ResourceList +from .base import ( + BoundModel, + CamelModel, + ResourceBase, + ResourceList, + TeamScopedResource, + WorkspaceScopedResource, +) from .handler import execute_operation from .operations import APIOperation, StreamOperation @@ -9,5 +16,7 @@ "ResourceBase", "ResourceList", "StreamOperation", + "TeamScopedResource", + "WorkspaceScopedResource", "execute_operation", ] diff --git a/src/codesphere/core/base.py b/src/codesphere/core/base.py index dee3728..2a2e7e0 100644 --- a/src/codesphere/core/base.py +++ b/src/codesphere/core/base.py @@ -32,6 +32,22 @@ async def _execute( ) +class TeamScopedResource(ResourceBase): + """Base for managers operating within one team.""" + + def __init__(self, http_client: APIHttpClient, team_id: int): + super().__init__(http_client) + self.team_id = team_id + + +class WorkspaceScopedResource(ResourceBase): + """Base for managers operating within one workspace.""" + + def __init__(self, http_client: APIHttpClient, workspace_id: int): + super().__init__(http_client) + self.workspace_id = workspace_id + + class CamelModel(BaseModel): model_config = ConfigDict( alias_generator=to_camel, diff --git a/src/codesphere/resources/team/__init__.py b/src/codesphere/resources/team/__init__.py index 7c13b3b..5734d43 100644 --- a/src/codesphere/resources/team/__init__.py +++ b/src/codesphere/resources/team/__init__.py @@ -1,9 +1,10 @@ -from .domain.resources import ( +from .domain import ( CustomDomainConfig, Domain, DomainBase, DomainRouting, DomainVerificationStatus, + TeamDomainManager, ) from .resources import TeamsResource from .schemas import Team, TeamBase, TeamCreate @@ -30,6 +31,7 @@ "Team", "TeamBase", "TeamCreate", + "TeamDomainManager", "TeamUsageManager", "TeamsResource", "UsageEventsResponse", diff --git a/src/codesphere/resources/team/domain/__init__.py b/src/codesphere/resources/team/domain/__init__.py index e69de29..fb53171 100644 --- a/src/codesphere/resources/team/domain/__init__.py +++ b/src/codesphere/resources/team/domain/__init__.py @@ -0,0 +1,19 @@ +from .resources import TeamDomainManager +from .schemas import ( + CustomDomainConfig, + Domain, + DomainBase, + DomainRouting, + DomainVerificationStatus, + RoutingMap, +) + +__all__ = [ + "CustomDomainConfig", + "Domain", + "DomainBase", + "DomainRouting", + "DomainVerificationStatus", + "RoutingMap", + "TeamDomainManager", +] diff --git a/src/codesphere/resources/team/domain/manager.py b/src/codesphere/resources/team/domain/manager.py deleted file mode 100644 index 19f53cd..0000000 --- a/src/codesphere/resources/team/domain/manager.py +++ /dev/null @@ -1,36 +0,0 @@ -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(ResourceBase): - def __init__(self, http_client: APIHttpClient, team_id: int): - super().__init__(http_client) - self.team_id = team_id - - async def list(self) -> list[Domain]: - result = await self._execute(_LIST_OP, team_id=self.team_id) - return result.root - - async def get(self, name: str) -> Domain: - return await self._execute(_GET_OP, team_id=self.team_id, name=name) - - async def create(self, name: str) -> Domain: - 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._execute( - _UPDATE_OP, team_id=self.team_id, name=name, data=config - ) - - async def update_workspace_connections( - self, name: str, connections: DomainRouting | RoutingMap - ) -> Domain: - payload = ( - connections.root if isinstance(connections, DomainRouting) else connections - ) - 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 efcab50..659e262 100644 --- a/src/codesphere/resources/team/domain/operations.py +++ b/src/codesphere/resources/team/domain/operations.py @@ -2,8 +2,7 @@ from ....core.base import ResourceList from ....core.operations import APIOperation -from .resources import Domain -from .schemas import CustomDomainConfig, DomainVerificationStatus +from .schemas import CustomDomainConfig, Domain, DomainVerificationStatus _LIST_OP = APIOperation( method="GET", diff --git a/src/codesphere/resources/team/domain/resources.py b/src/codesphere/resources/team/domain/resources.py index adff622..0edb762 100644 --- a/src/codesphere/resources/team/domain/resources.py +++ b/src/codesphere/resources/team/domain/resources.py @@ -1,52 +1,30 @@ -from __future__ import annotations +from ....core.base import TeamScopedResource +from .operations import _CREATE_OP, _GET_OP, _LIST_OP, _UPDATE_OP, _UPDATE_WS_OP +from .schemas import CustomDomainConfig, Domain, DomainRouting, RoutingMap -import logging -from ....core.base import BoundModel -from ....utils import update_model_fields -from .schemas import ( - CustomDomainConfig, - DomainBase, - DomainRouting, - DomainVerificationStatus, - RoutingMap, -) +class TeamDomainManager(TeamScopedResource): + async def list(self) -> list[Domain]: + result = await self._execute(_LIST_OP, team_id=self.team_id) + return result.root -log = logging.getLogger(__name__) + async def get(self, name: str) -> Domain: + return await self._execute(_GET_OP, team_id=self.team_id, name=name) + async def create(self, name: str) -> Domain: + return await self._execute(_CREATE_OP, team_id=self.team_id, name=name) -class Domain(DomainBase, BoundModel): - async def update(self, data: CustomDomainConfig) -> Domain: - from .operations import _UPDATE_OP - - response = await self._execute( - _UPDATE_OP, team_id=self.team_id, name=self.name, data=data + async def update(self, name: str, config: CustomDomainConfig) -> Domain: + return await self._execute( + _UPDATE_OP, team_id=self.team_id, name=name, data=config ) - update_model_fields(target=self, source=response) - return response async def update_workspace_connections( - self, connections: DomainRouting | RoutingMap + self, name: str, connections: DomainRouting | RoutingMap ) -> Domain: - from .operations import _UPDATE_WS_OP - payload = ( connections.root if isinstance(connections, DomainRouting) else connections ) - response = await self._execute( - _UPDATE_WS_OP, team_id=self.team_id, name=self.name, data=payload + return await self._execute( + _UPDATE_WS_OP, team_id=self.team_id, name=name, data=payload ) - update_model_fields(target=self, source=response) - return response - - async def verify_status(self) -> DomainVerificationStatus: - 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: - 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/domain/schemas.py b/src/codesphere/resources/team/domain/schemas.py index c48136e..4d3a54d 100644 --- a/src/codesphere/resources/team/domain/schemas.py +++ b/src/codesphere/resources/team/domain/schemas.py @@ -2,7 +2,8 @@ from pydantic import Field, RootModel -from ....core.base import CamelModel +from ....core.base import BoundModel, CamelModel +from ....utils import update_model_fields type RoutingMap = dict[str, list[int]] @@ -48,3 +49,40 @@ class DomainBase(CamelModel): domain_verification_status: DomainVerificationStatus custom_config_revision: int | None = None custom_config: CustomDomainConfig | None = None + + +class Domain(DomainBase, BoundModel): + async def update(self, data: CustomDomainConfig) -> Domain: + 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._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: + 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: + 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/schemas.py b/src/codesphere/resources/team/schemas.py index 5c3100e..086400b 100644 --- a/src/codesphere/resources/team/schemas.py +++ b/src/codesphere/resources/team/schemas.py @@ -3,8 +3,8 @@ from functools import cached_property from ...core.base import BoundModel, CamelModel -from .domain.manager import TeamDomainManager -from .usage.manager import TeamUsageManager +from .domain.resources import TeamDomainManager +from .usage.resources import TeamUsageManager class TeamCreate(CamelModel): diff --git a/src/codesphere/resources/team/usage/__init__.py b/src/codesphere/resources/team/usage/__init__.py index 3e56706..d53a802 100644 --- a/src/codesphere/resources/team/usage/__init__.py +++ b/src/codesphere/resources/team/usage/__init__.py @@ -1,6 +1,6 @@ """Team usage history resources.""" -from .manager import TeamUsageManager +from .resources import TeamUsageManager from .schemas import ( LandscapeServiceEvent, LandscapeServiceSummary, diff --git a/src/codesphere/resources/team/usage/manager.py b/src/codesphere/resources/team/usage/resources.py similarity index 90% rename from src/codesphere/resources/team/usage/manager.py rename to src/codesphere/resources/team/usage/resources.py index e46b5ce..c50d386 100644 --- a/src/codesphere/resources/team/usage/manager.py +++ b/src/codesphere/resources/team/usage/resources.py @@ -4,8 +4,7 @@ from datetime import datetime from functools import partial -from ....core.base import ResourceBase -from ....http_client import APIHttpClient +from ....core.base import TeamScopedResource from .operations import _GET_LANDSCAPE_EVENTS_OP, _GET_LANDSCAPE_SUMMARY_OP from .schemas import ( LandscapeServiceEvent, @@ -15,7 +14,12 @@ ) -class TeamUsageManager(ResourceBase): +def _clamp_page_size(value: int) -> int: + """The usage API accepts page sizes between 1 and 100.""" + return min(max(1, value), 100) + + +class TeamUsageManager(TeamScopedResource): """Manager for team usage history operations. Provides access to landscape service usage summaries and events with @@ -44,10 +48,6 @@ class TeamUsageManager(ResourceBase): ``` """ - def __init__(self, http_client: APIHttpClient, team_id: int): - super().__init__(http_client) - self.team_id = team_id - async def get_landscape_summary( self, begin_date: datetime | str, @@ -62,7 +62,7 @@ async def get_landscape_summary( "endDate": end_date.isoformat() if isinstance(end_date, datetime) else end_date, - "limit": min(max(1, limit), 100), + "limit": _clamp_page_size(limit), "offset": max(0, offset), } result = await self._execute( @@ -83,7 +83,7 @@ async def iter_all_landscape_summary( page_size: int = 100, ) -> AsyncIterator[LandscapeServiceSummary]: offset = 0 - page_size = min(max(1, page_size), 100) + page_size = _clamp_page_size(page_size) while True: response = await self.get_landscape_summary( @@ -116,7 +116,7 @@ async def get_landscape_events( "endDate": end_date.isoformat() if isinstance(end_date, datetime) else end_date, - "limit": min(max(1, limit), 100), + "limit": _clamp_page_size(limit), "offset": max(0, offset), } result = await self._execute( @@ -145,7 +145,7 @@ async def iter_all_landscape_events( page_size: int = 100, ) -> AsyncIterator[LandscapeServiceEvent]: offset = 0 - page_size = min(max(1, page_size), 100) + page_size = _clamp_page_size(page_size) while True: response = await self.get_landscape_events( diff --git a/src/codesphere/resources/team/usage/schemas.py b/src/codesphere/resources/team/usage/schemas.py index 76b491a..1167573 100644 --- a/src/codesphere/resources/team/usage/schemas.py +++ b/src/codesphere/resources/team/usage/schemas.py @@ -3,7 +3,7 @@ from collections.abc import Awaitable, Callable from datetime import datetime from enum import Enum -from typing import Any, Generic, TypeVar +from typing import Any, Generic, Self, TypeVar from pydantic import Field @@ -42,45 +42,33 @@ class LandscapeServiceEvent(CamelModel): class PaginatedResponse(CamelModel, Generic[ItemT]): total_items: int - limit: int | None = Field(default=25) - offset: int | None = Field(default=0) + limit: int = 25 + offset: int = 0 begin_date: datetime end_date: datetime + _refresh_op: Callable[..., Awaitable[Any]] | None = None + @property def has_next_page(self) -> bool: - current_limit = self.limit or 25 - current_offset = self.offset or 0 - return (current_offset + current_limit) < self.total_items + return (self.offset + self.limit) < self.total_items @property def has_prev_page(self) -> bool: - return (self.offset or 0) > 0 + return self.offset > 0 @property def current_page(self) -> int: - current_limit = self.limit or 25 - current_offset = self.offset or 0 - return (current_offset // current_limit) + 1 + return (self.offset // self.limit) + 1 @property def total_pages(self) -> int: - current_limit = self.limit or 25 if self.total_items == 0: return 1 - return (self.total_items + current_limit - 1) // current_limit - - -class UsageSummaryResponse(PaginatedResponse[LandscapeServiceSummary]): - summary: list[LandscapeServiceSummary] = Field(default_factory=list) - _refresh_op: Callable[..., Awaitable[Any]] | None = None - _team_id: int | None = None + return (self.total_items + self.limit - 1) // self.limit - @property - def items(self) -> list[LandscapeServiceSummary]: - return self.summary - - async def refresh(self) -> UsageSummaryResponse: + async def refresh(self) -> Self: + """Re-fetch this page with the same query parameters, in place.""" if self._refresh_op is None: raise RuntimeError( "Refresh operation not available. Use manager methods instead." @@ -94,36 +82,25 @@ async def refresh(self) -> UsageSummaryResponse: } ) for field_name in result.model_fields_set: - if field_name not in ("_refresh_op", "_team_id"): - setattr(self, field_name, getattr(result, field_name)) + setattr(self, field_name, getattr(result, field_name)) return self +class UsageSummaryResponse(PaginatedResponse[LandscapeServiceSummary]): + summary: list[LandscapeServiceSummary] = Field(default_factory=list) + _team_id: int | None = None + + @property + def items(self) -> list[LandscapeServiceSummary]: + return self.summary + + class UsageEventsResponse(PaginatedResponse[LandscapeServiceEvent]): events: list[LandscapeServiceEvent] = Field(default_factory=list) - _refresh_op: Callable[..., Awaitable[Any]] | None = None _team_id: int | None = None _resource_id: str | None = None @property def items(self) -> list[LandscapeServiceEvent]: return self.events - - async def refresh(self) -> UsageEventsResponse: - if self._refresh_op is None: - raise RuntimeError( - "Refresh operation not available. Use manager methods instead." - ) - result = await self._refresh_op( - params={ - "beginDate": self.begin_date.isoformat(), - "endDate": self.end_date.isoformat(), - "limit": self.limit, - "offset": self.offset, - } - ) - for field_name in result.model_fields_set: - if field_name not in ("_refresh_op", "_team_id", "_resource_id"): - setattr(self, field_name, getattr(result, field_name)) - return self diff --git a/src/codesphere/resources/workspace/envVars.py b/src/codesphere/resources/workspace/envVars.py new file mode 100644 index 0000000..63eef18 --- /dev/null +++ b/src/codesphere/resources/workspace/envVars.py @@ -0,0 +1,14 @@ +"""Deprecated alias for :mod:`codesphere.resources.workspace.env_vars`.""" + +import warnings + +from .env_vars import EnvVar, WorkspaceEnvVarManager + +warnings.warn( + "'codesphere.resources.workspace.envVars' is deprecated; " + "import from 'codesphere.resources.workspace.env_vars' instead.", + DeprecationWarning, + stacklevel=2, +) + +__all__ = ["EnvVar", "WorkspaceEnvVarManager"] diff --git a/src/codesphere/resources/workspace/envVars/__init__.py b/src/codesphere/resources/workspace/env_vars/__init__.py similarity index 62% rename from src/codesphere/resources/workspace/envVars/__init__.py rename to src/codesphere/resources/workspace/env_vars/__init__.py index dbd4fcb..8e2327d 100644 --- a/src/codesphere/resources/workspace/envVars/__init__.py +++ b/src/codesphere/resources/workspace/env_vars/__init__.py @@ -1,4 +1,4 @@ -from .models import WorkspaceEnvVarManager +from .resources import WorkspaceEnvVarManager from .schemas import EnvVar __all__ = ["EnvVar", "WorkspaceEnvVarManager"] diff --git a/src/codesphere/resources/workspace/envVars/operations.py b/src/codesphere/resources/workspace/env_vars/operations.py similarity index 100% rename from src/codesphere/resources/workspace/envVars/operations.py rename to src/codesphere/resources/workspace/env_vars/operations.py diff --git a/src/codesphere/resources/workspace/envVars/models.py b/src/codesphere/resources/workspace/env_vars/resources.py similarity index 61% rename from src/codesphere/resources/workspace/envVars/models.py rename to src/codesphere/resources/workspace/env_vars/resources.py index 1166c9e..e029813 100644 --- a/src/codesphere/resources/workspace/envVars/models.py +++ b/src/codesphere/resources/workspace/env_vars/resources.py @@ -2,26 +2,20 @@ import logging -from ....core.base import ResourceBase, ResourceList -from ....http_client import APIHttpClient +from ....core.base import ResourceList, WorkspaceScopedResource from .operations import _BULK_DELETE_OP, _BULK_SET_OP, _GET_OP from .schemas import EnvVar log = logging.getLogger(__name__) -class WorkspaceEnvVarManager(ResourceBase): - def __init__(self, http_client: APIHttpClient, workspace_id: int): - super().__init__(http_client) - self._workspace_id = workspace_id - self.id = workspace_id - +class WorkspaceEnvVarManager(WorkspaceScopedResource): async def get(self) -> ResourceList[EnvVar]: - return await self._execute(_GET_OP, id=self.id) + return await self._execute(_GET_OP, id=self.workspace_id) async def set(self, env_vars: ResourceList[EnvVar] | list[dict[str, str]]) -> None: payload = ResourceList[EnvVar].model_validate(env_vars) - await self._execute(_BULK_SET_OP, id=self.id, data=payload) + await self._execute(_BULK_SET_OP, id=self.workspace_id, data=payload) async def delete(self, items: list[str] | ResourceList[EnvVar]) -> None: if not items: @@ -37,4 +31,4 @@ async def delete(self, items: list[str] | ResourceList[EnvVar]) -> None: payload.append(item["name"]) if payload: - await self._execute(_BULK_DELETE_OP, id=self.id, data=payload) + await self._execute(_BULK_DELETE_OP, id=self.workspace_id, data=payload) diff --git a/src/codesphere/resources/workspace/envVars/schemas.py b/src/codesphere/resources/workspace/env_vars/schemas.py similarity index 52% rename from src/codesphere/resources/workspace/envVars/schemas.py rename to src/codesphere/resources/workspace/env_vars/schemas.py index 0c81005..89fd1c7 100644 --- a/src/codesphere/resources/workspace/envVars/schemas.py +++ b/src/codesphere/resources/workspace/env_vars/schemas.py @@ -1,7 +1,7 @@ -from pydantic import BaseModel +from ....core.base import CamelModel -class EnvVar(BaseModel): +class EnvVar(CamelModel): """Environment variable model.""" name: str diff --git a/src/codesphere/resources/workspace/git/__init__.py b/src/codesphere/resources/workspace/git/__init__.py index 3d77710..e91a0c2 100644 --- a/src/codesphere/resources/workspace/git/__init__.py +++ b/src/codesphere/resources/workspace/git/__init__.py @@ -1,4 +1,4 @@ -from .models import WorkspaceGitManager -from .schema import GitHead +from .resources import WorkspaceGitManager +from .schemas import GitHead __all__ = ["GitHead", "WorkspaceGitManager"] diff --git a/src/codesphere/resources/workspace/git/operations.py b/src/codesphere/resources/workspace/git/operations.py index 34dc25e..31896c9 100644 --- a/src/codesphere/resources/workspace/git/operations.py +++ b/src/codesphere/resources/workspace/git/operations.py @@ -3,7 +3,7 @@ from types import NoneType from ....core.operations import APIOperation -from .schema import GitHead +from .schemas import GitHead _GET_HEAD_OP = APIOperation( method="GET", diff --git a/src/codesphere/resources/workspace/git/models.py b/src/codesphere/resources/workspace/git/resources.py similarity index 55% rename from src/codesphere/resources/workspace/git/models.py rename to src/codesphere/resources/workspace/git/resources.py index 97a5c0c..d6da64c 100644 --- a/src/codesphere/resources/workspace/git/models.py +++ b/src/codesphere/resources/workspace/git/resources.py @@ -2,29 +2,23 @@ import logging -from ....core.base import ResourceBase -from ....http_client import APIHttpClient +from ....core.base import WorkspaceScopedResource from .operations import ( _GET_HEAD_OP, _PULL_OP, _PULL_WITH_REMOTE_AND_BRANCH_OP, _PULL_WITH_REMOTE_OP, ) -from .schema import GitHead +from .schemas import GitHead log = logging.getLogger(__name__) -class WorkspaceGitManager(ResourceBase): +class WorkspaceGitManager(WorkspaceScopedResource): """Manager for git operations on a workspace.""" - def __init__(self, http_client: APIHttpClient, workspace_id: int): - super().__init__(http_client) - self._workspace_id = workspace_id - self.id = workspace_id - async def get_head(self) -> GitHead: - return await self._execute(_GET_HEAD_OP, id=self.id) + return await self._execute(_GET_HEAD_OP, id=self.workspace_id) async def pull( self, @@ -34,11 +28,13 @@ async def pull( if remote is not None and branch is not None: await self._execute( _PULL_WITH_REMOTE_AND_BRANCH_OP, - id=self.id, + id=self.workspace_id, remote=remote, branch=branch, ) elif remote is not None: - await self._execute(_PULL_WITH_REMOTE_OP, id=self.id, remote=remote) + await self._execute( + _PULL_WITH_REMOTE_OP, id=self.workspace_id, remote=remote + ) else: - await self._execute(_PULL_OP, id=self.id) + await self._execute(_PULL_OP, id=self.workspace_id) diff --git a/src/codesphere/resources/workspace/git/schema.py b/src/codesphere/resources/workspace/git/schemas.py similarity index 100% rename from src/codesphere/resources/workspace/git/schema.py rename to src/codesphere/resources/workspace/git/schemas.py diff --git a/src/codesphere/resources/workspace/landscape/__init__.py b/src/codesphere/resources/workspace/landscape/__init__.py index 7060d87..cb77be2 100644 --- a/src/codesphere/resources/workspace/landscape/__init__.py +++ b/src/codesphere/resources/workspace/landscape/__init__.py @@ -1,4 +1,4 @@ -from .models import WorkspaceLandscapeManager +from .resources import WorkspaceLandscapeManager from .schemas import ( ManagedServiceBuilder, ManagedServiceConfig, diff --git a/src/codesphere/resources/workspace/landscape/models.py b/src/codesphere/resources/workspace/landscape/models.py deleted file mode 100644 index 8b2b556..0000000 --- a/src/codesphere/resources/workspace/landscape/models.py +++ /dev/null @@ -1,194 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import re -from typing import TYPE_CHECKING - -from ....core.base import ResourceBase, ResourceList -from ....http_client import APIHttpClient -from .operations import ( - _DEPLOY_OP, - _DEPLOY_WITH_PROFILE_OP, - _GET_PIPELINE_STATUS_OP, - _SCALE_OP, - _START_PIPELINE_STAGE_OP, - _START_PIPELINE_STAGE_WITH_PROFILE_OP, - _STOP_PIPELINE_STAGE_OP, - _TEARDOWN_OP, -) -from .schemas import ( - PipelineStage, - PipelineState, - PipelineStatusList, - Profile, - ProfileConfig, -) - -if TYPE_CHECKING: - from ..schemas import CommandOutput - -log = logging.getLogger(__name__) - -# Regex pattern to match ci..yml files -_PROFILE_FILE_PATTERN = re.compile(r"^ci\.([A-Za-z0-9_-]+)\.yml$") -# Pattern for valid profile names -_VALID_PROFILE_NAME = re.compile(r"^[A-Za-z0-9_-]+$") - - -def _validate_profile_name(name: str) -> None: - if not _VALID_PROFILE_NAME.match(name): - raise ValueError( - f"Invalid profile name '{name}'. Must match pattern ^[A-Za-z0-9_-]+$" - ) - - -def _profile_filename(name: str) -> str: - _validate_profile_name(name) - return f"ci.{name}.yml" - - -class WorkspaceLandscapeManager(ResourceBase): - def __init__(self, http_client: APIHttpClient, workspace_id: int): - super().__init__(http_client) - self._workspace_id = workspace_id - self.id = workspace_id - - async def _run_command(self, command: str) -> CommandOutput: - from ..operations import _EXECUTE_COMMAND_OP - from ..schemas import CommandInput - - return await self._execute( - _EXECUTE_COMMAND_OP, id=self.id, data=CommandInput(command=command) - ) - - async def list_profiles(self) -> ResourceList[Profile]: - result = await self._run_command("ls -1 *.yml 2>/dev/null || true") - - profiles: list[Profile] = [] - if result.output: - for line in result.output.strip().split("\n"): - if match := _PROFILE_FILE_PATTERN.match(line.strip()): - profiles.append(Profile(name=match.group(1))) - - return ResourceList[Profile](root=profiles) - - async def save_profile(self, name: str, config: ProfileConfig | str) -> None: - filename = _profile_filename(name) - - yaml_content = config.to_yaml() if isinstance(config, ProfileConfig) else config - - body = yaml_content if yaml_content.endswith("\n") else yaml_content + "\n" - await self._run_command( - f"cat > {filename} << 'PROFILE_EOF'\n{body}PROFILE_EOF\n" - ) - - async def get_profile(self, name: str) -> str: - result = await self._run_command(f"cat {_profile_filename(name)}") - return result.output - - async def delete_profile(self, name: str) -> None: - await self._run_command(f"rm -f {_profile_filename(name)}") - - async def deploy(self, profile: str | None = None) -> None: - if profile is not None: - _validate_profile_name(profile) - await self._execute(_DEPLOY_WITH_PROFILE_OP, id=self.id, profile=profile) - else: - await self._execute(_DEPLOY_OP, id=self.id) - - async def teardown(self) -> None: - await self._execute(_TEARDOWN_OP, id=self.id) - - async def scale(self, services: dict[str, int]) -> None: - await self._execute(_SCALE_OP, id=self.id, data=services) - - async def start_stage( - self, - stage: PipelineStage | str, - profile: str | None = None, - ) -> None: - if isinstance(stage, PipelineStage): - stage = stage.value - - if profile is not None: - _validate_profile_name(profile) - await self._execute( - _START_PIPELINE_STAGE_WITH_PROFILE_OP, - id=self.id, - stage=stage, - profile=profile, - ) - else: - 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(_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(_GET_PIPELINE_STATUS_OP, id=self.id, stage=stage) - - async def wait_for_stage( - self, - stage: PipelineStage | str, - *, - timeout: float = 300.0, - poll_interval: float = 5.0, - server: str | None = None, - ) -> PipelineStatusList: - if poll_interval <= 0: - raise ValueError("poll_interval must be greater than 0") - - stage_name = stage.value if isinstance(stage, PipelineStage) else stage - elapsed = 0.0 - - while elapsed < timeout: - status_list = await self.get_stage_status(stage) - - relevant_statuses = [] - for s in status_list: - if server is not None: - if s.server == server: - relevant_statuses.append(s) - else: - if s.steps or s.state != PipelineState.WAITING: - relevant_statuses.append(s) - - if not relevant_statuses: - log.debug( - "Pipeline stage '%s': no servers with steps yet, waiting...", - stage_name, - ) - await asyncio.sleep(poll_interval) - elapsed += poll_interval - continue - - all_completed = all( - s.state - in (PipelineState.SUCCESS, PipelineState.FAILURE, PipelineState.ABORTED) - for s in relevant_statuses - ) - - if all_completed: - log.debug("Pipeline stage '%s' completed.", stage_name) - return PipelineStatusList(root=relevant_statuses) - - states = [f"{s.server}={s.state.value}" for s in relevant_statuses] - log.debug( - "Pipeline stage '%s' status: %s (elapsed: %.1fs)", - stage_name, - ", ".join(states), - elapsed, - ) - await asyncio.sleep(poll_interval) - elapsed += poll_interval - - raise TimeoutError( - f"Pipeline stage '{stage_name}' did not complete within {timeout} seconds." - ) diff --git a/src/codesphere/resources/workspace/landscape/operations.py b/src/codesphere/resources/workspace/landscape/operations.py index 858b818..4da6622 100644 --- a/src/codesphere/resources/workspace/landscape/operations.py +++ b/src/codesphere/resources/workspace/landscape/operations.py @@ -1,7 +1,8 @@ from types import NoneType +from ....core.base import ResourceList from ....core.operations import APIOperation -from .schemas import PipelineStatusList +from .schemas import PipelineStatus _DEPLOY_OP = APIOperation( method="POST", @@ -48,5 +49,5 @@ _GET_PIPELINE_STATUS_OP = APIOperation( method="GET", endpoint_template="/workspaces/{id}/pipeline/{stage}", - response_model=PipelineStatusList, + response_model=ResourceList[PipelineStatus], ) diff --git a/src/codesphere/resources/workspace/landscape/resources.py b/src/codesphere/resources/workspace/landscape/resources.py index e69de29..68601e8 100644 --- a/src/codesphere/resources/workspace/landscape/resources.py +++ b/src/codesphere/resources/workspace/landscape/resources.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import asyncio +import logging +import re +from typing import TYPE_CHECKING + +from ....core.base import ResourceList, WorkspaceScopedResource +from .operations import ( + _DEPLOY_OP, + _DEPLOY_WITH_PROFILE_OP, + _GET_PIPELINE_STATUS_OP, + _SCALE_OP, + _START_PIPELINE_STAGE_OP, + _START_PIPELINE_STAGE_WITH_PROFILE_OP, + _STOP_PIPELINE_STAGE_OP, + _TEARDOWN_OP, +) +from .schemas import ( + PipelineStage, + PipelineState, + PipelineStatusList, + Profile, + ProfileConfig, +) + +if TYPE_CHECKING: + from ..schemas import CommandOutput + +log = logging.getLogger(__name__) + +# Regex pattern to match ci..yml files +_PROFILE_FILE_PATTERN = re.compile(r"^ci\.([A-Za-z0-9_-]+)\.yml$") +# Pattern for valid profile names +_VALID_PROFILE_NAME = re.compile(r"^[A-Za-z0-9_-]+$") + + +def _validate_profile_name(name: str) -> None: + if not _VALID_PROFILE_NAME.match(name): + raise ValueError( + f"Invalid profile name '{name}'. Must match pattern ^[A-Za-z0-9_-]+$" + ) + + +def _profile_filename(name: str) -> str: + _validate_profile_name(name) + return f"ci.{name}.yml" + + +class WorkspaceLandscapeManager(WorkspaceScopedResource): + async def _run_command(self, command: str) -> CommandOutput: + from ..operations import _EXECUTE_COMMAND_OP + from ..schemas import CommandInput + + return await self._execute( + _EXECUTE_COMMAND_OP, + id=self.workspace_id, + data=CommandInput(command=command), + ) + + async def list_profiles(self) -> ResourceList[Profile]: + result = await self._run_command("ls -1 *.yml 2>/dev/null || true") + + profiles: list[Profile] = [] + if result.output: + for line in result.output.strip().split("\n"): + if match := _PROFILE_FILE_PATTERN.match(line.strip()): + profiles.append(Profile(name=match.group(1))) + + return ResourceList[Profile](root=profiles) + + async def save_profile(self, name: str, config: ProfileConfig | str) -> None: + filename = _profile_filename(name) + + yaml_content = config.to_yaml() if isinstance(config, ProfileConfig) else config + + body = yaml_content if yaml_content.endswith("\n") else yaml_content + "\n" + await self._run_command( + f"cat > {filename} << 'PROFILE_EOF'\n{body}PROFILE_EOF\n" + ) + + async def get_profile(self, name: str) -> str: + result = await self._run_command(f"cat {_profile_filename(name)}") + return result.output + + async def delete_profile(self, name: str) -> None: + await self._run_command(f"rm -f {_profile_filename(name)}") + + async def deploy(self, profile: str | None = None) -> None: + if profile is not None: + _validate_profile_name(profile) + await self._execute( + _DEPLOY_WITH_PROFILE_OP, id=self.workspace_id, profile=profile + ) + else: + await self._execute(_DEPLOY_OP, id=self.workspace_id) + + async def teardown(self) -> None: + await self._execute(_TEARDOWN_OP, id=self.workspace_id) + + async def scale(self, services: dict[str, int]) -> None: + await self._execute(_SCALE_OP, id=self.workspace_id, data=services) + + async def start_stage( + self, + stage: PipelineStage | str, + profile: str | None = None, + ) -> None: + if isinstance(stage, PipelineStage): + stage = stage.value + + if profile is not None: + _validate_profile_name(profile) + await self._execute( + _START_PIPELINE_STAGE_WITH_PROFILE_OP, + id=self.workspace_id, + stage=stage, + profile=profile, + ) + else: + await self._execute( + _START_PIPELINE_STAGE_OP, id=self.workspace_id, stage=stage + ) + + async def stop_stage(self, stage: PipelineStage | str) -> None: + if isinstance(stage, PipelineStage): + stage = stage.value + + await self._execute(_STOP_PIPELINE_STAGE_OP, id=self.workspace_id, stage=stage) + + async def get_stage_status(self, stage: PipelineStage | str) -> PipelineStatusList: + if isinstance(stage, PipelineStage): + stage = stage.value + + return await self._execute( + _GET_PIPELINE_STATUS_OP, id=self.workspace_id, stage=stage + ) + + async def wait_for_stage( + self, + stage: PipelineStage | str, + *, + timeout: float = 300.0, + poll_interval: float = 5.0, + server: str | None = None, + ) -> PipelineStatusList: + if poll_interval <= 0: + raise ValueError("poll_interval must be greater than 0") + + stage_name = stage.value if isinstance(stage, PipelineStage) else stage + elapsed = 0.0 + + while elapsed < timeout: + status_list = await self.get_stage_status(stage) + + relevant_statuses = [] + for s in status_list: + if server is not None: + if s.server == server: + relevant_statuses.append(s) + else: + if s.steps or s.state != PipelineState.WAITING: + relevant_statuses.append(s) + + if not relevant_statuses: + log.debug( + "Pipeline stage '%s': no servers with steps yet, waiting...", + stage_name, + ) + await asyncio.sleep(poll_interval) + elapsed += poll_interval + continue + + all_completed = all( + s.state + in (PipelineState.SUCCESS, PipelineState.FAILURE, PipelineState.ABORTED) + for s in relevant_statuses + ) + + if all_completed: + log.debug("Pipeline stage '%s' completed.", stage_name) + return PipelineStatusList(root=relevant_statuses) + + states = [f"{s.server}={s.state.value}" for s in relevant_statuses] + log.debug( + "Pipeline stage '%s' status: %s (elapsed: %.1fs)", + stage_name, + ", ".join(states), + elapsed, + ) + await asyncio.sleep(poll_interval) + elapsed += poll_interval + + raise TimeoutError( + f"Pipeline stage '{stage_name}' did not complete within {timeout} seconds." + ) diff --git a/src/codesphere/resources/workspace/landscape/schemas.py b/src/codesphere/resources/workspace/landscape/schemas.py index 9f9653d..2f7ebe0 100644 --- a/src/codesphere/resources/workspace/landscape/schemas.py +++ b/src/codesphere/resources/workspace/landscape/schemas.py @@ -4,9 +4,9 @@ from typing import Any, Literal import yaml -from pydantic import BaseModel, Field, RootModel +from pydantic import BaseModel, Field -from ....core.base import CamelModel +from ....core.base import CamelModel, ResourceList class PipelineStage(str, Enum): @@ -38,17 +38,8 @@ class PipelineStatus(CamelModel): server: str -class PipelineStatusList(RootModel[list[PipelineStatus]]): - root: list[PipelineStatus] - - def __iter__(self): - return iter(self.root) - - def __getitem__(self, item): - return self.root[item] - - def __len__(self): - return len(self.root) +# Alias kept for backwards compatibility; ResourceList provides the same API. +PipelineStatusList = ResourceList[PipelineStatus] class Profile(BaseModel): diff --git a/src/codesphere/resources/workspace/logs/__init__.py b/src/codesphere/resources/workspace/logs/__init__.py index e4836b9..9950129 100644 --- a/src/codesphere/resources/workspace/logs/__init__.py +++ b/src/codesphere/resources/workspace/logs/__init__.py @@ -1,4 +1,4 @@ -from .models import LogStream, WorkspaceLogManager +from .resources import LogStream, WorkspaceLogManager from .schemas import LogEntry, LogProblem, LogStage __all__ = [ diff --git a/src/codesphere/resources/workspace/logs/models.py b/src/codesphere/resources/workspace/logs/resources.py similarity index 96% rename from src/codesphere/resources/workspace/logs/models.py rename to src/codesphere/resources/workspace/logs/resources.py index fae0c8f..129daaa 100644 --- a/src/codesphere/resources/workspace/logs/models.py +++ b/src/codesphere/resources/workspace/logs/resources.py @@ -7,9 +7,9 @@ import httpx +from ....core.base import WorkspaceScopedResource from ....core.operations import StreamOperation from ....exceptions import APIError, ValidationError, raise_for_status -from ....http_client import APIHttpClient from .operations import ( _STREAM_REPLICA_LOGS_OP, _STREAM_SERVER_LOGS_OP, @@ -144,16 +144,11 @@ def _handle_problem(self, data_str: str) -> None: raise APIError(message=f"Invalid problem event: {data_str}") from e -class WorkspaceLogManager: +class WorkspaceLogManager(WorkspaceScopedResource): """Manager for streaming workspace logs via SSE.""" - def __init__(self, http_client: APIHttpClient, workspace_id: int): - self._http_client = http_client - self._workspace_id = workspace_id - self.id = workspace_id - def _build_endpoint(self, operation: StreamOperation, **kwargs) -> str: - return operation.endpoint_template.format(id=self._workspace_id, **kwargs) + return operation.endpoint_template.format(id=self.workspace_id, **kwargs) def _open_stream( self, diff --git a/src/codesphere/resources/workspace/schemas.py b/src/codesphere/resources/workspace/schemas.py index 67fcb9e..dc7cbd7 100644 --- a/src/codesphere/resources/workspace/schemas.py +++ b/src/codesphere/resources/workspace/schemas.py @@ -6,7 +6,7 @@ from ...core.base import BoundModel, CamelModel from ...utils import update_model_fields -from .envVars import EnvVar, WorkspaceEnvVarManager +from .env_vars import EnvVar, WorkspaceEnvVarManager from .git import WorkspaceGitManager from .landscape import WorkspaceLandscapeManager from .logs import WorkspaceLogManager diff --git a/src/codesphere/utils.py b/src/codesphere/utils.py index 7db3bcf..e6dfe99 100644 --- a/src/codesphere/utils.py +++ b/src/codesphere/utils.py @@ -1,12 +1,9 @@ import logging -from typing import Any, TypeVar from pydantic import BaseModel log = logging.getLogger(__name__) -T = TypeVar("T", bound=BaseModel) - def update_model_fields(target: BaseModel, source: BaseModel) -> None: if log.isEnabledFor(logging.DEBUG): @@ -16,31 +13,3 @@ def update_model_fields(target: BaseModel, source: BaseModel) -> None: for field_name in source.model_fields_set: value = getattr(source, field_name) setattr(target, field_name, value) - - -def dict_to_model_list( - data: dict[Any, Any], - model_cls: type[T], - key_field: str | None = None, - value_field: str | None = None, -) -> list[T]: - if key_field is None or value_field is None: - for name, field_info in model_cls.model_fields.items(): - if field_info.json_schema_extra: - if field_info.json_schema_extra.get("is_dict_key"): - key_field = name - elif field_info.json_schema_extra.get("is_dict_value"): - value_field = name - - if not key_field or not value_field: - raise ValueError( - f"Could not determine key/value mapping for {model_cls.__name__}. " - "Please explicitly pass key_field/value_field OR mark fields in the model." - ) - - items = [] - for key, value in data.items(): - item = model_cls(**{key_field: key, value_field: value}) - items.append(item) - - return items diff --git a/tests/resources/team/domain/test_domain.py b/tests/resources/team/domain/test_domain.py index f513f91..4d77511 100644 --- a/tests/resources/team/domain/test_domain.py +++ b/tests/resources/team/domain/test_domain.py @@ -1,6 +1,6 @@ import pytest -from codesphere.resources.team.domain.manager import TeamDomainManager +from codesphere.resources.team.domain import TeamDomainManager from codesphere.resources.team.domain.resources import Domain from codesphere.resources.team.domain.schemas import ( CustomDomainConfig, diff --git a/tests/resources/team/usage/test_usage.py b/tests/resources/team/usage/test_usage.py index 0ff70bb..7b0d9b6 100644 --- a/tests/resources/team/usage/test_usage.py +++ b/tests/resources/team/usage/test_usage.py @@ -2,7 +2,7 @@ import pytest -from codesphere.resources.team.usage.manager import TeamUsageManager +from codesphere.resources.team.usage import TeamUsageManager from codesphere.resources.team.usage.schemas import ( LandscapeServiceEvent, LandscapeServiceSummary, diff --git a/tests/resources/workspace/env_vars/test_env_vars.py b/tests/resources/workspace/env_vars/test_env_vars.py index 5112246..5518766 100644 --- a/tests/resources/workspace/env_vars/test_env_vars.py +++ b/tests/resources/workspace/env_vars/test_env_vars.py @@ -3,7 +3,7 @@ import pytest -from codesphere.resources.workspace.envVars import EnvVar, WorkspaceEnvVarManager +from codesphere.resources.workspace.env_vars import EnvVar, WorkspaceEnvVarManager @dataclass @@ -137,3 +137,26 @@ def test_env_var_with_special_characters(self): ) assert "p@ss=word" in env_var.value + + def test_env_var_is_camel_model(self): + """EnvVar should derive from CamelModel like every other schema.""" + from codesphere.core.base import CamelModel + + assert issubclass(EnvVar, CamelModel) + env_var = EnvVar(name="DEBUG", value="true") + assert env_var.to_dict() == {"name": "DEBUG", "value": "true"} + + +class TestDeprecatedEnvVarsAlias: + """The old camelCase module path must keep working with a warning.""" + + def test_envvars_import_warns_and_reexports(self): + import importlib + import sys + + sys.modules.pop("codesphere.resources.workspace.envVars", None) + with pytest.warns(DeprecationWarning, match="env_vars"): + legacy = importlib.import_module("codesphere.resources.workspace.envVars") + + assert legacy.EnvVar is EnvVar + assert legacy.WorkspaceEnvVarManager is WorkspaceEnvVarManager diff --git a/tests/resources/workspace/landscape/test_pipeline.py b/tests/resources/workspace/landscape/test_pipeline.py index c8aa74b..f2ce161 100644 --- a/tests/resources/workspace/landscape/test_pipeline.py +++ b/tests/resources/workspace/landscape/test_pipeline.py @@ -10,8 +10,8 @@ PipelineStatus, PipelineStatusList, StepStatus, + WorkspaceLandscapeManager, ) -from codesphere.resources.workspace.landscape.models import WorkspaceLandscapeManager class TestPipelineSchemas: @@ -243,3 +243,16 @@ async def test_wait_for_stage_invalid_poll_interval(self, landscape_manager): await landscape_manager.wait_for_stage( PipelineStage.PREPARE, poll_interval=0 ) + + +class TestPipelineStatusListAlias: + """PipelineStatusList is now an alias of the shared ResourceList.""" + + def test_alias_is_resource_list(self): + from codesphere.core.base import ResourceList + from codesphere.resources.workspace.landscape import ( + PipelineStatus, + PipelineStatusList, + ) + + assert PipelineStatusList is ResourceList[PipelineStatus] diff --git a/tests/resources/workspace/logs/test_logs.py b/tests/resources/workspace/logs/test_logs.py index 44ae58e..4fcad9f 100644 --- a/tests/resources/workspace/logs/test_logs.py +++ b/tests/resources/workspace/logs/test_logs.py @@ -173,8 +173,7 @@ def log_manager(self, mock_http_client): def test_init(self, log_manager, mock_http_client): assert log_manager._http_client == mock_http_client - assert log_manager._workspace_id == 123 - assert log_manager.id == 123 + assert log_manager.workspace_id == 123 def test_build_endpoint(self, log_manager): endpoint = log_manager._build_endpoint(