streambus 0.2.0__tar.gz → 0.2.2__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 (30) hide show
  1. streambus-0.2.2/.github/workflows/ci.yml +15 -0
  2. streambus-0.2.2/.github/workflows/release.yml +53 -0
  3. {streambus-0.2.0 → streambus-0.2.2}/AGENTS.md +18 -0
  4. {streambus-0.2.0 → streambus-0.2.2}/PKG-INFO +10 -11
  5. {streambus-0.2.0 → streambus-0.2.2}/README.md +9 -10
  6. {streambus-0.2.0 → streambus-0.2.2}/pyproject.toml +1 -1
  7. {streambus-0.2.0 → streambus-0.2.2}/streambus/__init__.py +1 -1
  8. {streambus-0.2.0 → streambus-0.2.2}/streambus/listener.py +12 -2
  9. {streambus-0.2.0 → streambus-0.2.2}/streambus/transport.py +1 -0
  10. {streambus-0.2.0 → streambus-0.2.2}/streambus/transports/redis_streams.py +11 -0
  11. streambus-0.2.2/tests/test_pending.py +57 -0
  12. {streambus-0.2.0 → streambus-0.2.2}/uv.lock +2 -2
  13. streambus-0.2.0/.github/workflows/release.yml +0 -23
  14. {streambus-0.2.0 → streambus-0.2.2}/.gitignore +0 -0
  15. {streambus-0.2.0 → streambus-0.2.2}/CLAUDE.md +0 -0
  16. {streambus-0.2.0 → streambus-0.2.2}/LICENSE +0 -0
  17. {streambus-0.2.0 → streambus-0.2.2}/example/compose.yml +0 -0
  18. {streambus-0.2.0 → streambus-0.2.2}/example/consume.py +0 -0
  19. {streambus-0.2.0 → streambus-0.2.2}/example/publish.py +0 -0
  20. {streambus-0.2.0 → streambus-0.2.2}/example/pyproject.toml +0 -0
  21. {streambus-0.2.0 → streambus-0.2.2}/example/uv.lock +0 -0
  22. {streambus-0.2.0 → streambus-0.2.2}/streambus/config.py +0 -0
  23. {streambus-0.2.0 → streambus-0.2.2}/streambus/event.py +0 -0
  24. {streambus-0.2.0 → streambus-0.2.2}/streambus/exceptions.py +0 -0
  25. {streambus-0.2.0 → streambus-0.2.2}/streambus/publisher.py +0 -0
  26. {streambus-0.2.0 → streambus-0.2.2}/streambus/transports/__init__.py +0 -0
  27. {streambus-0.2.0 → streambus-0.2.2}/tests/__init__.py +0 -0
  28. {streambus-0.2.0 → streambus-0.2.2}/tests/test_event.py +0 -0
  29. {streambus-0.2.0 → streambus-0.2.2}/tests/test_listener.py +0 -0
  30. {streambus-0.2.0 → streambus-0.2.2}/tests/test_publisher.py +0 -0
