Skip to content

Commit 5f0a14d

Browse files
committed
feat: implement config discovery logic and add comprehensive error handling for configuration and registry operations
Signed-off-by: tercel <[email protected]>
1 parent 3508512 commit 5f0a14d

File tree

14 files changed

+1270
-35
lines changed

14 files changed

+1270
-35
lines changed

.githooks/prepare-commit-msg

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/bin/sh
2+
# Automatically add Signed-off-by line (DCO) if not already present
3+
4+
SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
5+
6+
if ! grep -qs "^$SOB" "$1"; then
7+
echo "" >> "$1"
8+
echo "$SOB" >> "$1"
9+
fi

CHANGELOG.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,43 @@ 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.15.0] - 2026-03-30
9+
10+
### Added
11+
12+
#### Config Bus Architecture (§9.4–§9.14)
13+
- **`Config.register_namespace(name, schema=None, env_prefix=None, defaults=None)`** — Class-level namespace registration. Any package can claim a named config subtree with optional JSON Schema validation, env prefix, and default values. Global registry is shared across all `Config` instances. Late registration is allowed; call `config.reload()` afterward to apply defaults and env overrides.
14+
- **`config.get("namespace.key.path")`** — Dot-path access with namespace resolution. First segment resolves to a registered namespace; remaining segments traverse the subtree.
15+
- **`config.namespace(name)`** — Returns the full config subtree for a registered namespace as a dict.
16+
- **`config.bind(ns, type)` / `config.get_typed(path, type)`** — Typed namespace access; `bind` returns a view of the namespace deserialized into `type`, `get_typed` deserializes a single dot-path value.
17+
- **`config.mount(namespace, from_file=...|from_dict=...)`** — Attach external config sources to a namespace without a unified YAML file. Primary integration path for third-party packages with existing config systems.
18+
- **`Config.registered_namespaces()`** — Class-level introspection; returns names of all registered namespaces.
19+
- **Unified YAML with namespace partitioning** — Single YAML file with namespace-keyed top-level sections. Automatic mode detection: legacy mode (no `apcore:` key, fully backward compatible) vs. namespace mode (`apcore:` key present). `_config` is a reserved meta-namespace (`strict`, `allow_unknown`).
20+
- **Per-namespace env override with longest-prefix-match dispatch** — Each namespace declares its own `env_prefix`. `APCORE__` double-underscore convention for apcore sub-packages (e.g., `APCORE__OBSERVABILITY`, `APCORE__SYS`) to avoid collision with the existing single-underscore `APCORE_` prefix used for flat keys.
21+
- **Hot-reload namespace support**`config.reload()` re-reads YAML, re-detects mode, re-applies namespace defaults and env overrides, re-validates, and re-reads mounted files.
22+
- **New error codes**`CONFIG_NAMESPACE_DUPLICATE`, `CONFIG_NAMESPACE_RESERVED`, `CONFIG_ENV_PREFIX_CONFLICT`, `CONFIG_MOUNT_ERROR`, `CONFIG_BIND_ERROR`
23+
24+
#### Error Formatter Registry (§8.8)
25+
- **`ErrorFormatter` protocol** — Interface for adapter-specific error formatters. Implementations transform `ModuleError` into the surface-specific wire format (e.g., MCP camelCase, JSON-RPC code mapping).
26+
- **`ErrorFormatterRegistry`** — Shared registry for surface-specific formatters:
27+
- `ErrorFormatterRegistry.register(surface, formatter)` — register a formatter for a named surface
28+
- `ErrorFormatterRegistry.get(surface)` — retrieve a registered formatter
29+
- `ErrorFormatterRegistry.format(surface, error)` — format an error, falling back to `error.to_dict()` if no formatter is registered for that surface
30+
- **New error code**`ERROR_FORMATTER_DUPLICATE`
31+
32+
#### Built-in Namespace Registrations (§9.15)
33+
- **`observability` namespace** (`APCORE__OBSERVABILITY` env prefix) — apcore pre-registers this namespace, promoting the existing `apcore.observability.*` flat config keys (tracing, metrics, logging, error_history, platform_notify) into a named subtree. Adapter packages (apcore-mcp, apcore-a2a, apcore-cli) should read from this namespace rather than independent logging defaults.
34+
- **`sys_modules` namespace** (`APCORE__SYS` env prefix) — apcore pre-registers this namespace, promoting the existing `apcore.sys_modules.*` flat keys into a named subtree. `register_sys_modules()` prefers `config.namespace("sys_modules")` in namespace mode with `config.get("sys_modules.*")` legacy fallback. Both registrations are 1:1 migrations of existing keys; there are no breaking changes.
35+
36+
#### Event Type Naming Convention and Collision Fix (§9.16)
37+
- **Canonical event names** — Two confirmed event type collisions in apcore-python are resolved:
38+
- `"module_health_changed"` (previously used for both enable/disable toggles and error-rate recovery) split into `apcore.module.toggled` (toggle on/off) and `apcore.health.recovered` (error rate recovery)
39+
- `"config_changed"` (previously used for both key updates and module reload) split into `apcore.config.updated` (runtime key update via `system.control.update_config`) and `apcore.module.reloaded` (hot-reload via `system.control.reload_module`)
40+
- **Naming convention**`apcore.*` is reserved for core framework events. Adapter packages use their own prefix: `apcore-mcp.*`, `apcore-a2a.*`, `apcore-cli.*`.
41+
- **Transition aliases** — All four legacy short-form names (`module_health_changed`, `config_changed`) continue to be emitted alongside the canonical names during the transition period.
42+
43+
---
44+
845
## [0.14.0] - 2026-03-24
946

