feat(resources): design and implement resource type extension interface #10784

Merged
HAL9000 merged 2 commits from feat/resources-extension-interface into master 2026-06-06 19:15:05 +00:00
5 changed files with 915 additions and 0 deletions
+1
View File
@@ -6,6 +6,7 @@ Changed `wf10_batch.robot` to be less likely to create files, and
`plan_generation_graph.robot` to give more test answers.
## [Unreleased]
- **feat(resources): resource type extension interface** (#9998): New `cleveragents.resources` package providing the stable public API third-party developers use to add custom resource types without modifying core code. Includes `ResourceType` ABC with five abstract lifecycle methods (`provision`, `deprovision`, `status`, `validate_config`, `to_dict`), a `ResourceConfig` Pydantic model (`name`, `resource_type`, `properties`), a `ResourceStatus` StrEnum (`PENDING`, `ACTIVE`, `FAILED`, `DEPROVISIONED`), and registry functions `register_resource_type` / `get_resource_type` / `list_resource_types`. Custom types are registered under namespaced names (e.g. `myorg/database`); registration raises `TypeError` for non-`ResourceType` subclasses and `ValueError` for duplicate names. 25 BDD scenarios in `features/resource_type_extension_interface.feature` cover enum values, config instantiation, ABC enforcement, all lifecycle method return types, and registry CRUD + error paths.
- **fix(test): move advanced context strategy test doubles to features/mocks** (#7574): Extracted `FakeEmbeddings`, `RelevanceScoringStrategy`, `AdaptiveContextSelector`, `ContextFusionStrategy`, and `_pack_budget` from `features/steps/advanced_context_strategies_steps.py` into a new `features/mocks/advanced_context_strategies_mocks.py` file per CONTRIBUTING.md mock-placement rules. Updated the Robot Framework helper `robot/helper_advanced_context_strategies.py` to import directly from `features.mocks` rather than manipulating `sys.path` to reach the Behave steps file. Added `None` guard in `step_assemble_context_query` before calling `selected.assemble()`, and added explicit `ValueError` for unknown strategy types in both `step_load_yaml_strategy` and `load_strategy_from_yaml_impl`.
- **fix(a2a): regression tests for stale cleveragents.acp removal** (#5566): Added two Behave BDD scenarios verifying that `cleveragents.acp` is not importable (raises `ImportError`) and that `src/cleveragents/acp/` does not exist in the source tree. These guard against regression of the `__pycache__`-based import that allowed the removed ACP module to still be loaded from bytecode after the v3.6.0 rename to `a2a`.
- **Virtual Resource Type Base Class** (#8610): Implemented `VirtualResource` base class with two example concrete implementations (`MetricResource`, `APIEndpointResource`) for abstract/computed resources that are derived rather than mapped to physical files. Virtual resources are computed on demand via a `compute_fn` callable. Includes Behave BDD scenarios in `features/resource_virtual_types.feature` exercising construction, computation, name validation, kwargs passthrough, exception handling, string representation, and subclassing. Resource names are validated against `^[a-zA-Z][a-zA-Z0-9_-]*$` (must start with a letter; alphanumeric, hyphens, and underscores otherwise).
@@ -0,0 +1,147 @@
Feature: Resource type extension interface
As a third-party developer
I want a stable extension interface for custom resource types
So that I can add new resource types without modifying core code
Scenario: ResourceStatus enum has all required values
Given the resource extension interface is imported
Then ResourceStatus should have value "PENDING"
And ResourceStatus should have value "ACTIVE"
And ResourceStatus should have value "FAILED"
And ResourceStatus should have value "DEPROVISIONED"
Scenario: ResourceStatus values are strings
Given the resource extension interface is imported
Then each ResourceStatus value should be a string
Scenario: ResourceConfig can be instantiated with required fields
Given the resource extension interface is imported
When I create a ResourceConfig with name "my-resource" and type "custom/db"
Then the ResourceConfig name should be "my-resource"
And the ResourceConfig resource_type should be "custom/db"
Scenario: ResourceConfig has optional properties field
Given the resource extension interface is imported
When I create a ResourceConfig with name "r1" and type "custom/t1"
Then the ResourceConfig properties should be an empty dict by default
Scenario: ResourceConfig accepts custom properties
Given the resource extension interface is imported
When I create a ResourceConfig with name "r2" type "custom/t2" and properties {"host": "localhost"}
Then the ResourceConfig properties should contain "host" with value "localhost"
Scenario: ResourceType is an abstract base class
Given the resource extension interface is imported
Then ResourceType should be an abstract class
Scenario: ResourceType cannot be instantiated directly
Given the resource extension interface is imported
When I try to instantiate ResourceType directly
Then a resource extension TypeError should be raised
Scenario: ResourceType requires provision method
Given the resource extension interface is imported
When I define a resext class missing the provision method
Then the resext class instantiation should raise TypeError
Scenario: ResourceType requires deprovision method
Given the resource extension interface is imported
When I define a resext class missing the deprovision method
Then the resext class instantiation should raise TypeError
Scenario: ResourceType requires status method
Given the resource extension interface is imported
When I define a resext class missing the status method
Then the resext class instantiation should raise TypeError
Scenario: ResourceType requires validate_config method
Given the resource extension interface is imported
When I define a resext class missing the validate_config method
Then the resext class instantiation should raise TypeError
Scenario: ResourceType requires to_dict method
Given the resource extension interface is imported
When I define a resext class missing the to_dict method
Then the resext class instantiation should raise TypeError
Scenario: Concrete ResourceType subclass can be instantiated
Given the resource extension interface is imported
When I define a complete concrete ResourceType subclass
Then it should be instantiable without errors
Scenario: Concrete ResourceType provision returns ResourceStatus
Given the resource extension interface is imported
And I have a concrete ResourceType instance
When I call provision with a valid ResourceConfig
Then the resext result should be a ResourceStatus
Scenario: Concrete ResourceType deprovision returns ResourceStatus
Given the resource extension interface is imported
And I have a concrete ResourceType instance
When I call deprovision with a valid ResourceConfig
Then the resext result should be a ResourceStatus
Scenario: Concrete ResourceType status returns ResourceStatus
Given the resource extension interface is imported
And I have a concrete ResourceType instance
When I call status with a valid ResourceConfig
Then the resext result should be a ResourceStatus
Scenario: Concrete ResourceType validate_config returns list
Given the resource extension interface is imported
And I have a concrete ResourceType instance
When I call validate_config with a valid ResourceConfig
Then the resext result should be a list
Scenario: Concrete ResourceType to_dict returns dict
Given the resource extension interface is imported
And I have a concrete ResourceType instance
When I call to_dict
Then the resext result should be a dict
Scenario: register_resource_type registers a class by name
Given the resource extension interface is imported
And the resext registry is cleared
When I register a resext class as "custom/my-type"
Then the resext registry should contain "custom/my-type"
Scenario: register_resource_type rejects non-ResourceType classes
Given the resource extension interface is imported
And the resext registry is cleared
When I try to register a non-resext class as "custom/bad-type"
Then a resource extension TypeError should be raised
Scenario: register_resource_type rejects duplicate names
Given the resource extension interface is imported
And the resext registry is cleared
When I register a resext class as "custom/dup-type"
And I try to register another resext class as "custom/dup-type"
Then a resource extension ValueError should be raised
Scenario: get_resource_type retrieves a registered class
Given the resource extension interface is imported
And the resext registry is cleared
When I register a resext class as "custom/get-type"
Then resext get_resource_type("custom/get-type") should return the class
Scenario: get_resource_type returns None for unknown names
Given the resource extension interface is imported
And the resext registry is cleared
Then resext get_resource_type("custom/unknown") should return None
Scenario: list_resource_types returns all registered names
Given the resource extension interface is imported
And the resext registry is cleared
When I register a resext class as "custom/list-type-a"
And I register a resext class as "custom/list-type-b"
Then resext list_resource_types should include "custom/list-type-a"
And resext list_resource_types should include "custom/list-type-b"
Scenario: All interface symbols are importable from cleveragents.resources
Given the resource extension interface is imported
Then ResourceType should be importable from cleveragents.resources
And ResourceConfig should be importable from cleveragents.resources
And ResourceStatus should be importable from cleveragents.resources
And register_resource_type should be importable from cleveragents.resources
And get_resource_type should be importable from cleveragents.resources
And list_resource_types should be importable from cleveragents.resources
@@ -0,0 +1,420 @@
"""Step definitions for resource_type_extension_interface.feature."""
from __future__ import annotations
from typing import Any
from behave import given, then, when
def _make_resext_class(
*,
skip_provision: bool = False,
skip_deprovision: bool = False,
skip_status: bool = False,
skip_validate_config: bool = False,
skip_to_dict: bool = False,
) -> type:
"""Return a concrete ResourceType subclass, optionally omitting methods."""
from cleveragents.resources import ResourceConfig, ResourceStatus, ResourceType
methods: dict[str, Any] = {}
if not skip_provision:
def provision(self: Any, config: ResourceConfig) -> ResourceStatus:
return ResourceStatus.ACTIVE
methods["provision"] = provision
if not skip_deprovision:
def deprovision(self: Any, config: ResourceConfig) -> ResourceStatus:
return ResourceStatus.DEPROVISIONED
methods["deprovision"] = deprovision
if not skip_status:
def status(self: Any, config: ResourceConfig) -> ResourceStatus:
return ResourceStatus.ACTIVE
methods["status"] = status
if not skip_validate_config:
def validate_config(self: Any, config: ResourceConfig) -> list[str]:
return []
methods["validate_config"] = validate_config
if not skip_to_dict:
def to_dict(self: Any) -> dict[str, object]:
return {"type": "test/concrete"}
methods["to_dict"] = to_dict
return type("ConcreteResextType", (ResourceType,), methods)
def _unique_name(context: Any, base_name: str) -> str:
"""Return a scenario-unique variant of *base_name*.
The real process-wide registry is shared across parallel scenarios; using a
per-scenario suffix prevents name collisions while still exercising the real
`register_resource_type` / `get_resource_type` / `list_resource_types`
code paths (which is what diff_coverage requires).
"""
return f"{base_name}-{context._resext_scenario_id}"
@given("the resource extension interface is imported")
def step_resext_import(context: Any) -> None:
import cleveragents.resources as resources_pkg
from cleveragents.resources import (
ResourceConfig,
ResourceStatus,
ResourceType,
get_resource_type,
list_resource_types,
register_resource_type,
)
context.resources_pkg = resources_pkg
context.ResourceConfig = ResourceConfig
context.ResourceStatus = ResourceStatus
context.ResourceType = ResourceType
context.register_resource_type = register_resource_type
context.get_resource_type = get_resource_type
context.list_resource_types = list_resource_types
context.raised_error = None
context.result = None
import uuid
context._resext_scenario_id = uuid.uuid4().hex[:8]
context.last_get_result = None
@given("the resext registry is cleared")
def step_resext_clear_registry(context: Any) -> None:
"""No-op: unique scenario IDs prevent conflicts in parallel tests."""
# Registry is not cleared to avoid race conditions in parallel tests
pass
@given("I have a concrete ResourceType instance")
def step_resext_have_instance(context: Any) -> None:
"""Create a concrete ResourceType instance and store on context."""
ConcreteClass = _make_resext_class()
context.concrete_instance = ConcreteClass()
@when('I create a ResourceConfig with name "{name}" and type "{rtype}"')
def step_resext_create_config(context: Any, name: str, rtype: str) -> None:
context.resource_config = context.ResourceConfig(name=name, resource_type=rtype)
@when(
'I create a ResourceConfig with name "{name}" type "{rtype}"'
' and properties {{"host": "localhost"}}'
)
def step_resext_create_config_with_props(context: Any, name: str, rtype: str) -> None:
context.resource_config = context.ResourceConfig(
name=name, resource_type=rtype, properties={"host": "localhost"}
)
Outdated
Review

BLOCKER — # type: ignore is unconditionally prohibited per CONTRIBUTING.md.

This suppresses Pyright's valid warning that ResourceType is abstract and cannot be instantiated. The intent is correct (testing that instantiation raises TypeError), but # type: ignore must never be used.

Fix: Assign to an Any-typed intermediate variable instead:

from typing import Any
resource_type_cls: Any = context.ResourceType
try:
    resource_type_cls()
    context.raised_error = None
except TypeError as exc:
    context.raised_error = exc

This is fully type-safe from Pyright's perspective (calling Any is always permitted), removes the suppression, and still correctly tests that ResourceType() raises TypeError at runtime.


Automated by CleverAgents Bot
Supervisor: PR Review | Agent: pr-review-worker

**BLOCKER — `# type: ignore` is unconditionally prohibited per CONTRIBUTING.md.** This suppresses Pyright's valid warning that `ResourceType` is abstract and cannot be instantiated. The intent is correct (testing that instantiation raises `TypeError`), but `# type: ignore` must never be used. **Fix**: Assign to an `Any`-typed intermediate variable instead: ```python from typing import Any resource_type_cls: Any = context.ResourceType try: resource_type_cls() context.raised_error = None except TypeError as exc: context.raised_error = exc ``` This is fully type-safe from Pyright's perspective (calling `Any` is always permitted), removes the suppression, and still correctly tests that `ResourceType()` raises `TypeError` at runtime. --- Automated by CleverAgents Bot Supervisor: PR Review | Agent: pr-review-worker
@when("I try to instantiate ResourceType directly")
def step_resext_try_instantiate_abstract(context: Any) -> None:
# Pyright otherwise flags this call because ResourceType is abstract; the
# runtime check that instantiation raises TypeError is the actual test.
# Indirecting through an ``Any``-typed alias satisfies Pyright without
# using a ``# type: ignore`` (prohibited by CONTRIBUTING.md).
resource_type_cls: Any = context.ResourceType
try:
resource_type_cls()
context.raised_error = None
except TypeError as exc:
context.raised_error = exc
@when("I define a resext class missing the provision method")
def step_resext_missing_provision(context: Any) -> None:
cls = _make_resext_class(skip_provision=True)
try:
cls()
context.raised_error = None
except TypeError as exc:
context.raised_error = exc
@when("I define a resext class missing the deprovision method")
def step_resext_missing_deprovision(context: Any) -> None:
cls = _make_resext_class(skip_deprovision=True)
try:
cls()
context.raised_error = None
except TypeError as exc:
context.raised_error = exc
@when("I define a resext class missing the status method")
def step_resext_missing_status(context: Any) -> None:
cls = _make_resext_class(skip_status=True)
try:
cls()
context.raised_error = None
except TypeError as exc:
context.raised_error = exc
@when("I define a resext class missing the validate_config method")
def step_resext_missing_validate_config(context: Any) -> None:
cls = _make_resext_class(skip_validate_config=True)
try:
cls()
context.raised_error = None
except TypeError as exc:
context.raised_error = exc
@when("I define a resext class missing the to_dict method")
def step_resext_missing_to_dict(context: Any) -> None:
cls = _make_resext_class(skip_to_dict=True)
try:
cls()
context.raised_error = None
except TypeError as exc:
context.raised_error = exc
@when("I define a complete concrete ResourceType subclass")
def step_resext_define_complete_subclass(context: Any) -> None:
context.ConcreteClass = _make_resext_class()
context.raised_error = None
@then("it should be instantiable without errors")
def step_resext_instantiable_without_errors(context: Any) -> None:
try:
instance = context.ConcreteClass()
assert instance is not None
except Exception as exc:
raise AssertionError(f"Unexpected error: {exc}") from exc
@when("I call provision with a valid ResourceConfig")
def step_resext_call_provision(context: Any) -> None:
cfg = context.ResourceConfig(name="test", resource_type="custom/test")
context.result = context.concrete_instance.provision(cfg)
@when("I call deprovision with a valid ResourceConfig")
def step_resext_call_deprovision(context: Any) -> None:
cfg = context.ResourceConfig(name="test", resource_type="custom/test")
context.result = context.concrete_instance.deprovision(cfg)
@when("I call status with a valid ResourceConfig")
def step_resext_call_status(context: Any) -> None:
cfg = context.ResourceConfig(name="test", resource_type="custom/test")
context.result = context.concrete_instance.status(cfg)
@when("I call validate_config with a valid ResourceConfig")
def step_resext_call_validate_config(context: Any) -> None:
cfg = context.ResourceConfig(name="test", resource_type="custom/test")
context.result = context.concrete_instance.validate_config(cfg)
@when("I call to_dict")
def step_resext_call_to_dict(context: Any) -> None:
context.result = context.concrete_instance.to_dict()
@when('I register a resext class as "{name}"')
def step_resext_register_class(context: Any, name: str) -> None:
cls = _make_resext_class()
unique = _unique_name(context, name)
context.last_registered_class = cls
context.last_registered_name = unique
context.raised_error = None
try:
context.register_resource_type(unique, cls)
# Immediately verify registration succeeded via the real registry.
context.last_get_result = context.get_resource_type(unique)
except (TypeError, ValueError) as exc:
context.raised_error = exc
@when('I try to register a non-resext class as "{name}"')
def step_resext_register_non_type(context: Any, name: str) -> None:
class NotAResourceType:
pass
unique = _unique_name(context, name)
context.raised_error = None
try:
context.register_resource_type(unique, NotAResourceType)
except TypeError as exc:
context.raised_error = exc
@when('I try to register another resext class as "{name}"')
def step_resext_register_duplicate(context: Any, name: str) -> None:
cls = _make_resext_class()
unique = _unique_name(context, name)
context.raised_error = None
try:
context.register_resource_type(unique, cls)
except ValueError as exc:
context.raised_error = exc
@then('ResourceStatus should have value "{value}"')
def step_resext_status_has_value(context: Any, value: str) -> None:
assert hasattr(context.ResourceStatus, value)
assert context.ResourceStatus[value].value == value
@then("each ResourceStatus value should be a string")
def step_resext_status_values_are_strings(context: Any) -> None:
for member in context.ResourceStatus:
assert isinstance(member.value, str)
@then('the ResourceConfig name should be "{name}"')
def step_resext_config_name(context: Any, name: str) -> None:
assert context.resource_config.name == name
@then('the ResourceConfig resource_type should be "{rtype}"')
def step_resext_config_type(context: Any, rtype: str) -> None:
assert context.resource_config.resource_type == rtype
@then("the ResourceConfig properties should be an empty dict by default")
def step_resext_config_empty_properties(context: Any) -> None:
assert context.resource_config.properties == {}
@then('the ResourceConfig properties should contain "{key}" with value "{value}"')
def step_resext_config_property(context: Any, key: str, value: str) -> None:
props = context.resource_config.properties
assert key in props
assert props[key] == value
@then("ResourceType should be an abstract class")
def step_resext_type_is_abstract(context: Any) -> None:
assert hasattr(context.ResourceType, "__abstractmethods__")
assert len(context.ResourceType.__abstractmethods__) > 0
@then("a resource extension TypeError should be raised")
def step_resext_type_error_raised(context: Any) -> None:
assert context.raised_error is not None
assert isinstance(context.raised_error, TypeError)
@then("a resource extension ValueError should be raised")
def step_resext_value_error_raised(context: Any) -> None:
assert context.raised_error is not None
assert isinstance(context.raised_error, ValueError)
@then("the resext class instantiation should raise TypeError")
def step_resext_instantiation_raises_type_error(context: Any) -> None:
assert context.raised_error is not None
assert isinstance(context.raised_error, TypeError)
@then("the resext result should be a ResourceStatus")
def step_resext_result_is_status(context: Any) -> None:
assert isinstance(context.result, context.ResourceStatus)
@then("the resext result should be a list")
def step_resext_result_is_list(context: Any) -> None:
assert isinstance(context.result, list)
@then("the resext result should be a dict")
def step_resext_result_is_dict(context: Any) -> None:
assert isinstance(context.result, dict)
@then('the resext registry should contain "{name}"')
def step_resext_registry_contains(context: Any, name: str) -> None:
unique = _unique_name(context, name)
names = context.list_resource_types()
assert unique in names, f"'{unique}' not in registry"
@then('resext get_resource_type("{name}") should return the class')
def step_resext_get_returns_class(context: Any, name: str) -> None:
unique = _unique_name(context, name)
result = context.get_resource_type(unique)
assert result is not None, f"get_resource_type('{unique}') returned None"
assert isinstance(result, type), f"Expected type, got {type(result)}"
from cleveragents.resources import ResourceType as RT
assert issubclass(result, RT), f"Expected subclass of ResourceType, got {result}"
@then('resext get_resource_type("{name}") should return None')
def step_resext_get_returns_none(context: Any, name: str) -> None:
unique = _unique_name(context, name)
result = context.get_resource_type(unique)
assert result is None
@then('resext list_resource_types should include "{name}"')
def step_resext_list_includes(context: Any, name: str) -> None:
unique = _unique_name(context, name)
names = context.list_resource_types()
assert unique in names, f"'{unique}' not in registry list"
@then("ResourceType should be importable from cleveragents.resources")
def step_resext_type_importable(context: Any) -> None:
from cleveragents.resources import ResourceType
assert ResourceType is not None
@then("ResourceConfig should be importable from cleveragents.resources")
def step_resext_config_importable(context: Any) -> None:
from cleveragents.resources import ResourceConfig
assert ResourceConfig is not None
@then("ResourceStatus should be importable from cleveragents.resources")
def step_resext_status_importable(context: Any) -> None:
from cleveragents.resources import ResourceStatus
assert ResourceStatus is not None
@then("register_resource_type should be importable from cleveragents.resources")
def step_resext_register_importable(context: Any) -> None:
from cleveragents.resources import register_resource_type
assert register_resource_type is not None
@then("get_resource_type should be importable from cleveragents.resources")
def step_resext_get_importable(context: Any) -> None:
from cleveragents.resources import get_resource_type
assert get_resource_type is not None
@then("list_resource_types should be importable from cleveragents.resources")
def step_resext_list_importable(context: Any) -> None:
from cleveragents.resources import list_resource_types
assert list_resource_types is not None
+72
View File
@@ -0,0 +1,72 @@
"""Public extension interface for CleverAgents resource types.
This package provides the stable API that third-party developers use to
implement custom resource types without modifying core code.
## Quick Start
To implement a custom resource type:
1. Subclass :class:`ResourceType` and implement all abstract methods.
2. Register the class with :func:`register_resource_type`.
3. Import and use the type from ``cleveragents.resources``.
Example::
from cleveragents.resources import (
ResourceType,
ResourceConfig,
ResourceStatus,
register_resource_type,
)
class MyDatabaseType(ResourceType):
def provision(self, config: ResourceConfig) -> ResourceStatus:
# Connect and initialise the database
return ResourceStatus.ACTIVE
def deprovision(self, config: ResourceConfig) -> ResourceStatus:
# Tear down the database
return ResourceStatus.DEPROVISIONED
def status(self, config: ResourceConfig) -> ResourceStatus:
# Check whether the resource is reachable
return ResourceStatus.ACTIVE
def validate_config(self, config: ResourceConfig) -> list[str]:
errors: list[str] = []
if not config.properties.get("host"):
errors.append("'host' is required in properties")
return errors
def to_dict(self) -> dict[str, object]:
return {"type": "myorg/database", "version": "1.0"}
register_resource_type("myorg/database", MyDatabaseType)
See Also:
- :class:`ResourceType` abstract base class all custom types must subclass
- :class:`ResourceConfig` configuration container passed to lifecycle methods
- :class:`ResourceStatus` enum of possible resource lifecycle states
- :func:`register_resource_type` register a custom type by name
- :func:`get_resource_type` retrieve a registered type class by name
- :func:`list_resource_types` list all registered type names
"""
from cleveragents.resources.extension import (
ResourceConfig,
ResourceStatus,
ResourceType,
get_resource_type,
list_resource_types,
register_resource_type,
)
__all__ = [
"ResourceConfig",
"ResourceStatus",
"ResourceType",
"get_resource_type",
"list_resource_types",
"register_resource_type",
]
+275
View File
@@ -0,0 +1,275 @@
"""Resource type extension interface for CleverAgents.
Provides the stable public API that third-party developers use to implement
custom resource types without modifying core code.
## Developer Guide: Implementing a Custom Resource Type
### Overview
A *resource type* defines the lifecycle behaviour of a category of resources
(e.g. a cloud database, a message queue, a container registry). Third-party
developers implement custom types by subclassing :class:`ResourceType` and
registering the class with :func:`register_resource_type`.
### Step 1 — Subclass ResourceType
All five abstract methods must be implemented::
from cleveragents.resources import ResourceType, ResourceConfig, ResourceStatus
class MyQueueType(ResourceType):
\"\"\"Custom message-queue resource type.\"\"\"
def provision(self, config: ResourceConfig) -> ResourceStatus:
\"\"\"Create the queue and return ACTIVE on success.\"\"\"
_create_queue(config.properties["queue_name"])
return ResourceStatus.ACTIVE
def deprovision(self, config: ResourceConfig) -> ResourceStatus:
\"\"\"Delete the queue and return DEPROVISIONED on success.\"\"\"
_delete_queue(config.properties["queue_name"])
return ResourceStatus.DEPROVISIONED
def status(self, config: ResourceConfig) -> ResourceStatus:
\"\"\"Return the current lifecycle state of the queue.\"\"\"
if _queue_exists(config.properties["queue_name"]):
return ResourceStatus.ACTIVE
return ResourceStatus.FAILED
def validate_config(self, config: ResourceConfig) -> list[str]:
\"\"\"Return a list of validation error messages (empty = valid).\"\"\"
errors: list[str] = []
if not config.properties.get("queue_name"):
errors.append("'queue_name' is required in properties")
return errors
def to_dict(self) -> dict[str, object]:
\"\"\"Return a serialisable representation of this type.\"\"\"
return {
"type": "myorg/queue",
"description": "Custom message-queue resource type",
"version": "1.0.0",
}
### Step 2 — Register the type
Use :func:`register_resource_type` with a namespaced name::
from cleveragents.resources import register_resource_type
register_resource_type("myorg/queue", MyQueueType)
Custom type names **must** follow the ``namespace/name`` format. Built-in
type names (e.g. ``git-checkout``) are reserved for core use.
### Step 3 — Retrieve and use the type
from cleveragents.resources import get_resource_type, ResourceConfig
cls = get_resource_type("myorg/queue")
if cls is not None:
instance = cls()
cfg = ResourceConfig(name="my-queue", resource_type="myorg/queue",
properties={"queue_name": "events"})
status = instance.provision(cfg)
### ResourceStatus lifecycle
Resources transition through the following states:
- ``PENDING`` provisioning has been requested but not yet completed.
- ``ACTIVE`` the resource is provisioned and operational.
- ``FAILED`` provisioning or a health check failed.
- ``DEPROVISIONED`` the resource has been torn down.
Based on:
- Issue #9998 — feat(resources): design and implement resource type
extension interface
- docs/specification.md Resource Types, Resource Registry
"""
from __future__ import annotations
import abc
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, Field
__all__ = [
"ResourceConfig",
"ResourceStatus",
"ResourceType",
"get_resource_type",
"list_resource_types",
"register_resource_type",
]
# ---------------------------------------------------------------------------
# ResourceStatus
# ---------------------------------------------------------------------------
class ResourceStatus(StrEnum):
"""Lifecycle state of a provisioned resource.
Attributes:
PENDING: Provisioning has been requested but not yet completed.
ACTIVE: The resource is provisioned and operational.
FAILED: Provisioning or a health check failed.
DEPROVISIONED: The resource has been torn down.
"""
PENDING = "PENDING"
ACTIVE = "ACTIVE"
FAILED = "FAILED"
DEPROVISIONED = "DEPROVISIONED"
# ---------------------------------------------------------------------------
# ResourceConfig
# ---------------------------------------------------------------------------
class ResourceConfig(BaseModel):
"""Configuration container passed to :class:`ResourceType` lifecycle methods.
Attributes:
name: Human-readable name for the resource instance.
resource_type: Namespaced type identifier (e.g. ``myorg/database``).
properties: Arbitrary key-value configuration for the resource.
Defaults to an empty dict.
"""
name: str
resource_type: str
properties: dict[str, Any] = Field(default_factory=dict)
# ---------------------------------------------------------------------------
# ResourceType ABC
# ---------------------------------------------------------------------------
class ResourceType(abc.ABC):
"""Abstract base class for all resource type implementations.
Third-party developers subclass this class to add custom resource types
to CleverAgents without modifying core code. All five lifecycle methods
are abstract and **must** be implemented by concrete subclasses.
Lifecycle methods:
provision: Create and initialise the resource.
deprovision: Tear down and clean up the resource.
status: Return the current lifecycle state.
validate_config: Validate a :class:`ResourceConfig` before use.
to_dict: Return a serialisable representation of this type.
See the module-level developer guide for a complete implementation example.
"""
@abc.abstractmethod
def provision(self, config: ResourceConfig) -> ResourceStatus:
"""Provision the resource described by *config*.
Args:
config: Configuration for the resource instance to create.
Returns:
:attr:`ResourceStatus.ACTIVE` on success,
:attr:`ResourceStatus.FAILED` on failure.
"""
@abc.abstractmethod
def deprovision(self, config: ResourceConfig) -> ResourceStatus:
"""Deprovision (tear down) the resource described by *config*.
Args:
config: Configuration identifying the resource instance to remove.
Returns:
:attr:`ResourceStatus.DEPROVISIONED` on success,
:attr:`ResourceStatus.FAILED` on failure.
"""
@abc.abstractmethod
def status(self, config: ResourceConfig) -> ResourceStatus:
"""Return the current lifecycle state of the resource.
Args:
config: Configuration identifying the resource instance to check.
Returns:
The current :class:`ResourceStatus` of the resource.
"""
@abc.abstractmethod
def validate_config(self, config: ResourceConfig) -> list[str]:
"""Validate *config* before provisioning or status checks.
Args:
config: Configuration to validate.
Returns:
A list of human-readable error messages. An empty list means
the configuration is valid.
"""
@abc.abstractmethod
def to_dict(self) -> dict[str, object]:
"""Return a serialisable representation of this resource type.
Returns:
A dict suitable for JSON/YAML serialisation describing this
resource type (e.g. name, version, description).
"""
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
#: Internal registry mapping type names to ResourceType subclasses.
_registry: dict[str, type[ResourceType]] = {}
def register_resource_type(name: str, cls: type[ResourceType]) -> None:
"""Register a custom resource type class by name.
Args:
name: Namespaced type name (e.g. ``myorg/database``).
cls: A concrete subclass of :class:`ResourceType`.
Raises:
TypeError: If *cls* is not a subclass of :class:`ResourceType`.
ValueError: If *name* is already registered.
"""
if not (isinstance(cls, type) and issubclass(cls, ResourceType)):
raise TypeError(
f"'{cls}' is not a subclass of ResourceType. "
"Only ResourceType subclasses can be registered."
)
if name in _registry:
raise ValueError(
f"Resource type '{name}' is already registered. "
"Use a unique name or unregister the existing type first."
)
_registry[name] = cls
def get_resource_type(name: str) -> type[ResourceType] | None:
"""Retrieve a registered resource type class by name.
Args:
name: The namespaced type name used during registration.
Returns:
The registered :class:`ResourceType` subclass, or ``None``.
"""
return _registry.get(name)
def list_resource_types() -> list[str]:
"""Return a sorted list of all registered resource type names."""
return sorted(_registry.keys())