fix(arch): route CLI project create through NamespacedProjectService
CI / benchmark-regression (push) Failing after 0s
CI / benchmark-publish (push) Failing after 0s
CI / push-validation (push) Successful in 23s
CI / helm (push) Successful in 43s
CI / build (push) Successful in 3m49s
CI / lint (push) Successful in 3m56s
CI / quality (push) Successful in 4m24s
CI / typecheck (push) Successful in 4m53s
CI / security (push) Successful in 4m55s
CI / e2e_tests (push) Successful in 7m0s
CI / integration_tests (push) Successful in 7m44s
CI / unit_tests (push) Successful in 8m37s
CI / docker (push) Successful in 1m37s
CI / coverage (push) Successful in 15m4s
CI / status-check (push) Successful in 3s
CI / docker (pull_request) Successful in 1m38s
CI / coverage (pull_request) Successful in 14m58s
CI / typecheck (pull_request) Successful in 4m24s
CI / push-validation (pull_request) Successful in 23s
CI / integration_tests (pull_request) Successful in 11m54s
CI / build (pull_request) Successful in 3m35s
CI / lint (pull_request) Successful in 3m49s
CI / helm (pull_request) Successful in 29s
CI / quality (pull_request) Successful in 4m13s
CI / security (pull_request) Successful in 4m38s
CI / e2e_tests (pull_request) Successful in 6m54s
CI / unit_tests (pull_request) Successful in 8m56s
CI / status-check (pull_request) Successful in 3s

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
This commit was merged in pull request #8297.
This commit is contained in:
2026-04-14 10:41:08 +00:00
committed by Forgejo
parent 7b1aeae282
commit e17a6ddec7
5 changed files with 862 additions and 28 deletions
+141
View File
@@ -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"
@@ -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<name>[^"]+)" 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<name>[^"]+)"'
r' with description "(?P<desc>[^"]+)" 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<name>[^"]+)" 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<name>[^"]+)" 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}'"
)
+10
View File
@@ -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(
@@ -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),
}
+31 -28
View File
@@ -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