1047
### Added

README.md

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
![Python](https://img.shields.io/badge/python-3.11+-blue.svg)
88
![License](https://img.shields.io/badge/license-Apache%202.0-green.svg)
9+
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/12294/badge)](https://www.bestpractices.dev/projects/12294)
910

1011
> **Build once, invoke by Code or AI.**
1112
@@ -40,7 +41,7 @@ A schema-enforced module standard for the AI-Perceivable era.
4041
| `Registry` | Module storage -- discover, register, get, list, watch |
4142
| `Executor` | Execution engine -- call with middleware pipeline, ACL, approval |
4243
| `Context` | Request context -- trace ID, identity, call chain, cancel token |
43-
| `Config` | Configuration -- load from YAML, get/set values |
44+
| `Config` | Configuration -- load from YAML, get/set values, namespace-partitioned Config Bus |
4445
| `Identity` | Caller identity -- id, type, roles, attributes |
4546
| `FunctionModule` | Wrapped function module created by `@module` decorator |
4647

@@ -99,6 +100,84 @@ A schema-enforced module standard for the AI-Perceivable era.
99100
| `CancelToken` | Cooperative cancellation token |
100101
| `BindingLoader` | Load modules from YAML binding files |
101102
| `ErrorCodeRegistry` | Central registry for structured error codes |
103+
| `ErrorFormatterRegistry` | Surface-specific error formatter registry (MCP, A2A, CLI adapters) |
104+
105+
## Configuration
106+
107+
### Config Bus and Namespace Registration
108+
109+
`Config` doubles as an ecosystem-level Config Bus. Any package can register a named namespace with optional JSON Schema validation, env prefix, and default values:
110+
111+
```python
112+
from apcore import Config
113+
114+
# Register a namespace (class-level, shared across all Config instances)
115+
Config.register_namespace(
116+
"my_plugin",
117+
schema={"type": "object", "properties": {"timeout_ms": {"type": "integer"}}},
118+
env_prefix="MY_PLUGIN__",
119+
defaults={"timeout_ms": 5000},
120+
)
121+
122+
# Load config as usual
123+
config = Config.load("project.yaml")
124+
125+
# Namespace-aware access
126+
timeout = config.get("my_plugin.timeout_ms") # dot-path with namespace resolution
127+
subtree = config.namespace("my_plugin") # full subtree as dict
128+
129+
# Typed access
130+
config.get_typed("my_plugin.timeout_ms", int)
131+
132+
# Mount an external source (no unified YAML required)
133+
config.mount("my_plugin", from_dict={"timeout_ms": 3000})
134+
135+
# Introspect registered namespaces
136+
names = Config.registered_namespaces()
137+
```
138+
139+
### Built-in Namespaces
140+
141+
apcore pre-registers two namespaces that promote its existing flat config keys:
142+
143+
| Namespace | Env prefix | Keys |
144+
|-----------|-----------|------|
145+
| `observability` | `APCORE__OBSERVABILITY` | tracing, metrics, logging, error_history, platform_notify |
146+
| `sys_modules` | `APCORE__SYS` | thresholds.error_rate, thresholds.latency_p99_ms |
147+
148+
### Environment Variable Conventions
149+
150+
| Pattern | When to use | Example |
151+
|---------|------------|---------|
152+
| `APCORE_KEY_NAME` | Override a flat top-level apcore key (existing convention) | `APCORE_EXECUTOR_DEFAULT__TIMEOUT=5000` |
153+
| `APCORE__NAMESPACE` prefix | Override keys inside a registered namespace (new convention) | `APCORE__OBSERVABILITY_TRACING_ENABLED=true` |
154+
| Custom prefix declared in `register_namespace` | Third-party packages with their own prefix | `MY_PLUGIN__TIMEOUT_MS=3000` |
155+
156+
The double-underscore separator (`__`) in `APCORE__` avoids collisions with the existing single-underscore `APCORE_` flat-key prefix. Within each namespace, a single `_` maps to `.` and `__` maps to a literal `_`.
157+
158+
### New Error Codes (0.15.0)
159+
160+
| Code | Meaning |
161+
|------|---------|
162+
| `CONFIG_NAMESPACE_DUPLICATE` | A namespace with this name is already registered |
163+
| `CONFIG_NAMESPACE_RESERVED` | The namespace name is reserved (`_config`, `apcore`) |
164+
| `CONFIG_ENV_PREFIX_CONFLICT` | Two namespaces share the same env prefix |
165+
| `CONFIG_MOUNT_ERROR` | Failed to load or parse a mounted config source |
166+
| `CONFIG_BIND_ERROR` | Failed to deserialize a namespace subtree into the requested type |
167+
| `ERROR_FORMATTER_DUPLICATE` | A formatter for this surface is already registered |
168+
169+
### Event Type Names
170+
171+
Canonical event type names use dot-namespaced identifiers. `apcore.*` is reserved for core framework events; adapter packages use their own prefix (e.g., `apcore-mcp.*`).
172+
173+
| Canonical name | Replaces | Emitted by |
174+
|---------------|---------|-----------|
175+
| `apcore.module.toggled` | `module_health_changed` | `system.control.toggle_feature` |
176+
| `apcore.health.recovered` | `module_health_changed` | `PlatformNotifyMiddleware` (error rate recovery) |
177+
| `apcore.config.updated` | `config_changed` | `system.control.update_config` |
178+
| `apcore.module.reloaded` | `config_changed` | `system.control.reload_module` |
179+
180+
The legacy short-form names are still emitted alongside the canonical names during the transition period.
102181

103182
## Documentation
104183

examples/global_client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import apcore
22

3+
34
# 1. No need to initialize anything if using the default global client
45
@apcore.module(id="math.add")
56
def add(a: int, b: int) -> int:
67
return a + b
78

9+
810
if __name__ == "__main__":
911
# 2. Call directly via apcore.call
1012
result = apcore.call("math.add", {"a": 10, "b": 5})

examples/simple_client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
# 1. Initialize the simplified client
44
client = APCore()
55

6+
67
# 2. Define a module using the client's decorator (auto-registers to client.registry)
78
@client.module(id="math.add", description="Add two integers")
89
def add(a: int, b: int) -> int:
910
"""Adds two numbers."""
1011
return a + b
1112

13+
1214
# 3. Call the module directly through the client
1315
if __name__ == "__main__":
1416
# Sync call

src/apcore/__init__.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@
4242
)
4343

