Skip to content

Commit 9f71fdd

Browse files
committed
Refactor test cases for improved readability and consistency
- Updated test cases in `test_bindings.py` to enhance YAML formatting and readability. - Refactored test cases in `test_decorator.py` for better code organization and clarity. - Improved formatting in `test_executor.py` and added thread safety tests for async operations. - Enhanced async test cases in `test_executor_async.py` for better readability. - Refactored integration tests in `test_integration_executor.py` for consistency in method signatures. - Improved middleware tests in `test_middleware.py` and `test_middleware_manager.py` for clarity and consistency. - Updated public API tests in `test_public_api.py` for better readability and consistency. - Enhanced redaction tests in `test_redaction.py` for improved clarity. - Bumped version in `uv.lock` from 0.1.0 to 0.1.1.
1 parent 1082fb1 commit 9f71fdd

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+2133
-579
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ build/
1010
.DS_Store
1111
.ruff_cache/
1212
.pytest_cache/
13+
.coverage
1314
.venv/
1415
venv/
1516
env/

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,27 @@ 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.1.1] - 2026-02-14
9+
10+
### Fixed
11+
12+
#### Thread Safety
13+
- **MiddlewareManager** - Added internal locking and snapshot pattern; `add()`, `remove()`, `execute_before()`, `execute_after()` are now thread-safe
14+
- **Executor** - Added lock to async module cache; use `snapshot()` for middleware iteration in `call_async()` and `middlewares` property
15+
- **ACL** - Internally synchronized; `check()`, `add_rule()`, `remove_rule()`, `reload()` are now safe for concurrent use
16+
- **Registry** - Extended existing `RLock` to cover all read paths (`get`, `has`, `count`, `module_ids`, `list`, `iter`, `get_definition`, `on`, `_trigger_event`, `clear_cache`)
17+
18+
#### Memory Leak
19+
- **InMemoryExporter** - Replaced unbounded `list` with `collections.deque(maxlen=10_000)` and added `threading.Lock` for thread-safe access
20+
21+
#### Robustness
22+
- **TracingMiddleware** - Added empty span stack guard in `after()` and `on_error()` to log a warning instead of raising `IndexError`
23+
- **Executor** - Set `daemon=True` on timeout and async bridge threads to prevent blocking process exit
24+
25+
### Changed
26+
27+
- **Context.child()** - Added docstring clarifying that `data` is intentionally shared between parent and child for middleware state propagation
28+
829
## [0.1.0] - 2026-02-13
930

1031
### Added
@@ -60,4 +81,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6081

6182
---
6283

84+
[0.1.1]: https://github.com/aipartnerup/apcore-python/releases/tag/v0.1.1
6385
[0.1.0]: https://github.com/aipartnerup/apcore-python/releases/tag/v0.1.0

pyproject.toml

Lines changed: 1 addition & 1 deletion
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.1.0"
7+
version = "0.1.1"
88
description = "Schema-driven module development framework for AI-perceivable interfaces"
99
readme = "README.md"
1010
requires-python = ">=3.11"

src/apcore/acl.py

Lines changed: 64 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import logging
1010
import os
11+
import threading
1112
from dataclasses import dataclass
1213
from typing import Any
1314

