Skip to content

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 @module decorator 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 *args parameters. When **kwargs is present, create the input model with extra="allow".
  • Handle multiple return types: dict produces a permissive model, BaseModel subclass is returned directly, None produces an empty permissive model, and other types produce a model with a single result field.
  • Support async functions: FunctionModule.execute must be a coroutine function when the wrapped function is async.
  • Provide three description sources with priority: explicit description parameter, 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 bindings list of module declarations.
  • Resolve module.path:callable target strings to actual Python callables via dynamic import.
  • Support class method binding: module:ClassName.method auto-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 with extra="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_schema and output_schema are Pydantic BaseModel subclasses, 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 the Context if 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_model for 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], Literal with validation, Annotated with Field constraints, nested BaseModel parameters, Context parameter skipping (type-based, not name-based), self skipping, *args skipping, **kwargs producing extra="allow", missing type hint error, forward reference NameError mapped to FuncMissingTypeHintError, *args + **kwargs only, empty function, multiple defaults, and from __future__ import annotations compatibility.
  • _generate_output_model(): Bare dict, typed dict[str, Any], BaseModel subclass returned directly, str/int/list[str] wrapped in result field, None return 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, iscoroutinefunction returns False.
  • Async execute: Correct await, dict passthrough, None/non-dict/BaseModel handling, Context injection, iscoroutinefunction returns 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 bindings key, non-list bindings, missing module_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_schema using type inference, auto_schema with 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_ref loading external file, schema_ref file not found error.
  • Registration and integration: load_bindings registers all modules, returns FunctionModule list, directory scanning, nonexistent directory error, empty directory returns empty list, fail-fast on first error.
  • Public API exports: BindingLoader importable from apcore.
  • End-to-end integration: BindingLoader -> Registry -> Executor.call() producing correct output with a dynamically created Python module and YAML binding file.