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
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:
@@ -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}'"
|
||||
)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user