Skip to content

Commit 8cce3b3

Browse files
committed
feat: Implement middleware priority for ordered execution, including range validation and adapter updates.
1 parent 360fddc commit 8cce3b3

File tree

6 files changed

+168
-5
lines changed

6 files changed

+168
-5
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ 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.3] - 2026-03-24
9+
10+
### Added
11+
- **Middleware priority**`Middleware` base class now accepts `priority: int` (0-1000, default 0). Higher priority executes first; equal priority preserves registration order. `BeforeMiddleware` and `AfterMiddleware` adapters also accept `priority`.
12+
- **Priority range validation**`ValueError` raised for priority values outside 0-1000
13+
814
## [0.13.2] - 2026-03-22
915

1016
### Changed

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.13.2"
7+
version = "0.13.3"
88
description = "Schema-driven module standard for AI-perceivable interfaces"
99
readme = "README.md"
1010
requires-python = ">=3.11"

src/apcore/middleware/adapters.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
class BeforeMiddleware(Middleware):
1313
"""Wraps a before-only callback function as a Middleware instance."""
1414

15-
def __init__(self, callback: Callable[[str, dict[str, Any], Context], dict[str, Any] | None]) -> None:
15+
def __init__(
16+
self, callback: Callable[[str, dict[str, Any], Context], dict[str, Any] | None], *, priority: int = 0
17+
) -> None:
1618
"""Store the callback for delegation."""
19+
super().__init__(priority=priority)
1720
self._callback = callback
1821

1922
def before(self, module_id: str, inputs: dict[str, Any], context: Context) -> dict[str, Any] | None:
@@ -27,8 +30,11 @@ class AfterMiddleware(Middleware):
2730
def __init__(
2831
self,
2932
callback: Callable[[str, dict[str, Any], dict[str, Any], Context], dict[str, Any] | None],
33+
*,
34+
priority: int = 0,
3035
) -> None:
3136
"""Store the callback for delegation."""
37+
super().__init__(priority=priority)
3238
self._callback = callback
3339

