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.
- streambus-0.2.2/.github/workflows/ci.yml +15 -0
- streambus-0.2.2/.github/workflows/release.yml +53 -0
- {streambus-0.2.0 → streambus-0.2.2}/AGENTS.md +18 -0
- {streambus-0.2.0 → streambus-0.2.2}/PKG-INFO +10 -11
- {streambus-0.2.0 → streambus-0.2.2}/README.md +9 -10
- {streambus-0.2.0 → streambus-0.2.2}/pyproject.toml +1 -1
- {streambus-0.2.0 → streambus-0.2.2}/streambus/__init__.py +1 -1
- {streambus-0.2.0 → streambus-0.2.2}/streambus/listener.py +12 -2
- {streambus-0.2.0 → streambus-0.2.2}/streambus/transport.py +1 -0
- {streambus-0.2.0 → streambus-0.2.2}/streambus/transports/redis_streams.py +11 -0
- streambus-0.2.2/tests/test_pending.py +57 -0
- {streambus-0.2.0 → streambus-0.2.2}/uv.lock +2 -2
- streambus-0.2.0/.github/workflows/release.yml +0 -23
- {streambus-0.2.0 → streambus-0.2.2}/.gitignore +0 -0
- {streambus-0.2.0 → streambus-0.2.2}/CLAUDE.md +0 -0
- {streambus-0.2.0 → streambus-0.2.2}/LICENSE +0 -0
- {streambus-0.2.0 → streambus-0.2.2}/example/compose.yml +0 -0
- {streambus-0.2.0 → streambus-0.2.2}/example/consume.py +0 -0
- {streambus-0.2.0 → streambus-0.2.2}/example/publish.py +0 -0
- {streambus-0.2.0 → streambus-0.2.2}/example/pyproject.toml +0 -0
- {streambus-0.2.0 → streambus-0.2.2}/example/uv.lock +0 -0
- {streambus-0.2.0 → streambus-0.2.2}/streambus/config.py +0 -0
- {streambus-0.2.0 → streambus-0.2.2}/streambus/event.py +0 -0
- {streambus-0.2.0 → streambus-0.2.2}/streambus/exceptions.py +0 -0
- {streambus-0.2.0 → streambus-0.2.2}/streambus/publisher.py +0 -0
- {streambus-0.2.0 → streambus-0.2.2}/streambus/transports/__init__.py +0 -0
- {streambus-0.2.0 → streambus-0.2.2}/tests/__init__.py +0 -0
- {streambus-0.2.0 → streambus-0.2.2}/tests/test_event.py +0 -0
- {streambus-0.2.0 → streambus-0.2.2}/tests/test_listener.py +0 -0
- {streambus-0.2.0 → streambus-0.2.2}/tests/test_publisher.py +0 -0
|
@@ -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.
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
125
|
+
transport=RedisStreamsTransport(RedisConfig(...)), # required
|
|
127
126
|
)
|
|
128
127
|
```
|
|
129
128
|
|
|
@@ -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
|
|
46
|
-
|
|
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
|
+
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|