Skip to content

Commit a63f29b

Browse files
committed
feat: Add support for defining module input/output schemas using plain dictionaries and fix related executor/registry crashes.
1 parent d8c67d6 commit a63f29b

9 files changed

Lines changed: 172 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.13.1] - 2026-03-19
9+
10+
### Added
11+
- **Dict schema support** — Modules can now define `input_schema` / `output_schema` as plain JSON Schema dicts instead of Pydantic model classes. A `_DictSchemaAdapter` transparently wraps dict schemas at registration time so all internal code paths (executor, schema exporter, `get_definition`) work without changes.
12+
13+
### Fixed
14+
- **`get_definition()` crash on dict schemas** — Previously called `.model_json_schema()` on dict objects, causing `AttributeError`
15+
- **Executor crash on dict schemas**`call()`, `call_async()`, and `stream()` all called `.model_validate()` on dict objects
16+
17+
### Improved
18+
- **File header docstrings** — Enhanced docstrings for `errors.py`, `executor.py`, and `version.py`
19+
20+
---
21+
822
## [0.13.0] - 2026-03-12
923

1024
### Added

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ A schema-enforced module standard for the AI-Perceivable era.
1515

1616
## Features
1717

18-
- **Schema-driven modules** -- Define input/output contracts using Pydantic models with automatic validation
18+
- **Schema-driven modules** -- Define input/output contracts using Pydantic models (with automatic validation) or plain JSON Schema dicts
1919
- **Execution Pipeline** -- Context creation, safety checks, ACL enforcement, approval gate, validation, middleware chains, and execution with timeout support
2020
- **`@module` decorator** -- Turn plain functions into fully schema-aware modules with zero boilerplate
2121
- **YAML bindings** -- Register modules declaratively without modifying source code
@@ -184,6 +184,30 @@ result = client.call("greet", {"name": "Alice"})
184184
# {"message": "Hello, Alice!"}
185185
```
186186

187+
### Alternative: Define schemas with plain dicts
188+
189+
If you prefer not to use Pydantic, pass raw JSON Schema dicts directly:
190+
191+
```python
192+
from apcore import APCore
193+
194+
client = APCore()
195+
196+
class WeatherModule:
197+
input_schema = {"type": "object", "properties": {"city": {"type": "string"}}}
198+
output_schema = {"type": "object", "properties": {"temp": {"type": "number"}}}
199+
description = "Get current temperature"
200+
201+
def execute(self, inputs: dict, context=None) -> dict:
202+
return {"temp": 22.5}
203+
204+
client.register("weather", WeatherModule())
205+
result = client.call("weather", {"city": "Tokyo"})
206+
# {"temp": 22.5}
207+
```
208+
209+
> **Note:** Dict schemas skip Pydantic input validation. Use Pydantic models when you need automatic type coercion and validation, or validate inside `execute()`.
210+
187211
### Add middleware
188212

189213
```python

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "apcore"
7-
version = "0.13.0"
7+
version = "0.13.1"
88
description = "Schema-driven module standard for AI-perceivable interfaces"
99
readme = "README.md"
1010
requires-python = ">=3.11"
@@ -62,7 +62,7 @@ dev = [
6262
"ruff>=0.1.0",
6363
"mypy>=1.0",
6464
"aiohttp>=3.9",
65-
"apdev[dev]>=0.2.0",
65+
"apdev[dev]>=0.2.3",
6666
]
6767

6868
[tool.setuptools.packages.find]

src/apcore/errors.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
"""Error hierarchy for apcore."""
1+
"""Error hierarchy for apcore.
2+
3+
Defines ModuleError (the base for all apcore errors), standard ErrorCodes,
4+
and specialized subclasses (ACLDeniedError, SchemaValidationError, etc.).
5+
Each error carries optional AI guidance fields (retryable, ai_guidance,
6+
user_fixable, suggestion) to enable Self-Healing agents.
7+
"""
28

39
from __future__ import annotations
410

src/apcore/executor.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
"""Executor and related utilities for apcore."""
1+
"""Executor — the module execution engine for apcore.
2+
3+
Resolves a module by ID, validates inputs against its schema, enforces ACL
4+
and approval policies, runs the middleware chain, and returns the result.
5+
Supports sync, async, and streaming execution modes.
6+
"""
27

38
from __future__ import annotations
49

src/apcore/registry/registry.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,43 @@
3232

3333
logger = logging.getLogger(__name__)
3434

