flowevent 1.2__tar.gz → 1.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {flowevent-1.2 → flowevent-1.2.1}/CHANGELOG.md +20 -0
  2. {flowevent-1.2/flowevent.egg-info → flowevent-1.2.1}/PKG-INFO +14 -12
  3. {flowevent-1.2 → flowevent-1.2.1}/README-TR.md +11 -10
  4. {flowevent-1.2 → flowevent-1.2.1}/README.md +13 -11
  5. {flowevent-1.2 → flowevent-1.2.1}/eventflow/__init__.py +1 -1
  6. {flowevent-1.2 → flowevent-1.2.1}/eventflow/async_event_bus.py +51 -31
  7. {flowevent-1.2 → flowevent-1.2.1}/eventflow/base.py +26 -25
  8. {flowevent-1.2 → flowevent-1.2.1}/eventflow/contracts.py +2 -0
  9. {flowevent-1.2 → flowevent-1.2.1}/eventflow/engine.py +5 -1
  10. {flowevent-1.2 → flowevent-1.2.1}/eventflow/event_bus.py +68 -40
  11. {flowevent-1.2 → flowevent-1.2.1}/eventflow/stream.py +3 -2
  12. {flowevent-1.2 → flowevent-1.2.1}/eventflow/subscription.py +18 -3
  13. {flowevent-1.2 → flowevent-1.2.1/flowevent.egg-info}/PKG-INFO +14 -12
  14. {flowevent-1.2 → flowevent-1.2.1}/pyproject.toml +1 -1
  15. {flowevent-1.2 → flowevent-1.2.1}/tests/test_bus.py +136 -0
  16. {flowevent-1.2 → flowevent-1.2.1}/tests/test_engine.py +36 -0
  17. {flowevent-1.2 → flowevent-1.2.1}/tests/test_public_contracts.py +2 -2
  18. {flowevent-1.2 → flowevent-1.2.1}/tests/test_scopes.py +85 -0
  19. {flowevent-1.2 → flowevent-1.2.1}/tests/test_stream.py +11 -0
  20. {flowevent-1.2 → flowevent-1.2.1}/tests/test_subscription.py +20 -7
  21. {flowevent-1.2 → flowevent-1.2.1}/LICENSE +0 -0
  22. {flowevent-1.2 → flowevent-1.2.1}/MANIFEST.in +0 -0
  23. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/__init__.py +0 -0
  24. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/_measure.py +0 -0
  25. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/benchmark_compare.py +0 -0
  26. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/benchmark_pipeline.py +0 -0
  27. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/benchmark_publish.py +0 -0
  28. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/benchmark_replay.py +0 -0
  29. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/benchmark_runtime.py +0 -0
  30. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/benchmark_stream.py +0 -0
  31. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/results/README.md +0 -0
  32. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/results/callback_errors.json +0 -0
  33. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/results/extended_publish.json +0 -0
  34. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/results/interleaved_publish.json +0 -0
  35. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/results/paired_baseline.json +0 -0
  36. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/results/paired_extended.json +0 -0
  37. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/results/pipeline.json +0 -0
  38. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/results/pipeline_diagnostics.json +0 -0
  39. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/results/process_peak_rss.json +0 -0
  40. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/results/replay_after.txt +0 -0
  41. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/results/replay_before.txt +0 -0
  42. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/results/runtime_load.json +0 -0
  43. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/results/stream.json +0 -0
  44. {flowevent-1.2 → flowevent-1.2.1}/benchmarks/results/v1_1_publish.json +0 -0
  45. {flowevent-1.2 → flowevent-1.2.1}/eventflow/_callbacks.py +0 -0
  46. {flowevent-1.2 → flowevent-1.2.1}/eventflow/_lifecycle.py +0 -0
  47. {flowevent-1.2 → flowevent-1.2.1}/eventflow/actions.py +0 -0
  48. {flowevent-1.2 → flowevent-1.2.1}/eventflow/callback.py +0 -0
  49. {flowevent-1.2 → flowevent-1.2.1}/eventflow/compatibility.py +0 -0
  50. {flowevent-1.2 → flowevent-1.2.1}/eventflow/context.py +0 -0
  51. {flowevent-1.2 → flowevent-1.2.1}/eventflow/errors.py +0 -0
  52. {flowevent-1.2 → flowevent-1.2.1}/eventflow/filtering.py +0 -0
  53. {flowevent-1.2 → flowevent-1.2.1}/eventflow/history.py +0 -0
  54. {flowevent-1.2 → flowevent-1.2.1}/eventflow/observability.py +0 -0
  55. {flowevent-1.2 → flowevent-1.2.1}/eventflow/pipeline/__init__.py +0 -0
  56. {flowevent-1.2 → flowevent-1.2.1}/eventflow/pipeline/async_.py +0 -0
  57. {flowevent-1.2 → flowevent-1.2.1}/eventflow/pipeline/base.py +0 -0
  58. {flowevent-1.2 → flowevent-1.2.1}/eventflow/pipeline/contracts.py +0 -0
  59. {flowevent-1.2 → flowevent-1.2.1}/eventflow/pipeline/registry.py +0 -0
  60. {flowevent-1.2 → flowevent-1.2.1}/eventflow/pipeline/sync.py +0 -0
  61. {flowevent-1.2 → flowevent-1.2.1}/eventflow/py.typed +0 -0
  62. {flowevent-1.2 → flowevent-1.2.1}/eventflow/scopes.py +0 -0
  63. {flowevent-1.2 → flowevent-1.2.1}/examples/__init__.py +0 -0
  64. {flowevent-1.2 → flowevent-1.2.1}/examples/bus_stream.py +0 -0
  65. {flowevent-1.2 → flowevent-1.2.1}/examples/integrated.py +0 -0
  66. {flowevent-1.2 → flowevent-1.2.1}/flowevent.egg-info/SOURCES.txt +0 -0
  67. {flowevent-1.2 → flowevent-1.2.1}/flowevent.egg-info/dependency_links.txt +0 -0
  68. {flowevent-1.2 → flowevent-1.2.1}/flowevent.egg-info/requires.txt +0 -0
  69. {flowevent-1.2 → flowevent-1.2.1}/flowevent.egg-info/top_level.txt +0 -0
  70. {flowevent-1.2 → flowevent-1.2.1}/setup.cfg +0 -0
  71. {flowevent-1.2 → flowevent-1.2.1}/tests/api_v1_1.json +0 -0
  72. {flowevent-1.2 → flowevent-1.2.1}/tests/api_v1_2.json +0 -0
  73. {flowevent-1.2 → flowevent-1.2.1}/tests/test_benchmarks.py +0 -0
  74. {flowevent-1.2 → flowevent-1.2.1}/tests/test_callback.py +0 -0
  75. {flowevent-1.2 → flowevent-1.2.1}/tests/test_context.py +0 -0
  76. {flowevent-1.2 → flowevent-1.2.1}/tests/test_delivery_limits.py +0 -0
  77. {flowevent-1.2 → flowevent-1.2.1}/tests/test_filtering.py +0 -0
  78. {flowevent-1.2 → flowevent-1.2.1}/tests/test_integration.py +0 -0
  79. {flowevent-1.2 → flowevent-1.2.1}/tests/test_lifecycle.py +0 -0
  80. {flowevent-1.2 → flowevent-1.2.1}/tests/test_middleware_limits.py +0 -0
  81. {flowevent-1.2 → flowevent-1.2.1}/tests/test_observability.py +0 -0
  82. {flowevent-1.2 → flowevent-1.2.1}/tests/test_pipeline.py +0 -0