@@ -0,0 +1,15 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v6
14
+ - run: uv sync --group dev
15
+ - run: uv run pytest -q
@@ -0,0 +1,53 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+ branches:
8
+ - main
9
+ paths:
10
+ - ".github/workflows/release.yml"
11
+
12
+ jobs:
13
+ build-and-publish:
14
+ if: >-
15
+ github.ref_type == 'tag' ||
16
+ github.event.head_commit.message == 'release: streambus v0.2.2'
17
+ runs-on: ubuntu-latest
18
+ environment: release
19
+ permissions:
20
+ contents: write
21
+ id-token: write
22
+
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+
26
+ - uses: astral-sh/setup-uv@v6
27
+
28
+ - name: Verify package version
29
+ run: |
30
+ python - <<'PY'
31
+ from pathlib import Path
32
+ import tomllib
33
+
34
+ package = tomllib.loads(Path("pyproject.toml").read_text())
35
+ assert package["project"]["version"] == "0.2.2"
36
+ PY
37
+
38
+ - run: uv sync --group dev
39
+ - run: uv run pytest -q
40
+ - run: uv build
41
+
42
+ - name: Create GitHub release
43
+ if: github.ref == 'refs/heads/main'
44
+ env:
45
+ GH_TOKEN: ${{ github.token }}
46
+ run: |
47
+ gh release create v0.2.2 dist/* \
48
+ --target "$GITHUB_SHA" \
49
+ --title "streambus v0.2.2" \
50
+ --notes "Recover idle pending Redis Streams messages via XAUTOCLAIM, with at-least-once delivery and tests."
51
+
52
+ - name: Publish to PyPI
53
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -103,6 +103,24 @@ class ClientEvent(StreamBusEvent):
103
103
  ```
104
104
  <!-- /rule:local-event-class -->
105
105
 
106
+ <!-- post-merge-workflow -->
107
+ ## Post-merge
108
+
109
+ Después de mergear un PR:
110
+
111
+ 1. Cambiar a `main` localmente y hacer pull.
112
+ 2. Ejecutar `uv run pytest` — si aparece algún fallo, abrir una rama nueva, corregirlo y hacer PR.
113
+ 3. Si todo verde: actualizar la versión en `pyproject.toml` y en `streambus/__init__.py` (`__version__`).
114
+ 4. Actualizar la documentación si el cambio afecta comportamiento visible para el caller.
115
+ 5. Publicar el tag: `git tag vX.Y.Z && git push origin vX.Y.Z`.
116
+ El workflow `.github/workflows/release.yml` se dispara automáticamente y publica en PyPI vía OIDC.
117
+
118
+ **Archivos con versión:**
119
+ - `pyproject.toml` — campo `version`
120
+ - `streambus/__init__.py` — `__version__`
121
+
122
+ <!-- /post-merge-workflow -->
123
+
106
124
  <!-- rule:exceptions -->
107
125
  ## Jerarquía de excepciones
108
126
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: streambus
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Lightweight Redis Streams event listener with a clean callback API
5
5
  Project-URL: Homepage, https://github.com/jmeiracorbal/streambus
6
6
  Project-URL: Repository, https://github.com/jmeiracorbal/streambus
@@ -80,12 +80,10 @@ class OrderCreated(StreamBusEvent):
80
80
  Publish it:
81
81
 
82
82
  ```python
83
- from streambus import EventPublisher, RedisConfig
83
+ from streambus import EventPublisher, RedisConfig, RedisStreamsTransport
84
84
 
85
- publisher = EventPublisher(
86
- stream="orders:events",
87
- config=RedisConfig(url="redis://localhost:6379"),
88
- )
85
+ transport = RedisStreamsTransport(RedisConfig(url="redis://localhost:6379"))
86
+ publisher = EventPublisher(stream="orders:events", transport=transport)
89
87
 
90
88
  publisher.publish(
91
89
  OrderCreated(
@@ -100,7 +98,7 @@ publisher.publish(
100
98
  Consume it:
101
99
 
102
100
  ```python
103
- from streambus import EventListener, RedisConfig
101
+ from streambus import EventListener, RedisConfig, RedisStreamsTransport
104
102
 
105
103
  def handle_order(event: OrderCreated) -> None:
106
104
  print(f"New order {event.order_id}: total {event.total}")
@@ -110,7 +108,7 @@ EventListener(
110
108
  group="notifications",
111
109
  handler=handle_order,
112
110
  event_class=OrderCreated,
113
- config=RedisConfig(url="redis://localhost:6379"),
111
+ transport=RedisStreamsTransport(RedisConfig(url="redis://localhost:6379")),
114
112
  ).run()
115
113
  ```
116
114
 
@@ -118,7 +116,7 @@ EventListener(
118
116
 
119
117
  streambus uses [Redis Streams](https://redis.io/docs/data-types/streams/) for transport: persistent storage, consumer groups, and at-least-once delivery. The library manages group creation, the blocking read loop, message acknowledgement, and automatic reconnection on connection loss.
120
118
 
121
- Each consumer group processes events independently. A message published to a stream reaches every group exactly once.
119
+ Each consumer group processes events independently. Delivery is at least once: unacknowledged messages can be redelivered, so handlers must be idempotent. The listener reclaims idle pending entries using Redis XAUTOCLAIM before reading new entries (Redis 6.2+ required).
122
120
 
123
121
  ## Events
124
122
 
@@ -153,11 +151,12 @@ EventListener(
153
151
  group="consumer-group", # required
154
152
  handler=my_handler, # required — callable(event) -> None
155
153
  event_class=MyEvent, # required — must subclass StreamBusEvent
156
- config=RedisConfig(...), # required
154
+ transport=RedisStreamsTransport(RedisConfig(...)), # required
157
155
  consumer=None, # defaults to "{group}-1"
158
156
  batch_size=10,
159
157
  block_ms=5000,
160
158
  retry_delay=3.0,
159
+ pending_idle_ms=60000, # minimum idle time before retrying pending entries
161
160
  )
162
161
  ```
163
162
 
@@ -166,7 +165,7 @@ EventListener(
166
165
  ```python
167
166
  EventPublisher(
168
167
  stream="stream:name", # required
169
- config=RedisConfig(...), # required
168
+ transport=RedisStreamsTransport(RedisConfig(...)), # required
170
169
  )
171
170
  ```
172
171
 
@@ -37,12 +37,10 @@ class OrderCreated(StreamBusEvent):
37
37
  Publish it:
38
38
 
39
39
  ```python
40
- from streambus import EventPublisher, RedisConfig
40
+ from streambus import EventPublisher, RedisConfig, RedisStreamsTransport
41
41
 
42
- publisher = EventPublisher(
43
- stream="orders:events",
44
- config=RedisConfig(url="redis://localhost:6379"),
45
- )
42
+ transport = RedisStreamsTransport(RedisConfig(url="redis://localhost:6379"))
43
+ publisher = EventPublisher(stream="orders:events", transport=transport)
46
44
 
47
45
  publisher.publish(
48
46
  OrderCreated(
@@ -57,7 +55,7 @@ publisher.publish(
57
55
  Consume it:
58
56
 
59
57
  ```python
60
- from streambus import EventListener, RedisConfig
58
+ from streambus import EventListener, RedisConfig, RedisStreamsTransport
61
59
 
62
60
  def handle_order(event: OrderCreated) -> None:
63
61
  print(f"New order {event.order_id}: total {event.total}")
@@ -67,7 +65,7 @@ EventListener(
67
65
  group="notifications",
68
66
  handler=handle_order,
69
67
  event_class=OrderCreated,
70
- config=RedisConfig(url="redis://localhost:6379"),
68
+ transport=RedisStreamsTransport(RedisConfig(url="redis://localhost:6379")),
71
69
  ).run()
72
70
  ```
73
71
 
@@ -75,7 +73,7 @@ EventListener(
75
73
 
76
74
  streambus uses [Redis Streams](https://redis.io/docs/data-types/streams/) for transport: persistent storage, consumer groups, and at-least-once delivery. The library manages group creation, the blocking read loop, message acknowledgement, and automatic reconnection on connection loss.
77
75
 
78
- Each consumer group processes events independently. A message published to a stream reaches every group exactly once.
76
+ Each consumer group processes events independently. Delivery is at least once: unacknowledged messages can be redelivered, so handlers must be idempotent. The listener reclaims idle pending entries using Redis XAUTOCLAIM before reading new entries (Redis 6.2+ required).
79
77
 
80
78
  ## Events
81
79
 
@@ -110,11 +108,12 @@ EventListener(
110
108
  group="consumer-group", # required
111
109
  handler=my_handler, # required — callable(event) -> None
112
110
  event_class=MyEvent, # required — must subclass StreamBusEvent
113
- config=RedisConfig(...), # required
111
+ transport=RedisStreamsTransport(RedisConfig(...)), # required
114
112
  consumer=None, # defaults to "{group}-1"
115
113
  batch_size=10,
116
114
  block_ms=5000,
117
115
  retry_delay=3.0,
116
+ pending_idle_ms=60000, # minimum idle time before retrying pending entries
118
117
  )
119
118
  ```
120
119
 
@@ -123,7 +122,7 @@ EventListener(
123
122
  ```python
124
123
  EventPublisher(
125
124
  stream="stream:name", # required
126
- config=RedisConfig(...), # required
125
+ transport=RedisStreamsTransport(RedisConfig(...)), # required
127
126
  )
128
127
  ```
129
128
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "streambus"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Lightweight Redis Streams event listener with a clean callback API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -18,4 +18,4 @@ __all__ = [
18
18
  "EventValidationError",
19
19
  "TransportConnectionError",
20
20
  ]
21
- __version__ = "0.2.0"
21
+ __version__ = "0.2.2"
@@ -21,6 +21,7 @@ class EventListener:
21
21
  batch_size: int = 10,
22
22
  block_ms: int = 5000,
23
23
  retry_delay: float = 3.0,
24
+ pending_idle_ms: int = 60000,
24
25
  ):
25
26
  if not stream:
26
27
  raise ConfigurationError("stream is required")
@@ -30,6 +31,8 @@ class EventListener:
30
31
  raise ConfigurationError("handler must be callable")
31
32
  if not (isinstance(event_class, type) and issubclass(event_class, StreamBusEvent)):
32
33
  raise ConfigurationError("event_class must be a subclass of StreamBusEvent")
34
+ if batch_size < 1 or block_ms < 0 or pending_idle_ms < 1:
35
+ raise ConfigurationError("batch_size must be positive, block_ms non-negative, and pending_idle_ms positive")
33
36
 
34
37
  self.stream = stream
35
38
  self.group = group
@@ -40,11 +43,17 @@ class EventListener:
40
43
  self.batch_size = batch_size
41
44
  self.block_ms = block_ms
42
45
  self.retry_delay = retry_delay
46
+ self.pending_idle_ms = pending_idle_ms
43
47
 
44
48
  def process_one_batch(self) -> int:
45
- messages = self.transport.read(
46
- self.stream, self.group, self.consumer, self.batch_size, self.block_ms,
49
+ # XAUTOCLAIM recovers unacknowledged messages from crashed/failed consumers.
50
+ messages = self.transport.claim_pending(
51
+ self.stream, self.group, self.consumer, self.batch_size, self.pending_idle_ms,
47
52
  )
53
+ if not messages:
54
+ messages = self.transport.read(
55
+ self.stream, self.group, self.consumer, self.batch_size, self.block_ms,
56
+ )
48
57
  count = 0
49
58
  for msg_id, data in messages:
50
59
  try:
@@ -53,6 +62,7 @@ class EventListener:
53
62
  self.transport.ack(self.stream, self.group, msg_id)
54
63
  count += 1
55
64
  except Exception:
65
+ # Leave the message in the pending entries list for retry after min idle.
56
66
  logger.exception("error processing message %s", msg_id)
57
67
  return count
58
68
 
@@ -5,6 +5,7 @@ from typing import Protocol, runtime_checkable
5
5
  class EventTransport(Protocol):
6
6
  def publish(self, stream: str, data: dict) -> None: ...
7
7
  def read(self, stream: str, group: str, consumer: str, batch_size: int, block_ms: int) -> list[tuple[str, dict]]: ...
8
+ def claim_pending(self, stream: str, group: str, consumer: str, batch_size: int, min_idle_ms: int) -> list[tuple[str, dict]]: ...
8
9
  def ack(self, stream: str, group: str, msg_id: str) -> None: ...
9
10
  def ensure_group(self, stream: str, group: str) -> None: ...
10
11
  def reconnect(self) -> None: ...
@@ -42,6 +42,17 @@ class RedisStreamsTransport:
42
42
  except redis_lib.exceptions.ConnectionError as exc:
43
43
  raise TransportConnectionError(str(exc)) from exc
44
44
 
45
+ def claim_pending(self, stream: str, group: str, consumer: str, batch_size: int, min_idle_ms: int) -> list[tuple[str, dict]]:
46
+ """Recover idle pending entries, including entries owned by other consumers."""
47
+ try:
48
+ _cursor, messages, *_deleted = self._get_client().xautoclaim(
49
+ stream, group, consumer, min_idle_time=min_idle_ms, start_id="0-0",
50
+ count=batch_size,
51
+ )
52
+ return [(msg_id, data) for msg_id, data in messages]
53
+ except redis_lib.exceptions.ConnectionError as exc:
54
+ raise TransportConnectionError(str(exc)) from exc
55
+
45
56
  def ack(self, stream: str, group: str, msg_id: str) -> None:
46
57
  try:
47
58
  self._get_client().xack(stream, group, msg_id)
@@ -0,0 +1,57 @@
1
+ from dataclasses import dataclass
2
+
3
+ import fakeredis
4
+
5
+ from streambus import EventListener, StreamBusEvent
6
+ from streambus.config import RedisConfig
7
+ from streambus.transports.redis_streams import RedisStreamsTransport
8
+
9
+
10
+ @dataclass(kw_only=True)
11
+ class SampleEvent(StreamBusEvent):
12
+ document: str
13
+
14
+
15
+ def test_recovers_pending_message_after_handler_failure() -> None:
16
+ client = fakeredis.FakeRedis(decode_responses=True)
17
+ transport = RedisStreamsTransport(RedisConfig(url="redis://localhost:6379"))
18
+ transport._client = client
19
+ transport.ensure_group("documents", "conversion")
20
+
21
+ client.xadd("documents", SampleEvent(event_type="document.changed", document="one.pdf").to_dict())
22
+ failed = EventListener(
23
+ stream="documents", group="conversion", consumer="worker-a",
24
+ handler=lambda event: (_ for _ in ()).throw(RuntimeError("conversion failed")),
25
+ event_class=SampleEvent, transport=transport, pending_idle_ms=1,
26
+ )
27
+ assert failed.process_one_batch() == 0
28
+ assert client.xpending("documents", "conversion")["pending"] == 1
29
+
30
+ recovered = []
31
+ successful = EventListener(
32
+ stream="documents", group="conversion", consumer="worker-b",
33
+ handler=recovered.append, event_class=SampleEvent,
34
+ transport=transport, pending_idle_ms=1,
35
+ )
36
+ # Time-free deterministic claim: zero idle would bypass the delay, but the library
37
+ # deliberately requires a positive delay, so emulate a sufficiently idle record.
38
+ import time
39
+ time.sleep(0.01)
40
+ assert successful.process_one_batch() == 1
41
+ assert [event.document for event in recovered] == ["one.pdf"]
42
+ assert client.xpending("documents", "conversion")["pending"] == 0
43
+
44
+
45
+ def test_processes_new_entries_when_no_pending_eligible() -> None:
46
+ client = fakeredis.FakeRedis(decode_responses=True)
47
+ transport = RedisStreamsTransport(RedisConfig(url="redis://localhost:6379"))
48
+ transport._client = client
49
+ transport.ensure_group("documents", "conversion")
50
+ client.xadd("documents", SampleEvent(event_type="document.changed", document="two.pdf").to_dict())
51
+ received = []
52
+ listener = EventListener(
53
+ stream="documents", group="conversion", handler=received.append,
54
+ event_class=SampleEvent, transport=transport, block_ms=0,
55
+ )
56
+ assert listener.process_one_batch() == 1
57
+ assert received[0].document == "two.pdf"
@@ -1,5 +1,5 @@
1
1
  version = 1
2
- revision = 2
2
+ revision = 5
3
3
  requires-python = ">=3.11"
4
4
 
5
5
  [[package]]
@@ -120,7 +120,7 @@ wheels = [
120
120
 
121
121
  [[package]]
122
122
  name = "streambus"
123
- version = "0.1.0"
123
+ version = "0.2.2"
124
124
  source = { editable = "." }
125
125
  dependencies = [
126
126
  { name = "redis" },
@@ -1,23 +0,0 @@
1
- name: Release
2
-
3
- on:
4
- push:
5
- tags:
6
- - "v*"
7
-
8
- jobs:
9
- build-and-publish:
10
- runs-on: ubuntu-latest
11
- environment: release
12
- permissions:
13
- id-token: write
14
-
15
- steps:
16
- - uses: actions/checkout@v4
17
-
18
- - uses: astral-sh/setup-uv@v4
19
-
20
- - run: uv build
21
-
22
- - name: Publish to PyPI
23
- uses: pypa/gh-action-pypi-publish@release/v1
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes