Compare commits

...

11 Commits

Author SHA1 Message Date
khyari hamza 80eeca540b feat: add scenarios for parsing namespaced project names and name validation
CI / lint (pull_request) Successful in 17s
CI / typecheck (pull_request) Successful in 25s
CI / security (pull_request) Successful in 17s
CI / quality (pull_request) Successful in 15s
CI / build (pull_request) Successful in 13s
CI / behave (3.13) (pull_request) Successful in 3m46s
CI / docker (pull_request) Successful in 38s
CI / coverage (pull_request) Successful in 4m21s
2026-02-12 16:18:34 +01:00
khyari hamza 99c29d5e66 refactor: standardize Field defaults in project model classes
CI / lint (pull_request) Successful in 18s
CI / typecheck (pull_request) Successful in 26s
CI / security (pull_request) Successful in 17s
CI / quality (pull_request) Successful in 15s
CI / build (pull_request) Successful in 12s
CI / behave (3.13) (pull_request) Successful in 3m57s
CI / docker (pull_request) Successful in 39s
CI / coverage (pull_request) Successful in 4m21s
2026-02-12 16:11:55 +01:00
khyari hamza aa9f9c4d0b refactor: fix linting issues
CI / lint (pull_request) Successful in 15s
CI / typecheck (pull_request) Failing after 24s
CI / coverage (pull_request) Has been skipped
CI / security (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 14s
CI / build (pull_request) Successful in 12s
CI / behave (3.13) (pull_request) Successful in 3m46s
CI / docker (pull_request) Has been skipped
2026-02-12 15:59:21 +01:00
khyari hamza ccacbcc64f chore: reset implementation_plan.md to match master
CI / typecheck (pull_request) Failing after 24s
CI / security (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 14s
CI / build (pull_request) Successful in 11s
CI / behave (3.13) (pull_request) Successful in 3m46s
CI / lint (pull_request) Failing after 14s
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
2026-02-12 15:54:38 +01:00
khyari hamza 62e6243285 chore: untrack working notes files
CI / lint (pull_request) Failing after 16s
CI / typecheck (pull_request) Failing after 26s
CI / coverage (pull_request) Has been skipped
CI / security (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 15s
CI / build (pull_request) Successful in 12s
CI / behave (3.13) (pull_request) Has been cancelled
CI / docker (pull_request) Has been cancelled
2026-02-12 15:48:17 +01:00
freemo 93fe2c53e0 Docs: revampted implementation plan again
CI / lint (pull_request) Failing after 16s
CI / typecheck (pull_request) Failing after 27s
CI / coverage (pull_request) Has been skipped
CI / security (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 15s
CI / build (pull_request) Successful in 11s
CI / behave (3.13) (pull_request) Successful in 3m45s
CI / docker (pull_request) Has been skipped
2026-02-12 14:32:17 +00:00
freemo 234ce87682 Docs: updated the implementation plan again 2026-02-12 14:32:17 +00:00
freemo a7d702e85d Docs: Updated implementation plan with new plan plus added asv requirements to commits 2026-02-12 14:32:17 +00:00
freemo 5a2953b99c Docs: Updated implementation plan with the new specification 2026-02-12 14:32:17 +00:00
khyari hamza 676810d62b feat: Add ValidationConfig and ContextConfig models with step definitions
- Introduced ValidationConfig for project validation commands including test, lint, type-check, and build commands.
- Added ContextConfig for project context indexing and filtering, with support for ignore/include patterns and file size limits.
- Implemented step definitions for both models in project_config_model_steps.py.
- Enhanced Project model to include validation_config and context_config fields.
- Created comprehensive step definitions for Resource model, including validation and manipulation steps in resource_model_steps.py.
- Updated resource.py to define Resource model with necessary fields and validation.
- Refactored project.py to integrate new models and maintain backward compatibility.
2026-02-12 14:32:10 +00:00
khyari hamza 76f9e37b18 feat: add ResourceType and SandboxStrategy enums with associated tests 2026-02-12 14:32:10 +00:00
9 changed files with 2138 additions and 16 deletions
+130
View File
@@ -0,0 +1,130 @@
Feature: Project Configuration Models
As a developer
I want ValidationConfig and ContextConfig domain models
So that projects can define validation commands and context indexing behavior
# ValidationConfig Tests (B1.5)
# B1.5a - Model creation with defaults
Scenario: Create a default ValidationConfig
Given a default ValidationConfig
Then the validation test_command should be none
And the validation lint_command should be none
And the validation type_check_command should be none
And the validation build_command should be none
And the validation custom_commands should be empty
And the validation timeout_seconds should be 300
And the validation fail_on_lint_error should be true
# B1.5b - Fields with values
Scenario: Create a ValidationConfig with all commands
Given a ValidationConfig with test "pytest" lint "ruff check ." typecheck "pyright" build "python -m build"
Then the validation test_command should be "pytest"
And the validation lint_command should be "ruff check ."
And the validation type_check_command should be "pyright"
And the validation build_command should be "python -m build"
Scenario: Create a ValidationConfig with custom commands
Given a ValidationConfig with custom commands "security=bandit,docs=mkdocs build"
Then the validation custom_commands should contain "security" with value "bandit"
And the validation custom_commands should contain "docs" with value "mkdocs build"
Scenario: Create a ValidationConfig with custom timeout
Given a ValidationConfig with timeout 600
Then the validation timeout_seconds should be 600
Scenario: Create a ValidationConfig with fail_on_lint_error false
Given a ValidationConfig with fail_on_lint_error false
Then the validation fail_on_lint_error should be false
# B1.5c - Helper methods
Scenario: get_all_commands returns all configured commands
Given a ValidationConfig with test "pytest" lint "ruff check ." typecheck "pyright" build "python -m build"
Then get_all_commands should include "test" with value "pytest"
And get_all_commands should include "lint" with value "ruff check ."
And get_all_commands should include "type_check" with value "pyright"
And get_all_commands should include "build" with value "python -m build"
Scenario: get_all_commands includes custom commands
Given a ValidationConfig with test "pytest" and custom commands "security=bandit"
Then get_all_commands should include "test" with value "pytest"
And get_all_commands should include "security" with value "bandit"
Scenario: get_all_commands returns empty dict when no commands configured
Given a default ValidationConfig
Then get_all_commands should be empty
Scenario: has_any_validation is true when commands exist
Given a ValidationConfig with test "pytest" lint "ruff check ." typecheck "pyright" build "python -m build"
Then has_any_validation should be true
Scenario: has_any_validation is false when no commands configured
Given a default ValidationConfig
Then has_any_validation should be false
Scenario: has_any_validation is true with only custom commands
Given a ValidationConfig with custom commands "security=bandit,docs=mkdocs build"
Then has_any_validation should be true
# ContextConfig Tests (B1.6)
# B1.6a - Model creation with defaults
Scenario: Create a default ContextConfig
Given a default ContextConfig
Then the context max_file_size should be 1000000
And the context max_files should be 100000
And the context indexing_strategy should be "full_text"
And the context chunking_policy should be "smart"
And the context chunk_size should be 1000
And the context include_patterns should be none
Scenario: Default ContextConfig has default ignore patterns
Given a default ContextConfig
Then the context ignore_patterns should contain ".git/"
And the context ignore_patterns should contain "node_modules/"
And the context ignore_patterns should contain "__pycache__/"
And the context ignore_patterns should contain ".venv/"
And the context ignore_patterns should contain "*.pyc"
And the context ignore_patterns should contain ".DS_Store"
# B1.6b - Fields with values
Scenario: Create a ContextConfig with custom ignore patterns
Given a ContextConfig with ignore patterns "dist/,build/"
Then the context ignore_patterns should contain "dist/"
And the context ignore_patterns should contain "build/"
And the context ignore_patterns should contain ".git/"
Scenario: Create a ContextConfig with include patterns
Given a ContextConfig with include patterns "*.py,*.ts"
Then the context include_patterns should contain "*.py"
And the context include_patterns should contain "*.ts"
Scenario: Create a ContextConfig with custom max_file_size
Given a ContextConfig with max_file_size 2000000
Then the context max_file_size should be 2000000
Scenario: Create a ContextConfig with custom indexing strategy
Given a ContextConfig with indexing_strategy "semantic"
Then the context indexing_strategy should be "semantic"
Scenario: Create a ContextConfig with custom chunking policy
Given a ContextConfig with chunking_policy "fixed"
Then the context chunking_policy should be "fixed"
Scenario: Create a ContextConfig with custom chunk size
Given a ContextConfig with chunk_size 500
Then the context chunk_size should be 500
# B1.6c - Duplicate ignore patterns are deduplicated
Scenario: ContextConfig deduplicates ignore patterns that overlap with defaults
Given a ContextConfig with ignore patterns ".git/,node_modules/,custom/"
Then the context ignore_patterns should contain ".git/"
And the context ignore_patterns should contain "node_modules/"
And the context ignore_patterns should contain "custom/"
And the context ignore_patterns should not contain duplicate ".git/"
+141
View File
@@ -0,0 +1,141 @@
Feature: Project Domain Model Extensions
As a developer
I want the Project model to support ULID identifiers, namespaces, resources, and configs
So that projects can be properly categorized and managed
# B1.1a - Import Resource, verify Project has new fields
Scenario: Create a Project with new B1.1 fields
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" namespace "local"
Then the project project_id should be "01ARZ3NDEKTSV4RRFFQ69G5FAV"
And the project name should be "my-project"
And the project namespace should be "local"
# B1.1b - Identity fields
Scenario: Project has default namespace
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
Then the project namespace should be "local"
Scenario: Project description defaults to none
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
Then the project description should be none
Scenario: Create a Project with description
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" and description "A test project"
Then the project description should be "A test project"
# B1.1c - Categorization fields
Scenario: Project tags default to empty list
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
Then the project tags should be empty
Scenario: Create a Project with tags
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" and tags "python,web,api"
Then the project tags should contain "python"
And the project tags should contain "web"
And the project tags should contain "api"
Scenario: Project resources default to empty list
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
Then the project resources should be empty
Scenario: Project validation_config defaults to none
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
Then the project validation_config should be none
Scenario: Project context_config has defaults
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
Then the project context_config should not be none
# B1.1d - Timestamp fields
Scenario: Project has timestamps
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
Then the project created_at should be set
And the project updated_at should be set
# B1.1e - is_remote computed property
Scenario: Project with no resources is not remote
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
Then the project is_remote should be false
Scenario: Project with all remote resources is remote
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" and all remote resources
Then the project is_remote should be true
Scenario: Project with mixed resources is not remote
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" and mixed resources
Then the project is_remote should be false
# B1.1f - Namespace validator
Scenario: Namespace local is valid
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" namespace "local"
Then the project namespace should be "local"
Scenario: Namespace with valid pattern is accepted
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" namespace "team_alpha"
Then the project namespace should be "team_alpha"
Scenario: Namespace system is rejected
When I try to create a Project with namespace "system"
Then a namespace validation error should be raised
Scenario: Namespace with uppercase is rejected
When I try to create a Project with namespace "MyNamespace"
Then a namespace validation error should be raised
Scenario: Namespace starting with number is rejected
When I try to create a Project with namespace "1invalid"
Then a namespace validation error should be raised
# B1.1g - Helper methods
Scenario: namespaced_name returns correct format
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" namespace "team_alpha"
Then the project namespaced_name should be "team_alpha/my-project"
Scenario: namespaced_name with local namespace
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" namespace "local"
Then the project namespaced_name should be "local/my-project"
Scenario: add_resource adds a resource to the project
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
When I add a resource named "my-repo" to the project
Then the project should have 1 resource
And the project should have a resource named "my-repo"
Scenario: remove_resource removes a resource from the project
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" and a resource named "my-repo"
When I remove the resource named "my-repo" from the project
Then the project resources should be empty
Scenario: get_resource returns the correct resource
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-project" and a resource named "my-repo"
When I get the resource named "my-repo" from the project
Then the retrieved resource name should be "my-repo"
Scenario: get_resource returns none for unknown name
Given a Project with project_id "01ARZ3NDEKTSV4RRFFQ69G5FAV" and name "my-project"
When I get the resource named "nonexistent" from the project
Then the retrieved resource should be none
# B1.1h - parse_namespaced_name static method
Scenario: parse_namespaced_name splits valid namespaced string
When I parse the project namespaced name "team_alpha/my-project"
Then the parsed project namespace should be "team_alpha"
And the parsed project name should be "my-project"
Scenario: parse_namespaced_name rejects string without slash
When I try to parse the project namespaced name "no-slash-here"
Then a project namespaced name parse error should be raised
# B1.1i - Name validation edge cases
Scenario: Project rejects name with special characters
When I try to create a Project with invalid name "bad@name!"
Then a name validation error should be raised
+204
View File
@@ -0,0 +1,204 @@
Feature: Resource Domain Model
As a developer
I want resource types and sandbox strategies defined as domain enums
So that the system can classify project resources and determine sandboxing behavior
# ResourceType Enum Tests (B1.3)
Scenario: ResourceType has all required values
Then the resource types should be "git_repository, filesystem, database, api_endpoint, document_corpus, cloud_infrastructure"
Scenario: ResourceType GIT_REPOSITORY has correct value
When I access ResourceType.GIT_REPOSITORY
Then the resource type value should be "git_repository"
Scenario: ResourceType FILESYSTEM has correct value
When I access ResourceType.FILESYSTEM
Then the resource type value should be "filesystem"
Scenario: ResourceType DATABASE has correct value
When I access ResourceType.DATABASE
Then the resource type value should be "database"
Scenario: ResourceType API_ENDPOINT has correct value
When I access ResourceType.API_ENDPOINT
Then the resource type value should be "api_endpoint"
Scenario: ResourceType DOCUMENT_CORPUS has correct value
When I access ResourceType.DOCUMENT_CORPUS
Then the resource type value should be "document_corpus"
Scenario: ResourceType CLOUD_INFRASTRUCTURE has correct value
When I access ResourceType.CLOUD_INFRASTRUCTURE
Then the resource type value should be "cloud_infrastructure"
Scenario: ResourceType is a string enum
When I access ResourceType.GIT_REPOSITORY
Then the resource type should be a string
Scenario: ResourceType can be created from string value
When I create a ResourceType from string "filesystem"
Then the resource type value should be "filesystem"
Scenario: Invalid resource type string raises error
When I try to create a ResourceType from string "invalid_type"
Then a ValueError should be raised
# SandboxStrategy Enum Tests (B1.4)
Scenario: SandboxStrategy has all required values
Then the sandbox strategies should be "git_worktree, copy_on_write, overlay, transaction_rollback, versioning, none"
Scenario: SandboxStrategy GIT_WORKTREE has correct value
When I access SandboxStrategy.GIT_WORKTREE
Then the sandbox strategy value should be "git_worktree"
Scenario: SandboxStrategy COPY_ON_WRITE has correct value
When I access SandboxStrategy.COPY_ON_WRITE
Then the sandbox strategy value should be "copy_on_write"
Scenario: SandboxStrategy OVERLAY has correct value
When I access SandboxStrategy.OVERLAY
Then the sandbox strategy value should be "overlay"
Scenario: SandboxStrategy TRANSACTION_ROLLBACK has correct value
When I access SandboxStrategy.TRANSACTION_ROLLBACK
Then the sandbox strategy value should be "transaction_rollback"
Scenario: SandboxStrategy VERSIONING has correct value
When I access SandboxStrategy.VERSIONING
Then the sandbox strategy value should be "versioning"
Scenario: SandboxStrategy NONE has correct value
When I access SandboxStrategy.NONE
Then the sandbox strategy value should be "none"
Scenario: SandboxStrategy is a string enum
When I access SandboxStrategy.GIT_WORKTREE
Then the sandbox strategy should be a string
# SandboxStrategy.supports_rollback Tests (B1.4b)
Scenario: GIT_WORKTREE supports rollback
Then SandboxStrategy.GIT_WORKTREE should support rollback
Scenario: COPY_ON_WRITE supports rollback
Then SandboxStrategy.COPY_ON_WRITE should support rollback
Scenario: OVERLAY supports rollback
Then SandboxStrategy.OVERLAY should support rollback
Scenario: TRANSACTION_ROLLBACK supports rollback
Then SandboxStrategy.TRANSACTION_ROLLBACK should support rollback
Scenario: VERSIONING supports rollback
Then SandboxStrategy.VERSIONING should support rollback
Scenario: NONE does not support rollback
Then SandboxStrategy.NONE should not support rollback
# SandboxStrategy.is_copy_based Tests (B1.4b)
Scenario: COPY_ON_WRITE is copy based
Then SandboxStrategy.COPY_ON_WRITE should be copy based
Scenario: OVERLAY is copy based
Then SandboxStrategy.OVERLAY should be copy based
Scenario: GIT_WORKTREE is not copy based
Then SandboxStrategy.GIT_WORKTREE should not be copy based
Scenario: TRANSACTION_ROLLBACK is not copy based
Then SandboxStrategy.TRANSACTION_ROLLBACK should not be copy based
Scenario: VERSIONING is not copy based
Then SandboxStrategy.VERSIONING should not be copy based
Scenario: NONE is not copy based
Then SandboxStrategy.NONE should not be copy based
# Resource Pydantic Model Tests (B1.2)
# B1.2a - Model structure (frozen=True)
Scenario: Create a valid Resource with all required fields
Given a Resource with required fields id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo"
Then the resource name should be "my-repo"
And the resource type should be ResourceType.GIT_REPOSITORY
And the resource location should be "/tmp/repo"
Scenario: Resource model is frozen
Given I have a valid Resource
When I try to modify the resource name
Then a validation error should be raised
# B1.2b - All fields present with defaults
Scenario: Resource has correct default values
Given a Resource with required fields id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo"
Then the resource is_remote should be false
And the resource sandbox_strategy should be SandboxStrategy.NONE
And the resource read_only should be false
And the resource metadata should be empty
And the resource created_at should be set
Scenario: Create a remote Resource
Given a remote Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "api-svc" type "api_endpoint" location "https://api.example.com"
Then the resource is_remote should be true
Scenario: Create a read-only Resource
Given a read-only Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "docs" type "document_corpus" location "/data/docs"
Then the resource read_only should be true
Scenario: Create a Resource with custom sandbox strategy
Given a Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo" strategy "git_worktree"
Then the resource sandbox_strategy should be SandboxStrategy.GIT_WORKTREE
Scenario: Create a Resource with metadata
Given a Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo" metadata "branch=main,remote=origin"
Then the resource metadata should contain key "branch" with value "main"
And the resource metadata should contain key "remote" with value "origin"
# B1.2c - Validators
Scenario: Reject Resource with invalid ULID
When I try to create a Resource with id "not-a-ulid" name "my-repo" type "git_repository" location "/tmp/repo"
Then a validation error should be raised
Scenario: Reject Resource with empty name
When I try to create a Resource with an empty name
Then a validation error should be raised
Scenario: Reject Resource with empty location
When I try to create a Resource with an empty location
Then a validation error should be raised
Scenario: Resource name is lowercased
Given a Resource with required fields id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "My-Repo" type "git_repository" location "/tmp/repo"
Then the resource name should be "my-repo"
Scenario: Reject Resource with invalid name characters
When I try to create a Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my repo!!" type "git_repository" location "/tmp/repo"
Then a validation error should be raised
# B1.2d - Properties
Scenario: Resource with sandbox strategy supports sandbox
Given a Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo" strategy "git_worktree"
Then the resource supports_sandbox should be true
Scenario: Resource with NONE sandbox strategy does not support sandbox
Given a Resource with required fields id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo"
Then the resource supports_sandbox should be false
Scenario: Writable Resource can_write is true
Given a Resource with required fields id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo"
Then the resource can_write should be true
Scenario: Read-only Resource can_write is false
Given a read-only Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "docs" type "document_corpus" location "/data/docs"
Then the resource can_write should be false
Scenario: Resource get_sandbox_path returns correct path
Given a Resource with id "01ARZ3NDEKTSV4RRFFQ69G5FAV" name "my-repo" type "git_repository" location "/tmp/repo" strategy "git_worktree"
Then the resource get_sandbox_path with base "/sandbox" should end with "my-repo"
@@ -0,0 +1,313 @@
"""Step definitions for project configuration model tests (B1.5, B1.6)."""
from __future__ import annotations
from behave import given, then
from behave.runner import Context
from cleveragents.domain.models.core.project import ContextConfig, ValidationConfig
# ValidationConfig Steps (B1.5)
@given("a default ValidationConfig")
def step_default_validation_config(context: Context) -> None:
"""Create a ValidationConfig with all defaults."""
context.validation_config = ValidationConfig()
@given(
'a ValidationConfig with test "{test}" lint "{lint}" typecheck "{typecheck}" build "{build}"'
)
def step_validation_config_all_commands(
context: Context, test: str, lint: str, typecheck: str, build: str
) -> None:
"""Create a ValidationConfig with all standard commands."""
context.validation_config = ValidationConfig(
test_command=test,
lint_command=lint,
type_check_command=typecheck,
build_command=build,
)
@given('a ValidationConfig with custom commands "{commands_str}"')
def step_validation_config_custom_commands(context: Context, commands_str: str) -> None:
"""Create a ValidationConfig with custom commands parsed from key=value pairs."""
custom = {}
for pair in commands_str.split(","):
key, value = pair.strip().split("=", 1)
custom[key.strip()] = value.strip()
context.validation_config = ValidationConfig(custom_commands=custom)
@given("a ValidationConfig with timeout {timeout:d}")
def step_validation_config_timeout(context: Context, timeout: int) -> None:
"""Create a ValidationConfig with custom timeout."""
context.validation_config = ValidationConfig(timeout_seconds=timeout)
@given("a ValidationConfig with fail_on_lint_error false")
def step_validation_config_no_lint_fail(context: Context) -> None:
"""Create a ValidationConfig with fail_on_lint_error disabled."""
context.validation_config = ValidationConfig(fail_on_lint_error=False)
@given('a ValidationConfig with test "{test}" and custom commands "{commands_str}"')
def step_validation_config_test_and_custom(
context: Context, test: str, commands_str: str
) -> None:
"""Create a ValidationConfig with test command and custom commands."""
custom = {}
for pair in commands_str.split(","):
key, value = pair.strip().split("=", 1)
custom[key.strip()] = value.strip()
context.validation_config = ValidationConfig(
test_command=test, custom_commands=custom
)
# ValidationConfig field assertions
@then("the validation test_command should be none")
def step_check_test_command_none(context: Context) -> None:
"""Verify test_command is None."""
assert context.validation_config.test_command is None
@then('the validation test_command should be "{expected}"')
def step_check_test_command(context: Context, expected: str) -> None:
"""Verify test_command value."""
assert context.validation_config.test_command == expected, (
f"Expected '{expected}', got '{context.validation_config.test_command}'"
)
@then("the validation lint_command should be none")
def step_check_lint_command_none(context: Context) -> None:
"""Verify lint_command is None."""
assert context.validation_config.lint_command is None
@then('the validation lint_command should be "{expected}"')
def step_check_lint_command(context: Context, expected: str) -> None:
"""Verify lint_command value."""
assert context.validation_config.lint_command == expected, (
f"Expected '{expected}', got '{context.validation_config.lint_command}'"
)
@then("the validation type_check_command should be none")
def step_check_typecheck_command_none(context: Context) -> None:
"""Verify type_check_command is None."""
assert context.validation_config.type_check_command is None
@then('the validation type_check_command should be "{expected}"')
def step_check_typecheck_command(context: Context, expected: str) -> None:
"""Verify type_check_command value."""
assert context.validation_config.type_check_command == expected, (
f"Expected '{expected}', got '{context.validation_config.type_check_command}'"
)
@then("the validation build_command should be none")
def step_check_build_command_none(context: Context) -> None:
"""Verify build_command is None."""
assert context.validation_config.build_command is None
@then('the validation build_command should be "{expected}"')
def step_check_build_command(context: Context, expected: str) -> None:
"""Verify build_command value."""
assert context.validation_config.build_command == expected, (
f"Expected '{expected}', got '{context.validation_config.build_command}'"
)
@then("the validation custom_commands should be empty")
def step_check_custom_commands_empty(context: Context) -> None:
"""Verify custom_commands is empty dict."""
assert context.validation_config.custom_commands == {}
@then('the validation custom_commands should contain "{key}" with value "{value}"')
def step_check_custom_command(context: Context, key: str, value: str) -> None:
"""Verify custom_commands contains expected key-value pair."""
assert key in context.validation_config.custom_commands, (
f"Expected key '{key}' in custom_commands"
)
assert context.validation_config.custom_commands[key] == value, (
f"Expected custom_commands['{key}'] = '{value}', "
f"got '{context.validation_config.custom_commands[key]}'"
)
@then("the validation timeout_seconds should be {expected:d}")
def step_check_timeout(context: Context, expected: int) -> None:
"""Verify timeout_seconds value."""
assert context.validation_config.timeout_seconds == expected
@then("the validation fail_on_lint_error should be true")
def step_check_fail_on_lint_true(context: Context) -> None:
"""Verify fail_on_lint_error is True."""
assert context.validation_config.fail_on_lint_error is True
@then("the validation fail_on_lint_error should be false")
def step_check_fail_on_lint_false(context: Context) -> None:
"""Verify fail_on_lint_error is False."""
assert context.validation_config.fail_on_lint_error is False
# ValidationConfig method assertions
@then('get_all_commands should include "{key}" with value "{value}"')
def step_check_get_all_commands_key(context: Context, key: str, value: str) -> None:
"""Verify get_all_commands includes expected key-value pair."""
commands = context.validation_config.get_all_commands()
assert key in commands, (
f"Expected key '{key}' in get_all_commands(), got {commands}"
)
assert commands[key] == value, (
f"Expected commands['{key}'] = '{value}', got '{commands[key]}'"
)
@then("get_all_commands should be empty")
def step_check_get_all_commands_empty(context: Context) -> None:
"""Verify get_all_commands returns empty dict."""
commands = context.validation_config.get_all_commands()
assert commands == {}, f"Expected empty dict, got {commands}"
@then("has_any_validation should be true")
def step_check_has_validation_true(context: Context) -> None:
"""Verify has_any_validation returns True."""
assert context.validation_config.has_any_validation() is True
@then("has_any_validation should be false")
def step_check_has_validation_false(context: Context) -> None:
"""Verify has_any_validation returns False."""
assert context.validation_config.has_any_validation() is False
# ContextConfig Steps (B1.6)
@given("a default ContextConfig")
def step_default_context_config(context: Context) -> None:
"""Create a ContextConfig with all defaults."""
context.context_config = ContextConfig()
@given('a ContextConfig with ignore patterns "{patterns}"')
def step_context_config_ignore_patterns(context: Context, patterns: str) -> None:
"""Create a ContextConfig with custom ignore patterns."""
pattern_list = [p.strip() for p in patterns.split(",")]
context.context_config = ContextConfig(ignore_patterns=pattern_list)
@given('a ContextConfig with include patterns "{patterns}"')
def step_context_config_include_patterns(context: Context, patterns: str) -> None:
"""Create a ContextConfig with include patterns."""
pattern_list = [p.strip() for p in patterns.split(",")]
context.context_config = ContextConfig(include_patterns=pattern_list)
@given("a ContextConfig with max_file_size {size:d}")
def step_context_config_max_file_size(context: Context, size: int) -> None:
"""Create a ContextConfig with custom max_file_size."""
context.context_config = ContextConfig(max_file_size=size)
@given('a ContextConfig with indexing_strategy "{strategy}"')
def step_context_config_indexing_strategy(context: Context, strategy: str) -> None:
"""Create a ContextConfig with custom indexing strategy."""
context.context_config = ContextConfig(indexing_strategy=strategy)
@given('a ContextConfig with chunking_policy "{policy}"')
def step_context_config_chunking_policy(context: Context, policy: str) -> None:
"""Create a ContextConfig with custom chunking policy."""
context.context_config = ContextConfig(chunking_policy=policy)
@given("a ContextConfig with chunk_size {size:d}")
def step_context_config_chunk_size(context: Context, size: int) -> None:
"""Create a ContextConfig with custom chunk size."""
context.context_config = ContextConfig(chunk_size=size)
# ContextConfig field assertions
@then("the context max_file_size should be {expected:d}")
def step_check_max_file_size(context: Context, expected: int) -> None:
"""Verify max_file_size value."""
assert context.context_config.max_file_size == expected
@then("the context max_files should be {expected:d}")
def step_check_max_files(context: Context, expected: int) -> None:
"""Verify max_files value."""
assert context.context_config.max_files == expected
@then('the context indexing_strategy should be "{expected}"')
def step_check_indexing_strategy(context: Context, expected: str) -> None:
"""Verify indexing_strategy value."""
assert context.context_config.indexing_strategy == expected
@then('the context chunking_policy should be "{expected}"')
def step_check_chunking_policy(context: Context, expected: str) -> None:
"""Verify chunking_policy value."""
assert context.context_config.chunking_policy == expected
@then("the context chunk_size should be {expected:d}")
def step_check_chunk_size(context: Context, expected: int) -> None:
"""Verify chunk_size value."""
assert context.context_config.chunk_size == expected
@then("the context include_patterns should be none")
def step_check_include_patterns_none(context: Context) -> None:
"""Verify include_patterns is None."""
assert context.context_config.include_patterns is None
@then('the context ignore_patterns should contain "{pattern}"')
def step_check_ignore_pattern(context: Context, pattern: str) -> None:
"""Verify ignore_patterns contains expected pattern."""
assert pattern in context.context_config.ignore_patterns, (
f"Expected '{pattern}' in ignore_patterns, "
f"got {context.context_config.ignore_patterns}"
)
@then('the context include_patterns should contain "{pattern}"')
def step_check_include_pattern(context: Context, pattern: str) -> None:
"""Verify include_patterns contains expected pattern."""
assert context.context_config.include_patterns is not None, (
"include_patterns is None"
)
assert pattern in context.context_config.include_patterns, (
f"Expected '{pattern}' in include_patterns, "
f"got {context.context_config.include_patterns}"
)
@then('the context ignore_patterns should not contain duplicate "{pattern}"')
def step_check_no_duplicate_ignore_pattern(context: Context, pattern: str) -> None:
"""Verify ignore_patterns does not contain duplicates of the given pattern."""
count = context.context_config.ignore_patterns.count(pattern)
assert count == 1, (
f"Expected exactly 1 occurrence of '{pattern}' in ignore_patterns, "
f"found {count}: {context.context_config.ignore_patterns}"
)
+361
View File
@@ -0,0 +1,361 @@
"""Step definitions for Project domain model extension tests (B1.1)."""
from __future__ import annotations
from pathlib import Path
from behave import given, then, when
from behave.runner import Context
from pydantic import ValidationError
from cleveragents.domain.models.core.project import Project
from cleveragents.domain.models.core.resource import (
Resource,
ResourceType,
)
VALID_ULID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
VALID_ULID_2 = "01ARZ3NDEKTSV4RRFFQ69G5FAW"
VALID_ULID_3 = "01ARZ3NDEKTSV4RRFFQ69G5FAX"
def _make_resource(
name: str,
is_remote: bool = False,
resource_id: str = VALID_ULID_2,
) -> Resource:
"""Helper to create a test Resource."""
return Resource(
resource_id=resource_id,
name=name,
type=ResourceType.GIT_REPOSITORY,
location="/tmp/repo" if not is_remote else "https://github.com/test",
is_remote=is_remote,
)
# Project creation steps
@given('a Project with project_id "{pid}" name "{name}" namespace "{namespace}"')
def step_create_project_with_namespace(
context: Context, pid: str, name: str, namespace: str
) -> None:
"""Create a Project with explicit namespace."""
context.project = Project(
project_id=pid,
name=name,
namespace=namespace,
path=Path("/tmp/test"),
)
@given('a Project with project_id "{pid}" and name "{name}"')
def step_create_project_minimal(context: Context, pid: str, name: str) -> None:
"""Create a Project with minimal fields."""
context.project = Project(
project_id=pid,
name=name,
path=Path("/tmp/test"),
)
@given('a Project with project_id "{pid}" name "{name}" and description "{desc}"')
def step_create_project_with_description(
context: Context, pid: str, name: str, desc: str
) -> None:
"""Create a Project with description."""
context.project = Project(
project_id=pid,
name=name,
description=desc,
path=Path("/tmp/test"),
)
@given('a Project with project_id "{pid}" name "{name}" and tags "{tags_str}"')
def step_create_project_with_tags(
context: Context, pid: str, name: str, tags_str: str
) -> None:
"""Create a Project with tags."""
tags = [t.strip() for t in tags_str.split(",")]
context.project = Project(
project_id=pid,
name=name,
tags=tags,
path=Path("/tmp/test"),
)
@given('a Project with project_id "{pid}" name "{name}" and all remote resources')
def step_create_project_all_remote(context: Context, pid: str, name: str) -> None:
"""Create a Project where all resources are remote."""
resources = [
_make_resource("api-svc", is_remote=True, resource_id=VALID_ULID_2),
_make_resource("cloud-db", is_remote=True, resource_id=VALID_ULID_3),
]
context.project = Project(
project_id=pid,
name=name,
resources=resources,
path=Path("/tmp/test"),
)
@given('a Project with project_id "{pid}" name "{name}" and mixed resources')
def step_create_project_mixed_resources(context: Context, pid: str, name: str) -> None:
"""Create a Project with both local and remote resources."""
resources = [
_make_resource("local-repo", is_remote=False, resource_id=VALID_ULID_2),
_make_resource("api-svc", is_remote=True, resource_id=VALID_ULID_3),
]
context.project = Project(
project_id=pid,
name=name,
resources=resources,
path=Path("/tmp/test"),
)
@given('a Project with project_id "{pid}" name "{name}" and a resource named "{rname}"')
def step_create_project_with_resource(
context: Context, pid: str, name: str, rname: str
) -> None:
"""Create a Project with one resource."""
resource = _make_resource(rname)
context.project = Project(
project_id=pid,
name=name,
resources=[resource],
path=Path("/tmp/test"),
)
# Namespace validation steps
@when('I try to create a Project with namespace "{namespace}"')
def step_try_create_project_bad_namespace(context: Context, namespace: str) -> None:
"""Attempt to create a Project with an invalid namespace."""
context.error = None
try:
context.project = Project(
project_id=VALID_ULID,
name="test-project",
namespace=namespace,
path=Path("/tmp/test"),
)
except (ValidationError, ValueError) as e:
context.error = e
@then("a namespace validation error should be raised")
def step_check_namespace_error(context: Context) -> None:
"""Verify a namespace validation error was raised."""
assert context.error is not None, (
"Expected a namespace validation error but none was raised"
)
# Resource manipulation steps
@when('I add a resource named "{rname}" to the project')
def step_add_resource(context: Context, rname: str) -> None:
"""Add a resource to the project."""
resource = _make_resource(rname)
context.project = context.project.add_resource(resource)
@when('I remove the resource named "{rname}" from the project')
def step_remove_resource(context: Context, rname: str) -> None:
"""Remove a resource from the project."""
context.project = context.project.remove_resource(rname)
@when('I get the resource named "{rname}" from the project')
def step_get_resource(context: Context, rname: str) -> None:
"""Get a resource by name from the project."""
context.retrieved_resource = context.project.get_resource(rname)
# Project field assertions
@then('the project project_id should be "{expected}"')
def step_check_project_id(context: Context, expected: str) -> None:
"""Verify project_id."""
assert context.project.project_id == expected
# Note: "the project name should be" step is defined in service_steps.py
@then('the project namespace should be "{expected}"')
def step_check_project_namespace(context: Context, expected: str) -> None:
"""Verify project namespace."""
assert context.project.namespace == expected
@then("the project description should be none")
def step_check_project_description_none(context: Context) -> None:
"""Verify description is None."""
assert context.project.description is None
@then('the project description should be "{expected}"')
def step_check_project_description(context: Context, expected: str) -> None:
"""Verify description value."""
assert context.project.description == expected
@then("the project tags should be empty")
def step_check_project_tags_empty(context: Context) -> None:
"""Verify tags is empty list."""
assert context.project.tags == []
@then('the project tags should contain "{tag}"')
def step_check_project_tag(context: Context, tag: str) -> None:
"""Verify tags contains expected value."""
assert tag in context.project.tags
@then("the project resources should be empty")
def step_check_project_resources_empty(context: Context) -> None:
"""Verify resources is empty list."""
assert context.project.resources == []
@then("the project validation_config should be none")
def step_check_project_validation_config_none(context: Context) -> None:
"""Verify validation_config is None."""
assert context.project.validation_config is None
@then("the project context_config should not be none")
def step_check_project_context_config_not_none(context: Context) -> None:
"""Verify context_config is set."""
assert context.project.context_config is not None
@then("the project created_at should be set")
def step_check_project_created_at(context: Context) -> None:
"""Verify created_at is set."""
assert context.project.created_at is not None
@then("the project updated_at should be set")
def step_check_project_updated_at(context: Context) -> None:
"""Verify updated_at is set."""
assert context.project.updated_at is not None
@then("the project is_remote should be false")
def step_check_project_not_remote(context: Context) -> None:
"""Verify project is not remote."""
assert context.project.is_remote is False
@then("the project is_remote should be true")
def step_check_project_is_remote(context: Context) -> None:
"""Verify project is remote."""
assert context.project.is_remote is True
@then('the project namespaced_name should be "{expected}"')
def step_check_project_namespaced_name(context: Context, expected: str) -> None:
"""Verify namespaced_name property."""
assert context.project.namespaced_name == expected
@then("the project should have {count:d} resource")
def step_check_project_resource_count(context: Context, count: int) -> None:
"""Verify resource count."""
assert len(context.project.resources) == count
@then('the project should have a resource named "{rname}"')
def step_check_project_has_resource(context: Context, rname: str) -> None:
"""Verify project has a resource with the given name."""
found = context.project.get_resource(rname)
assert found is not None, f"Expected resource '{rname}' not found"
@then('the retrieved resource name should be "{expected}"')
def step_check_retrieved_resource_name(context: Context, expected: str) -> None:
"""Verify retrieved resource name."""
assert context.retrieved_resource is not None
assert context.retrieved_resource.name == expected
@then("the retrieved resource should be none")
def step_check_retrieved_resource_none(context: Context) -> None:
"""Verify retrieved resource is None."""
assert context.retrieved_resource is None
# parse_namespaced_name steps
@when('I parse the project namespaced name "{namespaced}"')
def step_parse_project_namespaced_name(context: Context, namespaced: str) -> None:
"""Parse a namespaced name string via Project.parse_namespaced_name."""
namespace, name = Project.parse_namespaced_name(namespaced)
context.parsed_project_namespace = namespace
context.parsed_project_name = name
@when('I try to parse the project namespaced name "{namespaced}"')
def step_try_parse_project_namespaced_name(context: Context, namespaced: str) -> None:
"""Attempt to parse an invalid namespaced name string."""
context.error = None
try:
Project.parse_namespaced_name(namespaced)
except ValueError as e:
context.error = e
@then('the parsed project namespace should be "{expected}"')
def step_check_parsed_project_namespace(context: Context, expected: str) -> None:
"""Verify parsed project namespace."""
assert context.parsed_project_namespace == expected
@then('the parsed project name should be "{expected}"')
def step_check_parsed_project_name(context: Context, expected: str) -> None:
"""Verify parsed project name."""
assert context.parsed_project_name == expected
@then("a project namespaced name parse error should be raised")
def step_check_project_namespaced_name_error(context: Context) -> None:
"""Verify a namespaced name parse error was raised."""
assert context.error is not None, (
"Expected a ValueError for invalid namespaced name but none was raised"
)
# Name validation steps
@when('I try to create a Project with invalid name "{name}"')
def step_try_create_project_bad_name(context: Context, name: str) -> None:
"""Attempt to create a Project with an invalid name."""
context.error = None
try:
context.project = Project(
project_id=VALID_ULID,
name=name,
path=Path("/tmp/test"),
)
except (ValidationError, ValueError) as e:
context.error = e
@then("a name validation error should be raised")
def step_check_name_error(context: Context) -> None:
"""Verify a name validation error was raised."""
assert context.error is not None, (
"Expected a name validation error but none was raised"
)
+558
View File
@@ -0,0 +1,558 @@
"""Step definitions for Resource domain model tests (B1.2, B1.3, B1.4)."""
from __future__ import annotations
from pathlib import Path
from behave import given, then, when
from behave.runner import Context
from pydantic import ValidationError
from cleveragents.domain.models.core.resource import (
Resource,
ResourceType,
SandboxStrategy,
)
# ResourceType Steps
@then('the resource types should be "{expected_order}"')
def step_check_resource_type_values(context: Context, expected_order: str) -> None:
"""Verify that ResourceType has all expected values in order."""
expected = [v.strip() for v in expected_order.split(",")]
actual = [rt.value for rt in ResourceType]
assert actual == expected, f"Expected resource types {expected}, got {actual}"
@when("I access ResourceType.GIT_REPOSITORY")
def step_access_git_repository(context: Context) -> None:
"""Access the GIT_REPOSITORY enum member."""
context.resource_type = ResourceType.GIT_REPOSITORY
@when("I access ResourceType.FILESYSTEM")
def step_access_filesystem(context: Context) -> None:
"""Access the FILESYSTEM enum member."""
context.resource_type = ResourceType.FILESYSTEM
@when("I access ResourceType.DATABASE")
def step_access_database(context: Context) -> None:
"""Access the DATABASE enum member."""
context.resource_type = ResourceType.DATABASE
@when("I access ResourceType.API_ENDPOINT")
def step_access_api_endpoint(context: Context) -> None:
"""Access the API_ENDPOINT enum member."""
context.resource_type = ResourceType.API_ENDPOINT
@when("I access ResourceType.DOCUMENT_CORPUS")
def step_access_document_corpus(context: Context) -> None:
"""Access the DOCUMENT_CORPUS enum member."""
context.resource_type = ResourceType.DOCUMENT_CORPUS
@when("I access ResourceType.CLOUD_INFRASTRUCTURE")
def step_access_cloud_infrastructure(context: Context) -> None:
"""Access the CLOUD_INFRASTRUCTURE enum member."""
context.resource_type = ResourceType.CLOUD_INFRASTRUCTURE
@then('the resource type value should be "{expected}"')
def step_check_resource_type_value(context: Context, expected: str) -> None:
"""Check the resource type value matches expected."""
actual = context.resource_type.value
assert actual == expected, f"Expected value '{expected}', got '{actual}'"
@then("the resource type should be a string")
def step_check_resource_type_is_string(context: Context) -> None:
"""Verify ResourceType inherits from str."""
assert isinstance(context.resource_type, str), (
f"Expected ResourceType to be a string, got {type(context.resource_type)}"
)
@when('I create a ResourceType from string "{value}"')
def step_create_resource_type_from_string(context: Context, value: str) -> None:
"""Create a ResourceType from a string value."""
context.error = None
try:
context.resource_type = ResourceType(value)
except ValueError as e:
context.error = e
@when('I try to create a ResourceType from string "{value}"')
def step_try_create_resource_type_from_string(context: Context, value: str) -> None:
"""Attempt to create a ResourceType from an invalid string."""
context.error = None
try:
context.resource_type = ResourceType(value)
except ValueError as e:
context.error = e
@then("a ValueError should be raised")
def step_check_value_error_raised(context: Context) -> None:
"""Verify a ValueError was captured."""
assert context.error is not None, "Expected a ValueError but none was raised"
assert isinstance(context.error, ValueError), (
f"Expected ValueError, got {type(context.error).__name__}"
)
# SandboxStrategy Steps
@then('the sandbox strategies should be "{expected_order}"')
def step_check_sandbox_strategy_values(context: Context, expected_order: str) -> None:
"""Verify that SandboxStrategy has all expected values in order."""
expected = [v.strip() for v in expected_order.split(",")]
actual = [ss.value for ss in SandboxStrategy]
assert actual == expected, f"Expected sandbox strategies {expected}, got {actual}"
@when("I access SandboxStrategy.GIT_WORKTREE")
def step_access_git_worktree(context: Context) -> None:
"""Access the GIT_WORKTREE enum member."""
context.sandbox_strategy = SandboxStrategy.GIT_WORKTREE
@when("I access SandboxStrategy.COPY_ON_WRITE")
def step_access_copy_on_write(context: Context) -> None:
"""Access the COPY_ON_WRITE enum member."""
context.sandbox_strategy = SandboxStrategy.COPY_ON_WRITE
@when("I access SandboxStrategy.OVERLAY")
def step_access_overlay(context: Context) -> None:
"""Access the OVERLAY enum member."""
context.sandbox_strategy = SandboxStrategy.OVERLAY
@when("I access SandboxStrategy.TRANSACTION_ROLLBACK")
def step_access_transaction_rollback(context: Context) -> None:
"""Access the TRANSACTION_ROLLBACK enum member."""
context.sandbox_strategy = SandboxStrategy.TRANSACTION_ROLLBACK
@when("I access SandboxStrategy.VERSIONING")
def step_access_versioning(context: Context) -> None:
"""Access the VERSIONING enum member."""
context.sandbox_strategy = SandboxStrategy.VERSIONING
@when("I access SandboxStrategy.NONE")
def step_access_none_strategy(context: Context) -> None:
"""Access the NONE enum member."""
context.sandbox_strategy = SandboxStrategy.NONE
@then('the sandbox strategy value should be "{expected}"')
def step_check_sandbox_strategy_value(context: Context, expected: str) -> None:
"""Check the sandbox strategy value matches expected."""
actual = context.sandbox_strategy.value
assert actual == expected, f"Expected value '{expected}', got '{actual}'"
@then("the sandbox strategy should be a string")
def step_check_sandbox_strategy_is_string(context: Context) -> None:
"""Verify SandboxStrategy inherits from str."""
assert isinstance(context.sandbox_strategy, str), (
f"Expected SandboxStrategy to be a string, got {type(context.sandbox_strategy)}"
)
# SandboxStrategy.supports_rollback Tests
@then("SandboxStrategy.GIT_WORKTREE should support rollback")
def step_git_worktree_supports_rollback(context: Context) -> None:
"""Verify GIT_WORKTREE supports rollback."""
assert SandboxStrategy.GIT_WORKTREE.supports_rollback is True
@then("SandboxStrategy.COPY_ON_WRITE should support rollback")
def step_copy_on_write_supports_rollback(context: Context) -> None:
"""Verify COPY_ON_WRITE supports rollback."""
assert SandboxStrategy.COPY_ON_WRITE.supports_rollback is True
@then("SandboxStrategy.OVERLAY should support rollback")
def step_overlay_supports_rollback(context: Context) -> None:
"""Verify OVERLAY supports rollback."""
assert SandboxStrategy.OVERLAY.supports_rollback is True
@then("SandboxStrategy.TRANSACTION_ROLLBACK should support rollback")
def step_transaction_rollback_supports_rollback(context: Context) -> None:
"""Verify TRANSACTION_ROLLBACK supports rollback."""
assert SandboxStrategy.TRANSACTION_ROLLBACK.supports_rollback is True
@then("SandboxStrategy.VERSIONING should support rollback")
def step_versioning_supports_rollback(context: Context) -> None:
"""Verify VERSIONING supports rollback."""
assert SandboxStrategy.VERSIONING.supports_rollback is True
@then("SandboxStrategy.NONE should not support rollback")
def step_none_does_not_support_rollback(context: Context) -> None:
"""Verify NONE does not support rollback."""
assert SandboxStrategy.NONE.supports_rollback is False
# SandboxStrategy.is_copy_based Tests
@then("SandboxStrategy.COPY_ON_WRITE should be copy based")
def step_copy_on_write_is_copy_based(context: Context) -> None:
"""Verify COPY_ON_WRITE is copy based."""
assert SandboxStrategy.COPY_ON_WRITE.is_copy_based is True
@then("SandboxStrategy.OVERLAY should be copy based")
def step_overlay_is_copy_based(context: Context) -> None:
"""Verify OVERLAY is copy based."""
assert SandboxStrategy.OVERLAY.is_copy_based is True
@then("SandboxStrategy.GIT_WORKTREE should not be copy based")
def step_git_worktree_not_copy_based(context: Context) -> None:
"""Verify GIT_WORKTREE is not copy based."""
assert SandboxStrategy.GIT_WORKTREE.is_copy_based is False
@then("SandboxStrategy.TRANSACTION_ROLLBACK should not be copy based")
def step_transaction_rollback_not_copy_based(context: Context) -> None:
"""Verify TRANSACTION_ROLLBACK is not copy based."""
assert SandboxStrategy.TRANSACTION_ROLLBACK.is_copy_based is False
@then("SandboxStrategy.VERSIONING should not be copy based")
def step_versioning_not_copy_based(context: Context) -> None:
"""Verify VERSIONING is not copy based."""
assert SandboxStrategy.VERSIONING.is_copy_based is False
@then("SandboxStrategy.NONE should not be copy based")
def step_none_not_copy_based(context: Context) -> None:
"""Verify NONE is not copy based."""
assert SandboxStrategy.NONE.is_copy_based is False
# Resource Pydantic Model Steps (B1.2)
VALID_ULID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
@given(
'a Resource with required fields id "{resource_id}" name "{name}" type "{rtype}" location "{location}"'
)
def step_create_resource(
context: Context, resource_id: str, name: str, rtype: str, location: str
) -> None:
"""Create a Resource with required fields only."""
context.error = None
try:
context.resource = Resource(
resource_id=resource_id,
name=name,
type=ResourceType(rtype),
location=location,
)
except (ValidationError, ValueError) as e:
context.error = e
@given("I have a valid Resource")
def step_have_valid_resource(context: Context) -> None:
"""Create a valid Resource for mutation testing."""
context.resource = Resource(
resource_id=VALID_ULID,
name="my-repo",
type=ResourceType.GIT_REPOSITORY,
location="/tmp/repo",
)
@when("I try to modify the resource name")
def step_try_modify_resource_name(context: Context) -> None:
"""Attempt to modify a frozen model field."""
context.error = None
try:
context.resource.name = "new-name" # type: ignore[misc]
except ValidationError as e:
context.error = e
@given(
'a remote Resource with id "{resource_id}" name "{name}" type "{rtype}" location "{location}"'
)
def step_create_resource_remote(
context: Context, resource_id: str, name: str, rtype: str, location: str
) -> None:
"""Create a remote Resource."""
context.error = None
try:
context.resource = Resource(
resource_id=resource_id,
name=name,
type=ResourceType(rtype),
location=location,
is_remote=True,
)
except (ValidationError, ValueError) as e:
context.error = e
@given(
'a read-only Resource with id "{resource_id}" name "{name}" type "{rtype}" location "{location}"'
)
def step_create_resource_read_only(
context: Context, resource_id: str, name: str, rtype: str, location: str
) -> None:
"""Create a read-only Resource."""
context.error = None
try:
context.resource = Resource(
resource_id=resource_id,
name=name,
type=ResourceType(rtype),
location=location,
read_only=True,
)
except (ValidationError, ValueError) as e:
context.error = e
@given(
'a Resource with id "{resource_id}" name "{name}" type "{rtype}" location "{location}" strategy "{strategy}"'
)
def step_create_resource_with_strategy(
context: Context,
resource_id: str,
name: str,
rtype: str,
location: str,
strategy: str,
) -> None:
"""Create a Resource with a specific sandbox strategy."""
context.error = None
try:
context.resource = Resource(
resource_id=resource_id,
name=name,
type=ResourceType(rtype),
location=location,
sandbox_strategy=SandboxStrategy(strategy),
)
except (ValidationError, ValueError) as e:
context.error = e
@given(
'a Resource with id "{resource_id}" name "{name}" type "{rtype}" location "{location}" metadata "{meta_str}"'
)
def step_create_resource_with_metadata(
context: Context,
resource_id: str,
name: str,
rtype: str,
location: str,
meta_str: str,
) -> None:
"""Create a Resource with metadata parsed from key=value pairs."""
metadata = {}
for pair in meta_str.split(","):
key, value = pair.strip().split("=", 1)
metadata[key.strip()] = value.strip()
context.error = None
try:
context.resource = Resource(
resource_id=resource_id,
name=name,
type=ResourceType(rtype),
location=location,
metadata=metadata,
)
except (ValidationError, ValueError) as e:
context.error = e
@when(
'I try to create a Resource with id "{resource_id}" name "{name}" type "{rtype}" location "{location}"'
)
def step_try_create_resource(
context: Context, resource_id: str, name: str, rtype: str, location: str
) -> None:
"""Attempt to create a Resource that may fail validation."""
context.error = None
try:
context.resource = Resource(
resource_id=resource_id,
name=name,
type=ResourceType(rtype),
location=location,
)
except (ValidationError, ValueError) as e:
context.error = e
@when("I try to create a Resource with an empty name")
def step_try_create_resource_empty_name(context: Context) -> None:
"""Attempt to create a Resource with empty name."""
context.error = None
try:
context.resource = Resource(
resource_id=VALID_ULID,
name="",
type=ResourceType.GIT_REPOSITORY,
location="/tmp/repo",
)
except (ValidationError, ValueError) as e:
context.error = e
@when("I try to create a Resource with an empty location")
def step_try_create_resource_empty_location(context: Context) -> None:
"""Attempt to create a Resource with empty location."""
context.error = None
try:
context.resource = Resource(
resource_id=VALID_ULID,
name="my-repo",
type=ResourceType.GIT_REPOSITORY,
location="",
)
except (ValidationError, ValueError) as e:
context.error = e
# Resource field assertion steps
@then('the resource name should be "{expected}"')
def step_check_resource_name(context: Context, expected: str) -> None:
"""Verify resource name."""
assert context.resource.name == expected, (
f"Expected name '{expected}', got '{context.resource.name}'"
)
@then("the resource type should be ResourceType.GIT_REPOSITORY")
def step_check_resource_type_git(context: Context) -> None:
"""Verify resource type is GIT_REPOSITORY."""
assert context.resource.type == ResourceType.GIT_REPOSITORY
@then('the resource location should be "{expected}"')
def step_check_resource_location(context: Context, expected: str) -> None:
"""Verify resource location."""
assert context.resource.location == expected, (
f"Expected location '{expected}', got '{context.resource.location}'"
)
@then("the resource is_remote should be false")
def step_check_resource_not_remote(context: Context) -> None:
"""Verify resource is not remote."""
assert context.resource.is_remote is False
@then("the resource is_remote should be true")
def step_check_resource_is_remote(context: Context) -> None:
"""Verify resource is remote."""
assert context.resource.is_remote is True
@then("the resource sandbox_strategy should be SandboxStrategy.NONE")
def step_check_resource_strategy_none(context: Context) -> None:
"""Verify default sandbox strategy is NONE."""
assert context.resource.sandbox_strategy == SandboxStrategy.NONE
@then("the resource sandbox_strategy should be SandboxStrategy.GIT_WORKTREE")
def step_check_resource_strategy_worktree(context: Context) -> None:
"""Verify sandbox strategy is GIT_WORKTREE."""
assert context.resource.sandbox_strategy == SandboxStrategy.GIT_WORKTREE
@then("the resource read_only should be false")
def step_check_resource_not_readonly(context: Context) -> None:
"""Verify resource is not read-only."""
assert context.resource.read_only is False
@then("the resource read_only should be true")
def step_check_resource_is_readonly(context: Context) -> None:
"""Verify resource is read-only."""
assert context.resource.read_only is True
@then("the resource metadata should be empty")
def step_check_resource_metadata_empty(context: Context) -> None:
"""Verify resource metadata is empty dict."""
assert context.resource.metadata == {}, (
f"Expected empty metadata, got {context.resource.metadata}"
)
@then("the resource created_at should be set")
def step_check_resource_created_at(context: Context) -> None:
"""Verify resource has a created_at timestamp."""
assert context.resource.created_at is not None
@then('the resource metadata should contain key "{key}" with value "{value}"')
def step_check_resource_metadata_key(context: Context, key: str, value: str) -> None:
"""Verify resource metadata contains expected key-value pair."""
assert key in context.resource.metadata, (
f"Expected key '{key}' in metadata, got {context.resource.metadata}"
)
assert context.resource.metadata[key] == value, (
f"Expected metadata['{key}'] = '{value}', got '{context.resource.metadata[key]}'"
)
# Note: "a validation error should be raised" step is defined in domain_models_steps.py
# Resource property assertion steps
@then("the resource supports_sandbox should be true")
def step_check_supports_sandbox_true(context: Context) -> None:
"""Verify resource supports sandbox."""
assert context.resource.supports_sandbox is True
@then("the resource supports_sandbox should be false")
def step_check_supports_sandbox_false(context: Context) -> None:
"""Verify resource does not support sandbox."""
assert context.resource.supports_sandbox is False
@then("the resource can_write should be true")
def step_check_can_write_true(context: Context) -> None:
"""Verify resource can be written to."""
assert context.resource.can_write is True
@then("the resource can_write should be false")
def step_check_can_write_false(context: Context) -> None:
"""Verify resource cannot be written to."""
assert context.resource.can_write is False
@then('the resource get_sandbox_path with base "{base}" should end with "{suffix}"')
def step_check_sandbox_path(context: Context, base: str, suffix: str) -> None:
"""Verify get_sandbox_path returns expected path."""
result = context.resource.get_sandbox_path(Path(base))
assert str(result).endswith(suffix), (
f"Expected path ending with '{suffix}', got '{result}'"
)
@@ -46,9 +46,16 @@ from cleveragents.domain.models.core.plan_legacy import (
PlanStatus,
)
from cleveragents.domain.models.core.project import (
ContextConfig,
Project,
ProjectSettings,
ProjectStats,
ValidationConfig,
)
from cleveragents.domain.models.core.resource import (
Resource,
ResourceType,
SandboxStrategy,
)
__all__ = [
@@ -58,6 +65,7 @@ __all__ = [
"ChangeSet",
"CloudBillingFields",
"Context",
"ContextConfig",
"ContextFile",
"ContextType",
"ContextUpdateResult",
@@ -85,7 +93,11 @@ __all__ = [
"Project",
"ProjectSettings",
"ProjectStats",
"Resource",
"ResourceType",
"SandboxStrategy",
"SummaryForUpdateContextParams",
"User",
"ValidationConfig",
"can_transition",
]
+280 -16
View File
@@ -1,26 +1,158 @@
"""Project domain model for CleverAgents.
Based on Phase 0 discovery and ADR-004 (Pydantic Validation).
Includes ValidationConfig (B1.5), ContextConfig (B1.6), and the Project model (B1.1).
Based on Phase 0 discovery, implementation_plan.md, and ADR-004 (Pydantic Validation).
"""
import re
from datetime import datetime
from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field, field_validator
from cleveragents.domain.models.core.resource import Resource
ULID_PATTERN = r"^[0-9A-HJKMNP-TV-Z]{26}$"
NAMESPACE_PATTERN = r"^(local|[a-z][a-z0-9_]{0,49})$"
RESERVED_NAMESPACES = frozenset({"system", "internal", "admin", "root"})
DEFAULT_IGNORE_PATTERNS: list[str] = [
".git/",
"node_modules/",
"__pycache__/",
".venv/",
"*.pyc",
".DS_Store",
]
class ValidationConfig(BaseModel):
"""Configuration for project validation commands.
Defines test, lint, type-check, and build commands along with
custom commands and execution settings.
Implements B1.5 from the implementation plan.
"""
test_command: str | None = Field(default=None, description="Command to run tests")
lint_command: str | None = Field(default=None, description="Command to run linting")
type_check_command: str | None = Field(
default=None, description="Command to run type checking"
)
build_command: str | None = Field(
default=None, description="Command to build the project"
)
custom_commands: dict[str, str] = Field(
default_factory=dict,
description="Custom named validation commands",
)
timeout_seconds: int = Field(
default=300, description="Timeout for each validation command in seconds"
)
fail_on_lint_error: bool = Field(
default=True, description="Whether lint errors should fail validation"
)
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
def get_all_commands(self) -> dict[str, str]:
"""Return all configured commands as a name->command dict.
Returns:
Dictionary mapping command names to command strings.
Includes standard commands (test, lint, type_check, build)
and any custom commands.
"""
commands: dict[str, str] = {}
if self.test_command is not None:
commands["test"] = self.test_command
if self.lint_command is not None:
commands["lint"] = self.lint_command
if self.type_check_command is not None:
commands["type_check"] = self.type_check_command
if self.build_command is not None:
commands["build"] = self.build_command
commands.update(self.custom_commands)
return commands
def has_any_validation(self) -> bool:
"""Check if any validation commands are configured.
Returns:
True if at least one command (standard or custom) is set.
"""
return len(self.get_all_commands()) > 0
class ContextConfig(BaseModel):
"""Configuration for project context indexing and filtering.
Controls how project files are indexed, chunked, and filtered
for inclusion in AI context windows.
Implements B1.6 from the implementation plan.
"""
ignore_patterns: list[str] = Field(
default_factory=lambda: list(DEFAULT_IGNORE_PATTERNS),
description="File patterns to ignore during indexing",
)
include_patterns: list[str] | None = Field(
default=None, description="File patterns to include (None means include all)"
)
max_file_size: int = Field(
default=1_000_000, description="Maximum file size in bytes (1MB default)"
)
max_files: int = Field(
default=100_000, description="Maximum number of files to index"
)
indexing_strategy: str = Field(
default="full_text",
description="Indexing strategy: full_text, semantic, etc.",
)
chunking_policy: str = Field(
default="smart", description="Chunking policy: smart, fixed, etc."
)
chunk_size: int = Field(default=1000, description="Chunk size in tokens")
@field_validator("ignore_patterns")
@classmethod
def merge_default_ignore_patterns(
cls: "type[ContextConfig]", v: list[str]
) -> list[str]:
"""Merge user-provided ignore patterns with defaults.
Ensures default patterns like .git/, node_modules/, etc.
are always included.
"""
merged = list(DEFAULT_IGNORE_PATTERNS)
for pattern in v:
if pattern not in merged:
merged.append(pattern)
return merged
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
class ProjectSettings(BaseModel):
"""Project-specific settings and configuration."""
auto_build: bool = Field(False, description="Automatically build plans")
auto_apply: bool = Field(False, description="Automatically apply changes")
auto_build: bool = Field(default=False, description="Automatically build plans")
auto_apply: bool = Field(default=False, description="Automatically apply changes")
confirm_apply: bool = Field(
True, description="Require confirmation before applying"
default=True, description="Require confirmation before applying"
)
max_context_size: int = Field(
52428800, description="Maximum context size in bytes (50MB)"
default=52428800, description="Maximum context size in bytes (50MB)"
)
default_model: str = Field("mock-gpt", description="Default AI model")
default_model: str = Field(default="mock-gpt", description="Default AI model")
include_paths: list[str] = Field(
default_factory=list, description="Relative include globs"
)
@@ -37,10 +169,10 @@ class ProjectSettings(BaseModel):
class ProjectStats(BaseModel):
"""Statistics for a project."""
plans: int = Field(0, ge=0)
context_files: int = Field(0, ge=0)
changes: int = Field(0, ge=0)
current_plan: str | None = Field(None)
plans: int = Field(default=0, ge=0)
context_files: int = Field(default=0, ge=0)
changes: int = Field(default=0, ge=0)
current_plan: str | None = Field(default=None)
model_config = ConfigDict(
str_strip_whitespace=True,
@@ -52,19 +184,52 @@ class Project(BaseModel):
"""Domain model for a CleverAgents project.
A project represents a workspace with plans, contexts, and changes.
Projects can have resources, validation configs, and context configs.
Extends original model with B1.1 fields: project_id, namespace,
description, tags, resources, validation_config, context_config.
"""
id: int | None = Field(None, description="Project ID")
name: str = Field(..., min_length=1, max_length=255)
# Legacy fields (preserved for backward compatibility)
id: int | None = Field(default=None, description="Legacy integer project ID")
path: Path = Field(..., description="Project root path")
settings: ProjectSettings = Field(default_factory=lambda: ProjectSettings())
current_plan_id: int | None = Field(default=None)
# B1.1b - Identity fields
project_id: str | None = Field(
default=None, description="Unique ULID identifier", pattern=ULID_PATTERN
)
name: str = Field(..., min_length=1, max_length=255)
namespace: str = Field(
default="local", description="Project namespace for grouping"
)
description: str | None = Field(
default=None, description="Human-readable project description"
)
# B1.1c - Categorization fields
tags: list[str] = Field(
default_factory=list, description="Project tags for filtering"
)
resources: list[Resource] = Field(
default_factory=list, description="Project resource references"
)
validation_config: ValidationConfig | None = Field(
default=None, description="Validation command configuration"
)
context_config: ContextConfig = Field(
default_factory=lambda: ContextConfig(),
description="Context indexing and filtering configuration",
)
# B1.1d - Timestamps
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
settings: ProjectSettings = Field(default_factory=lambda: ProjectSettings()) # type: ignore
current_plan_id: int | None = Field(None)
@field_validator("name")
@classmethod
def validate_name(cls: type["Project"], v: str) -> str:
def validate_name(cls: "type[Project]", v: str) -> str:
"""Validate project name."""
if not v.replace("-", "").replace("_", "").replace(" ", "").isalnum():
raise ValueError(
@@ -72,9 +237,31 @@ class Project(BaseModel):
)
return v
@field_validator("namespace")
@classmethod
def validate_namespace(cls: "type[Project]", v: str) -> str:
"""Validate namespace format and reject reserved names.
Namespace must match pattern: 'local' or start with lowercase letter
followed by up to 49 lowercase alphanumeric/underscore chars.
Reserved names (system, internal, admin, root) are rejected.
"""
if not re.match(NAMESPACE_PATTERN, v):
raise ValueError(
f"Namespace '{v}' must match pattern: 'local' or "
"start with lowercase letter followed by lowercase "
"alphanumeric/underscore characters (max 50 chars)"
)
if v in RESERVED_NAMESPACES:
raise ValueError(
f"Namespace '{v}' is reserved. "
f"Reserved namespaces: {', '.join(sorted(RESERVED_NAMESPACES))}"
)
return v
@field_validator("path")
@classmethod
def validate_path(cls: type["Project"], v: Path) -> Path:
def validate_path(cls: "type[Project]", v: Path) -> Path:
"""Ensure path is absolute."""
return v.resolve()
@@ -83,3 +270,80 @@ class Project(BaseModel):
validate_assignment=True,
arbitrary_types_allowed=True, # Allow Path
)
# B1.1e - Computed property
@property
def is_remote(self) -> bool:
"""Check if all project resources are remote.
A project is remote only if it has resources and ALL of them
are remotely accessible. Empty resource lists return False.
"""
if not self.resources:
return False
return all(r.is_remote for r in self.resources)
# B1.1g - Helper methods
@property
def namespaced_name(self) -> str:
"""Return the fully qualified namespace/name string."""
return f"{self.namespace}/{self.name}"
@staticmethod
def parse_namespaced_name(namespaced: str) -> tuple[str, str]:
"""Parse a 'namespace/name' string into (namespace, name).
Args:
namespaced: String in 'namespace/name' format.
Returns:
Tuple of (namespace, name).
Raises:
ValueError: If the string doesn't contain exactly one '/'.
"""
parts = namespaced.split("/", 1)
if len(parts) != 2:
raise ValueError(f"Expected 'namespace/name' format, got '{namespaced}'")
return parts[0], parts[1]
def add_resource(self, resource: Resource) -> "Project":
"""Return a new Project with the resource added.
Since the model may be frozen or validated, this returns
a new instance with the resource appended.
Args:
resource: The Resource to add.
Returns:
New Project instance with the resource added.
"""
new_resources = [*list(self.resources), resource]
return self.model_copy(update={"resources": new_resources})
def remove_resource(self, name: str) -> "Project":
"""Return a new Project with the named resource removed.
Args:
name: Name of the resource to remove.
Returns:
New Project instance without the named resource.
"""
new_resources = [r for r in self.resources if r.name != name]
return self.model_copy(update={"resources": new_resources})
def get_resource(self, name: str) -> Resource | None:
"""Get a resource by name.
Args:
name: Name of the resource to find.
Returns:
The Resource if found, None otherwise.
"""
for r in self.resources:
if r.name == name:
return r
return None
@@ -0,0 +1,139 @@
"""Resource domain model for CleverAgents.
Defines ResourceType enum, SandboxStrategy enum, and the Resource
Pydantic model for classifying project resources and determining
sandboxing behavior.
Based on implementation_plan.md (B1.2, B1.3, B1.4) and ADR-004 (Pydantic Validation).
"""
from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from pathlib import Path
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
ULID_PATTERN = r"^[0-9A-HJKMNP-TV-Z]{26}$"
class ResourceType(StrEnum):
"""Types of resources a project can reference.
Each resource type maps to a category of external data source
or infrastructure that a project operates on.
"""
GIT_REPOSITORY = "git_repository"
FILESYSTEM = "filesystem"
DATABASE = "database"
API_ENDPOINT = "api_endpoint"
DOCUMENT_CORPUS = "document_corpus"
CLOUD_INFRASTRUCTURE = "cloud_infrastructure"
class SandboxStrategy(StrEnum):
"""Strategies for isolating resource modifications during plan execution.
Sandboxing ensures that changes made during plan execution can be
reviewed, committed, or rolled back without affecting the original resource.
"""
GIT_WORKTREE = "git_worktree"
COPY_ON_WRITE = "copy_on_write"
OVERLAY = "overlay"
TRANSACTION_ROLLBACK = "transaction_rollback"
VERSIONING = "versioning"
NONE = "none"
@property
def supports_rollback(self) -> bool:
"""Check if this strategy supports rolling back changes.
All strategies except NONE support rollback, since NONE means
modifications are immediate and irreversible.
"""
return self != SandboxStrategy.NONE
@property
def is_copy_based(self) -> bool:
"""Check if this strategy creates a copy of the resource.
COPY_ON_WRITE and OVERLAY both duplicate the resource data
to a separate location for isolation.
"""
return self in (SandboxStrategy.COPY_ON_WRITE, SandboxStrategy.OVERLAY)
class Resource(BaseModel):
"""A project resource reference.
Resources represent external data sources or repositories
that a project operates on. Each resource has a type, location,
and sandboxing strategy that determines how modifications are isolated.
Implements B1.2 from the implementation plan.
"""
resource_id: str = Field(
..., description="Unique ULID identifier", pattern=ULID_PATTERN
)
name: str = Field(
..., min_length=1, max_length=255, description="Human-readable name"
)
type: ResourceType = Field(..., description="Resource classification")
location: str = Field(..., min_length=1, description="Path or URI to the resource")
is_remote: bool = Field(
False, description="Whether the resource is remotely accessible"
)
sandbox_strategy: SandboxStrategy = Field(
SandboxStrategy.NONE,
description="Strategy for isolating modifications",
)
read_only: bool = Field(False, description="Whether the resource is read-only")
metadata: dict[str, Any] = Field(
default_factory=dict,
description="Arbitrary key-value metadata for the resource",
)
created_at: datetime = Field(
default_factory=datetime.now,
description="Timestamp when the resource was created",
)
@field_validator("name")
@classmethod
def validate_name(cls: type[Resource], v: str) -> str:
"""Enforce naming rules: alphanumeric with hyphens/underscores, lowercased."""
if not v.replace("-", "").replace("_", "").isalnum():
raise ValueError("Name must be alphanumeric with hyphens/underscores only")
return v.lower()
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
use_enum_values=False,
frozen=True,
)
@property
def supports_sandbox(self) -> bool:
"""Check if this resource has a sandbox strategy other than NONE."""
return self.sandbox_strategy != SandboxStrategy.NONE
@property
def can_write(self) -> bool:
"""Check if this resource allows write operations."""
return not self.read_only
def get_sandbox_path(self, base_path: Path) -> Path:
"""Get the sandbox directory path for this resource.
Args:
base_path: The base directory for sandboxes.
Returns:
Path combining base_path with the resource name.
"""
return base_path / self.name