@@ -41,9 +42,8 @@ class ACL:
4142
Implements PROTOCOL_SPEC section 6 for module access control.
4243
4344
Thread safety:
44-
- check() is read-only and fully thread-safe for concurrent calls.
45-
- add_rule(), remove_rule(), and reload() mutate the rules list and
46-
require external locking if called concurrently with check().
45+
Internally synchronized. All public methods (check, add_rule,
46+
remove_rule, reload) are safe to call concurrently.
4747
"""
4848

4949
def __init__(self, rules: list[ACLRule], default_effect: str = "deny") -> None:
@@ -58,6 +58,7 @@ def __init__(self, rules: list[ACLRule], default_effect: str = "deny") -> None:
5858
self._yaml_path: str | None = None
5959
self.debug: bool = False
6060
self._logger: logging.Logger = logging.getLogger("apcore.acl")
61+
self._lock = threading.Lock()
6162

6263
@classmethod
6364
def load(cls, yaml_path: str) -> ACL:
@@ -83,37 +84,49 @@ def load(cls, yaml_path: str) -> ACL:
8384
raise ACLRuleError(f"Invalid YAML in {yaml_path}: {e}") from e
8485

8586
if not isinstance(data, dict):
86-
raise ACLRuleError(f"ACL config must be a mapping, got {type(data).__name__}")
87+
raise ACLRuleError(
88+
f"ACL config must be a mapping, got {type(data).__name__}"
89+
)
8790

8891
if "rules" not in data:
8992
raise ACLRuleError("ACL config missing required 'rules' key")
9093

9194
raw_rules = data["rules"]
9295
if not isinstance(raw_rules, list):
93-
raise ACLRuleError(f"'rules' must be a list, got {type(raw_rules).__name__}")
96+
raise ACLRuleError(
97+
f"'rules' must be a list, got {type(raw_rules).__name__}"
98+
)
9499

95100
default_effect: str = data.get("default_effect", "deny")
96101
rules: list[ACLRule] = []
97102

98103
for i, raw_rule in enumerate(raw_rules):
99104
if not isinstance(raw_rule, dict):
100-
raise ACLRuleError(f"Rule {i} must be a mapping, got {type(raw_rule).__name__}")
105+
raise ACLRuleError(
106+
f"Rule {i} must be a mapping, got {type(raw_rule).__name__}"
107+
)
101108

102109
for key in ("callers", "targets", "effect"):
103110
if key not in raw_rule:
104111
raise ACLRuleError(f"Rule {i} missing required key '{key}'")
105112

106113
effect = raw_rule["effect"]
107114
if effect not in ("allow", "deny"):
108-
raise ACLRuleError(f"Rule {i} has invalid effect '{effect}', must be 'allow' or 'deny'")
115+
raise ACLRuleError(
116+
f"Rule {i} has invalid effect '{effect}', must be 'allow' or 'deny'"
117+
)
109118

110119
callers = raw_rule["callers"]
111120
if not isinstance(callers, list):
112-
raise ACLRuleError(f"Rule {i} 'callers' must be a list, got {type(callers).__name__}")
121+
raise ACLRuleError(
122+
f"Rule {i} 'callers' must be a list, got {type(callers).__name__}"
123+
)
113124

114125
targets = raw_rule["targets"]
115126
if not isinstance(targets, list):
116-
raise ACLRuleError(f"Rule {i} 'targets' must be a list, got {type(targets).__name__}")
127+
raise ACLRuleError(
128+
f"Rule {i} 'targets' must be a list, got {type(targets).__name__}"
129+
)
117130

118131
rules.append(
119132
ACLRule(
@@ -147,7 +160,11 @@ def check(
147160
"""
148161
effective_caller = "@external" if caller_id is None else caller_id
149162

