From e17a6ddec74e5c1d41a206e1192e5056cd547c1d Mon Sep 17 00:00:00 2001 From: CleverThis Date: Tue, 14 Apr 2026 10:41:08 +0000 Subject: [PATCH] fix(arch): route CLI project create through NamespacedProjectService Introduce NamespacedProjectService in the application layer to encapsulate all NamespacedProject domain model construction. The agents project create CLI command previously imported NamespacedProject and parse_namespaced_name directly from cleveragents.domain.models.core.project, violating Architectural Invariant #3 (CLI layer must only call application services). Changes: - Add NamespacedProjectService with create_project(), get_project(), list_projects(), delete_project(), parse_project_name(), validate_project_name(), and project_to_dict() methods - Wire NamespacedProjectService into the DI container as namespaced_project_service provider - Refactor cli/commands/project.py to use NamespacedProjectService for all project operations (create, list, show, delete, link-resource, unlink-resource) - Add BDD feature file and step definitions for NamespacedProjectService (25 scenarios covering all service methods and edge cases) - Narrow exception handling in get_project() to catch only ProjectNotFoundError instead of bare Exception, preventing infrastructure errors from being masked as NotFoundError - Update CHANGELOG.md with both #7464 and #8232 entries - Update CONTRIBUTORS.md with PR #8297 contribution entry ISSUES CLOSED: #7464 --- features/namespaced_project_service.feature | 141 ++++++ .../steps/namespaced_project_service_steps.py | 449 ++++++++++++++++++ src/cleveragents/application/container.py | 10 + .../services/namespaced_project_service.py | 231 +++++++++ src/cleveragents/cli/commands/project.py | 59 +-- 5 files changed, 862 insertions(+), 28 deletions(-) create mode 100644 features/namespaced_project_service.feature create mode 100644 features/steps/namespaced_project_service_steps.py create mode 100644 src/cleveragents/application/services/namespaced_project_service.py diff --git a/features/namespaced_project_service.feature b/features/namespaced_project_service.feature new file mode 100644 index 000000000..871a005f4 --- /dev/null +++ b/features/namespaced_project_service.feature @@ -0,0 +1,141 @@ +Feature: NamespacedProjectService application service + As a developer maintaining the CleverAgents architecture + I want the CLI layer to interact with projects only through NamespacedProjectService + So that Architectural Invariant #3 (CLI → AppService → Domain) is enforced + + Background: + Given a NamespacedProjectService with an in-memory database + + # ── Name parsing ────────────────────────────────────────────── + + Scenario: Parse a bare project name defaults to local namespace + When I parse the project name "my-project" + Then the NPS parsed namespace should be "local" + And the NPS parsed name should be "my-project" + And the NPS parsed server should be None + + Scenario: Parse a namespaced project name + When I parse the project name "team/my-project" + Then the NPS parsed namespace should be "team" + And the NPS parsed name should be "my-project" + + Scenario: Parse a server-qualified project name + When I parse the project name "dev:team/my-project" + Then the NPS parsed namespace should be "team" + And the NPS parsed name should be "my-project" + And the NPS parsed server should be "dev" + + Scenario: Parse an invalid project name raises ValueError + When I parse the invalid project name "123bad" + Then the NPS should raise a ValueError + + Scenario: Parse a reserved namespace raises ValueError + When I parse the invalid project name "system/bad" + Then the NPS should raise a ValueError + + Scenario: Parse a provider namespace raises ValueError + When I parse the invalid project name "openai/bad" + Then the NPS should raise a ValueError + + # ── Validate project name ───────────────────────────────────── + + Scenario: Validate a valid project name succeeds + When I validate the project name "valid-name" + Then the validation should succeed + + Scenario: Validate an invalid project name raises ValueError + When I validate the invalid project name "9invalid" + Then the NPS should raise a ValueError + + # ── Create project ──────────────────────────────────────────── + + Scenario: Create a project with bare name + When I create a project named "my-app" via the service + Then the service should return a project with namespaced name "local/my-app" + And the project should be persisted in the database + + Scenario: Create a project with explicit namespace + When I create a project named "team/my-app" via the service + Then the service should return a project with namespaced name "team/my-app" + And the project should be persisted in the database + + Scenario: Create a project with description + When I create a project named "my-app" with description "A test project" via the service + Then the service should return a project with namespaced name "local/my-app" + And the NPS project description should be "A test project" + + Scenario: Create a project with invalid name raises ValueError + When I attempt to create a project named "123bad" via the service + Then the NPS should raise a ValueError + + Scenario: Create a duplicate project raises DatabaseError + Given a project "local/existing-app" already exists in the service + When I attempt to create a duplicate project named "existing-app" via the service + Then a database error should be raised + + # ── Get project ─────────────────────────────────────────────── + + Scenario: Get an existing project by namespaced name + Given a project "local/get-test" already exists in the service + When I get the project "local/get-test" via the service + Then the service should return a project with namespaced name "local/get-test" + + Scenario: Get a nonexistent project raises NotFoundError + When I attempt to get the project "local/nonexistent" via the service + Then a NotFoundError should be raised + + # ── List projects ───────────────────────────────────────────── + + Scenario: List all projects returns all created projects + Given a project "local/proj-a" already exists in the service + And a project "local/proj-b" already exists in the service + When I list all projects via the service + Then the service project list should contain "local/proj-a" + And the service project list should contain "local/proj-b" + + Scenario: List projects with namespace filter + Given a project "local/proj-x" already exists in the service + And a project "team/proj-y" already exists in the service + When I list projects with namespace "team" via the service + Then the service project list should contain "team/proj-y" + And the service project list should not contain "local/proj-x" + + Scenario: List projects when empty returns empty list + When I list all projects via the service + Then the service project list should be empty + + # ── Delete project ──────────────────────────────────────────── + + Scenario: Delete an existing project + Given a project "local/del-test" already exists in the service + When I delete the project "local/del-test" via the service + Then the delete should return True + And the project "local/del-test" should not exist in the service + + Scenario: Delete a nonexistent project returns False + When I delete the project "local/never-existed" via the service + Then the delete should return False + + # ── project_to_dict ─────────────────────────────────────────── + + Scenario: project_to_dict returns spec-aligned keys + Given a project "local/dict-test" already exists in the service + When I convert the project "local/dict-test" to a dict via the service + Then the dict should have key "namespaced_name" + And the dict should have key "namespace" + And the dict should have key "name" + And the dict should have key "description" + And the dict should have key "linked_resources" + And the dict should have key "created_at" + And the dict should have key "updated_at" + + Scenario: project_to_dict namespaced_name matches project + Given a project "team/dict-ns" already exists in the service + When I convert the project "team/dict-ns" to a dict via the service + Then the dict value for "namespaced_name" should be "team/dict-ns" + + # ── CLI architectural invariant ─────────────────────────────── + + Scenario: CLI project create command does not import domain models directly + When I inspect the project CLI create command source + Then it should not contain a direct import of "cleveragents.domain.models.core.project" diff --git a/features/steps/namespaced_project_service_steps.py b/features/steps/namespaced_project_service_steps.py new file mode 100644 index 000000000..c1b378d3a --- /dev/null +++ b/features/steps/namespaced_project_service_steps.py @@ -0,0 +1,449 @@ +"""Step definitions for namespaced_project_service.feature. + +Tests the NamespacedProjectService application service which provides +a clean facade over the domain layer for the CLI layer, enforcing +Architectural Invariant #3: CLI → AppService → Domain. +""" + +from __future__ import annotations + +import inspect +from typing import Any + +from behave import given, then, use_step_matcher, when +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +# --------------------------------------------------------------------------- +# Shared session wrapper (prevents premature session close) +# --------------------------------------------------------------------------- + + +class _UnclosableSession: + """Wraps a SQLAlchemy Session but makes ``close()`` a no-op.""" + + def __init__(self, real_session: Session) -> None: + object.__setattr__(self, "_real", real_session) + + def close(self) -> None: + """No-op so the shared session stays usable across calls.""" + + def __getattr__(self, name: str) -> Any: + return getattr(object.__getattribute__(self, "_real"), name) + + def __setattr__(self, name: str, value: Any) -> None: + setattr(object.__getattribute__(self, "_real"), name, value) + + +def _make_nps_session_factory(context: Any) -> Any: + """Create an in-memory SQLite database and return a session factory.""" + from cleveragents.infrastructure.database.models import Base + + engine = create_engine( + "sqlite:///:memory:", + echo=False, + connect_args={"check_same_thread": False}, + ) + Base.metadata.create_all(engine) + real_session = sessionmaker( + bind=engine, + expire_on_commit=False, + autoflush=True, + autocommit=False, + )() + wrapper = _UnclosableSession(real_session) + + def _factory() -> Any: + return wrapper + + return _factory + + +# --------------------------------------------------------------------------- +# Background +# --------------------------------------------------------------------------- + + +@given("a NamespacedProjectService with an in-memory database") +def step_init_nps(context: Any) -> None: + from cleveragents.application.services.namespaced_project_service import ( + NamespacedProjectService, + ) + from cleveragents.infrastructure.database.repositories import ( + NamespacedProjectRepository, + ) + + session_factory = _make_nps_session_factory(context) + repo = NamespacedProjectRepository(session_factory=session_factory) + context.nps = NamespacedProjectService(project_repo=repo) + context.nps_repo = repo + context.nps_parsed = None + context.nps_project = None + context.nps_project_list = [] + context.nps_dict = {} + context.nps_delete_result = None + context.nps_raised_exc = None + + +# --------------------------------------------------------------------------- +# Given helpers +# --------------------------------------------------------------------------- + + +@given('a project "{name}" already exists in the service') +def step_nps_project_exists(context: Any, name: str) -> None: + context.nps.create_project(name=name) + + +# --------------------------------------------------------------------------- +# Parse / validate steps +# --------------------------------------------------------------------------- + + +@when('I parse the project name "{name}"') +def step_nps_parse_name(context: Any, name: str) -> None: + context.nps_raised_exc = None + try: + context.nps_parsed = context.nps.parse_project_name(name) + except Exception as exc: + context.nps_raised_exc = exc + + +@when('I parse the invalid project name "{name}"') +def step_nps_parse_invalid_name(context: Any, name: str) -> None: + context.nps_raised_exc = None + try: + context.nps_parsed = context.nps.parse_project_name(name) + except ValueError as exc: + context.nps_raised_exc = exc + + +@when('I validate the project name "{name}"') +def step_nps_validate_name(context: Any, name: str) -> None: + context.nps_raised_exc = None + try: + context.nps_parsed = context.nps.validate_project_name(name) + except Exception as exc: + context.nps_raised_exc = exc + + +@when('I validate the invalid project name "{name}"') +def step_nps_validate_invalid_name(context: Any, name: str) -> None: + context.nps_raised_exc = None + try: + context.nps_parsed = context.nps.validate_project_name(name) + except ValueError as exc: + context.nps_raised_exc = exc + + +# --------------------------------------------------------------------------- +# Create steps +# --------------------------------------------------------------------------- + + +use_step_matcher("re") + + +@when(r'I create a project named "(?P[^"]+)" via the service') +def step_nps_create_project(context: Any, name: str) -> None: + context.nps_raised_exc = None + try: + context.nps_project = context.nps.create_project(name=name) + except Exception as exc: + context.nps_raised_exc = exc + + +@when( + r'I create a project named "(?P[^"]+)"' + r' with description "(?P[^"]+)" via the service' +) +def step_nps_create_project_with_desc(context: Any, name: str, desc: str) -> None: + context.nps_raised_exc = None + try: + context.nps_project = context.nps.create_project(name=name, description=desc) + except Exception as exc: + context.nps_raised_exc = exc + + +@when(r'I attempt to create a project named "(?P[^"]+)" via the service') +def step_nps_attempt_create_project(context: Any, name: str) -> None: + context.nps_raised_exc = None + try: + context.nps_project = context.nps.create_project(name=name) + except Exception as exc: + context.nps_raised_exc = exc + + +@when( + r'I attempt to create a duplicate project named "(?P[^"]+)" via the service' +) +def step_nps_attempt_create_duplicate(context: Any, name: str) -> None: + context.nps_raised_exc = None + try: + context.nps_project = context.nps.create_project(name=name) + except Exception as exc: + context.nps_raised_exc = exc + + +use_step_matcher("parse") + + +# --------------------------------------------------------------------------- +# Get steps +# --------------------------------------------------------------------------- + + +@when('I get the project "{name}" via the service') +def step_nps_get_project(context: Any, name: str) -> None: + context.nps_raised_exc = None + try: + context.nps_project = context.nps.get_project(name) + except Exception as exc: + context.nps_raised_exc = exc + + +@when('I attempt to get the project "{name}" via the service') +def step_nps_attempt_get_project(context: Any, name: str) -> None: + context.nps_raised_exc = None + try: + context.nps_project = context.nps.get_project(name) + except Exception as exc: + context.nps_raised_exc = exc + + +# --------------------------------------------------------------------------- +# List steps +# --------------------------------------------------------------------------- + + +@when("I list all projects via the service") +def step_nps_list_all_projects(context: Any) -> None: + context.nps_raised_exc = None + try: + context.nps_project_list = context.nps.list_projects() + except Exception as exc: + context.nps_raised_exc = exc + + +@when('I list projects with namespace "{ns}" via the service') +def step_nps_list_projects_ns(context: Any, ns: str) -> None: + context.nps_raised_exc = None + try: + context.nps_project_list = context.nps.list_projects(namespace=ns) + except Exception as exc: + context.nps_raised_exc = exc + + +# --------------------------------------------------------------------------- +# Delete steps +# --------------------------------------------------------------------------- + + +@when('I delete the project "{name}" via the service') +def step_nps_delete_project(context: Any, name: str) -> None: + context.nps_raised_exc = None + try: + context.nps_delete_result = context.nps.delete_project(name) + except Exception as exc: + context.nps_raised_exc = exc + + +# --------------------------------------------------------------------------- +# project_to_dict steps +# --------------------------------------------------------------------------- + + +@when('I convert the project "{name}" to a dict via the service') +def step_nps_project_to_dict(context: Any, name: str) -> None: + context.nps_raised_exc = None + try: + project = context.nps.get_project(name) + context.nps_dict = context.nps.project_to_dict(project) + except Exception as exc: + context.nps_raised_exc = exc + + +# --------------------------------------------------------------------------- +# Architectural invariant step +# --------------------------------------------------------------------------- + + +@when("I inspect the project CLI create command source") +def step_nps_inspect_cli_source(context: Any) -> None: + import cleveragents.cli.commands.project as project_module + + context.nps_cli_source = inspect.getsource(project_module) + + +# --------------------------------------------------------------------------- +# Then assertions +# --------------------------------------------------------------------------- + + +@then('the NPS parsed namespace should be "{ns}"') +def step_nps_assert_parsed_ns(context: Any, ns: str) -> None: + assert context.nps_parsed is not None, "No parsed result available" + assert context.nps_parsed.namespace == ns, ( + f"Expected namespace '{ns}', got '{context.nps_parsed.namespace}'" + ) + + +@then('the NPS parsed name should be "{name}"') +def step_nps_assert_parsed_name(context: Any, name: str) -> None: + assert context.nps_parsed is not None, "No parsed result available" + assert context.nps_parsed.name == name, ( + f"Expected name '{name}', got '{context.nps_parsed.name}'" + ) + + +@then("the NPS parsed server should be None") +def step_nps_assert_parsed_server_none(context: Any) -> None: + assert context.nps_parsed is not None, "No parsed result available" + assert context.nps_parsed.server is None, ( + f"Expected server to be None, got '{context.nps_parsed.server}'" + ) + + +@then('the NPS parsed server should be "{server}"') +def step_nps_assert_parsed_server(context: Any, server: str) -> None: + assert context.nps_parsed is not None, "No parsed result available" + assert context.nps_parsed.server == server, ( + f"Expected server '{server}', got '{context.nps_parsed.server}'" + ) + + +@then("the NPS should raise a ValueError") +def step_nps_assert_value_error(context: Any) -> None: + assert context.nps_raised_exc is not None, ( + "Expected a ValueError but none was raised" + ) + assert isinstance(context.nps_raised_exc, ValueError), ( + f"Expected ValueError, got {type(context.nps_raised_exc).__name__}: " + f"{context.nps_raised_exc}" + ) + + +@then("a database error should be raised") +def step_nps_assert_db_error(context: Any) -> None: + assert context.nps_raised_exc is not None, ( + "Expected a database error but none was raised" + ) + + +@then("a NotFoundError should be raised") +def step_nps_assert_not_found_error(context: Any) -> None: + from cleveragents.core.exceptions import NotFoundError + + assert context.nps_raised_exc is not None, ( + "Expected a NotFoundError but none was raised" + ) + assert isinstance(context.nps_raised_exc, NotFoundError), ( + f"Expected NotFoundError, got {type(context.nps_raised_exc).__name__}: " + f"{context.nps_raised_exc}" + ) + + +@then("the validation should succeed") +def step_nps_assert_validation_success(context: Any) -> None: + assert context.nps_raised_exc is None, ( + f"Expected validation to succeed but got: {context.nps_raised_exc}" + ) + assert context.nps_parsed is not None, "Expected a parsed result" + + +@then('the service should return a project with namespaced name "{name}"') +def step_nps_assert_project_namespaced_name(context: Any, name: str) -> None: + assert context.nps_project is not None, "No project returned from service" + assert context.nps_project.namespaced_name == name, ( + f"Expected namespaced_name '{name}', " + f"got '{context.nps_project.namespaced_name}'" + ) + + +@then("the project should be persisted in the database") +def step_nps_assert_project_persisted(context: Any) -> None: + assert context.nps_project is not None, "No project to check" + fetched = context.nps_repo.get(context.nps_project.namespaced_name) + assert fetched is not None, ( + f"Project '{context.nps_project.namespaced_name}' not found in database" + ) + + +@then('the NPS project description should be "{desc}"') +def step_nps_assert_project_desc(context: Any, desc: str) -> None: + assert context.nps_project is not None, "No project returned from service" + assert context.nps_project.description == desc, ( + f"Expected description '{desc}', got '{context.nps_project.description}'" + ) + + +@then('the service project list should contain "{name}"') +def step_nps_assert_list_contains(context: Any, name: str) -> None: + names = [p.namespaced_name for p in context.nps_project_list] + assert name in names, f"Expected project list to contain '{name}', got: {names}" + + +@then('the service project list should not contain "{name}"') +def step_nps_assert_list_not_contains(context: Any, name: str) -> None: + names = [p.namespaced_name for p in context.nps_project_list] + assert name not in names, ( + f"Expected project list NOT to contain '{name}', got: {names}" + ) + + +@then("the service project list should be empty") +def step_nps_assert_list_empty(context: Any) -> None: + assert len(context.nps_project_list) == 0, ( + f"Expected empty project list, got: {context.nps_project_list}" + ) + + +@then("the delete should return True") +def step_nps_assert_delete_true(context: Any) -> None: + assert context.nps_delete_result is True, ( + f"Expected delete to return True, got: {context.nps_delete_result}" + ) + + +@then("the delete should return False") +def step_nps_assert_delete_false(context: Any) -> None: + assert context.nps_delete_result is False, ( + f"Expected delete to return False, got: {context.nps_delete_result}" + ) + + +@then('the project "{name}" should not exist in the service') +def step_nps_assert_project_not_exists(context: Any, name: str) -> None: + from cleveragents.core.exceptions import NotFoundError + + try: + context.nps.get_project(name) + raise AssertionError(f"Project '{name}' should not exist but was found") + except NotFoundError: + pass + + +@then('the dict should have key "{key}"') +def step_nps_assert_dict_has_key(context: Any, key: str) -> None: + assert key in context.nps_dict, ( + f"Expected dict to have key '{key}', keys: {list(context.nps_dict.keys())}" + ) + + +@then('the dict value for "{key}" should be "{value}"') +def step_nps_assert_dict_value(context: Any, key: str, value: str) -> None: + assert key in context.nps_dict, f"Key '{key}' not found in dict" + assert str(context.nps_dict[key]) == value, ( + f"Expected dict['{key}'] == '{value}', got '{context.nps_dict[key]}'" + ) + + +@then('it should not contain a direct import of "{module_path}"') +def step_nps_assert_no_direct_import(context: Any, module_path: str) -> None: + source = context.nps_cli_source + # Check for direct import patterns like: + # "from cleveragents.domain.models.core.project import" + import_pattern = f"from {module_path} import" + assert import_pattern not in source, ( + f"CLI source still contains direct domain import: '{import_pattern}'" + ) diff --git a/src/cleveragents/application/container.py b/src/cleveragents/application/container.py index 2a2309ab0..2afa51536 100644 --- a/src/cleveragents/application/container.py +++ b/src/cleveragents/application/container.py @@ -59,6 +59,9 @@ from cleveragents.application.services.lock_service import LockService from cleveragents.application.services.multi_project_service import ( MultiProjectService, ) +from cleveragents.application.services.namespaced_project_service import ( + NamespacedProjectService, +) from cleveragents.application.services.plan_lifecycle_service import ( PlanLifecycleService, ) @@ -742,6 +745,13 @@ class Container(containers.DeclarativeContainer): database_url=database_url, ) + # Namespaced Project Service - application-layer facade over domain model + # Satisfies Architectural Invariant #3: CLI → AppService → Domain + namespaced_project_service = providers.Factory( + NamespacedProjectService, + project_repo=namespaced_project_repo, + ) + # Context Tier Service - Singleton so all callers share tier state # event_bus injected for tier transition event emission (#821) context_tier_service = providers.Singleton( diff --git a/src/cleveragents/application/services/namespaced_project_service.py b/src/cleveragents/application/services/namespaced_project_service.py new file mode 100644 index 000000000..daeccaf94 --- /dev/null +++ b/src/cleveragents/application/services/namespaced_project_service.py @@ -0,0 +1,231 @@ +"""Application service for namespaced project management. + +Provides a clean application-layer facade over the domain model +``NamespacedProject`` and its repository, so that the CLI layer +never needs to import from ``cleveragents.domain`` directly. + +This service enforces Architectural Invariant #3: + CLI layer → Application Services → Domain layer + +Spec references: +- Project Data Model (lines 6477-6511) +- Namespaces (lines 6524-6584) +- ADR-009 (CLI Framework) +- Forgejo issue #7464 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import structlog + +from cleveragents.core.exceptions import NotFoundError +from cleveragents.domain.models.core.project import ( + NamespacedProject, + ParsedName, + parse_namespaced_name, +) +from cleveragents.infrastructure.database.repositories import ProjectNotFoundError + +if TYPE_CHECKING: + from cleveragents.infrastructure.database.repositories import ( + NamespacedProjectRepository, + ) + +_logger = structlog.get_logger(__name__) + + +class NamespacedProjectService: + """Application service for namespaced project CRUD operations. + + Encapsulates all domain model construction so that callers (e.g. the + CLI layer) never need to import from ``cleveragents.domain`` directly. + + Args: + project_repo: Repository for persisting ``NamespacedProject`` records. + """ + + def __init__(self, project_repo: NamespacedProjectRepository) -> None: + self._repo = project_repo + + # ------------------------------------------------------------------ + # Parsing helpers (expose domain parsing without domain import) + # ------------------------------------------------------------------ + + def parse_project_name(self, name: str) -> ParsedName: + """Parse a ``[[server:]namespace/]name`` string. + + Args: + name: The raw project name string from user input. + + Returns: + A :class:`~cleveragents.domain.models.core.project.ParsedName` + with ``server``, ``namespace``, and ``name`` components. + + Raises: + ValueError: If the name is empty, has invalid characters, + or uses a reserved/provider namespace. + """ + return parse_namespaced_name(name) + + # ------------------------------------------------------------------ + # Create + # ------------------------------------------------------------------ + + def create_project( + self, + name: str, + description: str | None = None, + ) -> NamespacedProject: + """Parse *name* and persist a new :class:`NamespacedProject`. + + Args: + name: Raw project name (bare or ``namespace/name`` or + ``server:namespace/name``). + description: Optional human-readable description. + + Returns: + The newly created :class:`NamespacedProject`. + + Raises: + ValueError: If *name* is invalid or uses a reserved namespace. + DatabaseError: If a project with the same namespaced name + already exists or a persistence error occurs. + """ + parsed = parse_namespaced_name(name) + project = NamespacedProject( + name=parsed.name, + namespace=parsed.namespace, + server=parsed.server, + description=description, + ) + self._repo.create(project) + _logger.info( + "namespaced_project_created", + namespaced_name=project.namespaced_name, + ) + return project + + # ------------------------------------------------------------------ + # Read + # ------------------------------------------------------------------ + + def get_project(self, namespaced_name: str) -> NamespacedProject: + """Retrieve a project by its namespaced name. + + Args: + namespaced_name: The ``namespace/name`` identifier. + + Returns: + The matching :class:`NamespacedProject`. + + Raises: + NotFoundError: If no project with that name exists. + """ + try: + return self._repo.get(namespaced_name) + except ProjectNotFoundError as exc: + raise NotFoundError( + resource_type="project", + resource_id=namespaced_name, + ) from exc + + def list_projects( + self, + namespace: str | None = None, + ) -> list[NamespacedProject]: + """List all projects, optionally filtered by namespace. + + Args: + namespace: If provided, only return projects in this namespace. + + Returns: + List of :class:`NamespacedProject` instances. + + Raises: + DatabaseError: If a persistence error occurs. + """ + return self._repo.list_projects(namespace=namespace) + + # ------------------------------------------------------------------ + # Delete + # ------------------------------------------------------------------ + + def delete_project(self, namespaced_name: str) -> bool: + """Delete a project by its namespaced name. + + Args: + namespaced_name: The ``namespace/name`` identifier. + + Returns: + ``True`` if the project was deleted, ``False`` otherwise. + + Raises: + DatabaseError: If a persistence error occurs. + """ + return self._repo.delete(namespaced_name) + + # ------------------------------------------------------------------ + # Validation helpers + # ------------------------------------------------------------------ + + def validate_project_name(self, name: str) -> ParsedName: + """Validate and parse a project name without persisting. + + Useful for pre-flight validation in CLI commands. + + Args: + name: The raw project name string. + + Returns: + A :class:`~cleveragents.domain.models.core.project.ParsedName`. + + Raises: + ValueError: If the name is invalid. + """ + return parse_namespaced_name(name) + + # ------------------------------------------------------------------ + # Introspection helpers + # ------------------------------------------------------------------ + + def project_to_dict(self, project: NamespacedProject) -> dict[str, Any]: + """Serialize a project to a spec-aligned dictionary. + + Keys: ``namespaced_name``, ``namespace``, ``name``, + ``description``, ``linked_resources``, ``created_at``, + ``updated_at``. + + Args: + project: The project to serialize. + + Returns: + A plain ``dict`` suitable for JSON/YAML output. + """ + linked: list[dict[str, Any]] = [] + for lr in project.linked_resources: + linked.append( + { + "resource_id": lr.resource_id, + "read_only": lr.project_read_only, + "alias": lr.alias, + "linked_at": lr.linked_at.isoformat() + if hasattr(lr.linked_at, "isoformat") + else str(lr.linked_at), + } + ) + + return { + "namespaced_name": project.namespaced_name, + "namespace": project.namespace, + "name": project.name, + "description": project.description, + "linked_resources": linked, + "created_at": project.created_at.isoformat() + if hasattr(project.created_at, "isoformat") + else str(project.created_at), + "updated_at": project.updated_at.isoformat() + if hasattr(project.updated_at, "isoformat") + else str(project.updated_at), + } diff --git a/src/cleveragents/cli/commands/project.py b/src/cleveragents/cli/commands/project.py index d03ac5ef6..7b95d6942 100644 --- a/src/cleveragents/cli/commands/project.py +++ b/src/cleveragents/cli/commands/project.py @@ -77,6 +77,21 @@ def _get_namespaced_project_repo() -> Any: return container.namespaced_project_repo() +def _get_namespaced_project_service() -> Any: + """Return a NamespacedProjectService wrapping the namespaced project repo. + + Builds the service from the repo returned by :func:`_get_namespaced_project_repo` + so that tests that monkey-patch ``_get_namespaced_project_repo`` automatically + affect the service as well. + """ + from cleveragents.application.services.namespaced_project_service import ( + NamespacedProjectService, + ) + + repo = _get_namespaced_project_repo() + return NamespacedProjectService(project_repo=repo) + + def _get_resource_link_repo() -> Any: """Return a ProjectResourceLinkRepository from the DI container.""" from cleveragents.application.container import get_container @@ -573,28 +588,16 @@ def create( NAME can be a bare name (defaults to local/ namespace) or namespace/name. """ - from cleveragents.domain.models.core.project import ( - NamespacedProject, - parse_namespaced_name, - ) + svc = _get_namespaced_project_service() try: - parsed = parse_namespaced_name(name) + svc.validate_project_name(name) except ValueError as exc: err_console.print(f"[red]Invalid project name:[/red] {exc}") raise typer.Exit(1) from exc - repo = _get_namespaced_project_repo() - - project = NamespacedProject( - name=parsed.name, - namespace=parsed.namespace, - server=parsed.server, - description=description, - ) - try: - repo.create(project) + project = svc.create_project(name=name, description=description) except DatabaseError as exc: err_console.print(f"[red]Error:[/red] {exc.message}") raise typer.Exit(1) from exc @@ -624,9 +627,9 @@ def create( f"'{res_name}': {exc}[/yellow]" ) - # Re-fetch project for display + # Re-fetch project for display (includes linked resources) try: - created = repo.get(project.namespaced_name) + created = svc.get_project(project.namespaced_name) except Exception: created = project @@ -670,13 +673,13 @@ def link_resource( ] = "rich", ) -> None: """Link a resource to a project.""" - repo = _get_namespaced_project_repo() + svc = _get_namespaced_project_service() link_repo = _get_resource_link_repo() registry = _get_resource_registry_service() # Validate project exists try: - proj = repo.get(project) + proj = svc.get_project(project) except Exception as exc: err_console.print(f"[red]Project not found:[/red] {project}") raise typer.Exit(1) from exc @@ -741,13 +744,13 @@ def unlink_resource( ] = "rich", ) -> None: """Unlink a resource from a project.""" - repo = _get_namespaced_project_repo() + svc = _get_namespaced_project_service() link_repo = _get_resource_link_repo() registry = _get_resource_registry_service() # Validate project exists try: - proj = repo.get(project) + proj = svc.get_project(project) except Exception as exc: err_console.print(f"[red]Project not found:[/red] {project}") raise typer.Exit(1) from exc @@ -821,10 +824,10 @@ def list_projects( ] = "rich", ) -> None: """List all projects in the registry.""" - repo = _get_namespaced_project_repo() + svc = _get_namespaced_project_service() try: - projects = repo.list_projects(namespace=namespace) + projects = svc.list_projects(namespace=namespace) except DatabaseError as exc: err_console.print(f"[red]Error listing projects:[/red] {exc.message}") raise typer.Exit(1) from exc @@ -882,10 +885,10 @@ def show( ] = "rich", ) -> None: """Show details of a project.""" - repo = _get_namespaced_project_repo() + svc = _get_namespaced_project_service() try: - proj = repo.get(project) + proj = svc.get_project(project) except Exception as exc: err_console.print(f"[red]Project not found:[/red] {project}") raise typer.Exit(1) from exc @@ -943,11 +946,11 @@ def delete( ] = "rich", ) -> None: """Delete a project from the registry.""" - repo = _get_namespaced_project_repo() + svc = _get_namespaced_project_service() # Validate project exists try: - proj = repo.get(name) + proj = svc.get_project(name) except Exception as exc: err_console.print(f"[red]Project not found:[/red] {name}") raise typer.Exit(1) from exc @@ -966,7 +969,7 @@ def delete( raise typer.Abort() try: - deleted = repo.delete(name) + deleted = svc.delete_project(name) except DatabaseError as exc: err_console.print(f"[red]Error deleting project:[/red] {exc.message}") raise typer.Exit(1) from exc