@@ -4,6 +4,26 @@ All notable changes to this project are documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [v1.2.1] - 2026-11-02
8
+
9
+ ### Changed
10
+
11
+ - Isolate sync and async subscriber matching in focused bus helpers while
12
+ keeping their cancellation and replay behavior separate.
13
+ - Share finite timeout validation and replay-history window selection across
14
+ both buses without merging their independent delivery paths.
15
+ - Count a publication only after a configured history append succeeds, and
16
+ reject non-string engine channel names instead of coercing them.
17
+ - Skip repeated event-loop lookups on stream reads after the owning consumer
18
+ task has been established.
19
+ - Run Twine metadata checks in strict mode in CI.
20
+
21
+ ### Fixed
22
+
23
+ - Claim engine-created channels for their owning engine, preserve finite
24
+ subscription deliveries when cancellation or executor submission fails, and
25
+ coordinate concurrent synchronous subscription cancellation retries.
26
+
7
27
  ## [v1.2] - 2026-11-02
8
28
 
9
29
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowevent
3
- Version: 1.2
3
+ Version: 1.2.1
4
4
  Summary: EventFlow: synchronous and asynchronous event delivery for Python.
5
5
  Author-email: TahsinCr <Tahsincr@outlook.com>
6
6
  License-Expression: MIT
@@ -30,7 +30,7 @@ Requires-Dist: pyright<2,>=1.1.400; extra == "dev"
30
30
  Requires-Dist: ruff<1,>=0.12; extra == "dev"
31
31
  Dynamic: license-file
32
32
 