4444
# Config
45-
from apcore.config import Config
45+
from apcore.config import Config, discover_config_file
46+
47+
# Error Formatter
48+
from apcore.error_formatter import ErrorFormatter, ErrorFormatterRegistry
4649

4750
# Errors
4851
from apcore.errors import (
@@ -62,12 +65,18 @@
6265
CallFrequencyExceededError,
6366
CircularCallError,
6467
CircularDependencyError,
68+
ConfigBindError,
69+
ConfigEnvPrefixConflictError,
6570
ConfigError,
71+
ConfigMountError,
72+
ConfigNamespaceDuplicateError,
73+
ConfigNamespaceReservedError,
6674
ConfigNotFoundError,
6775
DependencyNotFoundError,
6876
ErrorCodeCollisionError,
6977
ErrorCodeRegistry,
7078
ErrorCodes,
79+
ErrorFormatterDuplicateError,
7180
FeatureNotImplementedError,
7281
FuncMissingReturnTypeError,
7382
FuncMissingTypeHintError,
@@ -352,6 +361,10 @@ def enable(module_id: str, reason: str = "Enabled via APCore client") -> dict[st
352361
"DependencyInfo",
353362
# Config
354363
"Config",
364+
"discover_config_file",
365+
# Error Formatter
366+
"ErrorFormatter",
367+
"ErrorFormatterRegistry",
355368
# Registry constants
356369
"REGISTRY_EVENTS",
357370
"MODULE_ID_PATTERN",
@@ -379,9 +392,15 @@ def enable(module_id: str, reason: str = "Enabled via APCore client") -> dict[st
379392
"CallFrequencyExceededError",
380393
"CircularCallError",
381394
"CircularDependencyError",
395+
"ConfigBindError",
396+
"ConfigEnvPrefixConflictError",
382397
"ConfigError",
398+
"ConfigMountError",
399+
"ConfigNamespaceDuplicateError",
400+
"ConfigNamespaceReservedError",
383401
"ConfigNotFoundError",
384402
"DependencyNotFoundError",
403+
"ErrorFormatterDuplicateError",
385404
"ErrorCodeCollisionError",
386405
"ErrorCodeRegistry",
387406
"FeatureNotImplementedError",

src/apcore/error_formatter.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""ErrorFormatterRegistry -- per-adapter error formatting (§8.8)."""
2+
3+
from __future__ import annotations
4+
5+
import threading
6+
from typing import Any, Protocol, runtime_checkable
7+
8+
from apcore.errors import ErrorFormatterDuplicateError, ModuleError
9+
10+
__all__ = ["ErrorFormatter", "ErrorFormatterRegistry"]
11+
12+
13+
@runtime_checkable
14+
class ErrorFormatter(Protocol):
15+
"""Protocol for adapter-specific error formatters."""
16+
17+
def format(self, error: ModuleError, context: object) -> dict[str, Any]:
18+
"""Format a ModuleError into an adapter-specific dict."""
19+
...
20+
21+
22+
class ErrorFormatterRegistry:
23+
"""Registry of per-adapter error formatters (§8.8).
24+
25+
Thread-safe: all mutations are internally synchronized.
26+
"""
27+
28+
_registry: dict[str, ErrorFormatter] = {}
29+
_lock: threading.Lock = threading.Lock()
30+
31+
@classmethod
32+
def register(cls, adapter_name: str, formatter: ErrorFormatter) -> None:
33+
"""Register a formatter for the given adapter name.
34+
35+
Args:
36+
adapter_name: Unique adapter identifier (e.g. "mcp", "http").
37+
formatter: An object implementing the ErrorFormatter protocol.
38+
39+
Raises:
40+
ErrorFormatterDuplicateError: If a formatter is already registered
41+
for ``adapter_name``.
42+
"""
43+
with cls._lock:
44+
if adapter_name in cls._registry:
45+
raise ErrorFormatterDuplicateError(adapter_name=adapter_name)
46+
cls._registry[adapter_name] = formatter
47+
48+
@classmethod
49+
def get(cls, adapter_name: str) -> ErrorFormatter | None:
50+
"""Return the registered formatter for ``adapter_name``, or None."""
51+
with cls._lock:
52+
return cls._registry.get(adapter_name)
53+
54+
@classmethod
55+
def format(
56+
cls,
57+
adapter_name: str,
58+
error: ModuleError,
59+
context: object = None,
60+
) -> dict[str, Any]:
61+
"""Format ``error`` using the registered formatter for ``adapter_name``.
62+
63+
Falls back to ``error.to_dict()`` if no formatter is registered.
64+
65+
Args:
66+
adapter_name: The adapter whose formatter to use.
67+
error: The ModuleError to format.
68+
context: Optional adapter-specific context object.
69+
70+
Returns:
71+
A dict representation of the error.
72+
"""
73+
formatter = cls.get(adapter_name)
74+
if formatter is None:
75+
return error.to_dict()
76+
return formatter.format(error, context)
77+
78+
@classmethod
79+
def _reset(cls) -> None:
80+
"""Clear all registrations (for testing only)."""
81+
with cls._lock:
82+
cls._registry.clear()

0 commit comments

Comments
 (0)