3440
def after(

src/apcore/middleware/base.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,20 @@ class Middleware:
1212
1313
Subclass and override the methods you need. All methods return None by
1414
default, which signals 'no modification' to the middleware pipeline.
15+
16+
Attributes:
17+
priority: Execution priority (0-1000). Higher priority executes first.
18+
Middlewares with equal priority preserve registration order.
19+
Defaults to 0 for backward compatibility.
1520
"""
1621

22+
priority: int = 0
23+
24+
def __init__(self, *, priority: int = 0) -> None:
25+
if not (0 <= priority <= 1000):
26+
raise ValueError(f"priority must be between 0 and 1000, got {priority}")
27+
self.priority = priority
28+
1729
def before(self, module_id: str, inputs: dict[str, Any], context: Context) -> dict[str, Any] | None:
1830
"""Called before module execution. Return modified inputs or None."""
1931
return None

src/apcore/middleware/manager.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,21 @@ def __init__(self) -> None:
4444
self._lock = threading.Lock()
4545

4646
def add(self, middleware: Middleware) -> None:
47-
"""Append a middleware to the end of the execution list."""
47+
"""Insert a middleware sorted by priority (higher first).
48+
49+
Middlewares with equal priority preserve registration order
50+
(stable insertion). Priority range is 0-1000 per the protocol spec.
51+
"""
4852
with self._lock:
49-
self._middlewares.append(middleware)
53+
# Find the first position where the existing middleware has a
54+
# lower priority. This keeps higher-priority items first and
55+
# preserves registration order among equal priorities.
56+
insert_idx = len(self._middlewares)
57+
for i, existing in enumerate(self._middlewares):
58+
if existing.priority < middleware.priority:
59+
insert_idx = i
60+
break
61+
self._middlewares.insert(insert_idx, middleware)
5062

5163
def remove(self, middleware: Middleware) -> bool:
5264
"""Remove a middleware by identity (is). Returns True if found and removed."""

tests/test_middleware.py

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Tests for the Middleware base class and function adapters."""
1+
"""Tests for the Middleware base class, function adapters, and priority ordering."""
22

33
from __future__ import annotations
44

@@ -8,6 +8,7 @@
88

99
from apcore.context import Context
1010
from apcore.middleware import AfterMiddleware, BeforeMiddleware, Middleware
11+
from apcore.middleware.manager import MiddlewareManager
1112

1213

1314
# === Middleware Base Class ===
@@ -169,3 +170,129 @@ def test_callback_receives_correct_args(self) -> None:
169170
ctx = MagicMock(spec=Context)
170171
am.after("mod.id", {"k": "v"}, {"out": 1}, ctx)
171172
spy.assert_called_once_with("mod.id", {"k": "v"}, {"out": 1}, ctx)
173+
174+
175+
# === Middleware Priority Ordering ===
176+
177+
178+
class TestMiddlewarePriority:
179+
"""Tests for middleware priority ordering in MiddlewareManager."""
180+
181+
def test_default_priority_is_zero(self) -> None:
182+
"""Middleware instances default to priority 0."""
183+
mw = Middleware()
184+
assert mw.priority == 0
185+
186+
def test_custom_priority(self) -> None:
187+
"""Middleware accepts a custom priority via constructor."""
188+
mw = Middleware(priority=500)
189+
assert mw.priority == 500
190+
191+
def test_higher_priority_executes_first(self) -> None:
192+
"""Middlewares with higher priority appear earlier in the list."""
193+
manager = MiddlewareManager()
194+
low = Middleware(priority=100)
195+
high = Middleware(priority=900)
196+
mid = Middleware(priority=500)
197+
198+
manager.add(low)
199+
manager.add(high)
200+
manager.add(mid)
201+
202+
snapshot = manager.snapshot()
203+
assert snapshot == [high, mid, low]
204+
205+
def test_equal_priority_preserves_registration_order(self) -> None:
206+
"""Middlewares with the same priority are ordered by registration time."""
207+
manager = MiddlewareManager()
208+
first = Middleware(priority=100)
209+
second = Middleware(priority=100)
210+
third = Middleware(priority=100)
211+
212+
manager.add(first)
213+
manager.add(second)
214+
manager.add(third)
215+
216+
snapshot = manager.snapshot()
217+
assert snapshot == [first, second, third]
218+
219+
def test_mixed_priorities_with_ties(self) -> None:
220+
"""Mixed priorities sort correctly with registration-order tiebreaking."""
221+
manager = MiddlewareManager()
222+
a = Middleware(priority=500)
223+
b = Middleware(priority=100)
224+
c = Middleware(priority=500)
225+
d = Middleware(priority=1000)
226+
e = Middleware(priority=0)
227+
228+
manager.add(a)
229+
manager.add(b)
230+
manager.add(c)
231+
manager.add(d)
232+
manager.add(e)
233+
234+
snapshot = manager.snapshot()
235+
assert snapshot == [d, a, c, b, e]
236+
237+
def test_default_priority_backward_compatible(self) -> None:
238+
"""Middlewares without explicit priority still work (default 0)."""
239+
manager = MiddlewareManager()
240+
mw1 = Middleware()
241+
mw2 = Middleware()
242+
mw3 = Middleware()
243+
244+
manager.add(mw1)
245+
manager.add(mw2)
246+
manager.add(mw3)
247+
248+
snapshot = manager.snapshot()
249+
assert snapshot == [mw1, mw2, mw3]
250+
251+
def test_subclass_without_super_init_defaults_to_zero(self) -> None:
252+
"""Subclasses that don't call super().__init__() still have priority 0."""
253+
254+
class CustomMiddleware(Middleware):
255+
def __init__(self) -> None:
256+
self.custom_field = "hello"
257+
258+
mw = CustomMiddleware()
259+
assert mw.priority == 0
260+
261+
def test_remove_preserves_priority_order(self) -> None:
262+
"""Removing a middleware preserves the priority-sorted order."""
263+
manager = MiddlewareManager()
264+
low = Middleware(priority=100)
265+
high = Middleware(priority=900)
266+
mid = Middleware(priority=500)
267+
268+
manager.add(low)
269+
manager.add(high)
270+
manager.add(mid)
271+
manager.remove(mid)
272+
273+
snapshot = manager.snapshot()
274+
assert snapshot == [high, low]
275+
276+
def test_priority_below_zero_raises_value_error(self) -> None:
277+
"""Priority below 0 raises ValueError."""
278+
import pytest
279+
280+
with pytest.raises(ValueError, match="priority must be between 0 and 1000"):
281+
Middleware(priority=-1)
282+
283+
def test_priority_above_1000_raises_value_error(self) -> None:
284+
"""Priority above 1000 raises ValueError."""
285+
import pytest
286+
287+
with pytest.raises(ValueError, match="priority must be between 0 and 1000"):
288+
Middleware(priority=1001)
289+
290+
def test_before_middleware_accepts_priority(self) -> None:
291+
"""BeforeMiddleware forwards priority to the base class."""
292+
bm = BeforeMiddleware(lambda mid, inp, ctx: None, priority=42)
293+
assert bm.priority == 42
294+
295+
def test_after_middleware_accepts_priority(self) -> None:
296+
"""AfterMiddleware forwards priority to the base class."""
297+
am = AfterMiddleware(lambda mid, inp, out, ctx: None, priority=99)
298+
assert am.priority == 99

0 commit comments

Comments
 (0)