150-
for rule in self._rules:
163+
with self._lock:
164+
rules = list(self._rules)
165+
default_effect = self._default_effect
166+
167+
for rule in rules:
151168
if self._matches_rule(rule, effective_caller, target_id, context):
152169
decision = rule.effect == "allow"
153170
self._logger.debug(
@@ -159,7 +176,7 @@ def check(
159176
)
160177
return decision
161178

162-
default_decision = self._default_effect == "allow"
179+
default_decision = default_effect == "allow"
163180
self._logger.debug(
164181
"ACL check: caller=%s target=%s decision=%s rule=default",
165182
caller_id,
@@ -168,7 +185,9 @@ def check(
168185
)
169186
return default_decision
170187

171-
def _match_pattern(self, pattern: str, value: str, context: Context | None = None) -> bool:
188+
def _match_pattern(
189+
self, pattern: str, value: str, context: Context | None = None
190+
) -> bool:
172191
"""Match a single pattern against a value, with special pattern handling.
173192
174193
Handles @external and @system patterns locally, delegates all
@@ -177,7 +196,11 @@ def _match_pattern(self, pattern: str, value: str, context: Context | None = Non
177196
if pattern == "@external":
178197
return value == "@external"
179198
if pattern == "@system":
180-
return context is not None and context.identity is not None and context.identity.type == "system"
199+
return (
200+
context is not None
201+
and context.identity is not None
202+
and context.identity.type == "system"
203+
)
181204
return match_pattern(pattern, value)
182205

183206
def _matches_rule(
@@ -194,11 +217,15 @@ def _matches_rule(
194217
2. At least one target pattern matches the target (OR logic).
195218
3. If conditions are present, they must all be satisfied.
196219
"""
197-
caller_match = any(self._match_pattern(p, caller, context) for p in rule.callers)
220+
caller_match = any(
221+
self._match_pattern(p, caller, context) for p in rule.callers
222+
)
198223
if not caller_match:
199224
return False
200225

201-
target_match = any(self._match_pattern(p, target, context) for p in rule.targets)
226+
target_match = any(
227+
self._match_pattern(p, target, context) for p in rule.targets
228+
)
202229
if not target_match:
203230
return False
204231

@@ -208,7 +235,9 @@ def _matches_rule(
208235

209236
return True
210237

211-
def _check_conditions(self, conditions: dict[str, Any], context: Context | None) -> bool:
238+
def _check_conditions(
239+
self, conditions: dict[str, Any], context: Context | None
240+
) -> bool:
212241
"""Evaluate conditional rule parameters against the execution context.
213242
214243
Returns False if any condition is not satisfied.
@@ -217,7 +246,10 @@ def _check_conditions(self, conditions: dict[str, Any], context: Context | None)
217246
return False
218247

219248
if "identity_types" in conditions:
220-
if context.identity is None or context.identity.type not in conditions["identity_types"]:
249+
if (
250+
context.identity is None
251+
or context.identity.type not in conditions["identity_types"]
252+
):
221253
return False
222254

223255
if "roles" in conditions:
@@ -238,7 +270,8 @@ def add_rule(self, rule: ACLRule) -> None:
238270
Args:
239271
rule: The ACLRule to add.
240272
"""
241-
self._rules.insert(0, rule)
273+
with self._lock:
274+
self._rules.insert(0, rule)
242275

243276
def remove_rule(self, callers: list[str], targets: list[str]) -> bool:
244277
"""Remove the first rule matching the given callers and targets.
@@ -250,20 +283,24 @@ def remove_rule(self, callers: list[str], targets: list[str]) -> bool:
250283
Returns:
251284
True if a rule was found and removed, False otherwise.
252285
"""
253-
for i, rule in enumerate(self._rules):
254-
if rule.callers == callers and rule.targets == targets:
255-
self._rules.pop(i)
256-
return True
257-
return False
286+
with self._lock:
287+
for i, rule in enumerate(self._rules):
288+
if rule.callers == callers and rule.targets == targets:
289+
self._rules.pop(i)
290+
return True
291+
return False
258292

259293
def reload(self) -> None:
260294
"""Re-read the ACL from the original YAML file.
261295
262296
Only works if the ACL was created via ACL.load().
263297
Raises ACLRuleError if no YAML path was stored.
264298
"""
265-
if self._yaml_path is None:
299+
with self._lock:
300+
yaml_path = self._yaml_path
301+
if yaml_path is None:
266302
raise ACLRuleError("Cannot reload: ACL was not loaded from a YAML file")
267-
reloaded = ACL.load(self._yaml_path)
268-
self._rules = reloaded._rules
269-
self._default_effect = reloaded._default_effect
303+
reloaded = ACL.load(yaml_path)
304+
with self._lock:
305+
self._rules = reloaded._rules
306+
self._default_effect = reloaded._default_effect

src/apcore/bindings.py

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99
import yaml
1010
from pydantic import BaseModel, ConfigDict, create_model
1111

12-
from apcore.decorator import FunctionModule, _generate_input_model, _generate_output_model
12+
from apcore.decorator import (
13+
FunctionModule,
14+
_generate_input_model,
15+
_generate_output_model,
16+
)
1317
from apcore.errors import (
1418
BindingCallableNotFoundError,
1519
BindingFileInvalidError,
@@ -65,19 +69,15 @@ def _build_model_from_json_schema(
6569
class BindingLoader:
6670
"""Loads YAML binding files and creates FunctionModule instances."""
6771

68-
def load_bindings(
69-
self, file_path: str, registry: Registry
70-
) -> list[FunctionModule]:
72+
def load_bindings(self, file_path: str, registry: Registry) -> list[FunctionModule]:
7173
"""Load binding file and register all modules."""
7274
path = pathlib.Path(file_path)
7375
binding_file_dir = str(path.parent)
7476

7577
try:
7678
content = path.read_text()
7779
except OSError as exc:
78-
raise BindingFileInvalidError(
79-
file_path=file_path, reason=str(exc)
80-
) from exc
80+
raise BindingFileInvalidError(file_path=file_path, reason=str(exc)) from exc
8181

8282
try:
8383
data = yaml.safe_load(content)
@@ -87,9 +87,7 @@ def load_bindings(
8787
) from exc
8888

8989
if data is None:
90-
raise BindingFileInvalidError(
91-
file_path=file_path, reason="File is empty"
92-
)
90+
raise BindingFileInvalidError(file_path=file_path, reason="File is empty")
9391

9492
if "bindings" not in data:
9593
raise BindingFileInvalidError(
@@ -197,9 +195,7 @@ def _create_module_from_binding(
197195
input_schema = _generate_input_model(func)
198196
output_schema = _generate_output_model(func)
199197
except (FuncMissingTypeHintError, FuncMissingReturnTypeError) as exc:
200-
raise BindingSchemaMissingError(
201-
target=binding["target"]
202-
) from exc
198+
raise BindingSchemaMissingError(target=binding["target"]) from exc
203199
elif "input_schema" in binding or "output_schema" in binding:
204200
input_schema_dict = binding.get("input_schema", {})
205201
output_schema_dict = binding.get("output_schema", {})
@@ -237,9 +233,7 @@ def _create_module_from_binding(
237233
input_schema = _generate_input_model(func)
238234
output_schema = _generate_output_model(func)
239235
except (FuncMissingTypeHintError, FuncMissingReturnTypeError) as exc:
240-
raise BindingSchemaMissingError(
241-
target=binding["target"]
242-
) from exc
236+
raise BindingSchemaMissingError(target=binding["target"]) from exc
243237

244238
return FunctionModule(
245239
func=func,

src/apcore/context.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,13 @@ def create(
4949
)
5050

5151
def child(self, target_module_id: str) -> Context:
52-
"""Create a child Context for calling a target module."""
52+
"""Create a child Context for calling a target module.
53+
54+
The ``data`` dict is intentionally shared (not copied) between parent
55+
and child contexts. Middleware such as TracingMiddleware and
56+
MetricsMiddleware rely on this shared reference to maintain span and
57+
timing stacks across nested module-to-module calls.
58+
"""
5359
return Context(
5460
trace_id=self.trace_id,
5561
caller_id=self.call_chain[-1] if self.call_chain else None,

src/apcore/decorator.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ def _generate_input_model(func: Any) -> type[BaseModel]:
6767

6868
# Create the model, with extra="allow" if **kwargs was present
6969
if has_kwargs:
70-
return create_model("InputModel", __config__=ConfigDict(extra="allow"), **field_dict)
70+
return create_model(
71+
"InputModel", __config__=ConfigDict(extra="allow"), **field_dict
72+
)
7173
return create_model("InputModel", **field_dict)
7274

7375

@@ -158,8 +160,12 @@ def __init__(
158160
) -> None:
159161
self._func = func
160162
self.module_id = module_id
161-
self.input_schema = input_schema if input_schema is not None else _generate_input_model(func)
162-
self.output_schema = output_schema if output_schema is not None else _generate_output_model(func)
163+
self.input_schema = (
164+
input_schema if input_schema is not None else _generate_input_model(func)
165+
)
166+
self.output_schema = (
167+
output_schema if output_schema is not None else _generate_output_model(func)
168+
)
163169

164170
has_context, context_param_name = _has_context_param(func)
165171

0 commit comments

Comments
 (0)