feat(resource): add resource type inheritance and polymorphic tool matching
Implement ADR-042 single-inheritance for resource types with polymorphic tool and handler resolution. Core changes: - Add `inherits` field to ResourceTypeConfigSchema, ResourceTypeSpec, and ResourceTypeModel with chain depth validation (max 5), circular inheritance detection, and built-in-from-custom guard - New inheritance.py module (390 lines): resolve_inheritance_chain(), validate_chain(), is_subtype_of(), resolve_fields(), find_subtypes() - Wire inheritance into ResourceRegistryService: chain validation on register_type(), resolve_type_inheritance_chain(), is_subtype_of() - Add find_tools_for_resource() to ToolRegistry with polymorphic matching that walks the inheritance chain - Add resolve_handler_polymorphic() to handler resolver - Alembic migration m6_004_resource_type_inherits adds inherits column and index to resource_types table CLI changes: - `agents resource type list` shows Inherits column - `agents resource type show` displays inheritance chain Tests: - 30 BDD scenarios in resource_type_inheritance.feature (all pass) - 18 Robot Framework test cases - 14 ASV benchmark timing methods across 4 suites Docs: - docs/reference/resource_type_inheritance.md (full reference) - Updated docs/schema/resource_type.schema.yaml - CHANGELOG entry for #513 ISSUES CLOSED: #513
This commit is contained in:
@@ -12,6 +12,14 @@
|
||||
datetimes. Wired into the DI container. Includes 28 Behave BDD scenarios,
|
||||
3 Robot Framework integration tests, ASV benchmarks (5 time + 2 track), and
|
||||
reference documentation. (#195)
|
||||
|
||||
### Added
|
||||
- Resource type single-inheritance via `inherits` field (ADR-042) (#513)
|
||||
- Inheritance chain resolution, field merging, and polymorphic type matching
|
||||
- `ToolRegistry.find_tools_for_resource()` for polymorphic tool binding
|
||||
- Polymorphic handler resolution with ancestor-type fallback
|
||||
- CLI: `agents resource type list` shows Inherits column; `type show` displays inheritance chain
|
||||
- Alembic migration `m6_004_resource_type_inherits` adds `inherits` column to `resource_types`
|
||||
- Fixed `agents project show` not finding a project immediately after creation.
|
||||
Extended the `session.commit()` fix from #589 to also cover `update()` and
|
||||
`delete()` in `NamespacedProjectRepository`, and updated the class docstring
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Add inherits column to resource_types for type inheritance (ADR-042).
|
||||
|
||||
Revision ID: m6_004_resource_type_inherits
|
||||
Revises: m6_003_async_jobs_table
|
||||
Create Date: 2026-03-06 00:00:00
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "m6_004_resource_type_inherits"
|
||||
down_revision: str | Sequence[str] | None = "m6_003_async_jobs_table"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add inherits column and index to resource_types."""
|
||||
op.add_column(
|
||||
"resource_types",
|
||||
sa.Column("inherits", sa.String(255), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_resource_types_inherits",
|
||||
"resource_types",
|
||||
["inherits"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove inherits column and index from resource_types."""
|
||||
op.drop_index("ix_resource_types_inherits", table_name="resource_types")
|
||||
op.drop_column("resource_types", "inherits")
|
||||
@@ -0,0 +1,157 @@
|
||||
"""ASV benchmarks for resource type inheritance resolution engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from cleveragents.resource.inheritance import (
|
||||
find_subtypes,
|
||||
is_subtype_of,
|
||||
resolve_fields,
|
||||
resolve_inheritance_chain,
|
||||
)
|
||||
|
||||
|
||||
def _build_linear_registry(depth: int) -> tuple[dict[str, Any], str]:
|
||||
"""Build a linear inheritance chain of *depth* levels.
|
||||
|
||||
Returns ``(registry, leaf_name)`` where the chain is::
|
||||
|
||||
leaf -> level-(depth-2) -> ... -> level-0 -> root
|
||||
"""
|
||||
registry: dict[str, Any] = {"root": {}}
|
||||
prev = "root"
|
||||
for i in range(depth - 1):
|
||||
name = f"level-{i}"
|
||||
registry[name] = {"inherits": prev}
|
||||
prev = name
|
||||
return registry, prev
|
||||
|
||||
|
||||
def _build_wide_registry(width: int) -> dict[str, Any]:
|
||||
"""Build a registry with *width* direct children of a single ancestor."""
|
||||
registry: dict[str, Any] = {"ancestor": {}}
|
||||
for i in range(width):
|
||||
registry[f"child-{i}"] = {"inherits": "ancestor"}
|
||||
return registry
|
||||
|
||||
|
||||
def _build_field_registry(depth: int) -> tuple[dict[str, Any], str]:
|
||||
"""Build a registry with mergeable fields at each level."""
|
||||
registry: dict[str, Any] = {
|
||||
"root": {
|
||||
"sandbox_strategy": "copy_on_write",
|
||||
"resource_kind": "physical",
|
||||
"cli_args": [{"name": "base-arg", "type": "string"}],
|
||||
"child_types": ["root-child"],
|
||||
},
|
||||
}
|
||||
prev = "root"
|
||||
for i in range(depth - 1):
|
||||
name = f"level-{i}"
|
||||
registry[name] = {
|
||||
"inherits": prev,
|
||||
"resource_kind": f"kind-{i}",
|
||||
"cli_args": [{"name": f"arg-{i}", "type": "string"}],
|
||||
"child_types": [f"ct-{i}"],
|
||||
}
|
||||
prev = name
|
||||
return registry, prev
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chain resolution benchmarks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InheritanceChainResolutionSuite:
|
||||
"""Benchmark resolve_inheritance_chain at various depths."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self.reg_1, self.leaf_1 = _build_linear_registry(1)
|
||||
self.reg_3, self.leaf_3 = _build_linear_registry(3)
|
||||
self.reg_5, self.leaf_5 = _build_linear_registry(5)
|
||||
|
||||
def time_resolve_chain_1_level(self) -> None:
|
||||
resolve_inheritance_chain(self.leaf_1, self.reg_1)
|
||||
|
||||
def time_resolve_chain_3_level(self) -> None:
|
||||
resolve_inheritance_chain(self.leaf_3, self.reg_3)
|
||||
|
||||
def time_resolve_chain_5_level(self) -> None:
|
||||
resolve_inheritance_chain(self.leaf_5, self.reg_5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Polymorphic subtype check benchmarks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class IsSubtypeOfSuite:
|
||||
"""Benchmark is_subtype_of at various depths."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self.reg_1, self.leaf_1 = _build_linear_registry(1)
|
||||
self.reg_3, self.leaf_3 = _build_linear_registry(3)
|
||||
self.reg_5, self.leaf_5 = _build_linear_registry(5)
|
||||
|
||||
def time_is_subtype_identity(self) -> None:
|
||||
is_subtype_of("root", "root", self.reg_1)
|
||||
|
||||
def time_is_subtype_1_level(self) -> None:
|
||||
is_subtype_of(self.leaf_1, "root", self.reg_1)
|
||||
|
||||
def time_is_subtype_3_level(self) -> None:
|
||||
is_subtype_of(self.leaf_3, "root", self.reg_3)
|
||||
|
||||
def time_is_subtype_5_level(self) -> None:
|
||||
is_subtype_of(self.leaf_5, "root", self.reg_5)
|
||||
|
||||
def time_is_subtype_negative(self) -> None:
|
||||
is_subtype_of("root", self.leaf_5, self.reg_5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Field resolution benchmarks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ResolveFieldsSuite:
|
||||
"""Benchmark resolve_fields with field merging at various depths."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self.reg_1, self.leaf_1 = _build_field_registry(1)
|
||||
self.reg_3, self.leaf_3 = _build_field_registry(3)
|
||||
self.reg_5, self.leaf_5 = _build_field_registry(5)
|
||||
|
||||
def time_resolve_fields_1_level(self) -> None:
|
||||
resolve_fields(self.leaf_1, self.reg_1)
|
||||
|
||||
def time_resolve_fields_3_level(self) -> None:
|
||||
resolve_fields(self.leaf_3, self.reg_3)
|
||||
|
||||
def time_resolve_fields_5_level(self) -> None:
|
||||
resolve_fields(self.leaf_5, self.reg_5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_subtypes benchmarks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FindSubtypesSuite:
|
||||
"""Benchmark find_subtypes with varying registry widths."""
|
||||
|
||||
def setup(self) -> None:
|
||||
self.reg_10 = _build_wide_registry(10)
|
||||
self.reg_50 = _build_wide_registry(50)
|
||||
self.reg_200 = _build_wide_registry(200)
|
||||
|
||||
def time_find_subtypes_10(self) -> None:
|
||||
find_subtypes("ancestor", self.reg_10)
|
||||
|
||||
def time_find_subtypes_50(self) -> None:
|
||||
find_subtypes("ancestor", self.reg_50)
|
||||
|
||||
def time_find_subtypes_200(self) -> None:
|
||||
find_subtypes("ancestor", self.reg_200)
|
||||
@@ -0,0 +1,233 @@
|
||||
# Resource Type Inheritance
|
||||
|
||||
## Overview
|
||||
|
||||
Resource type inheritance allows custom resource types to derive from existing
|
||||
types, inheriting their field definitions, CLI arguments, DAG constraints, and
|
||||
tool bindings. A child type can override or extend its parent's configuration
|
||||
while remaining compatible with tools and handlers that target the parent type.
|
||||
|
||||
This feature was introduced in [ADR-042](../adr/ADR-042-resource-type-inheritance.md)
|
||||
to support polymorphic tool matching and reduce configuration duplication across
|
||||
related resource types.
|
||||
|
||||
**Key benefits:**
|
||||
|
||||
- Define specialised resource types without repeating shared configuration.
|
||||
- Tools bound to a parent type automatically match resources of any subtype.
|
||||
- Handler resolution falls back through the inheritance chain when a
|
||||
type-specific handler is not registered.
|
||||
- DAG queries respect the type hierarchy, returning resources whose type is a
|
||||
subtype of the requested ancestor.
|
||||
|
||||
## The `inherits` Field
|
||||
|
||||
A resource type declares its parent by setting the `inherits` field to the name
|
||||
of an existing resource type:
|
||||
|
||||
```yaml
|
||||
name: myorg/python-repo
|
||||
inherits: git-checkout
|
||||
resource_kind: physical
|
||||
sandbox_strategy: git_worktree
|
||||
description: A Git checkout containing a Python project.
|
||||
cli_args:
|
||||
- name: python-version
|
||||
type: string
|
||||
required: false
|
||||
description: Expected Python version.
|
||||
```
|
||||
|
||||
In this example `myorg/python-repo` inherits all fields from `git-checkout` and
|
||||
adds a `python-version` CLI argument.
|
||||
|
||||
The `inherits` field is optional. When omitted the type is a root type with no
|
||||
parent.
|
||||
|
||||
## Inheritance Chain Rules
|
||||
|
||||
The following five rules govern inheritance chains. Violations are reported by
|
||||
`validate_chain()` and prevent the type from being registered.
|
||||
|
||||
### 1. Single Inheritance Only
|
||||
|
||||
A resource type may declare at most one parent via `inherits`. Multiple
|
||||
inheritance is not supported.
|
||||
|
||||
### 2. Maximum Depth of 5 Levels
|
||||
|
||||
The inheritance chain from a type to its root ancestor must not exceed 5 levels
|
||||
(i.e., at most 4 `inherits` hops). Deeper chains raise
|
||||
`ResourceTypeInheritanceDepthError`.
|
||||
|
||||
```
|
||||
root -> A -> B -> C -> D # depth 5 — OK
|
||||
root -> A -> B -> C -> D -> E # depth 6 — rejected
|
||||
```
|
||||
|
||||
### 3. No Circular Inheritance
|
||||
|
||||
An inheritance chain must not contain cycles. If registering type `A` with
|
||||
`inherits: B` would create a cycle (e.g., `B` already inherits from `A`,
|
||||
directly or transitively), `ResourceTypeCircularInheritanceError` is raised.
|
||||
|
||||
### 4. Built-in Types May Not Inherit from Custom Types
|
||||
|
||||
Built-in resource types (`built_in: true`) are not permitted to set `inherits`
|
||||
to a custom (namespaced) type. Custom types may inherit from built-in types or
|
||||
from other custom types.
|
||||
|
||||
### 5. Cannot Remove a Parent While Subtypes Exist
|
||||
|
||||
A resource type's `inherits` field cannot be cleared or changed to a different
|
||||
parent while other registered types list it as their parent. Attempting to do so
|
||||
raises `ResourceTypeParentRemovalError`. Remove or re-parent all subtypes first.
|
||||
|
||||
## Field Resolution
|
||||
|
||||
When a type declares `inherits`, its effective configuration is computed by
|
||||
overlaying the child's explicitly declared scalar fields on top of the parent's
|
||||
resolved fields (which may themselves be inherited). This is a simple
|
||||
**last-writer-wins** override for scalar values:
|
||||
|
||||
| Field | Resolution |
|
||||
|--------------------|---------------------------------------------------|
|
||||
| `description` | Child value wins if set, otherwise parent's value. |
|
||||
| `resource_kind` | Child value wins if set, otherwise parent's value. |
|
||||
| `sandbox_strategy` | Child value wins if set, otherwise parent's value. |
|
||||
| `user_addable` | Child value wins if set, otherwise parent's value. |
|
||||
| `handler` | Child value wins if set, otherwise parent's value. |
|
||||
| `capabilities` | Child value wins if set, otherwise parent's value. |
|
||||
| `auto_discovery` | Child value wins if set, otherwise parent's value. |
|
||||
| `equivalence` | Child value wins if set, otherwise parent's value. |
|
||||
|
||||
The `name`, `built_in`, and `inherits` fields are never inherited — they are
|
||||
intrinsic to the declaring type.
|
||||
|
||||
## Collection Field Merging
|
||||
|
||||
Collection fields use **additive merging** by default: the child's entries are
|
||||
appended to the parent's resolved entries (duplicates are deduplicated). To
|
||||
replace the parent's collection entirely, set the corresponding
|
||||
`<field>_replace` flag to `true`.
|
||||
|
||||
### `cli_args`
|
||||
|
||||
Parent CLI arguments are included in the child type. The child may add new
|
||||
arguments or override a parent argument by declaring an argument with the same
|
||||
`name`. To discard all parent arguments and use only the child's definitions:
|
||||
|
||||
```yaml
|
||||
cli_args_replace: true
|
||||
cli_args:
|
||||
- name: custom-arg
|
||||
type: string
|
||||
```
|
||||
|
||||
### `child_types`
|
||||
|
||||
The child type's allowed `child_types` list is the union of the parent's and
|
||||
the child's declarations. To replace:
|
||||
|
||||
```yaml
|
||||
child_types_replace: true
|
||||
child_types:
|
||||
- myorg/sub-resource
|
||||
```
|
||||
|
||||
### `parent_types`
|
||||
|
||||
Behaves identically to `child_types` — additive merge with
|
||||
`parent_types_replace: true` for full replacement.
|
||||
|
||||
## Polymorphism Guarantees
|
||||
|
||||
### Tool Binding
|
||||
|
||||
`ToolRegistry.find_tools_for_resource()` returns tools bound to the resource's
|
||||
own type **and** every ancestor type in its inheritance chain. A tool registered
|
||||
for `git-checkout` will match resources of type `myorg/python-repo` if
|
||||
`myorg/python-repo` inherits from `git-checkout`.
|
||||
|
||||
### Handler Resolution
|
||||
|
||||
When resolving a handler for a resource, the system walks the inheritance chain
|
||||
from the most specific type to the root. The first type in the chain that has a
|
||||
registered handler is used. This allows specialised types to override behaviour
|
||||
while falling back to the parent handler when no override exists.
|
||||
|
||||
### DAG Queries
|
||||
|
||||
Resource DAG queries that filter by type respect the inheritance hierarchy. A
|
||||
query for all resources of type `git-checkout` also returns resources whose type
|
||||
is a subtype of `git-checkout`. Use `is_subtype_of()` for explicit type checks.
|
||||
|
||||
## API Reference
|
||||
|
||||
Public functions exported from `cleveragents.resource.inheritance`:
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `resolve_inheritance_chain` | `(type_name: str, type_registry: dict) -> list[str]` | Returns the ordered inheritance chain from `type_name` to the root ancestor (inclusive). Raises `ResourceTypeCircularInheritanceError` on cycles and `ResourceTypeParentNotFoundError` if an ancestor is missing. |
|
||||
| `validate_chain` | `(type_name: str, inherits: str \| None, type_registry: dict, is_built_in: bool) -> list[str]` | Validates the proposed inheritance chain and returns a list of error messages (empty on success). Checks all five chain rules. |
|
||||
| `is_subtype_of` | `(type_name: str, ancestor_name: str, type_registry: dict) -> bool` | Returns `True` if `type_name` is equal to or a subtype of `ancestor_name`. |
|
||||
| `resolve_fields` | `(type_name: str, type_registry: dict) -> dict` | Returns the fully resolved field dictionary for `type_name` after applying inheritance chain field resolution and collection merging. |
|
||||
| `find_subtypes` | `(ancestor_name: str, type_registry: dict) -> list[str]` | Returns all registered type names that are subtypes of `ancestor_name` (direct and transitive). |
|
||||
|
||||
## Exceptions
|
||||
|
||||
The following exceptions are defined in `cleveragents.resource.inheritance` and
|
||||
raised when inheritance rules are violated:
|
||||
|
||||
| Exception | Raised When |
|
||||
|-----------|-------------|
|
||||
| `ResourceTypeInheritanceDepthError` | The inheritance chain exceeds the maximum depth of 5 levels. |
|
||||
| `ResourceTypeCircularInheritanceError` | A circular inheritance chain is detected. |
|
||||
| `ResourceTypeParentNotFoundError` | The type named in `inherits` is not present in the type registry. |
|
||||
| `ResourceTypeParentRemovalError` | An attempt is made to remove or change a type's parent while subtypes still reference it. |
|
||||
|
||||
All exceptions inherit from `CleverAgentsError` and include the type name and
|
||||
chain details in their message for diagnostics.
|
||||
|
||||
## CLI
|
||||
|
||||
### `agents resource type list`
|
||||
|
||||
The tabular output includes an **Inherits** column showing the direct parent
|
||||
type name (blank for root types):
|
||||
|
||||
```
|
||||
Name Kind Sandbox Inherits
|
||||
────────────────────── ───────── ──────────────── ──────────────
|
||||
git-checkout physical git_worktree
|
||||
fs-directory physical copy_on_write
|
||||
myorg/python-repo physical git_worktree git-checkout
|
||||
```
|
||||
|
||||
### `agents resource type show <name>`
|
||||
|
||||
Displays the full resolved configuration for the type, including an
|
||||
**Inheritance Chain** section:
|
||||
|
||||
```
|
||||
Name: myorg/python-repo
|
||||
Inherits: git-checkout
|
||||
Inheritance Chain: myorg/python-repo -> git-checkout
|
||||
Resource Kind: physical
|
||||
Sandbox Strategy: git_worktree
|
||||
...
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
The `resource_types` table includes an `inherits` column:
|
||||
|
||||
| Column | Type | Nullable | Description |
|
||||
|------------|---------------|----------|------------------------------------------|
|
||||
| `inherits` | `String(255)` | Yes | Name of the parent resource type, or `NULL` for root types. |
|
||||
|
||||
This column is added by the Alembic migration
|
||||
`m6_004_resource_type_inherits`. The column is indexed to support efficient
|
||||
subtype lookups. Foreign-key integrity is enforced at the application layer
|
||||
(not via a database constraint) because type registration order during
|
||||
migrations may not satisfy FK ordering requirements.
|
||||
@@ -34,6 +34,13 @@ fields:
|
||||
description: >
|
||||
Human-readable description of the resource type.
|
||||
|
||||
# ─── Inheritance ──────────────────────────────────────────────
|
||||
inherits:
|
||||
type: string
|
||||
required: false
|
||||
description: >
|
||||
Parent resource type name. See ADR-042.
|
||||
|
||||
# ─── Classification ────────────────────────────────────────────
|
||||
resource_kind:
|
||||
type: string
|
||||
|
||||
@@ -34,21 +34,21 @@ Feature: Resource CLI coverage boost for remaining uncovered lines
|
||||
Then the CliRunner exit code should be non-zero
|
||||
And the CliRunner output should contain "Aborted"
|
||||
|
||||
# ---- type_remove row is None (lines 260-262) ----
|
||||
# ---- type_remove NotFoundError from service (lines 263-265) ----
|
||||
|
||||
Scenario: type_remove aborts when DB row is None after query
|
||||
Given a mock resource service whose session returns no row for the type
|
||||
Scenario: type_remove aborts when service.remove_type raises NotFoundError
|
||||
Given a mock resource service whose remove_type raises NotFoundError
|
||||
When I invoke type remove with --yes via CliRunner for the phantom type
|
||||
Then the CliRunner exit code should be non-zero
|
||||
And the CliRunner output should contain "Resource type not found"
|
||||
|
||||
# ---- type_remove generic exception triggers rollback (lines 267-269) ----
|
||||
# ---- type_remove ValidationError from service (lines 269-271) ----
|
||||
|
||||
Scenario: type_remove rolls back session on unexpected exception
|
||||
Given a mock resource service whose session delete raises a generic exception
|
||||
Scenario: type_remove aborts when service.remove_type raises ValidationError
|
||||
Given a mock resource service whose remove_type raises ValidationError
|
||||
When I invoke type remove with --yes via CliRunner for the failing type
|
||||
Then the CliRunner exit code should be non-zero
|
||||
And the mock session rollback should have been called for type remove
|
||||
And the CliRunner output should contain "Error:"
|
||||
|
||||
# ---- resource_remove edge_count > 0 (lines 653-658) ----
|
||||
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
Feature: Resource type inheritance and polymorphic matching
|
||||
ADR-042 defines single-inheritance for resource types.
|
||||
Types may declare an `inherits` field pointing to one parent type.
|
||||
The inheritance engine resolves chains, merges fields, detects cycles,
|
||||
enforces a depth limit, and enables polymorphic tool matching.
|
||||
|
||||
# ── 1. Schema validation ──────────────────────────────────
|
||||
|
||||
Scenario: YAML with inherits field parses correctly
|
||||
Given a type-inherit YAML string with inherits "container-instance"
|
||||
When I parse the type-inherit YAML via ResourceTypeConfigSchema
|
||||
Then the type-inherit schema inherits field is "container-instance"
|
||||
|
||||
Scenario: YAML with inherits set to null parses as root type
|
||||
Given a type-inherit YAML string with no inherits
|
||||
When I parse the type-inherit YAML via ResourceTypeConfigSchema
|
||||
Then the type-inherit schema inherits field is null
|
||||
|
||||
Scenario: Schema rejects self-inheritance
|
||||
Given a type-inherit YAML string where name equals inherits "acme/looper"
|
||||
When I attempt to parse the type-inherit YAML expecting an error
|
||||
Then the type-inherit parse error mentions "inherit from itself"
|
||||
|
||||
Scenario: Schema rejects built-in inheriting from custom type
|
||||
Given a type-inherit built-in YAML inheriting from custom "acme/custom-rt"
|
||||
When I attempt to parse the type-inherit YAML expecting an error
|
||||
Then the type-inherit parse error mentions "cannot inherit from custom"
|
||||
|
||||
Scenario: YAML with replace flags parses correctly
|
||||
Given a type-inherit YAML string with cli_args_replace true
|
||||
When I parse the type-inherit YAML via ResourceTypeConfigSchema
|
||||
Then the type-inherit schema cli_args_replace is true
|
||||
|
||||
# ── 2. Field resolution ───────────────────────────────────
|
||||
|
||||
Scenario: Scalar fields in subtype override parent
|
||||
Given a type-inherit registry with parent "container-instance" description "Parent desc"
|
||||
And a type-inherit child "acme/my-container" inheriting from "container-instance" with description "Child desc"
|
||||
When I resolve type-inherit fields for "acme/my-container"
|
||||
Then the type-inherit resolved description is "Child desc"
|
||||
|
||||
Scenario: Scalar fields fall through from parent when absent in child
|
||||
Given a type-inherit registry with parent "container-instance" having handler "parent.Handler"
|
||||
And a type-inherit child "acme/my-container" inheriting from "container-instance" without handler
|
||||
When I resolve type-inherit fields for "acme/my-container"
|
||||
Then the type-inherit resolved handler is "parent.Handler"
|
||||
|
||||
# ── 3. Collection merging ─────────────────────────────────
|
||||
|
||||
Scenario: cli_args additive merge — parent args preserved, child appended
|
||||
Given a type-inherit parent "container-instance" with cli_args "path,port"
|
||||
And a type-inherit child "acme/my-container" inheriting from "container-instance" with cli_args "env"
|
||||
When I resolve type-inherit fields for "acme/my-container"
|
||||
Then the type-inherit resolved cli_args names are "path, port, env"
|
||||
|
||||
Scenario: cli_args same-name replacement during merge
|
||||
Given a type-inherit parent "container-instance" with cli_args "path,port"
|
||||
And a type-inherit child "acme/my-container" inheriting from "container-instance" with cli_args "port"
|
||||
When I resolve type-inherit fields for "acme/my-container"
|
||||
Then the type-inherit resolved cli_args count is 2
|
||||
And the type-inherit resolved cli_args names are "path, port"
|
||||
|
||||
Scenario: cli_args replace mode replaces parent entirely
|
||||
Given a type-inherit parent "container-instance" with cli_args "path,port"
|
||||
And a type-inherit child "acme/my-container" inheriting from "container-instance" with cli_args "env" and cli_args_replace
|
||||
When I resolve type-inherit fields for "acme/my-container"
|
||||
Then the type-inherit resolved cli_args names are "env"
|
||||
|
||||
Scenario: child_types additive merge — union deduplication
|
||||
Given a type-inherit parent "container-instance" with child_types "fs-directory,fs-file"
|
||||
And a type-inherit child "acme/my-container" inheriting from "container-instance" with child_types "fs-file,fs-mount"
|
||||
When I resolve type-inherit fields for "acme/my-container"
|
||||
Then the type-inherit resolved child_types are "fs-directory, fs-file, fs-mount"
|
||||
|
||||
Scenario: child_types replace mode replaces parent entirely
|
||||
Given a type-inherit parent "container-instance" with child_types "fs-directory,fs-file"
|
||||
And a type-inherit child "acme/my-container" inheriting from "container-instance" with child_types "fs-mount" and child_types_replace
|
||||
When I resolve type-inherit fields for "acme/my-container"
|
||||
Then the type-inherit resolved child_types are "fs-mount"
|
||||
|
||||
Scenario: parent_types additive merge
|
||||
Given a type-inherit parent "container-instance" with parent_types "git-checkout"
|
||||
And a type-inherit child "acme/my-container" inheriting from "container-instance" with parent_types "fs-mount"
|
||||
When I resolve type-inherit fields for "acme/my-container"
|
||||
Then the type-inherit resolved parent_types are "git-checkout, fs-mount"
|
||||
|
||||
Scenario: parent_types replace mode
|
||||
Given a type-inherit parent "container-instance" with parent_types "git-checkout"
|
||||
And a type-inherit child "acme/my-container" inheriting from "container-instance" with parent_types "fs-mount" and parent_types_replace
|
||||
When I resolve type-inherit fields for "acme/my-container"
|
||||
Then the type-inherit resolved parent_types are "fs-mount"
|
||||
|
||||
# ── 4. Chain resolution ───────────────────────────────────
|
||||
|
||||
Scenario: Single-level inheritance chain resolution
|
||||
Given a type-inherit registry with "container-instance" as root
|
||||
And a type-inherit type "acme/devcontainer" inheriting from "container-instance"
|
||||
When I resolve the type-inherit chain for "acme/devcontainer"
|
||||
Then the type-inherit chain is "acme/devcontainer, container-instance"
|
||||
|
||||
Scenario: Multi-level inheritance chain resolution
|
||||
Given a type-inherit registry with "container-instance" as root
|
||||
And a type-inherit type "acme/app-container" inheriting from "container-instance"
|
||||
And a type-inherit type "acme/web-container" inheriting from "acme/app-container"
|
||||
When I resolve the type-inherit chain for "acme/web-container"
|
||||
Then the type-inherit chain is "acme/web-container, acme/app-container, container-instance"
|
||||
|
||||
Scenario: Root type chain is just itself
|
||||
Given a type-inherit registry with "container-instance" as root
|
||||
When I resolve the type-inherit chain for "container-instance"
|
||||
Then the type-inherit chain is "container-instance"
|
||||
|
||||
Scenario: validate_chain succeeds for valid chain
|
||||
Given a type-inherit registry with "container-instance" as root
|
||||
When I validate type-inherit chain for "acme/devcontainer" inheriting "container-instance"
|
||||
Then the type-inherit validated chain is "acme/devcontainer, container-instance"
|
||||
|
||||
Scenario: validate_chain rejects missing parent
|
||||
Given a type-inherit registry with "container-instance" as root
|
||||
When I validate type-inherit chain for "acme/devcontainer" inheriting "nonexistent" expecting error
|
||||
Then the type-inherit validation error is ResourceTypeParentNotFoundError
|
||||
|
||||
# ── 5. Polymorphism ───────────────────────────────────────
|
||||
|
||||
Scenario: is_subtype_of returns true for direct child
|
||||
Given a type-inherit registry with "container-instance" as root
|
||||
And a type-inherit type "acme/devcontainer" inheriting from "container-instance"
|
||||
When I check type-inherit is_subtype_of "acme/devcontainer" and "container-instance"
|
||||
Then the type-inherit subtype check is true
|
||||
|
||||
Scenario: is_subtype_of returns true for same type
|
||||
Given a type-inherit registry with "container-instance" as root
|
||||
When I check type-inherit is_subtype_of "container-instance" and "container-instance"
|
||||
Then the type-inherit subtype check is true
|
||||
|
||||
Scenario: is_subtype_of returns false for unrelated types
|
||||
Given a type-inherit registry with "container-instance" as root
|
||||
And a type-inherit registry also has "git-checkout" as root
|
||||
When I check type-inherit is_subtype_of "container-instance" and "git-checkout"
|
||||
Then the type-inherit subtype check is false
|
||||
|
||||
Scenario: is_subtype_of returns true for transitive ancestor
|
||||
Given a type-inherit registry with "container-instance" as root
|
||||
And a type-inherit type "acme/app-container" inheriting from "container-instance"
|
||||
And a type-inherit type "acme/web-container" inheriting from "acme/app-container"
|
||||
When I check type-inherit is_subtype_of "acme/web-container" and "container-instance"
|
||||
Then the type-inherit subtype check is true
|
||||
|
||||
Scenario: Tool bound to parent matches subtype resource
|
||||
Given a type-inherit registry with "container-instance" as root
|
||||
And a type-inherit type "acme/devcontainer" inheriting from "container-instance"
|
||||
And a type-inherit tool "ns/deploy" bound to resource type "container-instance"
|
||||
When I find type-inherit tools for resource type "acme/devcontainer"
|
||||
Then the type-inherit matched tools include "ns/deploy"
|
||||
|
||||
Scenario: Tool bound to child does not match parent resource
|
||||
Given a type-inherit registry with "container-instance" as root
|
||||
And a type-inherit type "acme/devcontainer" inheriting from "container-instance"
|
||||
And a type-inherit tool "ns/special" bound to resource type "acme/devcontainer"
|
||||
When I find type-inherit tools for resource type "container-instance"
|
||||
Then the type-inherit matched tools list is empty
|
||||
|
||||
Scenario: find_subtypes returns all descendants
|
||||
Given a type-inherit registry with "container-instance" as root
|
||||
And a type-inherit type "acme/app-container" inheriting from "container-instance"
|
||||
And a type-inherit type "acme/web-container" inheriting from "acme/app-container"
|
||||
When I find type-inherit subtypes of "container-instance"
|
||||
Then the type-inherit subtypes include "acme/app-container"
|
||||
And the type-inherit subtypes include "acme/web-container"
|
||||
|
||||
# ── 6. Cycle detection ────────────────────────────────────
|
||||
|
||||
Scenario: Circular two-type cycle is rejected
|
||||
Given a type-inherit registry with "acme/alpha" inheriting from "acme/beta"
|
||||
And a type-inherit registry with "acme/beta" inheriting from "acme/alpha"
|
||||
When I resolve the type-inherit chain for "acme/alpha" expecting error
|
||||
Then the type-inherit chain error is ResourceTypeCircularInheritanceError
|
||||
|
||||
# ── 7. Depth limit ────────────────────────────────────────
|
||||
|
||||
Scenario: Chain exceeding MAX_CHAIN_DEPTH is rejected
|
||||
Given a type-inherit registry with a chain of depth 6
|
||||
When I resolve the type-inherit chain for the deepest type expecting error
|
||||
Then the type-inherit chain error is ResourceTypeInheritanceDepthError
|
||||
|
||||
Scenario: Chain at exactly MAX_CHAIN_DEPTH succeeds
|
||||
Given a type-inherit registry with a chain of depth 5
|
||||
When I resolve the type-inherit chain for the deepest type
|
||||
Then the type-inherit chain length is 5
|
||||
|
||||
# ── 8. Parent removal guard ───────────────────────────────
|
||||
|
||||
Scenario: Parent type with subtypes cannot be removed
|
||||
Given a type-inherit registry with "container-instance" as root
|
||||
And a type-inherit type "acme/devcontainer" inheriting from "container-instance"
|
||||
When I attempt to type-inherit remove "container-instance"
|
||||
Then the type-inherit removal error is ResourceTypeParentRemovalError
|
||||
|
||||
# ── 9. Additional coverage ────────────────────────────────
|
||||
|
||||
Scenario: Circular three-type cycle is rejected
|
||||
Given a type-inherit registry with "acme/alpha" inheriting from "acme/beta"
|
||||
And a type-inherit registry with "acme/beta" inheriting from "acme/gamma"
|
||||
And a type-inherit registry with "acme/gamma" inheriting from "acme/alpha"
|
||||
When I resolve the type-inherit chain for "acme/alpha" expecting error
|
||||
Then the type-inherit chain error is ResourceTypeCircularInheritanceError
|
||||
|
||||
Scenario: Three-level field merge — grandchild overrides child and inherits root
|
||||
Given a type-inherit parent "container-instance" with cli_args "path,port"
|
||||
And a type-inherit child "acme/app-container" inheriting from "container-instance" with cli_args "env"
|
||||
And a type-inherit grandchild "acme/web-container" inheriting from "acme/app-container" with cli_args "host"
|
||||
When I resolve type-inherit fields for "acme/web-container"
|
||||
Then the type-inherit resolved cli_args names are "path, port, env, host"
|
||||
|
||||
Scenario: resolve_handler_polymorphic falls back to ancestor handler
|
||||
Given a type-inherit registry with "container-instance" as root with handler "cleveragents.resource.handlers.fs_directory:FsDirectoryHandler"
|
||||
And a type-inherit type "acme/devcontainer" inheriting from "container-instance"
|
||||
When I resolve type-inherit handler polymorphic for "acme/devcontainer"
|
||||
Then the type-inherit resolved handler is not None
|
||||
|
||||
Scenario: validate_chain rejects built-in type inheriting custom parent
|
||||
Given a type-inherit registry with "acme/custom-parent" as root
|
||||
When I validate type-inherit chain for built-in "my-builtin" inheriting "acme/custom-parent" expecting error
|
||||
Then the type-inherit validation error is ValueError
|
||||
|
||||
# ── 10. Coverage: edge cases ──────────────────────────────
|
||||
|
||||
Scenario: Chain resolution raises ParentNotFoundError for missing parent
|
||||
Given a type-inherit registry with "acme/child" inheriting from "acme/missing"
|
||||
When I resolve the type-inherit chain for "acme/child" expecting error
|
||||
Then the type-inherit chain error is ResourceTypeParentNotFoundError
|
||||
|
||||
Scenario: Chain resolution returns singleton for unregistered type
|
||||
When I resolve the type-inherit chain for "acme/never-registered"
|
||||
Then the type-inherit chain is "acme/never-registered"
|
||||
|
||||
Scenario: validate_chain returns singleton when inherits is None
|
||||
Given a type-inherit registry with "container-instance" as root
|
||||
When I validate type-inherit chain for "acme/standalone" inheriting nothing
|
||||
Then the type-inherit validated chain is "acme/standalone"
|
||||
|
||||
Scenario: is_subtype_of returns false when chain has errors
|
||||
Given a type-inherit registry with "acme/broken" inheriting from "acme/missing"
|
||||
When I check type-inherit is_subtype_of "acme/broken" and "acme/missing"
|
||||
Then the type-inherit subtype check is false
|
||||
|
||||
Scenario: resolve_fields with root-only type returns its own fields
|
||||
Given a type-inherit registry with parent "container-instance" description "Root container type"
|
||||
When I resolve type-inherit fields for "container-instance"
|
||||
Then the type-inherit resolved description is "Root container type"
|
||||
|
||||
Scenario: _get_inherits works with object-style entries
|
||||
Given a type-inherit registry with object-style entry "acme/obj-child" inheriting "container-instance"
|
||||
And a type-inherit registry with "container-instance" as root
|
||||
When I resolve the type-inherit chain for "acme/obj-child"
|
||||
Then the type-inherit chain is "acme/obj-child, container-instance"
|
||||
|
||||
Scenario: _to_dict works with Pydantic model_dump entries
|
||||
Given a type-inherit registry with pydantic-style entry "acme/pydantic-type" having description "Pydantic type"
|
||||
When I resolve type-inherit fields for "acme/pydantic-type"
|
||||
Then the type-inherit resolved description is "Pydantic type"
|
||||
|
||||
Scenario: _merge_cli_args returns child when parent has no args
|
||||
Given a type-inherit parent "empty-parent" with no cli_args
|
||||
And a type-inherit child "acme/child-only" inheriting from "empty-parent" with cli_args "path"
|
||||
When I resolve type-inherit fields for "acme/child-only"
|
||||
Then the type-inherit resolved cli_args names are "path"
|
||||
|
||||
Scenario: _merge_cli_args returns parent when child has no args
|
||||
Given a type-inherit parent "container-instance" with cli_args "path,port"
|
||||
And a type-inherit child "acme/bare-child" inheriting from "container-instance" with no cli_args
|
||||
When I resolve type-inherit fields for "acme/bare-child"
|
||||
Then the type-inherit resolved cli_args names are "path, port"
|
||||
|
||||
Scenario: resolve_handler_polymorphic raises when no handler in chain
|
||||
Given a type-inherit registry with "container-instance" as root
|
||||
And a type-inherit type "acme/no-handler" inheriting from "container-instance"
|
||||
When I resolve type-inherit handler polymorphic for "acme/no-handler" expecting error
|
||||
Then the type-inherit handler error is HandlerResolutionError
|
||||
|
||||
Scenario: Tool registry duplicate add raises ToolError
|
||||
Given a type-inherit tool registry with tool "ns/dup-tool" bound to "container-instance"
|
||||
When I add type-inherit tool "ns/dup-tool" again expecting error
|
||||
Then the type-inherit tool error is ToolError
|
||||
|
||||
Scenario: Tool registry get returns None for unknown tool
|
||||
Given a type-inherit empty tool registry
|
||||
When I get type-inherit tool "ns/unknown"
|
||||
Then the type-inherit tool result is None
|
||||
|
||||
Scenario: Tool registry list_tools with namespace filter
|
||||
Given a type-inherit tool registry with tool "ns/tool-a" bound to "container-instance"
|
||||
And a type-inherit tool registry with tool "other/tool-b" bound to "git-checkout"
|
||||
When I list type-inherit tools with namespace "ns"
|
||||
Then the type-inherit tools list has 1 entry
|
||||
|
||||
Scenario: Tool registry remove returns true for existing tool
|
||||
Given a type-inherit tool registry with tool "ns/to-remove" bound to "container-instance"
|
||||
When I remove type-inherit tool "ns/to-remove"
|
||||
Then the type-inherit tool remove result is true
|
||||
|
||||
Scenario: Tool registry remove returns false for missing tool
|
||||
Given a type-inherit empty tool registry
|
||||
When I remove type-inherit tool "ns/nonexistent"
|
||||
Then the type-inherit tool remove result is false
|
||||
|
||||
Scenario: find_tools_for_resource skips tools with no bindings
|
||||
Given a type-inherit registry with "container-instance" as root
|
||||
And a type-inherit tool registry with unbound tool "ns/no-bindings"
|
||||
When I find type-inherit tools for resource type "container-instance"
|
||||
Then the type-inherit matched tools list is empty
|
||||
|
||||
Scenario: Tool registry list_tools with source filter
|
||||
Given a type-inherit tool registry with tool "ns/tool-x" bound to "container-instance"
|
||||
When I list type-inherit tools with source "test"
|
||||
Then the type-inherit tools list has 1 entry
|
||||
|
||||
Scenario: resolve_handler raises on empty reference
|
||||
When I resolve type-inherit handler with empty reference expecting error
|
||||
Then the type-inherit handler error is HandlerResolutionError
|
||||
|
||||
Scenario: resolve_handler raises on missing module
|
||||
When I resolve type-inherit handler with reference "nonexistent.module:Cls" expecting error
|
||||
Then the type-inherit handler error is HandlerResolutionError
|
||||
|
||||
Scenario: resolve_handler raises on missing class
|
||||
When I resolve type-inherit handler with reference "cleveragents.resource.handlers.fs_directory:NonexistentClass" expecting error
|
||||
Then the type-inherit handler error is HandlerResolutionError
|
||||
|
||||
Scenario: resolve_handler raises on malformed colon-only reference
|
||||
When I resolve type-inherit handler with reference ":ClassName" expecting error
|
||||
Then the type-inherit handler error is HandlerResolutionError
|
||||
@@ -6,8 +6,8 @@ Covers the remaining uncovered lines and partial branches in
|
||||
- Lines 79-81: ``_get_registry_service()``
|
||||
- Lines 194-195: ``type_add`` FileNotFoundError handler
|
||||
- Lines 234-236: ``type_remove`` user declines confirmation
|
||||
- Lines 260-262: ``type_remove`` DB row is None
|
||||
- Lines 267-269: ``type_remove`` generic Exception → rollback
|
||||
- Lines 263-265: ``type_remove`` NotFoundError from service
|
||||
- Lines 269-271: ``type_remove`` ValidationError from service
|
||||
- Lines 653-658: ``resource_remove`` edge_count > 0
|
||||
- Lines 668-672: ``resource_remove`` generic Exception → rollback
|
||||
"""
|
||||
@@ -215,39 +215,25 @@ def step_invoke_type_remove_no(context: Context) -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario: type_remove aborts when DB row is None after query
|
||||
# Scenario: type_remove aborts when service.remove_type raises NotFoundError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a mock resource service whose session returns no row for the type")
|
||||
def step_mock_service_type_row_none(context: Context) -> None:
|
||||
@given("a mock resource service whose remove_type raises NotFoundError")
|
||||
def step_mock_service_remove_type_not_found(context: Context) -> None:
|
||||
from cleveragents.core.exceptions import NotFoundError
|
||||
|
||||
svc = MagicMock()
|
||||
svc.show_type.return_value = _make_mock_type_spec(built_in=False)
|
||||
|
||||
# Build the mock session with smart query dispatch
|
||||
mock_session = MagicMock()
|
||||
|
||||
# ResourceModel query → count returns 0 (no resources reference this type)
|
||||
resource_model_query = MagicMock()
|
||||
resource_model_query.filter.return_value.count.return_value = 0
|
||||
|
||||
# ResourceTypeModel query → first returns None (row not found)
|
||||
type_model_query = MagicMock()
|
||||
type_model_query.filter_by.return_value.first.return_value = None
|
||||
|
||||
mock_session.query.side_effect = _smart_query_side_effect(
|
||||
{
|
||||
"ResourceModel": resource_model_query,
|
||||
"ResourceTypeModel": type_model_query,
|
||||
}
|
||||
svc.remove_type.side_effect = NotFoundError(
|
||||
resource_type="resource_type",
|
||||
resource_id="test/mock-type",
|
||||
)
|
||||
svc._session.return_value = mock_session
|
||||
|
||||
context.rcb_mock_service = svc
|
||||
|
||||
|
||||
@when("I invoke type remove with --yes via CliRunner for the phantom type")
|
||||
def step_invoke_type_remove_row_none(context: Context) -> None:
|
||||
def step_invoke_type_remove_not_found(context: Context) -> None:
|
||||
from cleveragents.cli.commands.resource import app
|
||||
|
||||
runner = CliRunner()
|
||||
@@ -262,43 +248,23 @@ def step_invoke_type_remove_row_none(context: Context) -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario: type_remove rolls back session on unexpected exception
|
||||
# Scenario: type_remove aborts when service.remove_type raises ValidationError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a mock resource service whose session delete raises a generic exception")
|
||||
def step_mock_service_type_delete_exception(context: Context) -> None:
|
||||
@given("a mock resource service whose remove_type raises ValidationError")
|
||||
def step_mock_service_remove_type_validation_error(context: Context) -> None:
|
||||
svc = MagicMock()
|
||||
svc.show_type.return_value = _make_mock_type_spec(built_in=False)
|
||||
|
||||
mock_session = MagicMock()
|
||||
|
||||
# ResourceModel query → count returns 0
|
||||
resource_model_query = MagicMock()
|
||||
resource_model_query.filter.return_value.count.return_value = 0
|
||||
|
||||
# ResourceTypeModel query → first returns a mock row
|
||||
mock_row = MagicMock()
|
||||
type_model_query = MagicMock()
|
||||
type_model_query.filter_by.return_value.first.return_value = mock_row
|
||||
|
||||
mock_session.query.side_effect = _smart_query_side_effect(
|
||||
{
|
||||
"ResourceModel": resource_model_query,
|
||||
"ResourceTypeModel": type_model_query,
|
||||
}
|
||||
svc.remove_type.side_effect = ValidationError(
|
||||
message="Cannot remove type: 5 resource(s) still reference it.",
|
||||
details={"name": "test/mock-type"},
|
||||
)
|
||||
# session.delete raises a generic exception
|
||||
mock_session.delete.side_effect = RuntimeError("unexpected DB failure")
|
||||
|
||||
svc._session.return_value = mock_session
|
||||
|
||||
context.rcb_mock_service = svc
|
||||
context.rcb_mock_session = mock_session
|
||||
|
||||
|
||||
@when("I invoke type remove with --yes via CliRunner for the failing type")
|
||||
def step_invoke_type_remove_exception(context: Context) -> None:
|
||||
def step_invoke_type_remove_validation_error(context: Context) -> None:
|
||||
from cleveragents.cli.commands.resource import app
|
||||
|
||||
runner = CliRunner()
|
||||
@@ -312,11 +278,6 @@ def step_invoke_type_remove_exception(context: Context) -> None:
|
||||
)
|
||||
|
||||
|
||||
@then("the mock session rollback should have been called for type remove")
|
||||
def step_verify_type_rollback(context: Context) -> None:
|
||||
context.rcb_mock_session.rollback.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario: resource_remove aborts when resource has edges
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
||||
"""Robot Framework helper for resource type inheritance tests.
|
||||
|
||||
Each public function is exposed as a Robot keyword. When run standalone
|
||||
via ``python robot/helper_resource_type_inheritance.py <subcommand>``
|
||||
each command prints ``<subcommand>-ok`` on success.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from cleveragents.resource.inheritance import (
|
||||
MAX_CHAIN_DEPTH,
|
||||
ResourceTypeCircularInheritanceError,
|
||||
ResourceTypeInheritanceDepthError,
|
||||
find_subtypes,
|
||||
is_subtype_of,
|
||||
resolve_fields,
|
||||
resolve_inheritance_chain,
|
||||
validate_chain,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared test registries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SIMPLE_REGISTRY: dict[str, Any] = {
|
||||
"resource": {},
|
||||
"container-instance": {"inherits": "resource", "sandbox_strategy": "docker"},
|
||||
"devcontainer-instance": {
|
||||
"inherits": "container-instance",
|
||||
"sandbox_strategy": "devcontainer",
|
||||
"cli_args": [{"name": "profile", "type": "string"}],
|
||||
},
|
||||
}
|
||||
|
||||
_DEEP_REGISTRY: dict[str, Any] = {
|
||||
"root": {},
|
||||
"level-1": {"inherits": "root"},
|
||||
"level-2": {"inherits": "level-1"},
|
||||
"level-3": {"inherits": "level-2"},
|
||||
"level-4": {"inherits": "level-3"},
|
||||
}
|
||||
|
||||
_FIELD_REGISTRY: dict[str, Any] = {
|
||||
"base": {
|
||||
"sandbox_strategy": "copy_on_write",
|
||||
"resource_kind": "physical",
|
||||
"cli_args": [{"name": "path", "type": "string"}],
|
||||
"child_types": ["alpha"],
|
||||
},
|
||||
"mid": {
|
||||
"inherits": "base",
|
||||
"resource_kind": "virtual",
|
||||
"cli_args": [{"name": "tag", "type": "string"}],
|
||||
"child_types": ["beta"],
|
||||
},
|
||||
"leaf": {
|
||||
"inherits": "mid",
|
||||
"sandbox_strategy": "none",
|
||||
"cli_args": [
|
||||
{"name": "path", "type": "int"}, # overrides base's "path"
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
_WIDE_REGISTRY: dict[str, Any] = {
|
||||
"ancestor": {},
|
||||
}
|
||||
for _i in range(20):
|
||||
_WIDE_REGISTRY[f"child-{_i}"] = {"inherits": "ancestor"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Robot keywords
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resolve_inheritance_chain_keyword(
|
||||
type_name: str,
|
||||
) -> list[str]:
|
||||
"""Resolve an inheritance chain using the simple registry."""
|
||||
return resolve_inheritance_chain(type_name, _SIMPLE_REGISTRY)
|
||||
|
||||
|
||||
def resolve_deep_chain(type_name: str) -> list[str]:
|
||||
"""Resolve an inheritance chain using the deep (4-level) registry."""
|
||||
return resolve_inheritance_chain(type_name, _DEEP_REGISTRY)
|
||||
|
||||
|
||||
def check_is_subtype_of(
|
||||
type_name: str,
|
||||
ancestor_name: str,
|
||||
) -> bool:
|
||||
"""Return True if *type_name* is a subtype of *ancestor_name*."""
|
||||
return is_subtype_of(type_name, ancestor_name, _SIMPLE_REGISTRY)
|
||||
|
||||
|
||||
def check_is_subtype_deep(
|
||||
type_name: str,
|
||||
ancestor_name: str,
|
||||
) -> bool:
|
||||
"""Return True if *type_name* is a subtype of *ancestor_name* (deep)."""
|
||||
return is_subtype_of(type_name, ancestor_name, _DEEP_REGISTRY)
|
||||
|
||||
|
||||
def resolve_merged_fields(type_name: str) -> dict[str, Any]:
|
||||
"""Resolve merged fields for *type_name* using the field registry."""
|
||||
return resolve_fields(type_name, _FIELD_REGISTRY)
|
||||
|
||||
|
||||
def find_all_subtypes(ancestor_name: str) -> list[str]:
|
||||
"""Find all subtypes of *ancestor_name* in the wide registry."""
|
||||
return find_subtypes(ancestor_name, _WIDE_REGISTRY)
|
||||
|
||||
|
||||
def validate_chain_keyword(
|
||||
type_name: str,
|
||||
inherits: str | None,
|
||||
) -> list[str]:
|
||||
"""Validate a chain declaration against the simple registry."""
|
||||
return validate_chain(type_name, inherits, _SIMPLE_REGISTRY)
|
||||
|
||||
|
||||
def get_max_chain_depth() -> int:
|
||||
"""Return the configured MAX_CHAIN_DEPTH constant."""
|
||||
return MAX_CHAIN_DEPTH
|
||||
|
||||
|
||||
def detect_circular_inheritance() -> str:
|
||||
"""Attempt to resolve a circular chain; return error class name."""
|
||||
circular: dict[str, Any] = {
|
||||
"a": {"inherits": "b"},
|
||||
"b": {"inherits": "a"},
|
||||
}
|
||||
try:
|
||||
resolve_inheritance_chain("a", circular)
|
||||
return "no-error"
|
||||
except ResourceTypeCircularInheritanceError:
|
||||
return "ResourceTypeCircularInheritanceError"
|
||||
|
||||
|
||||
def detect_depth_exceeded() -> str:
|
||||
"""Attempt to resolve an overly-deep chain; return error class name."""
|
||||
deep: dict[str, Any] = {"t0": {}}
|
||||
for i in range(1, MAX_CHAIN_DEPTH + 2):
|
||||
deep[f"t{i}"] = {"inherits": f"t{i - 1}"}
|
||||
leaf = f"t{MAX_CHAIN_DEPTH + 1}"
|
||||
try:
|
||||
resolve_inheritance_chain(leaf, deep)
|
||||
return "no-error"
|
||||
except ResourceTypeInheritanceDepthError:
|
||||
return "ResourceTypeInheritanceDepthError"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Standalone CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _chain_resolve() -> None:
|
||||
chain = resolve_inheritance_chain("devcontainer-instance", _SIMPLE_REGISTRY)
|
||||
assert chain == ["devcontainer-instance", "container-instance", "resource"], (
|
||||
f"unexpected chain: {chain}"
|
||||
)
|
||||
print("chain-resolve-ok")
|
||||
|
||||
|
||||
def _subtype_check() -> None:
|
||||
assert is_subtype_of("devcontainer-instance", "resource", _SIMPLE_REGISTRY)
|
||||
assert not is_subtype_of("resource", "devcontainer-instance", _SIMPLE_REGISTRY)
|
||||
print("subtype-check-ok")
|
||||
|
||||
|
||||
def _field_merge() -> None:
|
||||
fields = resolve_fields("leaf", _FIELD_REGISTRY)
|
||||
assert fields["sandbox_strategy"] == "none", f"got {fields['sandbox_strategy']}"
|
||||
assert fields["resource_kind"] == "virtual", f"got {fields['resource_kind']}"
|
||||
# cli_args: "path" from leaf overrides base, "tag" from mid kept
|
||||
names = [a["name"] for a in fields["cli_args"]]
|
||||
assert "tag" in names, f"tag missing from {names}"
|
||||
assert "path" in names, f"path missing from {names}"
|
||||
print("field-merge-ok")
|
||||
|
||||
|
||||
def _find_subtypes_cmd() -> None:
|
||||
subtypes = find_subtypes("ancestor", _WIDE_REGISTRY)
|
||||
assert len(subtypes) == 20, f"expected 20 subtypes, got {len(subtypes)}"
|
||||
print("find-subtypes-ok")
|
||||
|
||||
|
||||
def _circular_detect() -> None:
|
||||
result = detect_circular_inheritance()
|
||||
assert result == "ResourceTypeCircularInheritanceError", f"got {result}"
|
||||
print("circular-detect-ok")
|
||||
|
||||
|
||||
_COMMANDS = {
|
||||
"chain-resolve": _chain_resolve,
|
||||
"subtype-check": _subtype_check,
|
||||
"field-merge": _field_merge,
|
||||
"find-subtypes": _find_subtypes_cmd,
|
||||
"circular-detect": _circular_detect,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
|
||||
print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
_COMMANDS[sys.argv[1]]()
|
||||
@@ -0,0 +1,114 @@
|
||||
*** Settings ***
|
||||
Documentation Resource type inheritance chain resolution and polymorphic type matching
|
||||
Library Process
|
||||
Library OperatingSystem
|
||||
Library Collections
|
||||
Library helper_resource_type_inheritance.py
|
||||
|
||||
*** Variables ***
|
||||
${PYTHON} python
|
||||
|
||||
*** Test Cases ***
|
||||
Single Inheritance Chain Resolution
|
||||
[Documentation] Resolve a two-level chain: devcontainer-instance -> container-instance -> resource
|
||||
${chain}= Resolve Inheritance Chain Keyword devcontainer-instance
|
||||
Length Should Be ${chain} 3
|
||||
Should Be Equal ${chain}[0] devcontainer-instance
|
||||
Should Be Equal ${chain}[1] container-instance
|
||||
Should Be Equal ${chain}[2] resource
|
||||
|
||||
Deep Chain Resolution
|
||||
[Documentation] Resolve a four-level chain through the deep registry
|
||||
${chain}= Resolve Deep Chain level-4
|
||||
Length Should Be ${chain} 5
|
||||
Should Be Equal ${chain}[0] level-4
|
||||
Should Be Equal ${chain}[4] root
|
||||
|
||||
Root Type Returns Single Element Chain
|
||||
[Documentation] A root type with no parent returns a one-element chain
|
||||
${chain}= Resolve Inheritance Chain Keyword resource
|
||||
Length Should Be ${chain} 1
|
||||
Should Be Equal ${chain}[0] resource
|
||||
|
||||
Polymorphic Subtype Check Positive
|
||||
[Documentation] devcontainer-instance is a subtype of resource (transitive)
|
||||
${result}= Check Is Subtype Of devcontainer-instance resource
|
||||
Should Be True ${result}
|
||||
|
||||
Polymorphic Subtype Check Negative
|
||||
[Documentation] resource is NOT a subtype of devcontainer-instance
|
||||
${result}= Check Is Subtype Of resource devcontainer-instance
|
||||
Should Not Be True ${result}
|
||||
|
||||
Polymorphic Identity Check
|
||||
[Documentation] A type is always a subtype of itself
|
||||
${result}= Check Is Subtype Of container-instance container-instance
|
||||
Should Be True ${result}
|
||||
|
||||
Deep Polymorphic Subtype Check
|
||||
[Documentation] level-4 is a subtype of root through four levels
|
||||
${result}= Check Is Subtype Deep level-4 root
|
||||
Should Be True ${result}
|
||||
|
||||
Field Merging Scalars Override
|
||||
[Documentation] Scalar fields from child override parent values
|
||||
${fields}= Resolve Merged Fields leaf
|
||||
Should Be Equal ${fields}[sandbox_strategy] none
|
||||
Should Be Equal ${fields}[resource_kind] virtual
|
||||
|
||||
Field Merging Collections Additive
|
||||
[Documentation] Collection fields merge additively by default
|
||||
${fields}= Resolve Merged Fields mid
|
||||
${child_types}= Get From Dictionary ${fields} child_types
|
||||
Should Contain ${child_types} alpha
|
||||
Should Contain ${child_types} beta
|
||||
|
||||
Field Merging CLI Args Same Name Replacement
|
||||
[Documentation] cli_args with matching name from child replace parent
|
||||
${fields}= Resolve Merged Fields leaf
|
||||
${cli_args}= Get From Dictionary ${fields} cli_args
|
||||
# "path" from leaf replaces "path" from base; "tag" from mid preserved
|
||||
${names}= Evaluate [a["name"] for a in $cli_args]
|
||||
Should Contain ${names} path
|
||||
Should Contain ${names} tag
|
||||
|
||||
Find All Subtypes Of Ancestor
|
||||
[Documentation] find_subtypes returns all direct children of a wide registry
|
||||
${subtypes}= Find All Subtypes ancestor
|
||||
Length Should Be ${subtypes} 20
|
||||
|
||||
Circular Inheritance Detected
|
||||
[Documentation] Circular chain raises ResourceTypeCircularInheritanceError
|
||||
${err}= Detect Circular Inheritance
|
||||
Should Be Equal ${err} ResourceTypeCircularInheritanceError
|
||||
|
||||
Depth Limit Exceeded Detected
|
||||
[Documentation] Chain exceeding MAX_CHAIN_DEPTH raises depth error
|
||||
${err}= Detect Depth Exceeded
|
||||
Should Be Equal ${err} ResourceTypeInheritanceDepthError
|
||||
|
||||
Max Chain Depth Constant
|
||||
[Documentation] MAX_CHAIN_DEPTH is exposed and equals 5
|
||||
${depth}= Get Max Chain Depth
|
||||
Should Be Equal As Integers ${depth} 5
|
||||
|
||||
Standalone Helper Chain Resolve
|
||||
[Documentation] Run helper as standalone script: chain-resolve
|
||||
${result}= Run Process ${PYTHON} robot/helper_resource_type_inheritance.py chain-resolve
|
||||
... env:PYTHONPATH=src
|
||||
Should Be Equal As Integers ${result.rc} 0 helper failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} chain-resolve-ok
|
||||
|
||||
Standalone Helper Subtype Check
|
||||
[Documentation] Run helper as standalone script: subtype-check
|
||||
${result}= Run Process ${PYTHON} robot/helper_resource_type_inheritance.py subtype-check
|
||||
... env:PYTHONPATH=src
|
||||
Should Be Equal As Integers ${result.rc} 0 helper failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} subtype-check-ok
|
||||
|
||||
Standalone Helper Field Merge
|
||||
[Documentation] Run helper as standalone script: field-merge
|
||||
${result}= Run Process ${PYTHON} robot/helper_resource_type_inheritance.py field-merge
|
||||
... env:PYTHONPATH=src
|
||||
Should Be Equal As Integers ${result.rc} 0 helper failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} field-merge-ok
|
||||
@@ -55,6 +55,14 @@ from cleveragents.infrastructure.database.models import (
|
||||
ResourceModel,
|
||||
ResourceTypeModel,
|
||||
)
|
||||
from cleveragents.resource.inheritance import (
|
||||
ResourceTypeParentNotFoundError,
|
||||
ResourceTypeParentRemovalError,
|
||||
find_subtypes,
|
||||
is_subtype_of,
|
||||
resolve_inheritance_chain,
|
||||
validate_chain,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -295,6 +303,9 @@ class ResourceRegistryService:
|
||||
def register_type(self, config_path: str | Path) -> ResourceTypeSpec:
|
||||
"""Load a YAML resource type config, validate, and persist.
|
||||
|
||||
If the type declares ``inherits``, the parent type must already
|
||||
be registered and the resulting chain is validated (ADR-042).
|
||||
|
||||
Args:
|
||||
config_path: Path to the YAML configuration file.
|
||||
|
||||
@@ -302,7 +313,8 @@ class ResourceRegistryService:
|
||||
The validated ``ResourceTypeSpec``.
|
||||
|
||||
Raises:
|
||||
ValidationError: If the config is invalid.
|
||||
ValidationError: If the config is invalid or chain rules
|
||||
are violated.
|
||||
"""
|
||||
path = Path(config_path)
|
||||
if not path.exists():
|
||||
@@ -326,6 +338,27 @@ class ResourceRegistryService:
|
||||
details={"name": spec.name},
|
||||
)
|
||||
|
||||
# Validate inheritance chain (ADR-042 rules 1-4).
|
||||
# NOTE: ADR-042 steps 3 (pre-compute merged fields) and 5
|
||||
# (validate handler loadability) are deferred to runtime.
|
||||
if spec.inherits is not None:
|
||||
registry = self._load_type_registry()
|
||||
try:
|
||||
validate_chain(
|
||||
spec.name,
|
||||
spec.inherits,
|
||||
registry,
|
||||
is_built_in=spec.built_in,
|
||||
)
|
||||
except (
|
||||
ResourceTypeParentNotFoundError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
raise ValidationError(
|
||||
message=str(exc),
|
||||
details={"name": spec.name, "inherits": spec.inherits},
|
||||
) from exc
|
||||
|
||||
db_model = _spec_to_db(spec, source=str(path))
|
||||
session.add(db_model)
|
||||
session.flush()
|
||||
@@ -385,6 +418,143 @@ class ResourceRegistryService:
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def remove_type(self, name: str) -> None:
|
||||
"""Remove a custom resource type after safety checks.
|
||||
|
||||
Enforces ADR-042 Rule 5: a type that is a parent of registered
|
||||
subtypes cannot be removed until the subtypes are removed first.
|
||||
|
||||
Args:
|
||||
name: Name of the resource type to remove.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If the type is not registered.
|
||||
ValidationError: If the type is built-in or has resources.
|
||||
ResourceTypeParentRemovalError: If subtypes still reference
|
||||
this type via ``inherits``.
|
||||
"""
|
||||
session = self._session()
|
||||
try:
|
||||
row = session.query(ResourceTypeModel).filter_by(name=name).first()
|
||||
if row is None:
|
||||
raise NotFoundError(
|
||||
resource_type="resource_type",
|
||||
resource_id=name,
|
||||
)
|
||||
|
||||
spec = _db_to_spec(row)
|
||||
if spec.built_in:
|
||||
raise ValidationError(
|
||||
message=f"Cannot remove built-in resource type: {name}",
|
||||
details={"name": name},
|
||||
)
|
||||
|
||||
# Guard: reject if resources still reference this type
|
||||
resource_count: int = (
|
||||
session.query(ResourceModel)
|
||||
.filter(ResourceModel.type_name == name)
|
||||
.count()
|
||||
)
|
||||
if resource_count > 0:
|
||||
raise ValidationError(
|
||||
message=(
|
||||
f"Cannot remove type '{name}': "
|
||||
f"{resource_count} resource(s) still reference it."
|
||||
),
|
||||
details={"name": name, "resource_count": resource_count},
|
||||
)
|
||||
|
||||
# Guard: ADR-042 Rule 5 — reject if subtypes exist
|
||||
registry = self._load_type_registry()
|
||||
subtypes = find_subtypes(name, registry)
|
||||
if subtypes:
|
||||
raise ResourceTypeParentRemovalError(
|
||||
f"Cannot remove '{name}': subtypes exist: {subtypes}"
|
||||
)
|
||||
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
except (NotFoundError, ValidationError, ResourceTypeParentRemovalError):
|
||||
session.rollback()
|
||||
raise
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def resolve_type_inheritance_chain(self, type_name: str) -> list[str]:
|
||||
"""Resolve the full inheritance chain for a resource type.
|
||||
|
||||
Walks the ``inherits`` links from *type_name* to the root ancestor.
|
||||
|
||||
Args:
|
||||
type_name: Name of the resource type to resolve.
|
||||
|
||||
Returns:
|
||||
List of type names from child to root, e.g.
|
||||
``["devcontainer-instance", "container-instance"]``.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If the type is not registered.
|
||||
ValidationError: If the chain is invalid (cycle, depth, etc.).
|
||||
"""
|
||||
registry = self._load_type_registry()
|
||||
if type_name not in registry:
|
||||
raise NotFoundError(
|
||||
resource_type="resource_type",
|
||||
resource_id=type_name,
|
||||
)
|
||||
try:
|
||||
return resolve_inheritance_chain(type_name, registry)
|
||||
except ValueError as exc:
|
||||
raise ValidationError(
|
||||
message=str(exc),
|
||||
details={"type_name": type_name},
|
||||
) from exc
|
||||
|
||||
def is_subtype_of(self, type_name: str, ancestor_name: str) -> bool:
|
||||
"""Check whether *type_name* is the same as or inherits from *ancestor_name*.
|
||||
|
||||
Args:
|
||||
type_name: The concrete type to check.
|
||||
ancestor_name: The declared/expected type.
|
||||
|
||||
Returns:
|
||||
``True`` if *type_name* equals *ancestor_name* or
|
||||
transitively inherits from it.
|
||||
"""
|
||||
registry = self._load_type_registry()
|
||||
return is_subtype_of(type_name, ancestor_name, registry)
|
||||
|
||||
def _load_type_registry(self) -> dict[str, dict[str, Any]]:
|
||||
"""Load all registered types into an in-memory dict for chain ops.
|
||||
|
||||
.. note::
|
||||
|
||||
Returns a **minimal** registry for chain resolution and subtype
|
||||
checks only. It does NOT include ``handler``, ``cli_args``,
|
||||
``capabilities``, or other fields needed by
|
||||
``resolve_fields()`` or ``resolve_handler_polymorphic()``.
|
||||
|
||||
Returns:
|
||||
Dict mapping type name to its config dict (with ``inherits``).
|
||||
"""
|
||||
session = self._session()
|
||||
try:
|
||||
rows = session.query(ResourceTypeModel).all()
|
||||
registry: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
name = str(row.name)
|
||||
raw_inherits = getattr(row, "inherits", None)
|
||||
registry[name] = {
|
||||
"inherits": str(raw_inherits) if raw_inherits else None,
|
||||
"built_in": "/" not in name,
|
||||
}
|
||||
return registry
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# -- Resource operations -------------------------------------------------
|
||||
|
||||
def register_resource(
|
||||
@@ -484,11 +654,20 @@ class ResourceRegistryService:
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def list_resources(self, type_name: str | None = None) -> list[Resource]:
|
||||
def list_resources(
|
||||
self,
|
||||
type_name: str | None = None,
|
||||
*,
|
||||
exact: bool = False,
|
||||
) -> list[Resource]:
|
||||
"""List registered resources.
|
||||
|
||||
Args:
|
||||
type_name: Optional type name filter.
|
||||
exact: When ``False`` (default) and *type_name* is given,
|
||||
include resources whose type is a **subtype** of
|
||||
*type_name* (ADR-042 polymorphic listing). When
|
||||
``True``, match only the exact type name.
|
||||
|
||||
Returns:
|
||||
List of ``Resource`` domain objects.
|
||||
@@ -497,7 +676,14 @@ class ResourceRegistryService:
|
||||
try:
|
||||
query = session.query(ResourceModel)
|
||||
if type_name is not None:
|
||||
query = query.filter_by(type_name=type_name)
|
||||
if exact:
|
||||
query = query.filter_by(type_name=type_name)
|
||||
else:
|
||||
# ADR-042: polymorphic listing — include subtypes
|
||||
registry = self._load_type_registry()
|
||||
subtypes = find_subtypes(type_name, registry)
|
||||
all_types = [type_name, *subtypes]
|
||||
query = query.filter(ResourceModel.type_name.in_(all_types))
|
||||
query = query.order_by(ResourceModel.created_at)
|
||||
rows = query.all()
|
||||
return [_db_resource_to_domain(row) for row in rows]
|
||||
@@ -572,20 +758,28 @@ class ResourceRegistryService:
|
||||
allowed_children: list[str] = (
|
||||
json.loads(str(allowed_raw)) if allowed_raw else []
|
||||
)
|
||||
if (
|
||||
allowed_children
|
||||
and child.resource_type_name not in allowed_children
|
||||
):
|
||||
raise ValidationError(
|
||||
message=(
|
||||
f"Type '{child.resource_type_name}' is not allowed "
|
||||
f"as a child of '{parent.resource_type_name}'"
|
||||
),
|
||||
details={
|
||||
"parent_type": parent.resource_type_name,
|
||||
"child_type": child.resource_type_name,
|
||||
},
|
||||
if allowed_children:
|
||||
# ADR-042: polymorphic matching — a child whose type
|
||||
# is a subtype of any allowed type satisfies the
|
||||
# constraint (e.g. allowed "container-instance"
|
||||
# accepts "acme/my-container" that inherits from it).
|
||||
registry = self._load_type_registry()
|
||||
child_type = child.resource_type_name
|
||||
match = any(
|
||||
is_subtype_of(child_type, allowed, registry)
|
||||
for allowed in allowed_children
|
||||
)
|
||||
if not match:
|
||||
raise ValidationError(
|
||||
message=(
|
||||
f"Type '{child_type}' is not allowed "
|
||||
f"as a child of '{parent.resource_type_name}'"
|
||||
),
|
||||
details={
|
||||
"parent_type": parent.resource_type_name,
|
||||
"child_type": child_type,
|
||||
},
|
||||
)
|
||||
|
||||
if parent.resource_id == child.resource_id:
|
||||
raise ValidationError(
|
||||
@@ -887,6 +1081,7 @@ def _spec_to_db(spec: ResourceTypeSpec, source: str) -> ResourceTypeModel:
|
||||
auto_discover_json=auto_discover_json,
|
||||
capabilities_json=capabilities_json,
|
||||
equivalence_json=equivalence_json,
|
||||
inherits=spec.inherits,
|
||||
source=source,
|
||||
created_at=now_iso,
|
||||
updated_at=now_iso,
|
||||
@@ -938,6 +1133,9 @@ def _db_to_spec(row: ResourceTypeModel) -> ResourceTypeSpec:
|
||||
raw_handler = getattr(row, "handler_ref", None)
|
||||
handler_str: str | None = str(raw_handler) if raw_handler is not None else None
|
||||
|
||||
raw_inherits = getattr(row, "inherits", None)
|
||||
inherits_str: str | None = str(raw_inherits) if raw_inherits is not None else None
|
||||
|
||||
return ResourceTypeSpec.from_config(
|
||||
{
|
||||
"name": name_str,
|
||||
@@ -953,6 +1151,7 @@ def _db_to_spec(row: ResourceTypeModel) -> ResourceTypeSpec:
|
||||
"handler": handler_str,
|
||||
"capabilities": capabilities,
|
||||
"built_in": is_builtin,
|
||||
"inherits": inherits_str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ Based on ``implementation_plan.md`` -- Tasks B0.cli.resources, B1.cli.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
@@ -71,6 +72,9 @@ from cleveragents.core.exceptions import (
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
)
|
||||
from cleveragents.resource.inheritance import ResourceTypeParentRemovalError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Main resource app
|
||||
app = typer.Typer(
|
||||
@@ -113,7 +117,8 @@ def _resource_type_dict(spec: Any) -> dict[str, object]:
|
||||
"default": arg.default,
|
||||
}
|
||||
)
|
||||
return {
|
||||
inherits = getattr(spec, "inherits", None)
|
||||
result: dict[str, object] = {
|
||||
"name": spec.name,
|
||||
"description": spec.description or "",
|
||||
"resource_kind": str(spec.resource_kind),
|
||||
@@ -126,6 +131,9 @@ def _resource_type_dict(spec: Any) -> dict[str, object]:
|
||||
"handler": spec.handler,
|
||||
"capabilities": spec.capabilities,
|
||||
}
|
||||
if inherits is not None:
|
||||
result["inherits"] = inherits
|
||||
return result
|
||||
|
||||
|
||||
def _resource_dict(resource: Any) -> dict[str, object]:
|
||||
@@ -235,7 +243,7 @@ def type_remove(
|
||||
try:
|
||||
service = _get_registry_service()
|
||||
|
||||
# Check if the type exists and whether it's built-in
|
||||
# Pre-check so we can show a friendly message before prompting
|
||||
spec = service.show_type(name)
|
||||
if spec.built_in:
|
||||
console.print(f"[red]Cannot remove built-in resource type:[/red] {name}")
|
||||
@@ -249,46 +257,15 @@ def type_remove(
|
||||
console.print("[yellow]Aborted.[/yellow]")
|
||||
raise typer.Abort()
|
||||
|
||||
# Delete via direct session access (service doesn't expose delete yet)
|
||||
session = service._session()
|
||||
try:
|
||||
from cleveragents.infrastructure.database.models import (
|
||||
ResourceModel,
|
||||
ResourceTypeModel,
|
||||
)
|
||||
|
||||
# Check for resources referencing this type
|
||||
resource_count: int = (
|
||||
session.query(ResourceModel)
|
||||
.filter(ResourceModel.type_name == name)
|
||||
.count()
|
||||
)
|
||||
if resource_count > 0:
|
||||
console.print(
|
||||
f"[red]Cannot remove type '{name}': "
|
||||
f"{resource_count} resource(s) still reference it.[/red]"
|
||||
)
|
||||
raise typer.Abort()
|
||||
|
||||
row = session.query(ResourceTypeModel).filter_by(name=name).first()
|
||||
if row is None:
|
||||
console.print(f"[red]Resource type not found:[/red] {name}")
|
||||
raise typer.Abort()
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
except typer.Abort:
|
||||
raise
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
service.remove_type(name)
|
||||
console.print(f"[green]Removed resource type:[/green] {name}")
|
||||
|
||||
except NotFoundError as exc:
|
||||
console.print(f"[red]Resource type not found:[/red] {name}")
|
||||
raise typer.Abort() from exc
|
||||
except ResourceTypeParentRemovalError as exc:
|
||||
console.print(f"[red]{exc}[/red]")
|
||||
raise typer.Abort() from exc
|
||||
except CleverAgentsError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc.message}")
|
||||
raise typer.Abort() from exc
|
||||
@@ -324,6 +301,7 @@ def type_list(
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Kind", style="blue")
|
||||
table.add_column("Sandbox", style="magenta")
|
||||
table.add_column("Inherits", style="green")
|
||||
table.add_column("Built-in", justify="center")
|
||||
table.add_column("User-addable", justify="center")
|
||||
table.add_column("Description", style="dim")
|
||||
@@ -332,10 +310,12 @@ def type_list(
|
||||
desc = spec.description or ""
|
||||
if len(desc) > 50:
|
||||
desc = desc[:47] + "..."
|
||||
inherits = getattr(spec, "inherits", None) or ""
|
||||
table.add_row(
|
||||
spec.name,
|
||||
str(spec.resource_kind),
|
||||
str(spec.sandbox_strategy),
|
||||
inherits,
|
||||
"yes" if spec.built_in else "no",
|
||||
"yes" if spec.user_addable else "no",
|
||||
desc,
|
||||
@@ -399,9 +379,25 @@ def _print_type_panel(spec: Any) -> None:
|
||||
else:
|
||||
args_display = " (none)"
|
||||
|
||||
inherits = getattr(spec, "inherits", None)
|
||||
inherits_display = inherits or "(none)"
|
||||
|
||||
# Attempt to resolve the full inheritance chain for display.
|
||||
chain_display = "(root type)"
|
||||
if inherits is not None:
|
||||
try:
|
||||
service = _get_registry_service()
|
||||
chain = service.resolve_type_inheritance_chain(spec.name)
|
||||
chain_display = " -> ".join(chain)
|
||||
except Exception: # CLI display; crash is worse than partial info
|
||||
logger.debug("Failed to resolve chain for %s: %s", spec.name, "error")
|
||||
chain_display = f"{spec.name} -> {inherits} -> ..."
|
||||
|
||||
details = (
|
||||
f"[bold]Name:[/bold] {spec.name}\n"
|
||||
f"[bold]Description:[/bold] {spec.description or '(none)'}\n"
|
||||
f"[bold]Inherits:[/bold] {inherits_display}\n"
|
||||
f"[bold]Inheritance Chain:[/bold] {chain_display}\n"
|
||||
f"[bold]Kind:[/bold] {spec.resource_kind}\n"
|
||||
f"[bold]Sandbox Strategy:[/bold] {spec.sandbox_strategy}\n"
|
||||
f"[bold]Built-in:[/bold] {'yes' if spec.built_in else 'no'}\n"
|
||||
|
||||
@@ -307,6 +307,11 @@ class ResourceTypeSpec(BaseModel):
|
||||
False,
|
||||
description=("Whether this is a built-in type (allows unnamespaced names)."),
|
||||
)
|
||||
inherits: str | None = Field(
|
||||
default=None,
|
||||
max_length=255,
|
||||
description="Parent resource type name (ADR-042 single inheritance).",
|
||||
)
|
||||
|
||||
# -- Name validation -----------------------------------------------------
|
||||
|
||||
@@ -371,6 +376,15 @@ class ResourceTypeSpec(BaseModel):
|
||||
"'equivalence' configuration for deduplication."
|
||||
)
|
||||
|
||||
# ADR-042 rule 3: no cycles (self-inheritance)
|
||||
if self.inherits is not None and self.inherits == self.name:
|
||||
raise ValueError(f"'{self.name}' cannot inherit from itself.")
|
||||
# ADR-042 rule 4: built-in must not inherit from custom
|
||||
if self.built_in and self.inherits is not None and "/" in self.inherits:
|
||||
raise ValueError(
|
||||
f"Built-in '{self.name}' cannot inherit from custom '{self.inherits}'."
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
# -- Factory methods ------------------------------------------------------
|
||||
@@ -427,6 +441,7 @@ class ResourceTypeSpec(BaseModel):
|
||||
},
|
||||
),
|
||||
built_in=config.get("built_in", False),
|
||||
inherits=config.get("inherits"),
|
||||
)
|
||||
|
||||
# -- CLI rendering --------------------------------------------------------
|
||||
@@ -448,6 +463,9 @@ class ResourceTypeSpec(BaseModel):
|
||||
result["user_addable"] = self.user_addable
|
||||
result["built_in"] = self.built_in
|
||||
|
||||
if self.inherits is not None:
|
||||
result["inherits"] = self.inherits
|
||||
|
||||
if self.cli_args:
|
||||
result["cli_args"] = [
|
||||
{
|
||||
|
||||
@@ -1320,6 +1320,9 @@ class ResourceTypeModel(Base): # type: ignore[misc]
|
||||
capabilities_json = Column(Text, nullable=True)
|
||||
equivalence_json = Column(Text, nullable=True)
|
||||
|
||||
# Single-inheritance parent type name (ADR-042)
|
||||
inherits = Column(String(255), nullable=True)
|
||||
|
||||
# Source: "builtin" or path to YAML config file
|
||||
source = Column(String(500), nullable=True)
|
||||
|
||||
@@ -1342,6 +1345,7 @@ class ResourceTypeModel(Base): # type: ignore[misc]
|
||||
Index("ix_resource_types_namespace", "namespace"),
|
||||
Index("ix_resource_types_kind", "resource_kind"),
|
||||
Index("ix_resource_types_user_addable", "user_addable"),
|
||||
Index("ix_resource_types_inherits", "inherits"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1 +1,27 @@
|
||||
"""Resource type schema loading and validation."""
|
||||
"""Resource type schema loading, validation, and inheritance resolution."""
|
||||
|
||||
from cleveragents.resource.inheritance import (
|
||||
MAX_CHAIN_DEPTH,
|
||||
ResourceTypeCircularInheritanceError,
|
||||
ResourceTypeInheritanceDepthError,
|
||||
ResourceTypeParentNotFoundError,
|
||||
ResourceTypeParentRemovalError,
|
||||
find_subtypes,
|
||||
is_subtype_of,
|
||||
resolve_fields,
|
||||
resolve_inheritance_chain,
|
||||
validate_chain,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MAX_CHAIN_DEPTH",
|
||||
"ResourceTypeCircularInheritanceError",
|
||||
"ResourceTypeInheritanceDepthError",
|
||||
"ResourceTypeParentNotFoundError",
|
||||
"ResourceTypeParentRemovalError",
|
||||
"find_subtypes",
|
||||
"is_subtype_of",
|
||||
"resolve_fields",
|
||||
"resolve_inheritance_chain",
|
||||
"validate_chain",
|
||||
]
|
||||
|
||||
@@ -26,6 +26,13 @@ from typing import Any
|
||||
|
||||
from cleveragents.resource.handlers.protocol import ResourceHandler
|
||||
|
||||
__all__ = [
|
||||
"HandlerResolutionError",
|
||||
"clear_handler_cache",
|
||||
"resolve_handler",
|
||||
"resolve_handler_polymorphic",
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache of already-resolved handler instances keyed by reference string
|
||||
@@ -114,6 +121,49 @@ def resolve_handler(handler_ref: str) -> ResourceHandler:
|
||||
return instance
|
||||
|
||||
|
||||
def resolve_handler_polymorphic(
|
||||
type_name: str,
|
||||
type_registry: dict[str, Any],
|
||||
) -> ResourceHandler:
|
||||
"""Resolve a handler for *type_name*, falling back to ancestor types.
|
||||
|
||||
Implements ADR-042 §Handler Inheritance: if the type has no direct
|
||||
handler, walk up the inheritance chain and use the first ancestor's
|
||||
handler.
|
||||
|
||||
Args:
|
||||
type_name: Resource type name.
|
||||
type_registry: Dict mapping type names to definitions. Each
|
||||
entry must support ``handler`` and ``inherits`` keys.
|
||||
|
||||
Returns:
|
||||
An instantiated handler.
|
||||
|
||||
Raises:
|
||||
HandlerResolutionError: If no handler is found in the chain.
|
||||
"""
|
||||
from cleveragents.resource.inheritance import resolve_inheritance_chain
|
||||
|
||||
chain = resolve_inheritance_chain(type_name, type_registry)
|
||||
|
||||
for ancestor in chain:
|
||||
entry = type_registry.get(ancestor)
|
||||
if entry is None:
|
||||
continue
|
||||
handler_ref = (
|
||||
entry.get("handler")
|
||||
if isinstance(entry, dict)
|
||||
else getattr(entry, "handler", None)
|
||||
)
|
||||
if handler_ref:
|
||||
return resolve_handler(str(handler_ref))
|
||||
|
||||
raise HandlerResolutionError(
|
||||
f"No handler found for '{type_name}' or any of its ancestors: "
|
||||
f"{' -> '.join(chain)}"
|
||||
)
|
||||
|
||||
|
||||
def clear_handler_cache() -> None:
|
||||
"""Clear the handler instance cache (useful for testing)."""
|
||||
with _cache_lock:
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Resource type inheritance resolution engine.
|
||||
|
||||
Implements ADR-042: single-inheritance resource type specialization with
|
||||
field resolution, collection merging, chain validation, and polymorphic
|
||||
type checking.
|
||||
|
||||
Key functions:
|
||||
|
||||
| Function | Purpose |
|
||||
|------------------------------|-----------------------------------------------|
|
||||
| ``resolve_inheritance_chain``| Walk ``inherits`` links from child to root |
|
||||
| ``resolve_fields`` | Merge fields along the chain (subtype wins) |
|
||||
| ``is_subtype_of`` | Polymorphic check: does A inherit from B? |
|
||||
| ``validate_chain`` | Registration-time chain validation |
|
||||
|
||||
Chain rules (ADR-042 §Inheritance Chain Rules):
|
||||
|
||||
1. Single inheritance only (at most one ``inherits`` value).
|
||||
2. Maximum chain depth of 5 levels.
|
||||
3. No circular inheritance.
|
||||
4. Built-in types may not inherit from custom (namespaced) types.
|
||||
5. Removing a parent type is prohibited while subtypes exist.
|
||||
|
||||
See Also:
|
||||
- ``docs/adr/ADR-042-resource-type-inheritance.md``
|
||||
- ``docs/specification.md`` lines 23460-23479
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Maximum inheritance chain depth (ADR-042 rule 2).
|
||||
MAX_CHAIN_DEPTH: int = 5
|
||||
|
||||
|
||||
class ResourceTypeInheritanceDepthError(ValueError):
|
||||
"""Raised when an inheritance chain exceeds the maximum depth."""
|
||||
|
||||
|
||||
class ResourceTypeCircularInheritanceError(ValueError):
|
||||
"""Raised when a circular inheritance chain is detected."""
|
||||
|
||||
|
||||
class ResourceTypeParentNotFoundError(ValueError):
|
||||
"""Raised when a parent type referenced by ``inherits`` is not registered."""
|
||||
|
||||
|
||||
class ResourceTypeParentRemovalError(ValueError):
|
||||
"""Raised when attempting to remove a type that has registered subtypes."""
|
||||
|
||||
|
||||
def resolve_inheritance_chain(
|
||||
type_name: str,
|
||||
type_registry: dict[str, Any],
|
||||
) -> list[str]:
|
||||
"""Walk the ``inherits`` links from *type_name* to the root type.
|
||||
|
||||
Returns the full chain as a list starting with *type_name* and
|
||||
ending with the root ancestor (the first type that has no
|
||||
``inherits`` value).
|
||||
|
||||
Args:
|
||||
type_name: Name of the type to resolve.
|
||||
type_registry: Mapping of type name to type definition. Each
|
||||
definition must support ``inherits`` as a key (dict) or
|
||||
attribute (object with ``.inherits``).
|
||||
|
||||
Returns:
|
||||
List of type names from child to root, e.g.
|
||||
``["devcontainer-instance", "container-instance"]``.
|
||||
|
||||
Raises:
|
||||
ResourceTypeParentNotFoundError: If a parent in the chain is
|
||||
not in *type_registry*.
|
||||
ResourceTypeCircularInheritanceError: If a cycle is detected.
|
||||
ResourceTypeInheritanceDepthError: If the chain exceeds
|
||||
:data:`MAX_CHAIN_DEPTH` levels.
|
||||
"""
|
||||
chain: list[str] = []
|
||||
visited: set[str] = set()
|
||||
current = type_name
|
||||
|
||||
while current is not None:
|
||||
if current in visited:
|
||||
raise ResourceTypeCircularInheritanceError(
|
||||
f"Circular inheritance detected: {' -> '.join(chain)} -> {current}"
|
||||
)
|
||||
|
||||
if len(chain) >= MAX_CHAIN_DEPTH:
|
||||
raise ResourceTypeInheritanceDepthError(
|
||||
f"Inheritance chain for '{type_name}' exceeds the maximum "
|
||||
f"depth of {MAX_CHAIN_DEPTH}: {' -> '.join(chain)}"
|
||||
)
|
||||
|
||||
visited.add(current)
|
||||
chain.append(current)
|
||||
|
||||
entry = type_registry.get(current)
|
||||
if entry is None and current != type_name:
|
||||
raise ResourceTypeParentNotFoundError(
|
||||
f"Parent type '{current}' (in chain for '{type_name}') "
|
||||
"is not registered."
|
||||
)
|
||||
if entry is None:
|
||||
# type_name itself is not in the registry. We return
|
||||
# [type_name] instead of raising so that callers like
|
||||
# is_subtype_of() can treat unregistered types as root
|
||||
# types with no ancestors (returns False for any ancestor
|
||||
# check). Service-level callers should verify existence
|
||||
# before calling.
|
||||
break
|
||||
|
||||
parent = _get_inherits(entry)
|
||||
current = parent
|
||||
|
||||
return chain
|
||||
|
||||
|
||||
def validate_chain(
|
||||
type_name: str,
|
||||
inherits: str | None,
|
||||
type_registry: dict[str, Any],
|
||||
*,
|
||||
is_built_in: bool = False,
|
||||
) -> list[str]:
|
||||
"""Validate an inheritance declaration at registration time.
|
||||
|
||||
Checks all five ADR-042 chain rules and returns the resolved chain
|
||||
on success.
|
||||
|
||||
Args:
|
||||
type_name: Name of the type being registered.
|
||||
inherits: Parent type name (or ``None`` for root types).
|
||||
type_registry: Current registry contents (must not yet contain
|
||||
*type_name*).
|
||||
is_built_in: Whether the type being registered is built-in.
|
||||
|
||||
Returns:
|
||||
The resolved inheritance chain (child to root).
|
||||
|
||||
Raises:
|
||||
ResourceTypeParentNotFoundError: If *inherits* names a type
|
||||
not in *type_registry*.
|
||||
ResourceTypeCircularInheritanceError: If adding this type
|
||||
would create a cycle.
|
||||
ResourceTypeInheritanceDepthError: If the resulting chain
|
||||
would exceed :data:`MAX_CHAIN_DEPTH`.
|
||||
ValueError: If a built-in type tries to inherit from a custom
|
||||
type.
|
||||
"""
|
||||
if inherits is None:
|
||||
return [type_name]
|
||||
|
||||
# Rule 1: single inheritance is enforced by the schema (one field).
|
||||
|
||||
# Rule 4: built-in types must not inherit from custom types.
|
||||
if is_built_in and "/" in inherits:
|
||||
raise ValueError(
|
||||
f"Built-in type '{type_name}' cannot inherit from custom type '{inherits}'."
|
||||
)
|
||||
|
||||
# Parent must exist.
|
||||
if inherits not in type_registry:
|
||||
raise ResourceTypeParentNotFoundError(
|
||||
f"Parent type '{inherits}' is not registered. "
|
||||
f"Cannot register '{type_name}' with inherits='{inherits}'."
|
||||
)
|
||||
|
||||
# Build a temporary registry entry to check the chain.
|
||||
temp_registry = dict(type_registry)
|
||||
temp_registry[type_name] = {"inherits": inherits}
|
||||
|
||||
# This will enforce rules 2 (depth) and 3 (cycles).
|
||||
return resolve_inheritance_chain(type_name, temp_registry)
|
||||
|
||||
|
||||
def is_subtype_of(
|
||||
type_name: str,
|
||||
ancestor_name: str,
|
||||
type_registry: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Check whether *type_name* is the same as or a subtype of *ancestor_name*.
|
||||
|
||||
This is the core polymorphism check used by:
|
||||
- Tool binding (ADR-042 §Tool Binding Polymorphism)
|
||||
- Auto-discovery child type matching
|
||||
- DAG query filtering
|
||||
|
||||
Args:
|
||||
type_name: The concrete type to check.
|
||||
ancestor_name: The declared/expected type.
|
||||
type_registry: Current registry contents.
|
||||
|
||||
Returns:
|
||||
``True`` if *type_name* equals *ancestor_name* or transitively
|
||||
inherits from it.
|
||||
"""
|
||||
if type_name == ancestor_name:
|
||||
return True
|
||||
|
||||
try:
|
||||
chain = resolve_inheritance_chain(type_name, type_registry)
|
||||
except (
|
||||
ResourceTypeParentNotFoundError,
|
||||
ResourceTypeCircularInheritanceError,
|
||||
ResourceTypeInheritanceDepthError,
|
||||
):
|
||||
return False
|
||||
|
||||
return ancestor_name in chain
|
||||
|
||||
|
||||
def resolve_fields(
|
||||
type_name: str,
|
||||
type_registry: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve the merged fields for *type_name* along its inheritance chain.
|
||||
|
||||
Field resolution (ADR-042 §Field Resolution):
|
||||
- Scalar fields: subtype's value overrides parent's.
|
||||
- Collection fields (``cli_args``, ``child_types``, ``parent_types``):
|
||||
additive merging by default; ``<field>_replace: true`` replaces.
|
||||
|
||||
Args:
|
||||
type_name: Name of the type to resolve.
|
||||
type_registry: Current registry contents.
|
||||
|
||||
Returns:
|
||||
A dict with the fully resolved field values.
|
||||
"""
|
||||
chain = resolve_inheritance_chain(type_name, type_registry)
|
||||
|
||||
# Walk from root to child (so child overrides parent).
|
||||
merged: dict[str, Any] = {}
|
||||
for name in reversed(chain):
|
||||
entry = type_registry.get(name)
|
||||
if entry is None:
|
||||
continue
|
||||
|
||||
fields = _to_dict(entry)
|
||||
|
||||
# Scalar fields: subtype wins.
|
||||
for key, value in fields.items():
|
||||
if key in _COLLECTION_FIELDS:
|
||||
continue
|
||||
if key.endswith("_replace"):
|
||||
continue
|
||||
if key == "inherits":
|
||||
continue
|
||||
merged[key] = value
|
||||
|
||||
# Collection fields: additive merge or replace.
|
||||
for field_name in _COLLECTION_FIELDS:
|
||||
replace_key = f"{field_name}_replace"
|
||||
should_replace = fields.get(replace_key, False)
|
||||
child_value = fields.get(field_name)
|
||||
|
||||
if child_value is None:
|
||||
continue
|
||||
|
||||
if should_replace or field_name not in merged:
|
||||
merged[field_name] = child_value
|
||||
else:
|
||||
merged[field_name] = _merge_collection(
|
||||
field_name,
|
||||
merged[field_name],
|
||||
child_value,
|
||||
)
|
||||
|
||||
# Preserve the inherits field from the original type.
|
||||
original = type_registry.get(type_name)
|
||||
if original is not None:
|
||||
merged["inherits"] = _get_inherits(original)
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def find_subtypes(
|
||||
ancestor_name: str,
|
||||
type_registry: dict[str, Any],
|
||||
) -> list[str]:
|
||||
"""Find all types that inherit from *ancestor_name* (directly or transitively).
|
||||
|
||||
Args:
|
||||
ancestor_name: The ancestor type name.
|
||||
type_registry: Current registry contents.
|
||||
|
||||
Returns:
|
||||
List of subtype names (does NOT include *ancestor_name* itself).
|
||||
"""
|
||||
subtypes: list[str] = []
|
||||
for name in type_registry:
|
||||
if name == ancestor_name:
|
||||
continue
|
||||
if is_subtype_of(name, ancestor_name, type_registry):
|
||||
subtypes.append(name)
|
||||
return subtypes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Collection fields that use additive merging (ADR-042 §Collection Field Merging).
|
||||
#: ``properties`` is also listed as additive in ADR-042; included here so
|
||||
#: that parent properties are inherited and children can override individual
|
||||
#: keys (dict merge) rather than replacing the entire mapping.
|
||||
_COLLECTION_FIELDS: frozenset[str] = frozenset(
|
||||
{"cli_args", "child_types", "parent_types", "properties"}
|
||||
)
|
||||
|
||||
|
||||
def _get_inherits(entry: Any) -> str | None:
|
||||
"""Extract the ``inherits`` value from a registry entry (dict or object)."""
|
||||
if isinstance(entry, dict):
|
||||
return entry.get("inherits")
|
||||
return getattr(entry, "inherits", None)
|
||||
|
||||
|
||||
def _to_dict(entry: Any) -> dict[str, Any]:
|
||||
"""Convert a registry entry to a dict for field merging."""
|
||||
if isinstance(entry, dict):
|
||||
return dict(entry)
|
||||
if hasattr(entry, "model_dump"):
|
||||
return entry.model_dump()
|
||||
return dict(vars(entry))
|
||||
|
||||
|
||||
def _merge_collection(
|
||||
field_name: str,
|
||||
parent_value: Any,
|
||||
child_value: Any,
|
||||
) -> Any:
|
||||
"""Merge a collection field using additive semantics.
|
||||
|
||||
For ``cli_args``: child entries with the same ``name`` replace parent
|
||||
entries (same-name replacement). For ``child_types`` and
|
||||
``parent_types``: union of both lists (deduplication).
|
||||
"""
|
||||
if field_name == "cli_args":
|
||||
return _merge_cli_args(parent_value, child_value)
|
||||
# child_types and parent_types: union, preserving order, child last.
|
||||
return _merge_string_list(parent_value, child_value)
|
||||
|
||||
|
||||
def _merge_cli_args(
|
||||
parent_args: list[Any],
|
||||
child_args: list[Any],
|
||||
) -> list[Any]:
|
||||
"""Merge CLI argument lists with same-name replacement.
|
||||
|
||||
Child args with the same ``name`` as a parent arg replace the parent
|
||||
entry. Otherwise child args are appended.
|
||||
"""
|
||||
if not parent_args:
|
||||
return list(child_args)
|
||||
if not child_args:
|
||||
return list(parent_args)
|
||||
|
||||
# Build lookup of child arg names for O(1) checking.
|
||||
child_names: set[str] = set()
|
||||
for arg in child_args:
|
||||
name = arg.get("name") if isinstance(arg, dict) else getattr(arg, "name", None)
|
||||
if name:
|
||||
child_names.add(name)
|
||||
|
||||
# Keep parent args that are NOT overridden by child.
|
||||
merged: list[Any] = []
|
||||
for arg in parent_args:
|
||||
name = arg.get("name") if isinstance(arg, dict) else getattr(arg, "name", None)
|
||||
if name not in child_names:
|
||||
merged.append(arg)
|
||||
|
||||
# Append all child args.
|
||||
merged.extend(child_args)
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_string_list(
|
||||
parent: list[str],
|
||||
child: list[str],
|
||||
) -> list[str]:
|
||||
"""Merge two string lists with deduplication, preserving order."""
|
||||
seen: set[str] = set()
|
||||
merged: list[str] = []
|
||||
for item in parent:
|
||||
if item not in seen:
|
||||
seen.add(item)
|
||||
merged.append(item)
|
||||
for item in child:
|
||||
if item not in seen:
|
||||
seen.add(item)
|
||||
merged.append(item)
|
||||
return merged
|
||||
@@ -211,6 +211,26 @@ class ResourceTypeConfigSchema(BaseModel):
|
||||
False,
|
||||
description="Whether this is a built-in type.",
|
||||
)
|
||||
inherits: str | None = Field(
|
||||
default=None,
|
||||
max_length=255,
|
||||
description=(
|
||||
"Parent resource type name to inherit from (single inheritance). "
|
||||
"See ADR-042 for full inheritance semantics."
|
||||
),
|
||||
)
|
||||
cli_args_replace: bool = Field(
|
||||
False,
|
||||
description="Replace parent cli_args entirely instead of merging.",
|
||||
)
|
||||
child_types_replace: bool = Field(
|
||||
False,
|
||||
description="Replace parent child_types entirely instead of merging.",
|
||||
)
|
||||
parent_types_replace: bool = Field(
|
||||
False,
|
||||
description="Replace parent parent_types entirely instead of merging.",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
@@ -245,6 +265,21 @@ class ResourceTypeConfigSchema(BaseModel):
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("inherits")
|
||||
@classmethod
|
||||
def validate_inherits(cls, v: str | None) -> str | None:
|
||||
"""Validate parent type name format if provided."""
|
||||
if v is None:
|
||||
return v
|
||||
if not _BUILTIN_NAME_RE.match(v) and not _NAMESPACED_RE.match(v):
|
||||
raise ValueError(
|
||||
f"Invalid inherits type name '{v}'. "
|
||||
"Names must start with a letter and contain only "
|
||||
"alphanumeric characters, hyphens, or underscores. "
|
||||
"Custom types must follow namespace/name format."
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("resource_kind")
|
||||
@classmethod
|
||||
def validate_resource_kind(cls, v: str) -> str:
|
||||
@@ -290,6 +325,18 @@ class ResourceTypeConfigSchema(BaseModel):
|
||||
"'equivalence' configuration."
|
||||
)
|
||||
|
||||
# Cannot inherit from self (ADR-042 rule 3: no cycles)
|
||||
if self.inherits is not None and self.inherits == self.name:
|
||||
raise ValueError(f"Resource type '{self.name}' cannot inherit from itself.")
|
||||
|
||||
# Built-in types must not inherit from custom types (ADR-042 rule 4)
|
||||
if self.built_in and self.inherits is not None and "/" in self.inherits:
|
||||
raise ValueError(
|
||||
f"Built-in resource type '{self.name}' cannot inherit from "
|
||||
f"custom type '{self.inherits}'. Built-in types may only "
|
||||
"inherit from other built-in types."
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
# ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -3,24 +3,30 @@
|
||||
Thread-safe registry that stores
|
||||
[ToolSpec][cleveragents.tool.runtime.ToolSpec] instances keyed by their
|
||||
namespaced name. Supports registration, lookup, listing with optional
|
||||
filters, and removal.
|
||||
filters, removal, and polymorphic resource-type matching.
|
||||
|
||||
## Operations
|
||||
|
||||
| Method | Description |
|
||||
|---------------------|---------------------------------------------------|
|
||||
| ``register(spec)`` | Add a tool; raises ``ToolError`` on name collision|
|
||||
| ``get(name)`` | Lookup by namespaced name; returns ``None`` if missing |
|
||||
| ``list_tools(...)`` | List with optional namespace/type filters |
|
||||
| ``remove(name)`` | Unregister; returns ``bool`` |
|
||||
| Method | Description |
|
||||
|-----------------------------|---------------------------------------------------|
|
||||
| ``register(spec)`` | Add a tool; raises ``ToolError`` on name collision|
|
||||
| ``get(name)`` | Lookup by namespaced name; returns ``None`` if missing |
|
||||
| ``list_tools(...)`` | List with optional namespace/type filters |
|
||||
| ``find_tools_for_resource`` | Polymorphic tool matching by resource type (ADR-042) |
|
||||
| ``remove(name)`` | Unregister; returns ``bool`` |
|
||||
|
||||
All mutating operations are protected by ``threading.RLock``.
|
||||
|
||||
See Also:
|
||||
- ADR-042: Resource Type Inheritance (§Tool Binding Polymorphism)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from cleveragents.resource.inheritance import is_subtype_of
|
||||
from cleveragents.tool.runtime import ToolError, ToolSpec
|
||||
|
||||
|
||||
@@ -90,6 +96,44 @@ class ToolRegistry:
|
||||
|
||||
return specs
|
||||
|
||||
def find_tools_for_resource(
|
||||
self,
|
||||
resource_type: str,
|
||||
type_registry: dict[str, Any],
|
||||
) -> list[ToolSpec]:
|
||||
"""Find tools whose resource bindings accept *resource_type*.
|
||||
|
||||
Implements ADR-042 §Tool Binding Polymorphism: a tool bound to
|
||||
a parent type (e.g., ``container-instance``) also matches any
|
||||
subtype (e.g., ``devcontainer-instance``).
|
||||
|
||||
Resource bindings are stored in ``tool.source_metadata`` under
|
||||
the key ``"resource_bindings"`` as a list of dicts with a
|
||||
``"resource_type"`` field.
|
||||
|
||||
Args:
|
||||
resource_type: The concrete resource type name to match.
|
||||
type_registry: Dict mapping type names to their definitions
|
||||
(must contain ``inherits`` keys for chain walking).
|
||||
|
||||
Returns:
|
||||
List of matching ``ToolSpec`` instances.
|
||||
"""
|
||||
with self._lock:
|
||||
specs = list(self._tools.values())
|
||||
|
||||
matched: list[ToolSpec] = []
|
||||
for spec in specs:
|
||||
bindings = spec.source_metadata.get("resource_bindings", [])
|
||||
if not bindings:
|
||||
continue
|
||||
for binding in bindings:
|
||||
slot_type = binding.get("resource_type", "")
|
||||
if is_subtype_of(resource_type, slot_type, type_registry):
|
||||
matched.append(spec)
|
||||
break # One matching binding is enough
|
||||
return matched
|
||||
|
||||
def remove(self, name: str) -> bool:
|
||||
"""Remove a tool from the registry.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user