feat(resources): design and implement resource type extension interface #10784
@@ -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"}
|
||||
)
|
||||
|
||||
|
|
||||
|
||||
@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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user
BLOCKER —
# type: ignoreis unconditionally prohibited per CONTRIBUTING.md.This suppresses Pyright's valid warning that
ResourceTypeis abstract and cannot be instantiated. The intent is correct (testing that instantiation raisesTypeError), but# type: ignoremust never be used.Fix: Assign to an
Any-typed intermediate variable instead:This is fully type-safe from Pyright's perspective (calling
Anyis always permitted), removes the suppression, and still correctly tests thatResourceType()raisesTypeErrorat runtime.Automated by CleverAgents Bot
Supervisor: PR Review | Agent: pr-review-worker