35+
36+
class _DictSchemaAdapter:
37+
"""Adapts a plain JSON Schema dict to the Pydantic model class interface.
38+
39+
Allows modules that define ``input_schema`` / ``output_schema`` as raw
40+
dicts to work transparently with the executor, schema exporter, and any
41+
other code that calls ``model_validate``, ``model_json_schema``, or
42+
``model_rebuild`` on a schema object.
43+
44+
Note: ``model_validate`` is a pass-through — no JSON Schema validation is
45+
performed. Adding real validation would require a ``jsonschema`` dependency
46+
which is not currently declared. Modules that need strict input checking
47+
should use Pydantic model classes or validate inside ``execute()``.
48+
"""
49+
50+
def __init__(self, schema: dict[str, Any]) -> None:
51+
self._schema = schema
52+
53+
def model_json_schema(self) -> dict[str, Any]:
54+
return self._schema
55+
56+
def model_validate(self, data: Any) -> Any:
57+
"""Pass-through: returns *data* unchanged (no validation)."""
58+
return data
59+
60+
def model_rebuild(self) -> None:
61+
pass
62+
63+
64+
def _ensure_schema_adapter(module: Any) -> None:
65+
"""Wrap raw dict schemas on *module* with ``_DictSchemaAdapter`` in-place."""
66+
for attr in ("input_schema", "output_schema"):
67+
value = getattr(module, attr, None)
68+
if isinstance(value, dict):
69+
setattr(module, attr, _DictSchemaAdapter(value))
70+
71+
3572
REGISTRY_EVENTS: dict[str, str] = {
3673
"REGISTER": "register",
3774
"UNREGISTER": "unregister",
@@ -400,6 +437,8 @@ def register(
400437
if len(module_id) > MAX_MODULE_ID_LENGTH:
401438
raise InvalidInputError(f"Module ID exceeds maximum length of {MAX_MODULE_ID_LENGTH}: {len(module_id)}")
402439

440+
_ensure_schema_adapter(module)
441+
403442
effective_version = version or getattr(module, "version", None) or "0.0.0"
404443

405444
is_versioned = version is not None
@@ -974,6 +1013,7 @@ def register_internal(self, module_id: str, module: Any) -> None:
9741013
9751014
Used by sys modules that use the reserved 'system.' prefix.
9761015
"""
1016+
_ensure_schema_adapter(module)
9771017
with self._lock:
9781018
self._modules[module_id] = module
9791019
self._lowercase_map[module_id.lower()] = module_id

src/apcore/version.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
"""Version negotiation (Algorithm A14)."""
1+
"""Version negotiation (Algorithm A14).
2+
3+
Compares caller-requested semver ranges against a module's declared version
4+
and selects the best compatible match, enabling safe multi-version coexistence.
5+
"""
26

37
from __future__ import annotations
48

tests/registry/test_registry.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,44 @@ def test_nonexistent_returns_none(self) -> None:
394394
reg = Registry()
395395
assert reg.get_definition("missing") is None
396396

397+
def test_dict_schema(self) -> None:
398+
"""get_definition() works when input_schema / output_schema are plain dicts."""
399+
400+
class DictSchemaModule:
401+
description = "Module with dict schemas"
402+
input_schema = {"type": "object", "properties": {"name": {"type": "string"}}}
403+
output_schema = {"type": "object", "properties": {"ok": {"type": "boolean"}}}
404+
version = "1.0.0"
405+
tags: list[str] = []
406+
407+
def execute(self, inputs: dict, context: object = None) -> dict:
408+
return {"ok": True}
409+
410+
reg = Registry()
411+
reg.register("dict.mod", DictSchemaModule())
412+
defn = reg.get_definition("dict.mod")
413+
assert defn is not None
414+
assert defn.input_schema == {"type": "object", "properties": {"name": {"type": "string"}}}
415+
assert defn.output_schema == {"type": "object", "properties": {"ok": {"type": "boolean"}}}
416+
417+
def test_no_schema(self) -> None:
418+
"""get_definition() returns empty dict when module has no schema."""
419+
420+
class NoSchemaModule:
421+
description = "Module without schemas"
422+
version = "1.0.0"
423+
tags: list[str] = []
424+
425+
def execute(self, inputs: dict, context: object = None) -> dict:
426+
return {}
427+
428+
reg = Registry()
429+
reg.register("no.typed", NoSchemaModule())
430+
defn = reg.get_definition("no.typed")
431+
assert defn is not None
432+
assert defn.input_schema == {}
433+
assert defn.output_schema == {}
434+
397435

398436
# ===== Event Callbacks =====
399437

tests/test_executor.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,41 @@ def test_none_inputs_treated_as_empty(self) -> None:
451451
assert result == {"result": "ok"}
452452
assert mod.execute_calls[0][0] == {}
453453

454+
def test_dict_schema_call(self) -> None:
455+
"""call() works when module schemas are plain dicts (not Pydantic models)."""
456+
457+
class DictSchemaModule:
458+
description = "Dict schema module"
459+
input_schema = {"type": "object", "properties": {"x": {"type": "integer"}}}
460+
output_schema = {"type": "object", "properties": {"y": {"type": "integer"}}}
461+
version = "1.0.0"
462+
tags: list[str] = []
463+
464+
def execute(self, inputs: dict[str, Any], context: Any) -> dict[str, Any]:
465+
return {"y": inputs.get("x", 0) + 1}
466+
467+
ex = _make_executor(module=DictSchemaModule())
468+
result = ex.call("test.module", {"x": 5})
469+
assert result == {"y": 6}
470+
471+
@pytest.mark.asyncio
472+
async def test_dict_schema_call_async(self) -> None:
473+
"""call_async() works when module schemas are plain dicts."""
474+
475+
class DictSchemaModule:
476+
description = "Dict schema module"
477+
input_schema = {"type": "object", "properties": {"x": {"type": "integer"}}}
478+
output_schema = {"type": "object", "properties": {"y": {"type": "integer"}}}
479+
version = "1.0.0"
480+
tags: list[str] = []
481+
482+
async def execute(self, inputs: dict[str, Any], context: Any) -> dict[str, Any]:
483+
return {"y": inputs.get("x", 0) + 1}
484+
485+
ex = _make_executor(module=DictSchemaModule())
486+
result = await ex.call_async("test.module", {"x": 5})
487+
assert result == {"y": 6}
488+
454489

455490
# === validate() Tests ===
456491

0 commit comments

Comments
 (0)