Decorator and YAML Bindings¶
Overview¶
Two complementary approaches for module creation: the @module decorator for zero-boilerplate function wrapping, and YAML bindings via BindingLoader for declarative, code-free module registration. Both approaches produce FunctionModule instances that participate fully in the executor pipeline (ACL, middleware, validation, async support). The decorator system includes automatic Pydantic model generation from function signatures, while the binding system supports four distinct schema resolution modes.
Requirements¶
Decorator System¶
- Provide a
@moduledecorator that works in three forms: bare (@module), with arguments (@module(id="x")), and as a function call (module(func, id="x")). - Automatically generate Pydantic input and output models from function type annotations via
_generate_input_model()and_generate_output_model(). - Detect and skip
Context-typed parameters in input model generation (type-based detection, not name-based). - Skip
self,cls, and*argsparameters. When**kwargsis present, create the input model withextra="allow". - Handle multiple return types:
dictproduces a permissive model,BaseModelsubclass is returned directly,Noneproduces an empty permissive model, and other types produce a model with a singleresultfield. - Support async functions:
FunctionModule.executemust be a coroutine function when the wrapped function is async. - Provide three description sources with priority: explicit
descriptionparameter, first line of docstring, fallback to"Module {func_name}". - Auto-generate module IDs from
__module__and__qualname__, with sanitization (lowercased, non-alphanumeric replaced, digit-leading segments prefixed with underscore). - Support optional registry integration for immediate registration on decoration.
Binding System¶
- Load YAML binding files containing a
bindingslist of module declarations. - Resolve
module.path:callabletarget strings to actual Python callables via dynamic import. - Support class method binding:
module:ClassName.methodauto-instantiates the class (must have a no-arg constructor) and resolves the bound method. - Support four schema modes:
auto_schema: true-- Infer schemas from function type annotations.- Inline
input_schema/output_schema-- Build Pydantic models from JSON Schema properties. schema_ref-- Load schema from an external YAML file (relative path resolution from binding file directory).- Default (no schema key) -- Falls back to auto-schema inference.
- Handle unsupported JSON Schema features (
oneOf,anyOf,allOf,$ref,format) gracefully by creating permissive models withextra="allow". - Support directory scanning via
load_binding_dir()with configurable glob pattern (default*.binding.yaml). - Fail fast on first error during binding loading.
Technical Design¶
Decorator Architecture¶
The module() function uses a dual-purpose design pattern:
# Bare decorator -- func_or_none receives the function
@module
def greet(name: str) -> str: ...
# With arguments -- func_or_none is None, returns a decorator
@module(id="custom.id", tags=["email"])
def greet(name: str) -> str: ...
# Function call form -- func_or_none receives the function, id is set
fm = module(greet, id="custom.id")
The internal _wrap() function:
1. Generates module_id (explicit or auto-generated via _make_auto_id()).
2. Creates a FunctionModule with inferred or provided schemas.
3. Optionally registers with the provided registry.
4. Either attaches FunctionModule as func.apcore_module (decorator forms) or returns it directly (function call form).
FunctionModule¶
FunctionModule wraps a Python function for the apcore executor pipeline:
- Schemas:
input_schemaandoutput_schemaare PydanticBaseModelsubclasses, either auto-generated or explicitly provided. - Execute closures: Two separate closures are created at construction time -- one for sync functions and one for async -- so that
inspect.iscoroutinefunction(fm.execute)returns the correct value. Both closures unpack inputs as keyword arguments and inject theContextif a Context-typed parameter was detected. - Result normalization:
_normalize_result()converts return values:None->{},dict-> passthrough,BaseModel->model_dump(), other ->{"result": value}.
Type Inference Engine¶
_generate_input_model(func):
- Uses typing.get_type_hints() with include_extras=True to resolve annotations (handles from __future__ import annotations).
- Iterates parameters, skipping self/cls, *args, **kwargs, and Context-typed parameters.
- Raises FuncMissingTypeHintError for untyped parameters.
- Creates a dynamic Pydantic model via create_model(), with extra="allow" if **kwargs was present.
_generate_output_model(func):
- Examines the return type annotation.
- Maps: dict/dict[str, T] -> permissive model, BaseModel subclass -> returned directly, None -> empty permissive model, other types -> model with result field.
- Raises FuncMissingReturnTypeError if no return annotation exists.
Binding Architecture¶
YAML File
|
+--> BindingLoader.load_bindings()
|
+--> Parse YAML, validate structure
|
+--> For each binding entry:
| |
| +--> resolve_target("module.path:callable")
| | Import module, getattr callable
| | For "Class.method": instantiate class, get bound method
| |
| +--> Determine schema mode:
| | auto_schema -> _generate_input/output_model()
| | inline schema -> _build_model_from_json_schema()
| | schema_ref -> load external YAML, then build
| | default -> try auto_schema
| |
| +--> Create FunctionModule
| +--> Register with Registry
|
+--> Return list[FunctionModule]
JSON Schema to Pydantic Conversion¶
_build_model_from_json_schema() maps JSON Schema types to Python types:
- string -> str, integer -> int, number -> float, boolean -> bool, array -> list, object -> dict.
- Fields listed in required array are marked as required (default ...), others default to None.
- Unsupported top-level features (oneOf, anyOf, allOf, $ref, format) result in a permissive model with extra="allow".
Error Hierarchy¶
All binding-related errors inherit from ModuleError:
- FuncMissingTypeHintError -- Parameter lacks type annotation (code: FUNC_MISSING_TYPE_HINT).
- FuncMissingReturnTypeError -- Function lacks return type annotation (code: FUNC_MISSING_RETURN_TYPE).
- BindingInvalidTargetError -- Target string missing : separator (code: BINDING_INVALID_TARGET).
- BindingModuleNotFoundError -- Python module cannot be imported (code: BINDING_MODULE_NOT_FOUND).
- BindingCallableNotFoundError -- Callable not found in module (code: BINDING_CALLABLE_NOT_FOUND).
- BindingNotCallableError -- Resolved attribute is not callable (code: BINDING_NOT_CALLABLE).
- BindingSchemaMissingError -- Auto-schema failed on untyped callable (code: BINDING_SCHEMA_MISSING).
- BindingFileInvalidError -- YAML file issues (missing, empty, parse error, structural) (code: BINDING_FILE_INVALID).
Key Files¶
| File | Lines | Purpose |
|---|---|---|
src/apcore/decorator.py |
264 | @module decorator, FunctionModule, type inference helpers, auto-ID generation |
src/apcore/bindings.py |
220 | BindingLoader with YAML parsing, target resolution, schema mode handling |
Dependencies¶
Internal¶
apcore.context.Context-- Injected into wrapped functions when a Context-typed parameter is detected.apcore.registry.Registry-- Module registration for both decorator and binding paths.apcore.errors-- 8 error classes for decorator and binding failure modes.
External¶
pydantic--BaseModel,ConfigDict,create_modelfor dynamic model generation.inspect(stdlib) -- Function signature introspection, parameter kind detection, coroutine function detection.typing(stdlib) --get_type_hints()for annotation resolution with forward reference support.re(stdlib) -- Regex for auto-ID sanitization.importlib(stdlib) -- Dynamic module import for target resolution in bindings.pathlib(stdlib) -- Path operations for binding file and schema_ref resolution.yaml(PyYAML) -- YAML parsing for binding files and schema references.
Testing Strategy¶
Decorator Tests (tests/test_decorator.py)¶
- Error classes: All 8 error classes instantiate correctly, have correct codes, include expected details, and inherit from
ModuleError. Cross-cutting parametrized test verifies inheritance and code attributes for all error classes. - _generate_input_model(): Simple primitives, default values,
Optional[str], union types (str | int),list[str],dict[str, int],Literalwith validation,AnnotatedwithFieldconstraints, nestedBaseModelparameters, Context parameter skipping (type-based, not name-based),selfskipping,*argsskipping,**kwargsproducingextra="allow", missing type hint error, forward referenceNameErrormapped toFuncMissingTypeHintError,*args+**kwargsonly, empty function, multiple defaults, andfrom __future__ import annotationscompatibility. - _generate_output_model(): Bare
dict, typeddict[str, Any],BaseModelsubclass returned directly,str/int/list[str]wrapped in result field,Nonereturn producing empty permissive model, missing return type error, and result field invariant. - _has_context_param(): Function with Context detected (True, param_name), function without Context (False, None), detection is type-based (works with any parameter name), and non-Context named "context" not detected.
- FunctionModule constructor: Input/output schemas are BaseModel subclasses, module_id stored correctly, description priority chain (explicit > docstring > fallback), multiline docstring uses first line only, optional attributes stored.
- Sync execute: Correct function call, dict passthrough, None ->
{}, BaseModel ->model_dump(), string/int ->{"result": value}, Context injection, no Context injection when absent, exception propagation,iscoroutinefunctionreturns False. - Async execute: Correct await, dict passthrough, None/non-dict/BaseModel handling, Context injection,
iscoroutinefunctionreturns True, exception propagation. - @module with args: Returns original function, attaches
.apcore_module, correct id, registry integration, function remains callable, tags/version stored. - Bare @module: Returns original function, attaches
.apcore_module, auto-generates id containing function name and module path. - module() function call form: Returns
FunctionModule, registry integration, correct schemas. - _make_auto_id(): Combines
__module__+__qualname__, replaces<locals>., lowercased, non-alphanumeric replaced, digit-leading segments prefixed. - Integration: Full pipeline through
Executor.call()for sync, async, Context injection, non-dict returns, BaseModel params, bare decorator, and function call form.
Binding Tests (tests/test_bindings.py)¶
- YAML parsing: Single and multiple binding entries, empty file error, missing
bindingskey, non-listbindings, missingmodule_id/target, YAML syntax errors. - Target resolution: Function resolution (
os.path:join), class method resolution with auto-instantiation, class requiring constructor args error, missing colon separator, nonexistent module, nonexistent callable, non-callable attribute. - Schema modes:
auto_schemausing type inference,auto_schemawith untyped callable error, inline schema model creation, inline schema with untyped callable, inline basic type mapping (string/integer/number/boolean), required array marking, unsupported features producing permissive model,schema_refloading external file,schema_reffile not found error. - Registration and integration:
load_bindingsregisters all modules, returns FunctionModule list, directory scanning, nonexistent directory error, empty directory returns empty list, fail-fast on first error. - Public API exports:
BindingLoaderimportable fromapcore. - End-to-end integration: BindingLoader -> Registry -> Executor.call() producing correct output with a dynamically created Python module and YAML binding file.