33
- [![Version: v1.2][version-shield]](https://github.com/TahsinCr/python-eventflow/blob/main/CHANGELOG.md)
33
+ [![Version: v1.2.1][version-shield]](https://github.com/TahsinCr/python-eventflow/blob/main/CHANGELOG.md)
34
34
  [![Python 3.10+][python-shield]](https://www.python.org/downloads/)
35
35
  [![MIT License][license-shield]](https://github.com/TahsinCr/python-eventflow/blob/main/LICENSE)
36
36
 
@@ -146,7 +146,7 @@ The installed package exposes its version through `eventflow.__version__`:
146
146
  ~~~python
147
147
  from eventflow import __version__
148
148
 
149
- print(__version__) # 1.2
149
+ print(__version__) # 1.2.1
150
150
  ~~~
151
151
 
152
152
  <a id="first-event"></a>
@@ -327,10 +327,11 @@ directly. The bus attempts delivery to the selected handlers, then raises an
327
327
  a `PublishResult`. The `bus.stats` property provides cumulative
328
328
  `published`, `delivered`, and `failed` counts.
329
329
 
330
- These counters are not a per-publish invariant. `published` is incremented
331
- before callbacks run; `delivered` and `failed` are finalized afterward. An
332
- in-flight or cancelled async publish can leave them different. `failed` counts
333
- collected errors from handlers, filters, and actions, not only failed handlers.
330
+ These counters are not a per-publish invariant. `published` advances after
331
+ history recording succeeds (or is skipped when history is disabled) and before
332
+ callbacks run; `delivered` and `failed` are finalized afterward. An in-flight
333
+ or cancelled async publish can leave them different. `failed` counts collected
334
+ errors from handlers, filters, and actions, not only failed handlers.
334
335
 
335
336
  To catch grouped failures with the same import on Python 3.10 and newer, import
336
337
  `ExceptionGroup` from `eventflow.compatibility`. On Python 3.11 and newer, that
@@ -607,10 +608,11 @@ with EventEngine(history=50) as engine:
607
608
  print(result.delivered)
608
609
  ~~~
609
610
 
610
- Channel names are stripped and lowercased, so `" Orders "` and `"orders"`
611
- refer to the same channel. `engine.channel(name)` returns a channel, creating
612
- it when necessary. Use `engine.add(name, bus)` to register a bus with custom
613
- settings. `remove(name)` closes one channel; `stop()` closes every channel.
611
+ Channel names must be strings and are stripped and lowercased, so `" Orders "`
612
+ and `"orders"` refer to the same channel. `engine.channel(name)` returns a
613
+ channel, creating it when necessary. Use `engine.add(name, bus)` to register a
614
+ bus with custom settings. `remove(name)` closes one channel; `stop()` closes
615
+ every channel.
614
616
  An engine can own a bus instance under only one channel name at a time. A
615
617
  successful `add()` claims that bus for the engine; another engine cannot claim
616
618
  the same instance and receives `ValueError`. Removing or stopping the owner
@@ -1166,6 +1168,6 @@ Copyright © 2026 TahsinCr. EventFlow is licensed under the MIT License.
1166
1168
  See [LICENSE](https://github.com/TahsinCr/python-eventflow/blob/main/LICENSE) and
1167
1169
  [CHANGELOG](https://github.com/TahsinCr/python-eventflow/blob/main/CHANGELOG.md).
1168
1170
 
1169
- [version-shield]: https://img.shields.io/badge/version-v1.2-b7a3e3.svg?style=for-the-badge
1171
+ [version-shield]: https://img.shields.io/badge/version-v1.2.1-b7a3e3.svg?style=for-the-badge
1170
1172
  [python-shield]: https://img.shields.io/badge/Python-3.10%2B-3776AB.svg?style=for-the-badge&logo=python&logoColor=white
1171
1173
  [license-shield]: https://img.shields.io/badge/license-MIT-blue.svg?style=for-the-badge
@@ -1,4 +1,4 @@
1
- [![Sürüm: v1.2][version-shield]](https://github.com/TahsinCr/python-eventflow/blob/main/CHANGELOG.md)
1
+ [![Sürüm: v1.2.1][version-shield]](https://github.com/TahsinCr/python-eventflow/blob/main/CHANGELOG.md)
2
2
  [![Python 3.10+][python-shield]](https://www.python.org/downloads/)
3
3
  [![MIT Lisansı][license-shield]](https://github.com/TahsinCr/python-eventflow/blob/main/LICENSE)
4
4
 
@@ -118,7 +118,7 @@ Kurulu paketin sürüm numarasına `eventflow.__version__` üzerinden erişebili
118
118
  ~~~python
119
119
  from eventflow import __version__
120
120
 
121
- print(__version__) # 1.2
121
+ print(__version__) # 1.2.1
122
122
  ~~~
123
123
 
124
124
  <a id="first-event"></a>
@@ -304,11 +304,12 @@ denedikten sonra toplanan hatalar için `ExceptionGroup` yükseltir; o yayın
304
304
  çağrısı `PublishResult` döndürmez. `bus.stats` toplam `published`,
305
305
  `delivered` ve `failed` sayaçlarını verir.
306
306
 
307
- Bu sayaçlar her yayın için değişmez bir eşitlik oluşturmaz. `published` callback'ler
308
- çalışmadan önce artar; `delivered` ve `failed` işlem sonunda güncellenir. Devam
309
- eden veya iptal edilen async yayınlarda değerler farklı kalabilir. `failed`,
310
- yalnızca başarısız handler'ları değil; handler, filtre ve action hatası
311
- kayıtlarını sayar.
307
+ Bu sayaçlar her yayın için değişmez bir eşitlik oluşturmaz. `published`, history
308
+ kaydı başarılı olduktan (history kapalıysa bu adım atlandıktan) sonra ve
309
+ callback'ler çalışmadan önce artar; `delivered` ve `failed` işlem sonunda
310
+ güncellenir. Devam eden veya iptal edilen async yayınlarda değerler farklı
311
+ kalabilir. `failed`, yalnızca başarısız handler'ları değil; handler, filtre ve
312
+ action hatası kayıtlarını sayar.
312
313
 
313
314
  Python 3.10 ve üzerindeki sürümlerde aynı import ile gruplandırılmış hataları
314
315
  yakalamak için `ExceptionGroup` sınıfını `eventflow.compatibility` modülünden
@@ -590,8 +591,8 @@ with EventEngine(history=50) as engine:
590
591
  print(result.delivered)
591
592
  ~~~
592
593
 
593
- Kanal adlarının başındaki ve sonundaki boşluklar silinir, harfler küçültülür.
594
- Bu nedenle `" Orders "` ve `"orders"` aynı kanala gider.
594
+ Kanal adları string olmalı; başındaki ve sonundaki boşluklar silinir, harfler
595
+ küçültülür. Bu nedenle `" Orders "` ve `"orders"` aynı kanala gider.
595
596
  `engine.channel(name)` kanalı döndürür veya oluşturur.
596
597
  `engine.add(name, bus)` özel ayarlı bus kaydeder. `remove(name)` bir kanalı
597
598
  kapatıp kaldırır; `stop()` tüm kanalları kapatır. Engine ayrıca `running` ve
@@ -1160,6 +1161,6 @@ Copyright © 2026 TahsinCr. EventFlow, MIT Lisansı ile lisanslanmıştır.
1160
1161
  [CHANGELOG](https://github.com/TahsinCr/python-eventflow/blob/main/CHANGELOG.md)
1161
1162
  dosyalarına bakın.
1162
1163
 
1163
- [version-shield]: https://img.shields.io/badge/version-v1.2-b7a3e3.svg?style=for-the-badge
1164
+ [version-shield]: https://img.shields.io/badge/version-v1.2.1-b7a3e3.svg?style=for-the-badge
1164
1165
  [python-shield]: https://img.shields.io/badge/Python-3.10%2B-3776AB.svg?style=for-the-badge&logo=python&logoColor=white
1165
1166
  [license-shield]: https://img.shields.io/badge/license-MIT-blue.svg?style=for-the-badge
@@ -1,4 +1,4 @@
1
- [![Version: v1.2][version-shield]](https://github.com/TahsinCr/python-eventflow/blob/main/CHANGELOG.md)
1
+ [![Version: v1.2.1][version-shield]](https://github.com/TahsinCr/python-eventflow/blob/main/CHANGELOG.md)
2
2
  [![Python 3.10+][python-shield]](https://www.python.org/downloads/)
3
3
  [![MIT License][license-shield]](https://github.com/TahsinCr/python-eventflow/blob/main/LICENSE)
4
4
 
@@ -114,7 +114,7 @@ The installed package exposes its version through `eventflow.__version__`:
114
114
  ~~~python
115
115
  from eventflow import __version__
116
116
 
117
- print(__version__) # 1.2
117
+ print(__version__) # 1.2.1
118
118
  ~~~
119
119
 
120
120
  <a id="first-event"></a>
@@ -295,10 +295,11 @@ directly. The bus attempts delivery to the selected handlers, then raises an
295
295
  a `PublishResult`. The `bus.stats` property provides cumulative
296
296
  `published`, `delivered`, and `failed` counts.
297
297
 
298
- These counters are not a per-publish invariant. `published` is incremented
299
- before callbacks run; `delivered` and `failed` are finalized afterward. An
300
- in-flight or cancelled async publish can leave them different. `failed` counts
301
- collected errors from handlers, filters, and actions, not only failed handlers.
298
+ These counters are not a per-publish invariant. `published` advances after
299
+ history recording succeeds (or is skipped when history is disabled) and before
300
+ callbacks run; `delivered` and `failed` are finalized afterward. An in-flight
301
+ or cancelled async publish can leave them different. `failed` counts collected
302
+ errors from handlers, filters, and actions, not only failed handlers.
302
303
 
303
304
  To catch grouped failures with the same import on Python 3.10 and newer, import
304
305
  `ExceptionGroup` from `eventflow.compatibility`. On Python 3.11 and newer, that
@@ -575,10 +576,11 @@ with EventEngine(history=50) as engine:
575
576
  print(result.delivered)
576
577
  ~~~
577
578
 
578
- Channel names are stripped and lowercased, so `" Orders "` and `"orders"`
579
- refer to the same channel. `engine.channel(name)` returns a channel, creating
580
- it when necessary. Use `engine.add(name, bus)` to register a bus with custom
581
- settings. `remove(name)` closes one channel; `stop()` closes every channel.
579
+ Channel names must be strings and are stripped and lowercased, so `" Orders "`
580
+ and `"orders"` refer to the same channel. `engine.channel(name)` returns a
581
+ channel, creating it when necessary. Use `engine.add(name, bus)` to register a
582
+ bus with custom settings. `remove(name)` closes one channel; `stop()` closes
583
+ every channel.
582
584
  An engine can own a bus instance under only one channel name at a time. A
583
585
  successful `add()` claims that bus for the engine; another engine cannot claim
584
586
  the same instance and receives `ValueError`. Removing or stopping the owner
@@ -1134,6 +1136,6 @@ Copyright © 2026 TahsinCr. EventFlow is licensed under the MIT License.
1134
1136
  See [LICENSE](https://github.com/TahsinCr/python-eventflow/blob/main/LICENSE) and
1135
1137
  [CHANGELOG](https://github.com/TahsinCr/python-eventflow/blob/main/CHANGELOG.md).
1136
1138
 
1137
- [version-shield]: https://img.shields.io/badge/version-v1.2-b7a3e3.svg?style=for-the-badge
1139
+ [version-shield]: https://img.shields.io/badge/version-v1.2.1-b7a3e3.svg?style=for-the-badge
1138
1140
  [python-shield]: https://img.shields.io/badge/Python-3.10%2B-3776AB.svg?style=for-the-badge&logo=python&logoColor=white
1139
1141
  [license-shield]: https://img.shields.io/badge/license-MIT-blue.svg?style=for-the-badge
@@ -3,7 +3,7 @@
3
3
  from importlib import import_module
4
4
  from typing import TYPE_CHECKING, Any
5
5
 
6
- __version__ = "1.2"
6
+ __version__ = "1.2.1"
7
7
 
8
8
  from .actions import (
9
9
  AsyncEventBusActions,
@@ -14,9 +14,8 @@ from .base import (
14
14
  BaseEventBus,
15
15
  normalize_replay_capacity,
16
16
  validate_nonnegative_count,
17
- validate_nonnegative_timeout,
18
17
  validate_positive_capacity,
19
- validate_positive_timeout,
18
+ _validate_timeout,
20
19
  )
21
20
  from .contracts import DeliveryMode, ErrorPolicy, EventBusStats, PublishResult
22
21
  from .compatibility import ExceptionGroup
@@ -51,6 +50,10 @@ class _AsyncSubscriber(Generic[T]):
51
50
  self.remaining -= 1
52
51
  return True, self.remaining == 0
53
52
 
53
+ def restore_claim(self) -> None:
54
+ if self.remaining is not None:
55
+ self.remaining += 1
56
+
54
57
  def buffer_if_replaying(self, event: T) -> bool:
55
58
  if not self.replaying:
56
59
  return False
@@ -178,13 +181,11 @@ class AsyncEventBus(BaseEventBus[T]):
178
181
  async with self._lock:
179
182
  self._ensure_open()
180
183
  history = self._history
181
- if replay and history is not None:
182
- if event_filter is None and event_type is None and predicate is None:
183
- replay_snapshot = history.query(limit=replay)
184
- else:
185
- replay_snapshot = history.query()
186
- else:
187
- replay_snapshot = ()
184
+ replay_snapshot = self._query_replay(
185
+ history,
186
+ replay,
187
+ any(option is not None for option in (event_filter, event_type, predicate)),
188
+ )
188
189
  subscription, subscriber = self._subscribe_locked(
189
190
  callback,
190
191
  normalized_filter,
@@ -278,33 +279,18 @@ class AsyncEventBus(BaseEventBus[T]):
278
279
  async with self._lock:
279
280
  self._ensure_open()
280
281
  subscribers = tuple(self._subscribers.values())
281
- self._published += 1
282
282
  self._record(event)
283
+ self._published += 1
283
284
 
284
- matched: list[_AsyncSubscriber[T]] = []
285
285
  errors: list[Exception] = []
286
286
  if self._actions.on_before is not None:
287
287
  try:
288
288
  await self._actions.on_before(event)
289
289
  except Exception as exc:
290
290
  errors.append(exc)
291
- for subscriber in subscribers:
292
- if not subscriber.subscription.active:
293
- continue
294
- try:
295
- matches = subscriber.event_filter.matches(event)
296
- except Exception as exc:
297
- errors.append(exc)
298
- continue
299
- if matches:
300
- try:
301
- if subscriber.buffer_if_replaying(event):
302
- continue
303
- except BufferError as exc:
304
- await subscriber.subscription.cancel()
305
- errors.append(exc)
306
- continue
307
- matched.append(subscriber)
291
+
292
+ matched, matching_errors = await self._match_subscribers(subscribers, event)
293
+ errors.extend(matching_errors)
308
294
 
309
295
  delivered = 0
310
296
  if self._delivery_mode is DeliveryMode.SEQUENTIAL:
@@ -353,6 +339,32 @@ class AsyncEventBus(BaseEventBus[T]):
353
339
  raise ExceptionGroup("Async event delivery failed", errors)
354
340
  return result
355
341
 
342
+ async def _match_subscribers(
343
+ self,
344
+ subscribers: tuple[_AsyncSubscriber[T], ...],
345
+ event: T,
346
+ ) -> tuple[list[_AsyncSubscriber[T]], list[Exception]]:
347
+ matched: list[_AsyncSubscriber[T]] = []
348
+ errors: list[Exception] = []
349
+ for subscriber in subscribers:
350
+ if not subscriber.subscription.active:
351
+ continue
352
+ try:
353
+ matches = subscriber.event_filter.matches(event)
354
+ except Exception as exc:
355
+ errors.append(exc)
356
+ continue
357
+ if matches:
358
+ try:
359
+ if subscriber.buffer_if_replaying(event):
360
+ continue
361
+ except BufferError as exc:
362
+ await subscriber.subscription.cancel()
363
+ errors.append(exc)
364
+ continue
365
+ matched.append(subscriber)
366
+ return matched, errors
367
+
356
368
  @staticmethod
357
369
  def _collect_concurrent_outcomes(
358
370
  outcomes: list[tuple[bool, Exception | None] | BaseException],
@@ -452,7 +464,11 @@ class AsyncEventBus(BaseEventBus[T]):
452
464
  if not claimed:
453
465
  return False, None
454
466
  if final:
455
- await subscriber.subscription._consume()
467
+ try:
468
+ await subscriber.subscription._consume()
469
+ except BaseException:
470
+ subscriber.restore_claim()
471
+ raise
456
472
  try:
457
473
  await subscriber.callback(event)
458
474
  except Exception as exc:
@@ -470,7 +486,7 @@ class AsyncEventBus(BaseEventBus[T]):
470
486
  """Publish the same event periodically until cancelled or completed."""
471
487
 
472
488
  self._bind_loop()
473
- interval = validate_positive_timeout(interval, name="Event publish interval")
489
+ interval = _validate_timeout(interval, name="Event publish interval")
474
490
  if times is not None and (
475
491
  isinstance(times, bool) or not isinstance(times, int) or times <= 0
476
492
  ):
@@ -536,7 +552,11 @@ class AsyncEventBus(BaseEventBus[T]):
536
552
  ) -> T | None:
537
553
  loop = self._bind_loop()
538
554
  if timeout is not None:
539
- timeout = validate_nonnegative_timeout(timeout, name="Event wait timeout")
555
+ timeout = _validate_timeout(
556
+ timeout,
557
+ name="Event wait timeout",
558
+ allow_zero=True,
559
+ )
540
560
  normalized_filter = coerce_event_filter(
541
561
  event_filter,
542
562
  event_type=event_type,
@@ -33,17 +33,20 @@ def normalize_replay_capacity(value: int) -> int:
33
33
  return validate_positive_capacity(value, name="Replay buffer limit")
34
34
 
35
35
 
36
- def validate_positive_timeout(value: float, *, name: str) -> float:
37
- """Validate and normalize a finite timeout value."""
38
-
39
- if (
40
- isinstance(value, bool)
41
- or not isinstance(value, (int, float))
42
- or not math.isfinite(value)
43
- or value <= 0
44
- ):
45
- raise ValueError(f"{name} must be positive and finite")
46
- return float(value)
36
+ def _validate_timeout(value: float, *, name: str, allow_zero: bool = False) -> float:
37
+ qualifier = "non-negative" if allow_zero else "positive"
38
+ message = f"{name} must be {qualifier} and finite"
39
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
40
+ raise ValueError(message)
41
+ try:
42
+ timeout = float(value)
43
+ except OverflowError as exc:
44
+ if allow_zero:
45
+ raise ValueError(message) from exc
46
+ raise ValueError(message) from None
47
+ if not math.isfinite(timeout) or timeout < 0 or (timeout == 0 and not allow_zero):
48
+ raise ValueError(message)
49
+ return timeout
47
50
 
48
51
 
49
52
  def validate_nonnegative_count(value: int, *, name: str) -> int:
@@ -54,20 +57,6 @@ def validate_nonnegative_count(value: int, *, name: str) -> int:
54
57
  return value
55
58
 
56
59
 
57
- def validate_nonnegative_timeout(value: float, *, name: str) -> float:
58
- """Validate and normalize a finite timeout that may be zero."""
59
-
60
- if isinstance(value, bool) or not isinstance(value, (int, float)):
61
- raise ValueError(f"{name} must be non-negative and finite")
62
- try:
63
- normalized = float(value)
64
- except OverflowError as exc:
65
- raise ValueError(f"{name} must be non-negative and finite") from exc
66
- if not math.isfinite(normalized) or normalized < 0:
67
- raise ValueError(f"{name} must be non-negative and finite")
68
- return normalized
69
-
70
-
71
60
  class BaseEventBus(Generic[T], ABC):
72
61
  def __init__(
73
62
  self,
@@ -125,6 +114,18 @@ class BaseEventBus(Generic[T], ABC):
125
114
  if self._history is not None:
126
115
  self._history.append(event)
127
116
 
117
+ @staticmethod
118
+ def _query_replay(
119
+ history: EventHistory[T] | None,
120
+ replay: int,
121
+ has_filter: bool,
122
+ ) -> tuple[T, ...]:
123
+ if not replay or history is None:
124
+ return ()
125
+ if has_filter:
126
+ return history.query()
127
+ return history.query(limit=replay)
128
+
128
129
  @staticmethod
129
130
  def _delivery_limit(*, once: bool, times: int | None) -> int | None:
130
131
  if times is not None:
@@ -35,6 +35,8 @@ class PublishResult:
35
35
 
36
36
  @dataclass(frozen=True, slots=True)
37
37
  class EventBusStats:
38
+ """Aggregate counts; published follows successful configured history recording."""
39
+
38
40
  published: int = 0
39
41
  delivered: int = 0
40
42
  failed: int = 0
@@ -24,7 +24,9 @@ from .subscription import AsyncSubscription, Subscription
24
24
 
25
25
 
26
26
  def _channel_name(name: str) -> str:
27
- normalized = str(name).strip().lower()
27
+ if not isinstance(name, str):
28
+ raise TypeError("Event channel name must be a string")
29
+ normalized = name.strip().lower()
28
30
  if not normalized:
29
31
  raise ValueError("Event channel name cannot be empty")
30
32
  return normalized
@@ -95,6 +97,7 @@ class EventEngine:
95
97
  executor=self._executor,
96
98
  actions=self._actions,
97
99
  )
100
+ bus._claim_engine_ownership(self)
98
101
  self._channels[name] = bus
99
102
  self._running = True
100
103
  return bus
@@ -313,6 +316,7 @@ class AsyncEventEngine:
313
316
  max_concurrency=self._max_concurrency,
314
317
  actions=self._actions,
315
318
  )
319
+ bus._claim_engine_ownership(self)
316
320
  self._channels[name] = bus
317
321
  self._running = True
318
322
  return bus
@@ -17,9 +17,8 @@ from .base import (
17
17
  BaseEventBus,
18
18
  normalize_replay_capacity,
19
19
  validate_nonnegative_count,
20
- validate_nonnegative_timeout,
21
20
  validate_positive_capacity,
22
- validate_positive_timeout,
21
+ _validate_timeout,
23
22
  )
24
23
  from .contracts import ErrorPolicy, EventBusStats, PublishResult
25
24
  from .compatibility import ExceptionGroup
@@ -73,6 +72,11 @@ class _Subscriber(Generic[T]):
73
72
  self.remaining -= 1
74
73
  return True, self.remaining == 0
75
74
 
75
+ def restore_claim(self) -> None:
76
+ with self.lock:
77
+ if self.remaining is not None:
78
+ self.remaining += 1
79
+
76
80
  def buffer_if_replaying(self, event: T) -> bool:
77
81
  with self.lock:
78
82
  if not self.replaying:
@@ -116,10 +120,7 @@ class EventBus(BaseEventBus[T]):
116
120
  max_schedules,
117
121
  name="Maximum periodic schedules",
118
122
  )
119
- shutdown_timeout = validate_positive_timeout(
120
- shutdown_timeout,
121
- name="Shutdown timeout",
122
- )
123
+ shutdown_timeout = _validate_timeout(shutdown_timeout, name="Shutdown timeout")
123
124
  direct_actions = (on_before, on_after, on_error, on_timeout)
124
125
  if actions is not None and any(action is not None for action in direct_actions):
125
126
  raise ValueError("Use actions or direct on_* callbacks, not both")
@@ -193,13 +194,11 @@ class EventBus(BaseEventBus[T]):
193
194
  history = self._history
194
195
  subscription_id = self._next_id
195
196
  self._next_id += 1
196
- if replay and history is not None:
197
- if event_filter is None and event_type is None and predicate is None:
198
- replay_snapshot = history.query(limit=replay)
199
- else:
200
- replay_snapshot = history.query()
201
- else:
202
- replay_snapshot = ()
197
+ replay_snapshot = self._query_replay(
198
+ history,
199
+ replay,
200
+ any(option is not None for option in (event_filter, event_type, predicate)),
201
+ )
203
202
 
204
203
  def cancel() -> None:
205
204
  with self._lock:
@@ -277,33 +276,18 @@ class EventBus(BaseEventBus[T]):
277
276
  with self._lock:
278
277
  self._ensure_open()
279
278
  subscribers = tuple(self._subscribers.values())
280
- self._published += 1
281
279
  self._record(event)
280
+ self._published += 1
282
281
 
283
- matched: list[_Subscriber[T]] = []
284
282
  errors: list[Exception] = []
285
283
  if self._actions.on_before is not None:
286
284
  try:
287
285
  synchronous_result(self._actions.on_before(event))
288
286
  except Exception as exc:
289
287
  errors.append(exc)
290
- for subscriber in subscribers:
291
- if not subscriber.subscription.active:
292
- continue
293
- try:
294
- matches = subscriber.event_filter.matches(event)
295
- except Exception as exc:
296
- errors.append(exc)
297
- continue
298
- if matches:
299
- try:
300
- if subscriber.buffer_if_replaying(event):
301
- continue
302
- except BufferError as exc:
303
- subscriber.subscription.cancel()
304
- errors.append(exc)
305
- continue
306
- matched.append(subscriber)
288
+
289
+ matched, matching_errors = self._match_subscribers(subscribers, event)
290
+ errors.extend(matching_errors)
307
291
 
308
292
  delivered = 0
309
293
  active_executors = getattr(_executor_context, "active", frozenset())
@@ -320,17 +304,20 @@ class EventBus(BaseEventBus[T]):
320
304
  claimed, final = subscriber.claim()
321
305
  if not claimed:
322
306
  continue
323
- if final:
324
- subscriber.subscription._consume()
325
307
  try:
326
308
  future = self._executor.submit(
327
309
  self._invoke_executor_callback,
328
- subscriber.callback,
310
+ subscriber,
329
311
  event,
330
312
  active_executors,
313
+ final,
331
314
  )
332
315
  except Exception as exc:
316
+ subscriber.restore_claim()
333
317
  errors.append(exc)
318
+ except BaseException:
319
+ subscriber.restore_claim()
320
+ raise
334
321
  else:
335
322
  futures.append((subscriber, future))
336
323
  for _subscriber, future in futures:
@@ -368,12 +355,45 @@ class EventBus(BaseEventBus[T]):
368
355
  raise ExceptionGroup("Event delivery failed", errors)
369
356
  return result
370
357
 
358
+ def _match_subscribers(
359
+ self,
360
+ subscribers: tuple[_Subscriber[T], ...],
361
+ event: T,
362
+ ) -> tuple[list[_Subscriber[T]], list[Exception]]:
363
+ matched: list[_Subscriber[T]] = []
364
+ errors: list[Exception] = []
365
+ for subscriber in subscribers:
366
+ if not subscriber.subscription.active:
367
+ continue
368
+ try:
369
+ matches = subscriber.event_filter.matches(event)
370
+ except Exception as exc:
371
+ errors.append(exc)
372
+ continue
373
+ if matches:
374
+ try:
375
+ if subscriber.buffer_if_replaying(event):
376
+ continue
377
+ except BufferError as exc:
378
+ subscriber.subscription.cancel()
379
+ errors.append(exc)
380
+ continue
381
+ matched.append(subscriber)
382
+ return matched, errors
383
+
371
384
  def _invoke_executor_callback(
372
385
  self,
373
- callback: Callable[[T], None],
386
+ subscriber: _Subscriber[T],
374
387
  event: T,
375
388
  inherited_executors: frozenset[int],
389
+ final: bool,
376
390
  ) -> None:
391
+ if final:
392
+ try:
393
+ subscriber.subscription._consume()
394
+ except BaseException:
395
+ subscriber.restore_claim()
396
+ raise
377
397
  previous = getattr(_executor_context, "active", None)
378
398
  active = set(inherited_executors)
379
399
  if previous is not None:
@@ -382,7 +402,7 @@ class EventBus(BaseEventBus[T]):
382
402
  active.add(id(self._executor))
383
403
  _executor_context.active = frozenset(active)
384
404
  try:
385
- _invoke_sync_callback(callback, event)
405
+ _invoke_sync_callback(subscriber.callback, event)
386
406
  finally:
387
407
  if previous is None:
388
408
  del _executor_context.active
@@ -414,7 +434,11 @@ class EventBus(BaseEventBus[T]):
414
434
  if not claimed:
415
435
  return False, None
416
436
  if final:
417
- subscriber.subscription._consume()
437
+ try:
438
+ subscriber.subscription._consume()
439
+ except BaseException:
440
+ subscriber.restore_claim()
441
+ raise
418
442
  try:
419
443
  _invoke_sync_callback(subscriber.callback, event)
420
444
  except Exception as exc:
@@ -431,7 +455,7 @@ class EventBus(BaseEventBus[T]):
431
455
  ) -> Subscription:
432
456
  """Publish the same event periodically until cancelled or completed."""
433
457
 
434
- interval = validate_positive_timeout(interval, name="Event publish interval")
458
+ interval = _validate_timeout(interval, name="Event publish interval")
435
459
  if times is not None and (
436
460
  isinstance(times, bool) or not isinstance(times, int) or times <= 0
437
461
  ):
@@ -499,7 +523,11 @@ class EventBus(BaseEventBus[T]):
499
523
  timeout: float | None = None,
500
524
  ) -> T | None:
501
525
  if timeout is not None:
502
- timeout = validate_nonnegative_timeout(timeout, name="Event wait timeout")
526
+ timeout = _validate_timeout(
527
+ timeout,
528
+ name="Event wait timeout",
529
+ allow_zero=True,
530
+ )
503
531
  normalized_filter = coerce_event_filter(
504
532
  event_filter,
505
533
  event_type=event_type,