feature/m1-resource-type-schema #68
@@ -0,0 +1,92 @@
|
||||
"""ASV benchmarks for resource type domain model validation."""
|
||||
|
||||
_BASE_CONFIG = {
|
||||
"name": "bench/model-type",
|
||||
"description": "Benchmark resource type",
|
||||
"resource_kind": "physical",
|
||||
"sandbox_strategy": "git_worktree",
|
||||
"built_in": False,
|
||||
"user_addable": True,
|
||||
"cli_args": [
|
||||
{
|
||||
"name": "path",
|
||||
"type": "path",
|
||||
"required": True,
|
||||
"description": "Path",
|
||||
},
|
||||
{
|
||||
"name": "branch",
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": "Branch",
|
||||
},
|
||||
],
|
||||
"parent_types": [],
|
||||
"child_types": ["fs-directory"],
|
||||
"capabilities": {
|
||||
"read": True,
|
||||
"write": True,
|
||||
"sandbox": True,
|
||||
"checkpoint": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TimeFromConfig:
|
||||
"""Benchmark ResourceTypeSpec.from_config() throughput."""
|
||||
|
||||
def setup(self):
|
||||
from cleveragents.domain.models.core.resource_type import (
|
||||
ResourceTypeSpec,
|
||||
)
|
||||
|
||||
self.spec_cls = ResourceTypeSpec
|
||||
self.config = dict(_BASE_CONFIG)
|
||||
|
||||
def time_from_config(self):
|
||||
self.spec_cls.from_config(self.config)
|
||||
|
||||
def time_from_config_10x(self):
|
||||
for _ in range(10):
|
||||
self.spec_cls.from_config(self.config)
|
||||
|
||||
|
||||
class TimeAsCliDict:
|
||||
"""Benchmark as_cli_dict() rendering."""
|
||||
|
||||
def setup(self):
|
||||
from cleveragents.domain.models.core.resource_type import (
|
||||
ResourceTypeSpec,
|
||||
)
|
||||
|
||||
self.spec = ResourceTypeSpec.from_config(dict(_BASE_CONFIG))
|
||||
|
||||
def time_as_cli_dict(self):
|
||||
self.spec.as_cli_dict()
|
||||
|
||||
def time_as_cli_dict_10x(self):
|
||||
for _ in range(10):
|
||||
self.spec.as_cli_dict()
|
||||
|
||||
|
||||
class TimeNameValidation:
|
||||
"""Benchmark name validation for built-in vs custom types."""
|
||||
|
||||
def setup(self):
|
||||
from cleveragents.domain.models.core.resource_type import (
|
||||
ResourceTypeSpec,
|
||||
)
|
||||
|
||||
self.spec_cls = ResourceTypeSpec
|
||||
self.builtin_config = {
|
||||
**_BASE_CONFIG,
|
||||
"name": "git-checkout",
|
||||
"built_in": True,
|
||||
}
|
||||
self.custom_config = dict(_BASE_CONFIG)
|
||||
|
||||
def time_builtin_validation(self):
|
||||
self.spec_cls.from_config(self.builtin_config)
|
||||
|
||||
def time_custom_validation(self):
|
||||
self.spec_cls.from_config(self.custom_config)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""ASV benchmarks for resource type YAML schema validation."""
|
||||
|
||||
_VALID_YAML = """
|
||||
name: bench/perf-type
|
||||
description: Benchmark resource type
|
||||
resource_kind: physical
|
||||
sandbox_strategy: git_worktree
|
||||
user_addable: true
|
||||
cli_args:
|
||||
- name: path
|
||||
type: path
|
||||
required: true
|
||||
description: Path to resource
|
||||
- name: branch
|
||||
type: string
|
||||
required: false
|
||||
description: Branch name
|
||||
parent_types: []
|
||||
child_types:
|
||||
- fs-directory
|
||||
capabilities:
|
||||
read: true
|
||||
write: true
|
||||
sandbox: true
|
||||
checkpoint: false
|
||||
"""
|
||||
|
||||
|
||||
class TimeSchemaValidation:
|
||||
"""Benchmark resource type schema YAML validation throughput."""
|
||||
|
||||
def setup(self):
|
||||
from cleveragents.resource.schema import ResourceTypeConfigSchema
|
||||
|
||||
self.schema_cls = ResourceTypeConfigSchema
|
||||
self.yaml_string = _VALID_YAML
|
||||
|
||||
def time_from_yaml(self):
|
||||
self.schema_cls.from_yaml(self.yaml_string)
|
||||
|
||||
def time_from_yaml_10x(self):
|
||||
for _ in range(10):
|
||||
self.schema_cls.from_yaml(self.yaml_string)
|
||||
|
||||
|
||||
class TimeSchemaEnvInterpolation:
|
||||
"""Benchmark env var interpolation in schema loading."""
|
||||
|
||||
def setup(self):
|
||||
import os
|
||||
|
||||
from cleveragents.resource.schema import ResourceTypeConfigSchema
|
||||
|
||||
os.environ["BENCH_HANDLER"] = "bench.handler:Handler"
|
||||
self.schema_cls = ResourceTypeConfigSchema
|
||||
self.yaml_string = """
|
||||
name: bench/env-type
|
||||
resource_kind: physical
|
||||
sandbox_strategy: none
|
||||
handler: ${BENCH_HANDLER}
|
||||
"""
|
||||
|
||||
def time_env_interpolation(self):
|
||||
self.schema_cls.from_yaml(self.yaml_string)
|
||||
@@ -0,0 +1,117 @@
|
||||
# Resource Type Model
|
||||
|
||||
## Overview
|
||||
|
||||
Resource types define the schema-level constraints for categories of resources in CleverAgents. Each resource type specifies:
|
||||
|
||||
- **CLI arguments**: What parameters are accepted when adding a resource of this type
|
||||
- **Classification**: Whether resources are physical (files, directories) or virtual (branches, derived)
|
||||
- **DAG constraints**: Allowed parent and child type relationships
|
||||
- **Sandbox strategy**: How changes are isolated during plan execution
|
||||
- **Capabilities**: Read, write, sandbox, and checkpoint support
|
||||
- **Auto-discovery**: Rules for automatic resource detection
|
||||
- **Handler**: Implementation reference for type-specific operations
|
||||
|
||||
## Built-in vs Custom Types
|
||||
|
||||
### Built-in Types
|
||||
|
||||
Built-in types use simple unnamespaced names and are registered by the system:
|
||||
|
||||
- `git-checkout` — Git repository checkout (sandbox: `git_worktree`)
|
||||
- `fs-directory` — Filesystem directory (sandbox: `copy_on_write`)
|
||||
- `fs-mount` — Filesystem mount point (sandbox: `copy_on_write`)
|
||||
|
||||
### Custom Types
|
||||
|
||||
Custom types must follow the `namespace/name` pattern:
|
||||
|
||||
```yaml
|
||||
name: myorg/postgres-db
|
||||
resource_kind: physical
|
||||
sandbox_strategy: transaction_rollback
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
### Name Validation
|
||||
|
||||
- Built-in types: `^[a-zA-Z][a-zA-Z0-9_-]*$` (e.g., `git-checkout`)
|
||||
- Custom types: `^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$` (e.g., `myorg/custom-db`)
|
||||
- The `built_in` flag controls which pattern is required
|
||||
|
||||
### CLI Argument Names
|
||||
|
||||
- Must be CLI-safe: `^[a-z][a-z0-9_-]*$`
|
||||
- Maps to `--<name>` flags (e.g., `path` → `--path`)
|
||||
- Supported types: `string`, `integer`, `float`, `boolean`, `path`
|
||||
|
||||
### Virtual Type Constraints
|
||||
|
||||
Virtual resource types **must** include an `equivalence` configuration for deduplication.
|
||||
|
||||
## Example: Git Checkout
|
||||
|
||||
```yaml
|
||||
schema_version: "1"
|
||||
name: git-checkout
|
||||
description: Git repository checkout
|
||||
resource_kind: physical
|
||||
sandbox_strategy: git_worktree
|
||||
built_in: true
|
||||
user_addable: true
|
||||
cli_args:
|
||||
- name: path
|
||||
type: path
|
||||
required: true
|
||||
description: Path to the git repository
|
||||
- name: branch
|
||||
type: string
|
||||
required: false
|
||||
description: Branch to checkout
|
||||
default: main
|
||||
parent_types: []
|
||||
child_types:
|
||||
- fs-directory
|
||||
capabilities:
|
||||
read: true
|
||||
write: true
|
||||
sandbox: true
|
||||
checkpoint: true
|
||||
handler: cleveragents.resource.handlers.git:GitCheckoutHandler
|
||||
```
|
||||
|
||||
## Example: Custom Virtual Type
|
||||
|
||||
```yaml
|
||||
schema_version: "1"
|
||||
name: myorg/derived-index
|
||||
description: Derived search index from source files
|
||||
resource_kind: virtual
|
||||
sandbox_strategy: snapshot
|
||||
built_in: false
|
||||
equivalence:
|
||||
strategy: content_hash
|
||||
fields: [source_path, index_version]
|
||||
cli_args:
|
||||
- name: source-path
|
||||
type: path
|
||||
required: true
|
||||
description: Path to source files
|
||||
parent_types:
|
||||
- fs-directory
|
||||
capabilities:
|
||||
read: true
|
||||
write: false
|
||||
sandbox: true
|
||||
checkpoint: false
|
||||
```
|
||||
|
||||
## Schema Reference
|
||||
|
||||
See `docs/schema/resource_type.schema.yaml` for the formal schema definition.
|
||||
|
||||
## Related
|
||||
|
||||
- [Resource Model](./resource_model.md) — Resource instance model
|
||||
- [Database Schema](./database_schema.md) — Persistence layer
|
||||
@@ -0,0 +1,187 @@
|
||||
# ============================================================================
|
||||
# CleverAgents Resource Type Configuration Schema
|
||||
# ============================================================================
|
||||
#
|
||||
# Formal YAML schema definition for resource type configuration files.
|
||||
# Resource types define the schema-level constraints for categories of
|
||||
# resources (e.g., git-checkout, fs-directory, fs-mount).
|
||||
#
|
||||
# Built-in resource types (git-checkout, fs-directory, fs-mount) are
|
||||
# unnamespaced. Custom resource types must follow namespace/name format.
|
||||
#
|
||||
# Schema Version: 1
|
||||
# ============================================================================
|
||||
|
||||
schema_version: "1"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Top-Level Fields
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
fields:
|
||||
# ─── Identity ─────────────────────────────────────────────────
|
||||
name:
|
||||
type: string
|
||||
required: true
|
||||
description: >
|
||||
Resource type name. Built-in types use simple names (e.g.,
|
||||
git-checkout). Custom types use namespace/name format
|
||||
(e.g., myorg/custom-db).
|
||||
|
||||
description:
|
||||
type: string
|
||||
required: false
|
||||
description: >
|
||||
Human-readable description of the resource type.
|
||||
|
||||
# ─── Classification ────────────────────────────────────────────
|
||||
resource_kind:
|
||||
type: string
|
||||
required: true
|
||||
enum: [physical, virtual]
|
||||
description: >
|
||||
Classification of resources of this type as physical
|
||||
(tangible assets like files/directories) or virtual
|
||||
(derived/computed assets like branches).
|
||||
|
||||
sandbox_strategy:
|
||||
type: string
|
||||
required: true
|
||||
enum: [git_worktree, copy_on_write, transaction_rollback, snapshot, none]
|
||||
description: >
|
||||
Default sandbox isolation strategy for resources of this type.
|
||||
|
||||
# ─── Behavior ──────────────────────────────────────────────────
|
||||
user_addable:
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
description: >
|
||||
Whether users can manually add resources of this type
|
||||
via the CLI.
|
||||
|
||||
built_in:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
description: >
|
||||
Whether this is a built-in resource type. Built-in types
|
||||
can use unnamespaced names.
|
||||
|
||||
# ─── CLI Arguments ─────────────────────────────────────────────
|
||||
cli_args:
|
||||
type: array
|
||||
required: false
|
||||
description: >
|
||||
CLI arguments accepted when adding a resource of this type.
|
||||
Each argument maps to a --<name> flag.
|
||||
items:
|
||||
type: object
|
||||
required_fields: [name]
|
||||
additional_properties: false
|
||||
fields:
|
||||
name:
|
||||
type: string
|
||||
required: true
|
||||
pattern: "^[a-z][a-z0-9_-]*$"
|
||||
description: "CLI argument name (lowercase, maps to --<name>)."
|
||||
type:
|
||||
type: string
|
||||
required: false
|
||||
enum: [string, integer, float, boolean, path]
|
||||
default: string
|
||||
description: "Data type of the argument."
|
||||
required:
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
description: "Whether the argument must be provided."
|
||||
description:
|
||||
type: string
|
||||
required: false
|
||||
description: "Human-readable description shown in help text."
|
||||
default:
|
||||
type: any
|
||||
required: false
|
||||
description: "Default value when argument is not provided."
|
||||
validation_pattern:
|
||||
type: string
|
||||
required: false
|
||||
description: "Regex pattern for string/path argument validation."
|
||||
|
||||
# ─── DAG Constraints ───────────────────────────────────────────
|
||||
parent_types:
|
||||
type: array
|
||||
required: false
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Allowed parent resource type names in the resource DAG.
|
||||
|
||||
child_types:
|
||||
type: array
|
||||
required: false
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Allowed child resource type names in the resource DAG.
|
||||
|
||||
# ─── Auto-Discovery ────────────────────────────────────────────
|
||||
auto_discovery:
|
||||
type: object
|
||||
required: false
|
||||
description: >
|
||||
Handler-specific auto-discovery configuration.
|
||||
|
||||
# ─── Equivalence (Virtual Types) ────────────────────────────────
|
||||
equivalence:
|
||||
type: object
|
||||
required: false
|
||||
description: >
|
||||
Equivalence rules for virtual resource deduplication.
|
||||
Required for virtual resource types.
|
||||
|
||||
# ─── Handler ────────────────────────────────────────────────────
|
||||
handler:
|
||||
type: string
|
||||
required: false
|
||||
description: >
|
||||
Handler implementation reference (module:class or plugin name).
|
||||
|
||||
# ─── Capabilities ──────────────────────────────────────────────
|
||||
capabilities:
|
||||
type: object
|
||||
required: false
|
||||
description: >
|
||||
Capability flags for resources of this type.
|
||||
fields:
|
||||
read:
|
||||
type: boolean
|
||||
default: true
|
||||
description: "Whether resources can be read."
|
||||
write:
|
||||
type: boolean
|
||||
default: true
|
||||
description: "Whether resources can be modified."
|
||||
sandbox:
|
||||
type: boolean
|
||||
default: true
|
||||
description: "Whether resources support sandbox isolation."
|
||||
checkpoint:
|
||||
type: boolean
|
||||
default: false
|
||||
description: "Whether resources support checkpoint/rollback."
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Constraints
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
required: [name, resource_kind, sandbox_strategy]
|
||||
additional_properties: false
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Conditional Requirements
|
||||
# ----------------------------------------------------------------------------
|
||||
# - Virtual resource types (resource_kind: virtual) MUST include an
|
||||
# 'equivalence' configuration block.
|
||||
# - Custom types (built_in: false) MUST use namespace/name format.
|
||||
@@ -0,0 +1,287 @@
|
||||
Feature: Resource Type Model and Schema Loader
|
||||
As a CleverAgents developer
|
||||
I want resource type definitions validated at load time
|
||||
So that invalid configurations are caught before runtime
|
||||
|
||||
# ── Name validation ─────────────────────────────────────────
|
||||
|
||||
Scenario: Built-in resource type with unnamespaced name
|
||||
Given a resource type config with name "git-checkout" and built_in true
|
||||
When I create a ResourceTypeSpec from config
|
||||
Then the resource type should be valid
|
||||
And the rt_spec name should be "git-checkout"
|
||||
|
||||
Scenario: Custom resource type with namespaced name
|
||||
Given a resource type config with name "myorg/custom-db" and built_in false
|
||||
When I create a ResourceTypeSpec from config
|
||||
Then the resource type should be valid
|
||||
And the rt_spec name should be "myorg/custom-db"
|
||||
|
||||
Scenario: Custom type without namespace is rejected
|
||||
Given a resource type config with name "no-namespace" and built_in false
|
||||
When I try to create a ResourceTypeSpec from config
|
||||
Then the resource type creation should fail with "namespace/name"
|
||||
|
||||
Scenario: Invalid name with special characters is rejected
|
||||
Given a resource type config with name "bad!name" and built_in false
|
||||
When I try to create a ResourceTypeSpec from config
|
||||
Then the resource type creation should fail with "Invalid resource type name"
|
||||
|
||||
# ── ResourceKind ────────────────────────────────────────────
|
||||
|
||||
Scenario: Physical resource kind
|
||||
Given a resource type config with resource_kind "physical"
|
||||
When I create a ResourceTypeSpec from config
|
||||
Then the resource type resource_kind should be "physical"
|
||||
|
||||
Scenario: Virtual resource kind
|
||||
Given a resource type config with resource_kind "virtual" and equivalence set
|
||||
When I create a ResourceTypeSpec from config
|
||||
Then the resource type resource_kind should be "virtual"
|
||||
|
||||
Scenario: Virtual resource kind without equivalence is rejected
|
||||
Given a resource type config with resource_kind "virtual" and no equivalence
|
||||
When I try to create a ResourceTypeSpec from config
|
||||
Then the resource type creation should fail with "equivalence"
|
||||
|
||||
# ── SandboxStrategy ─────────────────────────────────────────
|
||||
|
||||
Scenario: Git worktree sandbox strategy
|
||||
Given a resource type config with sandbox_strategy "git_worktree"
|
||||
When I create a ResourceTypeSpec from config
|
||||
Then the resource type sandbox_strategy should be "git_worktree"
|
||||
|
||||
Scenario: Copy on write sandbox strategy
|
||||
Given a resource type config with sandbox_strategy "copy_on_write"
|
||||
When I create a ResourceTypeSpec from config
|
||||
Then the resource type sandbox_strategy should be "copy_on_write"
|
||||
|
||||
Scenario: Transaction rollback sandbox strategy
|
||||
Given a resource type config with sandbox_strategy "transaction_rollback"
|
||||
When I create a ResourceTypeSpec from config
|
||||
Then the resource type sandbox_strategy should be "transaction_rollback"
|
||||
|
||||
Scenario: Snapshot sandbox strategy
|
||||
Given a resource type config with sandbox_strategy "snapshot"
|
||||
When I create a ResourceTypeSpec from config
|
||||
Then the resource type sandbox_strategy should be "snapshot"
|
||||
|
||||
Scenario: None sandbox strategy
|
||||
Given a resource type config with sandbox_strategy "none"
|
||||
When I create a ResourceTypeSpec from config
|
||||
Then the resource type sandbox_strategy should be "none"
|
||||
|
||||
# ── CLI Arguments ───────────────────────────────────────────
|
||||
|
||||
Scenario: Valid CLI argument parsing
|
||||
Given a resource type config with a cli_arg named "path" of type "path"
|
||||
When I create a ResourceTypeSpec from config
|
||||
Then the resource type should have 1 cli_arg
|
||||
And the first cli_arg name should be "path"
|
||||
|
||||
Scenario: Multiple CLI arguments
|
||||
Given a resource type config with cli_args "path" and "branch"
|
||||
When I create a ResourceTypeSpec from config
|
||||
Then the resource type should have 2 cli_args
|
||||
|
||||
Scenario: CLI argument with invalid name is rejected
|
||||
Given a resource type config with a cli_arg named "BadName" of type "string"
|
||||
When I try to create a ResourceTypeSpec from config
|
||||
Then the resource type creation should fail with "not CLI-safe"
|
||||
|
||||
Scenario: CLI argument with invalid type is rejected
|
||||
Given a resource type config with a cli_arg named "count" of type "bigint"
|
||||
When I try to create a ResourceTypeSpec from config
|
||||
Then the resource type creation should fail with "Invalid argument type"
|
||||
|
||||
# ── Parent/Child Types ──────────────────────────────────────
|
||||
|
||||
Scenario: Resource type with parent types
|
||||
Given a resource type config with parent_types "git-checkout"
|
||||
When I create a ResourceTypeSpec from config
|
||||
Then the resource type should have parent_types containing "git-checkout"
|
||||
|
||||
Scenario: Resource type with child types
|
||||
Given a resource type config with child_types "fs-directory"
|
||||
When I create a ResourceTypeSpec from config
|
||||
Then the resource type should have child_types containing "fs-directory"
|
||||
|
||||
Scenario: Resource type with both parent and child types
|
||||
Given a resource type config with parent_types_set "git-checkout" and child_types_set "fs-directory"
|
||||
When I create a ResourceTypeSpec from config
|
||||
Then the resource type should have parent_types containing "git-checkout"
|
||||
And the resource type should have child_types containing "fs-directory"
|
||||
|
||||
# ── from_config() ───────────────────────────────────────────
|
||||
|
||||
Scenario: from_config with missing name
|
||||
Given a resource type config dict without name
|
||||
When I try to call from_config
|
||||
Then from_config should fail with "must include 'name'"
|
||||
|
||||
Scenario: from_config with missing resource_kind
|
||||
Given a resource type config dict without resource_kind
|
||||
When I try to call from_config
|
||||
Then from_config should fail with "must include 'resource_kind'"
|
||||
|
||||
Scenario: from_config with missing sandbox_strategy
|
||||
Given a resource type config dict without sandbox_strategy
|
||||
When I try to call from_config
|
||||
Then from_config should fail with "must include 'sandbox_strategy'"
|
||||
|
||||
Scenario: from_config with valid full config
|
||||
Given a full resource type config dict
|
||||
When I call from_config
|
||||
Then the resource type spec should be valid
|
||||
|
||||
# ── as_cli_dict() ───────────────────────────────────────────
|
||||
|
||||
Scenario: as_cli_dict returns expected keys
|
||||
Given a resource type config with name "myorg/test-type" and built_in false
|
||||
When I create a ResourceTypeSpec and call as_cli_dict
|
||||
Then the rt_cli dict should contain key "name" with value "myorg/test-type"
|
||||
And the rt_cli dict should contain key "resource_kind"
|
||||
And the rt_cli dict should contain key "sandbox_strategy"
|
||||
And the rt_cli dict should contain key "capabilities"
|
||||
|
||||
# ── Schema loader ───────────────────────────────────────────
|
||||
|
||||
Scenario: Schema loader from YAML string
|
||||
Given a valid resource type YAML string
|
||||
When I load the resource type via from_yaml
|
||||
Then the loaded schema should be valid
|
||||
|
||||
Scenario: Schema loader from YAML file
|
||||
Given a valid resource type YAML file
|
||||
When I load the resource type via from_yaml_file
|
||||
Then the loaded schema should be valid
|
||||
|
||||
Scenario: Schema loader with env var interpolation
|
||||
Given a resource type YAML with env var "${RT_TEST_HANDLER}"
|
||||
And the env var "RT_TEST_HANDLER" is set to "test.handler:Handler"
|
||||
When I load the resource type via from_yaml
|
||||
Then the handler should be "test.handler:Handler"
|
||||
|
||||
Scenario: Schema loader with empty YAML
|
||||
When I try to load an empty YAML string
|
||||
Then the schema loader should fail with "empty"
|
||||
|
||||
Scenario: Schema loader with None YAML
|
||||
When I try to load a None YAML string
|
||||
Then the schema loader should fail with "None"
|
||||
|
||||
Scenario: Schema loader with non-mapping YAML
|
||||
When I try to load a YAML list
|
||||
Then the schema loader should fail with "mapping"
|
||||
|
||||
Scenario: Schema loader with unsupported version
|
||||
Given a resource type YAML with schema_version "99"
|
||||
When I try to load the resource type via from_yaml
|
||||
Then the schema loader should fail with "Unsupported schema version"
|
||||
|
||||
Scenario: Schema loader from non-existent file
|
||||
When I try to load from a non-existent file
|
||||
Then the schema loader should raise FileNotFoundError
|
||||
|
||||
Scenario: Schema loader from directory instead of file
|
||||
When I try to load from a directory path
|
||||
Then the schema loader should fail with "not a file"
|
||||
|
||||
# ── Capabilities ────────────────────────────────────────────
|
||||
|
||||
Scenario: Default capabilities
|
||||
Given a resource type config with default capabilities
|
||||
When I create a ResourceTypeSpec from config
|
||||
Then the capabilities should have read true
|
||||
And the capabilities should have write true
|
||||
And the capabilities should have sandbox true
|
||||
And the capabilities should have checkpoint false
|
||||
|
||||
Scenario: Custom capabilities
|
||||
Given a resource type config with custom capabilities
|
||||
When I create a ResourceTypeSpec from config
|
||||
Then the capabilities should have read true
|
||||
And the capabilities should have write false
|
||||
And the capabilities should have sandbox true
|
||||
And the capabilities should have checkpoint true
|
||||
|
||||
Scenario: Unknown capability key is rejected
|
||||
Given a resource type config with unknown capability "teleport"
|
||||
When I try to create a ResourceTypeSpec from config
|
||||
Then the resource type creation should fail with "Unknown capability"
|
||||
|
||||
# ── CLI argument edge cases ──────────────────────────────────
|
||||
|
||||
Scenario: cli_args entry that is not a mapping is rejected
|
||||
Given a resource type config with a non-mapping cli_arg
|
||||
When I try to create a ResourceTypeSpec from config
|
||||
Then the resource type creation should fail with "must be a mapping"
|
||||
|
||||
# ── as_cli_dict coverage ──────────────────────────────────────
|
||||
|
||||
Scenario: as_cli_dict includes all optional fields when present
|
||||
Given a full resource type config with handler and auto_discovery
|
||||
When I create a ResourceTypeSpec and call as_cli_dict
|
||||
Then the rt_cli dict should contain key "description"
|
||||
And the rt_cli dict should contain key "cli_args"
|
||||
And the rt_cli dict should contain key "child_types"
|
||||
And the rt_cli dict should contain key "handler"
|
||||
And the rt_cli dict should contain key "auto_discovery"
|
||||
And the rt_cli dict should contain key "capabilities"
|
||||
|
||||
Scenario: as_cli_dict with equivalence field
|
||||
Given a resource type config with resource_kind "virtual" and equivalence set
|
||||
When I create a ResourceTypeSpec and call as_cli_dict
|
||||
Then the rt_cli dict should contain key "equivalence"
|
||||
|
||||
Scenario: as_cli_dict with parent_types
|
||||
Given a resource type config with parent_types "git-checkout"
|
||||
When I create a ResourceTypeSpec and call as_cli_dict
|
||||
Then the rt_cli dict should contain key "parent_types"
|
||||
|
||||
# ── Schema-level validation ───────────────────────────────────
|
||||
|
||||
Scenario: Schema rejects invalid cli_arg name
|
||||
Given a resource type YAML with invalid cli_arg name
|
||||
When I try to load the resource type via from_yaml
|
||||
Then the schema loader should fail with "not CLI-safe"
|
||||
|
||||
Scenario: Schema rejects invalid cli_arg type
|
||||
Given a resource type YAML with invalid cli_arg type
|
||||
When I try to load the resource type via from_yaml
|
||||
Then the schema loader should fail with "Invalid argument type"
|
||||
|
||||
Scenario: Schema rejects invalid resource type name
|
||||
Given a resource type YAML with invalid name
|
||||
When I try to load the resource type via from_yaml
|
||||
Then the schema loader should fail with "Invalid resource type name"
|
||||
|
||||
Scenario: Schema rejects invalid resource_kind
|
||||
Given a resource type YAML with invalid resource_kind
|
||||
When I try to load the resource type via from_yaml
|
||||
Then the schema loader should fail with "Invalid resource_kind"
|
||||
|
||||
Scenario: Schema rejects invalid sandbox_strategy
|
||||
Given a resource type YAML with invalid sandbox_strategy
|
||||
When I try to load the resource type via from_yaml
|
||||
Then the schema loader should fail with "Invalid sandbox_strategy"
|
||||
|
||||
Scenario: Schema rejects custom type without namespace
|
||||
Given a resource type YAML with custom type no namespace
|
||||
When I try to load the resource type via from_yaml
|
||||
Then the schema loader should fail with "namespace/name"
|
||||
|
||||
Scenario: Schema rejects virtual type without equivalence
|
||||
Given a resource type YAML with virtual kind no equivalence
|
||||
When I try to load the resource type via from_yaml
|
||||
Then the schema loader should fail with "equivalence"
|
||||
|
||||
Scenario: Schema loader from_yaml_file with None path
|
||||
When I try to load from a None file path
|
||||
Then the schema loader should fail with "None"
|
||||
|
||||
Scenario: Schema env var interpolation in list items
|
||||
Given a resource type YAML with env var in list
|
||||
And the env var "RT_TEST_PARENT" is set to "custom-parent"
|
||||
When I load the resource type via from_yaml
|
||||
Then the loaded schema should have parent type "custom-parent"
|
||||
@@ -0,0 +1,583 @@
|
||||
"""Step definitions for resource_type_model.feature."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.domain.models.core.resource_type import (
|
||||
ResourceKind,
|
||||
ResourceTypeSpec,
|
||||
SandboxStrategy,
|
||||
)
|
||||
from cleveragents.resource.schema import ResourceTypeConfigSchema
|
||||
|
||||
|
||||
def _base_config(
|
||||
name: str = "myorg/test-type",
|
||||
resource_kind: str = "physical",
|
||||
sandbox_strategy: str = "git_worktree",
|
||||
built_in: bool = False,
|
||||
) -> dict:
|
||||
"""Return a minimal valid resource type config dict."""
|
||||
return {
|
||||
"name": name,
|
||||
"resource_kind": resource_kind,
|
||||
"sandbox_strategy": sandbox_strategy,
|
||||
"built_in": built_in,
|
||||
"description": "Test resource type",
|
||||
}
|
||||
|
||||
|
||||
# ── Given steps ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@given('a resource type config with name "{name}" and built_in {built_in}')
|
||||
def step_config_with_name_builtin(context, name, built_in):
|
||||
context.rt_config = _base_config(name=name, built_in=built_in.lower() == "true")
|
||||
|
||||
|
||||
@given('a resource type config with resource_kind "{kind}"')
|
||||
def step_config_with_kind(context, kind):
|
||||
context.rt_config = _base_config(resource_kind=kind)
|
||||
|
||||
|
||||
@given('a resource type config with resource_kind "{kind}" and equivalence set')
|
||||
def step_config_with_kind_and_equiv(context, kind):
|
||||
config = _base_config(resource_kind=kind)
|
||||
config["equivalence"] = {"strategy": "content_hash", "fields": ["hash"]}
|
||||
context.rt_config = config
|
||||
|
||||
|
||||
@given('a resource type config with resource_kind "{kind}" and no equivalence')
|
||||
def step_config_with_kind_no_equiv(context, kind):
|
||||
context.rt_config = _base_config(resource_kind=kind)
|
||||
|
||||
|
||||
@given('a resource type config with sandbox_strategy "{strategy}"')
|
||||
def step_config_with_sandbox(context, strategy):
|
||||
context.rt_config = _base_config(sandbox_strategy=strategy)
|
||||
|
||||
|
||||
@given('a resource type config with a cli_arg named "{name}" of type "{arg_type}"')
|
||||
def step_config_with_cli_arg(context, name, arg_type):
|
||||
config = _base_config()
|
||||
config["cli_args"] = [
|
||||
{"name": name, "type": arg_type, "required": True, "description": "Test arg"}
|
||||
]
|
||||
context.rt_config = config
|
||||
|
||||
|
||||
@given('a resource type config with cli_args "{arg1}" and "{arg2}"')
|
||||
def step_config_with_two_cli_args(context, arg1, arg2):
|
||||
config = _base_config()
|
||||
config["cli_args"] = [
|
||||
{"name": arg1, "type": "string", "required": True, "description": "Arg 1"},
|
||||
{"name": arg2, "type": "string", "required": False, "description": "Arg 2"},
|
||||
]
|
||||
context.rt_config = config
|
||||
|
||||
|
||||
@given('a resource type config with parent_types "{parent}"')
|
||||
def step_config_with_parent_types(context, parent):
|
||||
config = _base_config()
|
||||
config["parent_types"] = [parent]
|
||||
context.rt_config = config
|
||||
|
||||
|
||||
@given('a resource type config with child_types "{child}"')
|
||||
def step_config_with_child_types(context, child):
|
||||
config = _base_config()
|
||||
config["child_types"] = [child]
|
||||
context.rt_config = config
|
||||
|
||||
|
||||
@given(
|
||||
'a resource type config with parent_types_set "{parent}" and child_types_set "{child}"'
|
||||
)
|
||||
def step_config_with_parent_and_child(context, parent, child):
|
||||
config = _base_config()
|
||||
config["parent_types"] = [parent]
|
||||
config["child_types"] = [child]
|
||||
context.rt_config = config
|
||||
|
||||
|
||||
@given("a resource type config dict without name")
|
||||
def step_config_without_name(context):
|
||||
context.rt_config = {
|
||||
"resource_kind": "physical",
|
||||
"sandbox_strategy": "git_worktree",
|
||||
}
|
||||
|
||||
|
||||
@given("a resource type config dict without resource_kind")
|
||||
def step_config_without_kind(context):
|
||||
context.rt_config = {"name": "myorg/test", "sandbox_strategy": "git_worktree"}
|
||||
|
||||
|
||||
@given("a resource type config dict without sandbox_strategy")
|
||||
def step_config_without_strategy(context):
|
||||
context.rt_config = {"name": "myorg/test", "resource_kind": "physical"}
|
||||
|
||||
|
||||
@given("a full resource type config dict")
|
||||
def step_full_config(context):
|
||||
context.rt_config = _base_config()
|
||||
context.rt_config["cli_args"] = [
|
||||
{"name": "path", "type": "path", "required": True, "description": "Path"}
|
||||
]
|
||||
context.rt_config["parent_types"] = []
|
||||
context.rt_config["child_types"] = ["fs-directory"]
|
||||
context.rt_config["handler"] = "test.handler:TestHandler"
|
||||
|
||||
|
||||
@given("a valid resource type YAML string")
|
||||
def step_valid_yaml_string(context):
|
||||
context.rt_yaml = """
|
||||
name: myorg/yaml-test
|
||||
description: YAML test type
|
||||
resource_kind: physical
|
||||
sandbox_strategy: git_worktree
|
||||
cli_args:
|
||||
- name: path
|
||||
type: path
|
||||
required: true
|
||||
description: Path to resource
|
||||
"""
|
||||
|
||||
|
||||
@given("a valid resource type YAML file")
|
||||
def step_valid_yaml_file(context):
|
||||
content = """
|
||||
name: myorg/file-test
|
||||
description: File test type
|
||||
resource_kind: physical
|
||||
sandbox_strategy: copy_on_write
|
||||
cli_args:
|
||||
- name: path
|
||||
type: path
|
||||
required: true
|
||||
description: Path
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
|
||||
tmp.write(content)
|
||||
context.rt_yaml_path = tmp.name
|
||||
|
||||
|
||||
@given('a resource type YAML with env var "${{RT_TEST_HANDLER}}"')
|
||||
def step_yaml_with_env_var(context):
|
||||
context.rt_yaml = """
|
||||
name: myorg/env-test
|
||||
description: Env test
|
||||
resource_kind: physical
|
||||
sandbox_strategy: none
|
||||
handler: ${RT_TEST_HANDLER}
|
||||
"""
|
||||
|
||||
|
||||
@given('the env var "{var}" is set to "{value}"')
|
||||
def step_set_env_var(context, var, value):
|
||||
os.environ[var] = value
|
||||
try:
|
||||
context._env_vars_to_clean.append(var)
|
||||
except (AttributeError, KeyError):
|
||||
context._env_vars_to_clean = [var]
|
||||
|
||||
|
||||
@given('a resource type YAML with schema_version "{version}"')
|
||||
def step_yaml_with_version(context, version):
|
||||
context.rt_yaml = f"""
|
||||
schema_version: "{version}"
|
||||
name: myorg/version-test
|
||||
resource_kind: physical
|
||||
sandbox_strategy: none
|
||||
"""
|
||||
|
||||
|
||||
@given("a resource type config with default capabilities")
|
||||
def step_config_default_caps(context):
|
||||
context.rt_config = _base_config()
|
||||
|
||||
|
||||
@given("a resource type config with custom capabilities")
|
||||
def step_config_custom_caps(context):
|
||||
config = _base_config()
|
||||
config["capabilities"] = {
|
||||
"read": True,
|
||||
"write": False,
|
||||
"sandbox": True,
|
||||
"checkpoint": True,
|
||||
}
|
||||
context.rt_config = config
|
||||
|
||||
|
||||
@given('a resource type config with unknown capability "{cap}"')
|
||||
def step_config_unknown_cap(context, cap):
|
||||
config = _base_config()
|
||||
config["capabilities"] = {
|
||||
"read": True,
|
||||
"write": True,
|
||||
"sandbox": True,
|
||||
"checkpoint": False,
|
||||
cap: True,
|
||||
}
|
||||
context.rt_config = config
|
||||
|
||||
|
||||
@given("a resource type config with non-bool capability")
|
||||
def step_config_nonbool_cap(context):
|
||||
config = _base_config()
|
||||
config["capabilities"] = {
|
||||
"read": "yes",
|
||||
"write": True,
|
||||
"sandbox": True,
|
||||
"checkpoint": False,
|
||||
}
|
||||
context.rt_config = config
|
||||
|
||||
|
||||
@given("a resource type config with a non-mapping cli_arg")
|
||||
def step_config_nonmapping_cli_arg(context):
|
||||
config = _base_config()
|
||||
config["cli_args"] = ["not-a-mapping"]
|
||||
context.rt_config = config
|
||||
|
||||
|
||||
@given("a full resource type config with handler and auto_discovery")
|
||||
def step_full_config_with_handler(context):
|
||||
context.rt_config = _base_config()
|
||||
context.rt_config["cli_args"] = [
|
||||
{"name": "path", "type": "path", "required": True, "description": "Path"}
|
||||
]
|
||||
context.rt_config["parent_types"] = []
|
||||
context.rt_config["child_types"] = ["fs-directory"]
|
||||
context.rt_config["handler"] = "test.handler:TestHandler"
|
||||
context.rt_config["auto_discovery"] = {"pattern": "*.yaml"}
|
||||
context.rt_config["equivalence"] = None
|
||||
|
||||
|
||||
@given("a resource type YAML with invalid cli_arg name")
|
||||
def step_yaml_invalid_arg_name(context):
|
||||
context.rt_yaml = """
|
||||
name: myorg/test
|
||||
resource_kind: physical
|
||||
sandbox_strategy: none
|
||||
cli_args:
|
||||
- name: BadName
|
||||
type: string
|
||||
required: true
|
||||
description: Bad
|
||||
"""
|
||||
|
||||
|
||||
@given("a resource type YAML with invalid cli_arg type")
|
||||
def step_yaml_invalid_arg_type(context):
|
||||
context.rt_yaml = """
|
||||
name: myorg/test
|
||||
resource_kind: physical
|
||||
sandbox_strategy: none
|
||||
cli_args:
|
||||
- name: count
|
||||
type: bigint
|
||||
required: true
|
||||
description: Bad type
|
||||
"""
|
||||
|
||||
|
||||
@given("a resource type YAML with invalid name")
|
||||
def step_yaml_invalid_name(context):
|
||||
context.rt_yaml = """
|
||||
name: "bad!name"
|
||||
resource_kind: physical
|
||||
sandbox_strategy: none
|
||||
"""
|
||||
|
||||
|
||||
@given("a resource type YAML with invalid resource_kind")
|
||||
def step_yaml_invalid_kind(context):
|
||||
context.rt_yaml = """
|
||||
name: myorg/test
|
||||
resource_kind: quantum
|
||||
sandbox_strategy: none
|
||||
"""
|
||||
|
||||
|
||||
@given("a resource type YAML with invalid sandbox_strategy")
|
||||
def step_yaml_invalid_strategy(context):
|
||||
context.rt_yaml = """
|
||||
name: myorg/test
|
||||
resource_kind: physical
|
||||
sandbox_strategy: teleport
|
||||
"""
|
||||
|
||||
|
||||
@given("a resource type YAML with custom type no namespace")
|
||||
def step_yaml_custom_no_namespace(context):
|
||||
context.rt_yaml = """
|
||||
name: no-namespace
|
||||
resource_kind: physical
|
||||
sandbox_strategy: none
|
||||
built_in: false
|
||||
"""
|
||||
|
||||
|
||||
@given("a resource type YAML with virtual kind no equivalence")
|
||||
def step_yaml_virtual_no_equiv(context):
|
||||
context.rt_yaml = """
|
||||
name: myorg/virtual-test
|
||||
resource_kind: virtual
|
||||
sandbox_strategy: none
|
||||
"""
|
||||
|
||||
|
||||
@given("a resource type YAML with env var in list")
|
||||
def step_yaml_env_var_in_list(context):
|
||||
context.rt_yaml = """
|
||||
name: myorg/list-env-test
|
||||
resource_kind: physical
|
||||
sandbox_strategy: none
|
||||
parent_types:
|
||||
- ${RT_TEST_PARENT}
|
||||
"""
|
||||
|
||||
|
||||
# ── When steps ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@when("I create a ResourceTypeSpec from config")
|
||||
def step_create_spec(context):
|
||||
context.rt_spec = ResourceTypeSpec.from_config(context.rt_config)
|
||||
|
||||
|
||||
@when("I try to create a ResourceTypeSpec from config")
|
||||
def step_try_create_spec(context):
|
||||
try:
|
||||
context.rt_spec = ResourceTypeSpec.from_config(context.rt_config)
|
||||
context.rt_error = None
|
||||
except (ValueError, Exception) as exc:
|
||||
context.rt_error = str(exc)
|
||||
|
||||
|
||||
@when("I try to call from_config")
|
||||
def step_try_from_config(context):
|
||||
try:
|
||||
context.rt_spec = ResourceTypeSpec.from_config(context.rt_config)
|
||||
context.rt_error = None
|
||||
except ValueError as exc:
|
||||
context.rt_error = str(exc)
|
||||
|
||||
|
||||
@when("I call from_config")
|
||||
def step_call_from_config(context):
|
||||
context.rt_spec = ResourceTypeSpec.from_config(context.rt_config)
|
||||
|
||||
|
||||
@when("I create a ResourceTypeSpec and call as_cli_dict")
|
||||
def step_create_and_cli_dict(context):
|
||||
context.rt_spec = ResourceTypeSpec.from_config(context.rt_config)
|
||||
context.rt_cli_dict = context.rt_spec.as_cli_dict()
|
||||
|
||||
|
||||
@when("I load the resource type via from_yaml")
|
||||
def step_load_from_yaml(context):
|
||||
context.rt_schema = ResourceTypeConfigSchema.from_yaml(context.rt_yaml)
|
||||
|
||||
|
||||
@when("I load the resource type via from_yaml_file")
|
||||
def step_load_from_yaml_file(context):
|
||||
context.rt_schema = ResourceTypeConfigSchema.from_yaml_file(context.rt_yaml_path)
|
||||
|
||||
|
||||
@when("I try to load an empty YAML string")
|
||||
def step_try_load_empty_yaml(context):
|
||||
try:
|
||||
ResourceTypeConfigSchema.from_yaml("")
|
||||
context.rt_error = None
|
||||
except ValueError as exc:
|
||||
context.rt_error = str(exc)
|
||||
|
||||
|
||||
@when("I try to load a None YAML string")
|
||||
def step_try_load_none_yaml(context):
|
||||
try:
|
||||
ResourceTypeConfigSchema.from_yaml(None)
|
||||
context.rt_error = None
|
||||
except ValueError as exc:
|
||||
context.rt_error = str(exc)
|
||||
|
||||
|
||||
@when("I try to load a YAML list")
|
||||
def step_try_load_yaml_list(context):
|
||||
try:
|
||||
ResourceTypeConfigSchema.from_yaml("- item1\n- item2\n")
|
||||
context.rt_error = None
|
||||
except ValueError as exc:
|
||||
context.rt_error = str(exc)
|
||||
|
||||
|
||||
@when("I try to load the resource type via from_yaml")
|
||||
def step_try_load_from_yaml(context):
|
||||
try:
|
||||
context.rt_schema = ResourceTypeConfigSchema.from_yaml(context.rt_yaml)
|
||||
context.rt_error = None
|
||||
except (ValueError, Exception) as exc:
|
||||
context.rt_error = str(exc)
|
||||
|
||||
|
||||
@when("I try to load from a non-existent file")
|
||||
def step_try_load_nonexistent(context):
|
||||
try:
|
||||
ResourceTypeConfigSchema.from_yaml_file("/nonexistent/file.yaml")
|
||||
context.rt_error = None
|
||||
except FileNotFoundError:
|
||||
context.rt_error = "FileNotFoundError"
|
||||
|
||||
|
||||
@when("I try to load from a directory path")
|
||||
def step_try_load_directory(context):
|
||||
try:
|
||||
ResourceTypeConfigSchema.from_yaml_file(tempfile.gettempdir())
|
||||
context.rt_error = None
|
||||
except ValueError as exc:
|
||||
context.rt_error = str(exc)
|
||||
|
||||
|
||||
# ── Then steps ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@then("the resource type should be valid")
|
||||
def step_rt_valid(context):
|
||||
assert context.rt_spec is not None
|
||||
|
||||
|
||||
@then('the rt_spec name should be "{name}"')
|
||||
def step_rt_name(context, name):
|
||||
assert context.rt_spec.name == name
|
||||
|
||||
|
||||
@then('the resource type creation should fail with "{fragment}"')
|
||||
def step_rt_creation_fail(context, fragment):
|
||||
assert context.rt_error is not None, "Expected error but got none"
|
||||
assert fragment in context.rt_error, (
|
||||
f"Expected '{fragment}' in error: {context.rt_error}"
|
||||
)
|
||||
|
||||
|
||||
@then('the resource type resource_kind should be "{kind}"')
|
||||
def step_rt_kind(context, kind):
|
||||
assert context.rt_spec.resource_kind == ResourceKind(kind)
|
||||
|
||||
|
||||
@then('the resource type sandbox_strategy should be "{strategy}"')
|
||||
def step_rt_strategy(context, strategy):
|
||||
assert context.rt_spec.sandbox_strategy == SandboxStrategy(strategy)
|
||||
|
||||
|
||||
@then("the resource type should have {count:d} cli_arg")
|
||||
def step_rt_cli_arg_count_singular(context, count):
|
||||
assert len(context.rt_spec.cli_args) == count
|
||||
|
||||
|
||||
@then("the resource type should have {count:d} cli_args")
|
||||
def step_rt_cli_arg_count(context, count):
|
||||
assert len(context.rt_spec.cli_args) == count
|
||||
|
||||
|
||||
@then('the first cli_arg name should be "{name}"')
|
||||
def step_rt_first_cli_arg(context, name):
|
||||
assert context.rt_spec.cli_args[0].name == name
|
||||
|
||||
|
||||
@then('the resource type should have parent_types containing "{parent}"')
|
||||
def step_rt_parent_types(context, parent):
|
||||
assert parent in context.rt_spec.parent_types
|
||||
|
||||
|
||||
@then('the resource type should have child_types containing "{child}"')
|
||||
def step_rt_child_types(context, child):
|
||||
assert child in context.rt_spec.child_types
|
||||
|
||||
|
||||
@then('from_config should fail with "{fragment}"')
|
||||
def step_from_config_fail(context, fragment):
|
||||
assert context.rt_error is not None, "Expected error but got none"
|
||||
assert fragment in context.rt_error, (
|
||||
f"Expected '{fragment}' in error: {context.rt_error}"
|
||||
)
|
||||
|
||||
|
||||
@then("the resource type spec should be valid")
|
||||
def step_rt_spec_valid(context):
|
||||
assert context.rt_spec is not None
|
||||
|
||||
|
||||
@then('the rt_cli dict should contain key "{key}" with value "{value}"')
|
||||
def step_cli_dict_key_value(context, key, value):
|
||||
assert key in context.rt_cli_dict
|
||||
assert str(context.rt_cli_dict[key]) == value
|
||||
|
||||
|
||||
@then('the rt_cli dict should contain key "{key}"')
|
||||
def step_cli_dict_key(context, key):
|
||||
assert key in context.rt_cli_dict
|
||||
|
||||
|
||||
@then("the loaded schema should be valid")
|
||||
def step_loaded_schema_valid(context):
|
||||
assert context.rt_schema is not None
|
||||
|
||||
|
||||
@then('the handler should be "{handler}"')
|
||||
def step_handler_value(context, handler):
|
||||
assert context.rt_schema.handler == handler
|
||||
|
||||
|
||||
@then('the schema loader should fail with "{fragment}"')
|
||||
def step_schema_loader_fail(context, fragment):
|
||||
assert context.rt_error is not None, "Expected error but got none"
|
||||
assert fragment.lower() in context.rt_error.lower(), (
|
||||
f"Expected '{fragment}' in error: {context.rt_error}"
|
||||
)
|
||||
|
||||
|
||||
@then("the schema loader should raise FileNotFoundError")
|
||||
def step_schema_loader_fnf(context):
|
||||
assert context.rt_error == "FileNotFoundError"
|
||||
|
||||
|
||||
@then("the capabilities should have read {value}")
|
||||
def step_caps_read(context, value):
|
||||
expected = value.lower() == "true"
|
||||
assert context.rt_spec.capabilities["read"] is expected
|
||||
|
||||
|
||||
@then("the capabilities should have write {value}")
|
||||
def step_caps_write(context, value):
|
||||
expected = value.lower() == "true"
|
||||
assert context.rt_spec.capabilities["write"] is expected
|
||||
|
||||
|
||||
@then("the capabilities should have sandbox {value}")
|
||||
def step_caps_sandbox(context, value):
|
||||
expected = value.lower() == "true"
|
||||
assert context.rt_spec.capabilities["sandbox"] is expected
|
||||
|
||||
|
||||
@then("the capabilities should have checkpoint {value}")
|
||||
def step_caps_checkpoint(context, value):
|
||||
expected = value.lower() == "true"
|
||||
assert context.rt_spec.capabilities["checkpoint"] is expected
|
||||
|
||||
|
||||
@when("I try to load from a None file path")
|
||||
def step_try_load_none_file_path(context):
|
||||
try:
|
||||
ResourceTypeConfigSchema.from_yaml_file(None)
|
||||
context.rt_error = None
|
||||
except ValueError as exc:
|
||||
context.rt_error = str(exc)
|
||||
|
||||
|
||||
@then('the loaded schema should have parent type "{parent}"')
|
||||
def step_loaded_schema_parent_type(context, parent):
|
||||
assert parent in context.rt_schema.parent_types
|
||||
+25
-25
@@ -2069,31 +2069,31 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
|
||||
**PARALLEL SUBTRACK B0.sandbox [Hamza]**: git_worktree + copy_on_write sandbox strategies (completed)
|
||||
**SEQUENTIAL MERGE NOTE**: B0.type-model → B0.builtins → B0.repo.* → B0.services → B0.cli.*. B0.db.resources is done; B0.db.projects must land before B0.repo.projects.
|
||||
|
||||
- [ ] **COMMIT (Owner: Jeff | Group: B0.type-model | Branch: feature/m1-resource-type-schema | Planned: Day 7 | Expected: Day 10) - Commit message: "feat(resource): add resource type model + schema loader"**
|
||||
- [ ] Meta [Jeff]: Only mark this commit complete after every subtask is done and `git commit -m "feat(resource): add resource type model + schema loader"` has executed.
|
||||
- [ ] Git [Jeff]: `git checkout master`
|
||||
- [ ] Git [Jeff]: `git pull origin master`
|
||||
- [ ] Git [Jeff]: `git checkout -b feature/m1-resource-type-schema`
|
||||
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [ ] Code [Jeff]: Create `src/cleveragents/domain/models/core/resource_type.py` with `ResourceTypeName`, `ResourceTypeArgument`, `ResourceTypeSpec`, `ResourceKind` enum (physical/virtual), and `SandboxStrategy` enum (git_worktree/copy_on_write/transaction_rollback/snapshot/none).
|
||||
- [ ] Code [Jeff]: Define `ResourceTypeArgument` fields per spec (`name`, `type`, `required`, `description`, `default`, `validation_pattern`) and validate `name` to map to CLI `--<name>`.
|
||||
- [ ] Code [Jeff]: Add `ResourceTypeSpec` fields per spec: `user_addable`, `cli_args`, `child_types`, `parent_types`, `auto_discovery`, `equivalence` (virtual only), `handler`, `sandbox_strategy`, and `capabilities` (read/write/sandbox/checkpoint).
|
||||
- [ ] Code [Jeff]: Enforce namespaced name rules for custom types; allow unnamespaced built-ins (e.g., `git-checkout`, `fs-directory`) via a dedicated `built_in` flag.
|
||||
- [ ] Code [Jeff]: Add `docs/schema/resource_type.schema.yaml` mirroring the spec JSON schema (fields, enums, required list, `cliArg`/`childType` defs, and conditional `equivalence` requirement for virtual types).
|
||||
- [ ] Code [Jeff]: Add resource type YAML loader in `src/cleveragents/resource/schema.py` with `${ENV_VAR}` interpolation, schema version guardrails, and explicit error messages for invalid names, constraints, and `cli_args` definitions.
|
||||
- [ ] Docs [Jeff]: Add `docs/reference/resource_type_model.md` with minimal examples and validation rules (include git-checkout + fs-directory and physical/virtual notes).
|
||||
- [ ] Tests (Behave) [Jeff]: Add scenarios for resource type name validation, `cli_args` parsing, `child_types`/`parent_types` constraint validation, and unnamespaced built-in allowance.
|
||||
- [ ] Tests (Behave) [Jeff]: Add schema loader scenarios for env var interpolation, version mismatch, and handler metadata validation.
|
||||
- [ ] Tests (Robot) [Jeff]: Add Robot test that loads a ResourceType YAML fixture and asserts required fields are present.
|
||||
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/resource_type_schema_bench.py` for YAML validation throughput.
|
||||
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/resource_type_model_bench.py` for resource type validation and constraint checks.
|
||||
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
|
||||
- [ ] Git [Jeff]: `git add .`
|
||||
- [ ] Git [Jeff]: `git commit -m "feat(resource): add resource type model + schema loader"`
|
||||
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m1-resource-type-schema` to `master` with description "Add resource type domain model + YAML schema loader with tests and docs.".
|
||||
- [ ] Git [Jeff]: `git checkout master`
|
||||
- [ ] Git [Jeff]: `git branch -d feature/m1-resource-type-schema`
|
||||
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
- [X] **COMMIT (Owner: Jeff | Group: B0.type-model | Branch: feature/m1-resource-type-schema | Planned: Day 7 | Expected: Day 10) - Commit message: "feat(resource): add resource type model + schema loader"** Done: Day 7, February 15, 2026
|
||||
- [X] Meta [Jeff]: Only mark this commit complete after every subtask is done and `git commit -m "feat(resource): add resource type model + schema loader"` has executed. Done: Day 7, February 15, 2026
|
||||
- [X] Git [Jeff]: `git checkout master` Done: Day 7, February 15, 2026
|
||||
- [X] Git [Jeff]: `git pull origin master` Done: Day 7, February 15, 2026
|
||||
- [X] Git [Jeff]: `git checkout -b feature/m1-resource-type-schema` Done: Day 7, February 15, 2026
|
||||
- [X] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit) Done: Day 7, February 15, 2026
|
||||
- [X] Code [Jeff]: Create `src/cleveragents/domain/models/core/resource_type.py` with `ResourceTypeName`, `ResourceTypeArgument`, `ResourceTypeSpec`, `ResourceKind` enum (physical/virtual), and `SandboxStrategy` enum (git_worktree/copy_on_write/transaction_rollback/snapshot/none). Done: Day 7, February 15, 2026
|
||||
- [X] Code [Jeff]: Define `ResourceTypeArgument` fields per spec (`name`, `type`, `required`, `description`, `default`, `validation_pattern`) and validate `name` to map to CLI `--<name>`. Done: Day 7, February 15, 2026
|
||||
- [X] Code [Jeff]: Add `ResourceTypeSpec` fields per spec: `user_addable`, `cli_args`, `child_types`, `parent_types`, `auto_discovery`, `equivalence` (virtual only), `handler`, `sandbox_strategy`, and `capabilities` (read/write/sandbox/checkpoint). Done: Day 7, February 15, 2026
|
||||
- [X] Code [Jeff]: Enforce namespaced name rules for custom types; allow unnamespaced built-ins (e.g., `git-checkout`, `fs-directory`) via a dedicated `built_in` flag. Done: Day 7, February 15, 2026
|
||||
- [X] Code [Jeff]: Add `docs/schema/resource_type.schema.yaml` mirroring the spec JSON schema (fields, enums, required list, `cliArg`/`childType` defs, and conditional `equivalence` requirement for virtual types). Done: Day 7, February 15, 2026
|
||||
- [X] Code [Jeff]: Add resource type YAML loader in `src/cleveragents/resource/schema.py` with `${ENV_VAR}` interpolation, schema version guardrails, and explicit error messages for invalid names, constraints, and `cli_args` definitions. Done: Day 7, February 15, 2026
|
||||
- [X] Docs [Jeff]: Add `docs/reference/resource_type_model.md` with minimal examples and validation rules (include git-checkout + fs-directory and physical/virtual notes). Done: Day 7, February 15, 2026
|
||||
- [X] Tests (Behave) [Jeff]: Add scenarios for resource type name validation, `cli_args` parsing, `child_types`/`parent_types` constraint validation, and unnamespaced built-in allowance. Done: Day 7, February 15, 2026
|
||||
- [X] Tests (Behave) [Jeff]: Add schema loader scenarios for env var interpolation, version mismatch, and handler metadata validation. Done: Day 7, February 15, 2026
|
||||
- [X] Tests (Robot) [Jeff]: Add Robot test that loads a ResourceType YAML fixture and asserts required fields are present. Done: Day 7, February 15, 2026
|
||||
- [X] Tests (ASV) [Jeff]: Add `benchmarks/resource_type_schema_bench.py` for YAML validation throughput. Done: Day 7, February 15, 2026
|
||||
- [X] Tests (ASV) [Jeff]: Add `benchmarks/resource_type_model_bench.py` for resource type validation and constraint checks. Done: Day 7, February 15, 2026
|
||||
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes. Done: Day 7, February 15, 2026
|
||||
- [X] Git [Jeff]: `git add .` Done: Day 7, February 15, 2026
|
||||
- [X] Git [Jeff]: `git commit -m "feat(resource): add resource type model + schema loader"` Done: Day 7, February 15, 2026
|
||||
- [X] Forgejo PR [Jeff]: Open PR from `feature/m1-resource-type-schema` to `master` with description "Add resource type domain model + YAML schema loader with tests and docs.". Done: Day 7, February 15, 2026
|
||||
- [X] Git [Jeff]: `git checkout master` Done: Day 7, February 15, 2026
|
||||
- [X] Git [Jeff]: `git branch -d feature/m1-resource-type-schema` Done: Day 7, February 15, 2026
|
||||
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%. Done: Day 7, February 15, 2026
|
||||
|
||||
|
||||
- [ ] **COMMIT (Owner: Jeff | Group: B0.builtins | Branch: feature/m1-resource-builtins | Planned: Day 8 | Expected: Day 10) - Commit message: "feat(resource): add git-checkout and fs-directory resource types"**
|
||||
|
||||
@@ -392,6 +392,10 @@ def integration_tests(session: nox.Session):
|
||||
session.install("-e", ".[tests]")
|
||||
session.env["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = "true"
|
||||
|
||||
# Override PYTHONPATH so the editable install (src/) for this project
|
||||
# is used instead of any inherited PYTHONPATH from the outer environment.
|
||||
session.env["PYTHONPATH"] = "src"
|
||||
|
||||
# Propagate venv bin to PATH so Run Process in robot files finds
|
||||
# the venv's python/robot rather than the system copies.
|
||||
venv_bin = os.path.join(session.virtualenv.location, "bin")
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
name: myorg/robot-test
|
||||
description: Robot test resource type
|
||||
resource_kind: physical
|
||||
sandbox_strategy: git_worktree
|
||||
user_addable: true
|
||||
cli_args:
|
||||
- name: path
|
||||
type: path
|
||||
required: true
|
||||
description: Path to resource
|
||||
- name: branch
|
||||
type: string
|
||||
required: false
|
||||
description: Branch name
|
||||
default: main
|
||||
parent_types: []
|
||||
child_types:
|
||||
- fs-directory
|
||||
capabilities:
|
||||
read: true
|
||||
write: true
|
||||
sandbox: true
|
||||
checkpoint: false
|
||||
handler: test.handler:RobotTestHandler
|
||||
@@ -0,0 +1,51 @@
|
||||
*** Settings ***
|
||||
Documentation Resource Type Schema YAML Load Test
|
||||
Library Process
|
||||
Library OperatingSystem
|
||||
|
||||
*** Variables ***
|
||||
${PYTHON} python
|
||||
${FIXTURE_YAML} ${CURDIR}/fixtures/resource_type_fixture.yaml
|
||||
|
||||
*** Test Cases ***
|
||||
Load Resource Type YAML Fixture And Assert Fields
|
||||
[Documentation] Load a resource type YAML fixture and verify fields
|
||||
${script}= Catenate SEPARATOR=\n
|
||||
... import sys
|
||||
... import yaml
|
||||
... from cleveragents.resource.schema import ResourceTypeConfigSchema
|
||||
... schema = ResourceTypeConfigSchema.from_yaml_file("${FIXTURE_YAML}")
|
||||
... assert schema.name == "myorg/robot-test", f"name mismatch: {schema.name}"
|
||||
... assert schema.resource_kind == "physical", f"kind mismatch: {schema.resource_kind}"
|
||||
... assert schema.sandbox_strategy == "git_worktree", f"strategy mismatch: {schema.sandbox_strategy}"
|
||||
... assert len(schema.cli_args) == 2, f"cli_args count mismatch: {len(schema.cli_args)}"
|
||||
... assert schema.cli_args[0].name == "path", f"first arg name mismatch: {schema.cli_args[0].name}"
|
||||
... assert schema.cli_args[1].name == "branch", f"second arg name mismatch: {schema.cli_args[1].name}"
|
||||
... assert schema.capabilities["read"] is True
|
||||
... assert schema.capabilities["write"] is True
|
||||
... print("All resource type schema assertions passed")
|
||||
${result}= Run Process ${PYTHON} -c ${script}
|
||||
Should Be Equal As Integers ${result.rc} 0 Resource type schema load failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} All resource type schema assertions passed
|
||||
|
||||
Resource Type Domain Model From Config
|
||||
[Documentation] Load a resource type config dict and verify domain model
|
||||
${script}= Catenate SEPARATOR=\n
|
||||
... import yaml
|
||||
... from cleveragents.domain.models.core.resource_type import ResourceTypeSpec
|
||||
... f = open("${FIXTURE_YAML}")
|
||||
... config = yaml.safe_load(f)
|
||||
... f.close()
|
||||
... spec = ResourceTypeSpec.from_config(config)
|
||||
... assert spec.name == "myorg/robot-test", f"name: {spec.name}"
|
||||
... assert spec.resource_kind.value == "physical"
|
||||
... assert spec.sandbox_strategy.value == "git_worktree"
|
||||
... cli_dict = spec.as_cli_dict()
|
||||
... assert "name" in cli_dict
|
||||
... assert "capabilities" in cli_dict
|
||||
... print("All resource type domain model assertions passed")
|
||||
${result}= Run Process ${PYTHON} -c ${script}
|
||||
Should Be Equal As Integers ${result.rc} 0 Domain model load failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} All resource type domain model assertions passed
|
||||
|
||||
*** Keywords ***
|
||||
@@ -69,6 +69,16 @@ from cleveragents.domain.models.core.resource import (
|
||||
SandboxStrategy,
|
||||
)
|
||||
|
||||
# Resource type domain model
|
||||
from cleveragents.domain.models.core.resource_type import (
|
||||
ResourceKind,
|
||||
ResourceTypeArgument,
|
||||
ResourceTypeSpec,
|
||||
)
|
||||
from cleveragents.domain.models.core.resource_type import (
|
||||
SandboxStrategy as ResourceTypeSandboxStrategy,
|
||||
)
|
||||
|
||||
# Session domain model
|
||||
from cleveragents.domain.models.core.session import (
|
||||
MessageRole,
|
||||
@@ -159,7 +169,11 @@ __all__ = [
|
||||
"Resource",
|
||||
"ResourceAccessMode",
|
||||
"ResourceCapabilities",
|
||||
"ResourceKind",
|
||||
"ResourceSlot",
|
||||
"ResourceTypeArgument",
|
||||
"ResourceTypeSandboxStrategy",
|
||||
"ResourceTypeSpec",
|
||||
"SandboxStrategy",
|
||||
"Session",
|
||||
"SessionExportError",
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
"""ResourceType domain model for CleverAgents.
|
||||
|
||||
Defines the schema-level definitions that constrain categories of resources
|
||||
(e.g., git-checkout, fs-directory, fs-mount). Resource types specify:
|
||||
|
||||
- Accepted CLI arguments
|
||||
- Physical/virtual classification
|
||||
- Permitted parent/child type relationships
|
||||
- Auto-discovery rules
|
||||
- Sandbox strategy
|
||||
- Handler implementation
|
||||
|
||||
Built-in types (git-checkout, fs-mount) are unnamespaced; custom types are
|
||||
YAML-defined and must follow the ``namespace/name`` pattern.
|
||||
|
||||
Based on:
|
||||
- docs/specification.md — Resource Types, Resource Registry
|
||||
- implementation_plan.md — Task B0.type-model
|
||||
- ADR-004: Pydantic v2 Validation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from enum import StrEnum
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: CLI-safe argument name: lowercase alphanumeric, hyphens, underscores.
|
||||
_CLI_ARG_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
|
||||
|
||||
#: Namespaced name: ``namespace/name`` with alphanumeric, hyphens, underscores.
|
||||
_NAMESPACED_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*/[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
||||
|
||||
#: Built-in (unnamespaced) name: simple alphanumeric + hyphens/underscores.
|
||||
_BUILTIN_NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]*$")
|
||||
|
||||
#: Valid argument types for resource type CLI arguments.
|
||||
_VALID_ARG_TYPES = frozenset({"string", "integer", "float", "boolean", "path"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ResourceKind(StrEnum):
|
||||
"""Classification of a resource type as physical or virtual.
|
||||
|
||||
Physical resources represent tangible assets (files, directories, repos).
|
||||
Virtual resources represent derived/computed assets (branches, tree entries).
|
||||
"""
|
||||
|
||||
PHYSICAL = "physical"
|
||||
VIRTUAL = "virtual"
|
||||
|
||||
|
||||
class SandboxStrategy(StrEnum):
|
||||
"""Sandbox isolation strategy for a resource type.
|
||||
|
||||
Determines how changes to resources of this type are isolated
|
||||
during plan execution.
|
||||
"""
|
||||
|
||||
GIT_WORKTREE = "git_worktree"
|
||||
COPY_ON_WRITE = "copy_on_write"
|
||||
TRANSACTION_ROLLBACK = "transaction_rollback"
|
||||
SNAPSHOT = "snapshot"
|
||||
NONE = "none"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResourceTypeArgument
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ResourceTypeArgument(BaseModel):
|
||||
"""Definition of a CLI argument accepted by a resource type.
|
||||
|
||||
Arguments map to CLI flags ``--<name>`` when adding resources of this type.
|
||||
For example, a ``git-checkout`` type might accept ``--path`` and ``--branch``.
|
||||
"""
|
||||
|
||||
name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=64,
|
||||
description="CLI argument name (maps to --<name>).",
|
||||
)
|
||||
type: str = Field(
|
||||
"string",
|
||||
description="Data type: string | integer | float | boolean | path.",
|
||||
)
|
||||
required: bool = Field(
|
||||
True,
|
||||
description="Whether the argument must be provided.",
|
||||
)
|
||||
description: str = Field(
|
||||
"",
|
||||
max_length=500,
|
||||
description="Human-readable description shown in help text.",
|
||||
)
|
||||
default: Any | None = Field(
|
||||
default=None,
|
||||
description="Default value when argument is not provided.",
|
||||
)
|
||||
validation_pattern: str | None = Field(
|
||||
default=None,
|
||||
description="Regex pattern for string/path argument validation.",
|
||||
)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_cli_name(cls, v: str) -> str:
|
||||
"""Validate argument name is CLI-safe (maps to ``--<name>``)."""
|
||||
if not _CLI_ARG_NAME_RE.match(v):
|
||||
raise ValueError(
|
||||
f"Argument name '{v}' is not CLI-safe. "
|
||||
"Names must start with a lowercase letter and contain only "
|
||||
"lowercase alphanumeric characters, hyphens, or underscores."
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("type")
|
||||
@classmethod
|
||||
def validate_type(cls, v: str) -> str:
|
||||
"""Validate argument type is one of the allowed types."""
|
||||
v_lower = v.lower()
|
||||
if v_lower not in _VALID_ARG_TYPES:
|
||||
valid = ", ".join(sorted(_VALID_ARG_TYPES))
|
||||
raise ValueError(f"Invalid argument type '{v}'. Allowed types: {valid}.")
|
||||
return v_lower
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
extra="forbid",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ResourceTypeSpec
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ResourceTypeSpec(BaseModel):
|
||||
"""Schema-level definition constraining a category of resources.
|
||||
|
||||
Resource types define the structure and behavior of resources that
|
||||
conform to them. For example, a ``git-checkout`` type defines that
|
||||
resources need ``--path`` and ``--branch`` CLI arguments and use
|
||||
the ``git_worktree`` sandbox strategy.
|
||||
|
||||
Built-in types can use simple names (e.g., ``git-checkout``).
|
||||
Custom types must follow the ``namespace/name`` pattern (e.g.,
|
||||
``myorg/custom-db``).
|
||||
"""
|
||||
|
||||
# Built-in resource type names (unnamespaced)
|
||||
BUILTIN_NAMES: ClassVar[frozenset[str]] = frozenset(
|
||||
{
|
||||
"git-checkout",
|
||||
"fs-directory",
|
||||
"fs-mount",
|
||||
"fs-file",
|
||||
}
|
||||
)
|
||||
|
||||
name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Resource type name (built-in or namespaced).",
|
||||
)
|
||||
description: str = Field(
|
||||
"",
|
||||
description="Human-readable description of this resource type.",
|
||||
)
|
||||
resource_kind: ResourceKind = Field(
|
||||
...,
|
||||
description="Physical or virtual classification.",
|
||||
)
|
||||
sandbox_strategy: SandboxStrategy = Field(
|
||||
...,
|
||||
description="Default sandbox strategy for resources of this type.",
|
||||
)
|
||||
user_addable: bool = Field(
|
||||
True,
|
||||
description="Whether users can manually add resources of this type.",
|
||||
)
|
||||
cli_args: list[ResourceTypeArgument] = Field(
|
||||
default_factory=list,
|
||||
description="CLI arguments accepted when adding a resource of this type.",
|
||||
)
|
||||
parent_types: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Allowed parent resource type names in the DAG.",
|
||||
)
|
||||
child_types: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Allowed child resource type names in the DAG.",
|
||||
)
|
||||
auto_discovery: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Auto-discovery rules (handler-specific configuration).",
|
||||
)
|
||||
equivalence: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Equivalence rules for virtual resource deduplication.",
|
||||
)
|
||||
handler: str | None = Field(
|
||||
default=None,
|
||||
description="Handler implementation reference (module:class or plugin name).",
|
||||
)
|
||||
capabilities: dict[str, bool] = Field(
|
||||
default_factory=lambda: {
|
||||
"read": True,
|
||||
"write": True,
|
||||
"sandbox": True,
|
||||
"checkpoint": False,
|
||||
},
|
||||
description="Capability flags: read, write, sandbox, checkpoint.",
|
||||
)
|
||||
built_in: bool = Field(
|
||||
False,
|
||||
description=("Whether this is a built-in type (allows unnamespaced names)."),
|
||||
)
|
||||
|
||||
# -- Name validation -----------------------------------------------------
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
"""Validate resource type name format.
|
||||
|
||||
Built-in types can be unnamespaced (e.g., ``git-checkout``).
|
||||
This validator only checks basic format; the cross-field check
|
||||
in ``_validate_model`` enforces namespace rules for custom types.
|
||||
"""
|
||||
if not _BUILTIN_NAME_RE.match(v) and not _NAMESPACED_RE.match(v):
|
||||
raise ValueError(
|
||||
f"Invalid resource 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
|
||||
|
||||
# -- Capabilities validation ---------------------------------------------
|
||||
|
||||
@field_validator("capabilities")
|
||||
@classmethod
|
||||
def validate_capabilities(cls, v: dict[str, bool]) -> dict[str, bool]:
|
||||
"""Ensure capabilities dict has the expected boolean keys."""
|
||||
expected = {"read", "write", "sandbox", "checkpoint"}
|
||||
for key in expected:
|
||||
if key not in v:
|
||||
v[key] = key != "checkpoint"
|
||||
for key in v:
|
||||
if key not in expected:
|
||||
raise ValueError(
|
||||
f"Unknown capability '{key}'. "
|
||||
f"Allowed capabilities: {', '.join(sorted(expected))}."
|
||||
)
|
||||
if not isinstance(v[key], bool):
|
||||
raise ValueError(
|
||||
f"Capability '{key}' must be a boolean, "
|
||||
f"got {type(v[key]).__name__}."
|
||||
)
|
||||
return v
|
||||
|
||||
# -- Cross-field validation -----------------------------------------------
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_model(self) -> ResourceTypeSpec:
|
||||
"""Cross-field validation for resource type constraints."""
|
||||
# Custom types must be namespaced
|
||||
if not self.built_in and "/" not in self.name:
|
||||
raise ValueError(
|
||||
f"Custom resource type '{self.name}' must follow "
|
||||
"namespace/name format (e.g., 'myorg/custom-db'). "
|
||||
"Only built-in types can be unnamespaced."
|
||||
)
|
||||
|
||||
# Virtual types require equivalence rules
|
||||
if self.resource_kind == ResourceKind.VIRTUAL and self.equivalence is None:
|
||||
raise ValueError(
|
||||
f"Virtual resource type '{self.name}' requires an "
|
||||
"'equivalence' configuration for deduplication."
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
# -- Factory methods ------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: dict[str, Any]) -> ResourceTypeSpec:
|
||||
"""Create a ResourceTypeSpec from a YAML configuration dict.
|
||||
|
||||
Args:
|
||||
config: Parsed YAML configuration with resource type fields.
|
||||
|
||||
Returns:
|
||||
Validated ``ResourceTypeSpec`` instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If required fields are missing or invalid.
|
||||
"""
|
||||
if "name" not in config:
|
||||
raise ValueError("Resource type config must include 'name'.")
|
||||
if "resource_kind" not in config:
|
||||
raise ValueError("Resource type config must include 'resource_kind'.")
|
||||
if "sandbox_strategy" not in config:
|
||||
raise ValueError("Resource type config must include 'sandbox_strategy'.")
|
||||
|
||||
# Parse CLI arguments from list of mappings
|
||||
cli_args: list[ResourceTypeArgument] = []
|
||||
for arg_data in config.get("cli_args", []):
|
||||
if isinstance(arg_data, dict):
|
||||
cli_args.append(ResourceTypeArgument.model_validate(arg_data))
|
||||
else:
|
||||
raise ValueError(
|
||||
f"cli_args entry must be a mapping, got {type(arg_data).__name__}."
|
||||
)
|
||||
|
||||
return cls(
|
||||
name=config["name"],
|
||||
description=config.get("description", ""),
|
||||
resource_kind=ResourceKind(config["resource_kind"]),
|
||||
sandbox_strategy=SandboxStrategy(config["sandbox_strategy"]),
|
||||
user_addable=config.get("user_addable", True),
|
||||
cli_args=cli_args,
|
||||
parent_types=config.get("parent_types", []),
|
||||
child_types=config.get("child_types", []),
|
||||
auto_discovery=config.get("auto_discovery"),
|
||||
equivalence=config.get("equivalence"),
|
||||
handler=config.get("handler"),
|
||||
capabilities=config.get(
|
||||
"capabilities",
|
||||
{
|
||||
"read": True,
|
||||
"write": True,
|
||||
"sandbox": True,
|
||||
"checkpoint": False,
|
||||
},
|
||||
),
|
||||
built_in=config.get("built_in", False),
|
||||
)
|
||||
|
||||
# -- CLI rendering --------------------------------------------------------
|
||||
|
||||
def as_cli_dict(self) -> dict[str, Any]:
|
||||
"""Return a stable dict for CLI output rendering.
|
||||
|
||||
Keys are ordered for deterministic display.
|
||||
"""
|
||||
result: dict[str, Any] = {
|
||||
"name": self.name,
|
||||
"resource_kind": self.resource_kind.value,
|
||||
"sandbox_strategy": self.sandbox_strategy.value,
|
||||
}
|
||||
|
||||
if self.description:
|
||||
result["description"] = self.description
|
||||
|
||||
result["user_addable"] = self.user_addable
|
||||
result["built_in"] = self.built_in
|
||||
|
||||
if self.cli_args:
|
||||
result["cli_args"] = [
|
||||
{
|
||||
"name": arg.name,
|
||||
"type": arg.type,
|
||||
"required": arg.required,
|
||||
"description": arg.description,
|
||||
}
|
||||
for arg in self.cli_args
|
||||
]
|
||||
|
||||
if self.parent_types:
|
||||
result["parent_types"] = self.parent_types
|
||||
if self.child_types:
|
||||
result["child_types"] = self.child_types
|
||||
|
||||
result["capabilities"] = self.capabilities
|
||||
|
||||
if self.auto_discovery is not None:
|
||||
result["auto_discovery"] = self.auto_discovery
|
||||
if self.equivalence is not None:
|
||||
result["equivalence"] = self.equivalence
|
||||
if self.handler is not None:
|
||||
result["handler"] = self.handler
|
||||
|
||||
return result
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
use_enum_values=False,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Resource type schema loading and validation."""
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Resource type YAML configuration schema, validation, and loading.
|
||||
|
||||
Provides :class:`ResourceTypeConfigSchema`, a Pydantic model that:
|
||||
|
||||
* Loads raw YAML (string or file) and validates it against the resource type schema.
|
||||
* Interpolates ``${ENV_VAR}`` placeholders from environment variables.
|
||||
* Enforces schema version guardrails.
|
||||
* Produces clear, actionable error messages for every validation failure.
|
||||
|
||||
Schema definition lives in ``docs/schema/resource_type.schema.yaml``.
|
||||
|
||||
Based on:
|
||||
- docs/specification.md — Resource Types, Resource Registry
|
||||
- implementation_plan.md — Task B0.type-model
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Constants
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
#: Pattern for ``${VAR}`` environment variable references.
|
||||
_ENV_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
|
||||
#: Supported schema versions.
|
||||
_SUPPORTED_VERSIONS = frozenset({"1"})
|
||||
|
||||
#: Valid resource kinds.
|
||||
_VALID_KINDS = frozenset({"physical", "virtual"})
|
||||
|
||||
#: Valid sandbox strategies.
|
||||
_VALID_STRATEGIES = frozenset(
|
||||
{
|
||||
"git_worktree",
|
||||
"copy_on_write",
|
||||
"transaction_rollback",
|
||||
"snapshot",
|
||||
"none",
|
||||
}
|
||||
)
|
||||
|
||||
#: Valid CLI argument types.
|
||||
_VALID_ARG_TYPES = frozenset({"string", "integer", "float", "boolean", "path"})
|
||||
|
||||
#: CLI-safe argument name.
|
||||
_CLI_ARG_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
|
||||
|
||||
#: Namespaced name pattern.
|
||||
_NAMESPACED_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*/[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
||||
|
||||
#: Built-in (unnamespaced) name pattern.
|
||||
_BUILTIN_NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]*$")
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Nested models
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ResourceTypeArgSchema(BaseModel):
|
||||
"""Schema for a single CLI argument in a resource type config."""
|
||||
|
||||
name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=64,
|
||||
description="CLI argument name (maps to --<name>).",
|
||||
)
|
||||
type: str = Field(
|
||||
"string",
|
||||
description="Data type: string | integer | float | boolean | path.",
|
||||
)
|
||||
required: bool = Field(
|
||||
True,
|
||||
description="Whether the argument must be provided.",
|
||||
)
|
||||
description: str = Field(
|
||||
"",
|
||||
max_length=500,
|
||||
description="Human-readable description.",
|
||||
)
|
||||
default: Any | None = Field(
|
||||
default=None,
|
||||
description="Default value when argument is not provided.",
|
||||
)
|
||||
validation_pattern: str | None = Field(
|
||||
default=None,
|
||||
description="Regex pattern for string/path argument validation.",
|
||||
)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
"""Ensure argument name is CLI-safe."""
|
||||
if not _CLI_ARG_NAME_RE.match(v):
|
||||
raise ValueError(
|
||||
f"Argument name '{v}' is not CLI-safe. "
|
||||
"Use lowercase alphanumeric, hyphens, or underscores, "
|
||||
"starting with a letter."
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("type")
|
||||
@classmethod
|
||||
def validate_type(cls, v: str) -> str:
|
||||
"""Ensure argument type is valid."""
|
||||
v_lower = v.lower()
|
||||
if v_lower not in _VALID_ARG_TYPES:
|
||||
valid = ", ".join(sorted(_VALID_ARG_TYPES))
|
||||
raise ValueError(f"Invalid argument type '{v}'. Allowed types: {valid}.")
|
||||
return v_lower
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
extra="forbid",
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Main schema model
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ResourceTypeConfigSchema(BaseModel):
|
||||
"""Pydantic model for a resource type YAML configuration file.
|
||||
|
||||
Validates all fields described in ``docs/schema/resource_type.schema.yaml``.
|
||||
|
||||
Create instances via the factory class methods:
|
||||
|
||||
- :meth:`from_yaml` — parse a YAML string
|
||||
- :meth:`from_yaml_file` — parse a YAML file from disk
|
||||
"""
|
||||
|
||||
schema_version: str = Field(
|
||||
"1",
|
||||
description="Schema version for forward compatibility.",
|
||||
)
|
||||
name: str = Field(
|
||||
...,
|
||||
description="Resource type name (built-in or namespaced).",
|
||||
)
|
||||
description: str = Field(
|
||||
"",
|
||||
description="Short description of the resource type.",
|
||||
)
|
||||
resource_kind: str = Field(
|
||||
...,
|
||||
description="Classification: physical | virtual.",
|
||||
)
|
||||
sandbox_strategy: str = Field(
|
||||
...,
|
||||
description="Default sandbox strategy.",
|
||||
)
|
||||
user_addable: bool = Field(
|
||||
True,
|
||||
description="Whether users can manually add resources of this type.",
|
||||
)
|
||||
cli_args: list[ResourceTypeArgSchema] = Field(
|
||||
default_factory=list,
|
||||
description="CLI arguments accepted for this resource type.",
|
||||
)
|
||||
parent_types: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Allowed parent resource type names.",
|
||||
)
|
||||
child_types: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Allowed child resource type names.",
|
||||
)
|
||||
auto_discovery: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Auto-discovery configuration.",
|
||||
)
|
||||
equivalence: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Equivalence rules for virtual types.",
|
||||
)
|
||||
handler: str | None = Field(
|
||||
default=None,
|
||||
description="Handler implementation reference.",
|
||||
)
|
||||
capabilities: dict[str, bool] = Field(
|
||||
default_factory=lambda: {
|
||||
"read": True,
|
||||
"write": True,
|
||||
"sandbox": True,
|
||||
"checkpoint": False,
|
||||
},
|
||||
description="Capability flags.",
|
||||
)
|
||||
built_in: bool = Field(
|
||||
False,
|
||||
description="Whether this is a built-in type.",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
extra="forbid",
|
||||
)
|
||||
|
||||
# ────────────────────────────────────────────────────────
|
||||
# Field validators
|
||||
# ────────────────────────────────────────────────────────
|
||||
|
||||
@field_validator("schema_version")
|
||||
@classmethod
|
||||
def validate_schema_version(cls, v: str) -> str:
|
||||
"""Validate schema version is supported."""
|
||||
if v not in _SUPPORTED_VERSIONS:
|
||||
supported = ", ".join(sorted(_SUPPORTED_VERSIONS))
|
||||
raise ValueError(
|
||||
f"Unsupported schema version '{v}'. Supported versions: {supported}."
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
"""Validate resource type name format."""
|
||||
if not _BUILTIN_NAME_RE.match(v) and not _NAMESPACED_RE.match(v):
|
||||
raise ValueError(
|
||||
f"Invalid resource 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:
|
||||
"""Validate resource kind is physical or virtual."""
|
||||
v_lower = v.lower()
|
||||
if v_lower not in _VALID_KINDS:
|
||||
raise ValueError(
|
||||
f"Invalid resource_kind '{v}'. Must be 'physical' or 'virtual'."
|
||||
)
|
||||
return v_lower
|
||||
|
||||
@field_validator("sandbox_strategy")
|
||||
@classmethod
|
||||
def validate_sandbox_strategy(cls, v: str) -> str:
|
||||
"""Validate sandbox strategy is one of the allowed values."""
|
||||
v_lower = v.lower()
|
||||
if v_lower not in _VALID_STRATEGIES:
|
||||
valid = ", ".join(sorted(_VALID_STRATEGIES))
|
||||
raise ValueError(
|
||||
f"Invalid sandbox_strategy '{v}'. Allowed strategies: {valid}."
|
||||
)
|
||||
return v_lower
|
||||
|
||||
# ────────────────────────────────────────────────────────
|
||||
# Model-level validators
|
||||
# ────────────────────────────────────────────────────────
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_cross_fields(self) -> ResourceTypeConfigSchema:
|
||||
"""Cross-field validation for resource type config."""
|
||||
# Custom types must be namespaced
|
||||
if not self.built_in and "/" not in self.name:
|
||||
raise ValueError(
|
||||
f"Custom resource type '{self.name}' must follow "
|
||||
"namespace/name format. Only built-in types can be "
|
||||
"unnamespaced."
|
||||
)
|
||||
|
||||
# Virtual types require equivalence
|
||||
if self.resource_kind == "virtual" and self.equivalence is None:
|
||||
raise ValueError(
|
||||
f"Virtual resource type '{self.name}' requires an "
|
||||
"'equivalence' configuration."
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
# ────────────────────────────────────────────────────────
|
||||
# Factory class methods
|
||||
# ────────────────────────────────────────────────────────
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, yaml_string: str) -> ResourceTypeConfigSchema:
|
||||
"""Parse and validate a resource type YAML string.
|
||||
|
||||
Args:
|
||||
yaml_string: Raw YAML content.
|
||||
|
||||
Returns:
|
||||
Validated ``ResourceTypeConfigSchema`` instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If the YAML is not a mapping or is empty.
|
||||
pydantic.ValidationError: If schema validation fails.
|
||||
"""
|
||||
if yaml_string is None:
|
||||
raise ValueError("YAML string cannot be None. Provide a valid YAML string.")
|
||||
if not yaml_string.strip():
|
||||
raise ValueError(
|
||||
"YAML string is empty. "
|
||||
"Provide a valid resource type YAML configuration."
|
||||
)
|
||||
|
||||
raw = yaml.safe_load(yaml_string)
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(
|
||||
f"Resource type YAML must be a mapping (key: value), "
|
||||
f"got {type(raw).__name__}."
|
||||
)
|
||||
|
||||
interpolated = _interpolate_env_vars(raw)
|
||||
return cls.model_validate(interpolated)
|
||||
|
||||
@classmethod
|
||||
def from_yaml_file(cls, path: str | Path) -> ResourceTypeConfigSchema:
|
||||
"""Load and validate a resource type YAML file from disk.
|
||||
|
||||
Args:
|
||||
path: Path to the YAML file.
|
||||
|
||||
Returns:
|
||||
Validated ``ResourceTypeConfigSchema`` instance.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the file does not exist.
|
||||
ValueError: If file content is invalid.
|
||||
pydantic.ValidationError: If schema validation fails.
|
||||
"""
|
||||
if path is None:
|
||||
raise ValueError(
|
||||
"File path cannot be None. "
|
||||
"Provide a valid path to a resource type YAML file."
|
||||
)
|
||||
|
||||
filepath = Path(path)
|
||||
if not filepath.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Resource type YAML file not found: {filepath}. "
|
||||
"Check the file path and try again."
|
||||
)
|
||||
if not filepath.is_file():
|
||||
raise ValueError(
|
||||
f"Path is not a file: {filepath}. "
|
||||
"Provide a path to a YAML file, not a directory."
|
||||
)
|
||||
|
||||
content = filepath.read_text(encoding="utf-8")
|
||||
return cls.from_yaml(content)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────
|
||||
# Internal helpers
|
||||
# ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _interpolate_env_vars(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Replace ``${VAR}`` references with environment variable values.
|
||||
|
||||
Only string values are interpolated. Missing environment variables
|
||||
are left as-is (no error) to allow deferred resolution.
|
||||
"""
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if isinstance(value, str):
|
||||
result[key] = _ENV_VAR_RE.sub(_env_replacer, value)
|
||||
elif isinstance(value, dict):
|
||||
result[key] = _interpolate_env_vars(value)
|
||||
elif isinstance(value, list):
|
||||
result[key] = [
|
||||
(
|
||||
_interpolate_env_vars(item)
|
||||
if isinstance(item, dict)
|
||||
else (
|
||||
_ENV_VAR_RE.sub(_env_replacer, item)
|
||||
if isinstance(item, str)
|
||||
else item
|
||||
)
|
||||
)
|
||||
for item in value
|
||||
]
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _env_replacer(match: re.Match[str]) -> str:
|
||||
"""Replace a single ``${VAR}`` match with its env value."""
|
||||
var_name = match.group(1)
|
||||
return os.environ.get(var_name, match.group(0))
|
||||
@@ -23,6 +23,16 @@ _format_plain # noqa: B018, F821
|
||||
_format_plain_dict # noqa: B018, F821
|
||||
_format_table # noqa: B018, F821
|
||||
|
||||
# Resource type domain model — public API used by schema loader and CLI
|
||||
ResourceKind # noqa: B018, F821
|
||||
ResourceTypeArgument # noqa: B018, F821
|
||||
ResourceTypeSpec # noqa: B018, F821
|
||||
ResourceTypeSandboxStrategy # noqa: B018, F821
|
||||
|
||||
# Resource type schema loader — public API
|
||||
ResourceTypeConfigSchema # noqa: B018, F821
|
||||
ResourceTypeArgSchema # noqa: B018, F821
|
||||
|
||||
# CLI action/plan spec dict helpers used by --format flag
|
||||
_action_spec_dict # noqa: B018, F821
|
||||
_plan_spec_dict # noqa: B018, F821
|
||||
|
||||
Reference in New Issue
Block a user