wexample-queue 1.0.1__tar.gz → 2.0.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: wexample-queue
3
- Version: 1.0.1
3
+ Version: 2.0.0
4
4
  Summary: Manage Queuing
5
5
  Author-Email: weeger <contact@wexample.com>
6
6
  License: MIT
@@ -8,6 +8,9 @@ Classifier: Programming Language :: Python :: 3
8
8
  Classifier: License :: OSI Approved :: MIT License
9
9
  Classifier: Operating System :: OS Independent
10
10
  Requires-Python: >=3.10
11
+ Requires-Dist: pika>=1.3.0
12
+ Requires-Dist: wexample-app>=19.3.0
13
+ Requires-Dist: wexample-helpers>=20.1.0
11
14
  Provides-Extra: dev
12
15
  Requires-Dist: pytest; extra == "dev"
13
16
  Requires-Dist: pytest-cov; extra == "dev"
@@ -15,15 +18,66 @@ Description-Content-Type: text/markdown
15
18
 
16
19
  # queue
17
20
 
18
- Version: 1.0.1
21
+ Version: 2.0.0
19
22
 
20
- The repository does not provide any concrete code that could be documented for now.
23
+ `wexample-queue` is what a worker needs to live on a RabbitMQ queue and nothing more: a connector holding the broker connection, and a service base whose whole subject is one queue. A subclass writes the name of the queue and what to do with a message; connecting, declaring, decoding, acknowledging and giving up are the same for every worker and are already written.
24
+
25
+ It exists because the other end of the queue is not always Python. The body of a message is plain JSON, published on the default exchange with the queue name as routing key — which is where a Symfony Messenger transport declaring that same queue binds its own exchange, so both ends meet in the same place without either knowing the other.
26
+
27
+ ## A worker
28
+
29
+ ```python
30
+ from wexample_queue.service.abstract_queue_service import AbstractQueueService
31
+
32
+ @base_class
33
+ class ProcessRunService(AbstractQueueService):
34
+ queue_name: str = public_field(
35
+ default="process_run",
36
+ description="Name of the queue this service consumes",
37
+ )
38
+
39
+ def process_message(self, message: dict[str, Any]) -> None:
40
+ self.log(f"Run {message['id']} asked for in {message['workdir']}")
41
+ ```
42
+
43
+ Calling `start()` opens the connection, declares the queue and blocks consuming
44
+ until `stop()`. Returning from `process_message()` acknowledges the message;
45
+ raising rejects it without putting it back, because requeueing a message that
46
+ just failed is asking for the same failure at once and for ever. Where it goes
47
+ then is the broker's business — a dead letter exchange if the queue has one,
48
+ nowhere if it has not.
49
+
50
+ ## Publishing
51
+
52
+ ```python
53
+ self.connector.publish("process_run_event", {"kind": "process_run", "id": run_id})
54
+ ```
55
+
56
+ ## The broker
57
+
58
+ Where the broker is comes from the environment, under four keys the connector
59
+ declares as expected:
60
+
61
+ ```
62
+ RABBITMQ_HOST
63
+ RABBITMQ_PORT
64
+ RABBITMQ_USER
65
+ RABBITMQ_PASSWORD
66
+ ```
67
+
68
+ Reading them from there rather than from a caller is what keeps a worker and
69
+ whoever publishes to it from holding two addresses that could disagree.
21
70
 
22
71
  ## Table of Contents
23
72
 
73
+ - [A worker](#a-worker)
74
+ - [Publishing](#publishing)
75
+ - [The broker](#the-broker)
24
76
  - [Installation](#installation)
25
77
  - [Tests](#tests)
78
+ - [Architecture](#architecture)
26
79
  - [Integration in the Suite](#integration-in-the-suite)
80
+ - [Dependencies](#dependencies)
27
81
  - [Versioning & Compatibility Policy](#versioning--compatibility-policy)
28
82
  - [License](#license)
29
83
  - [About us](#about-us)
@@ -40,7 +94,34 @@ pip install wexample-queue
40
94
 
41
95
  Requires Python >=3.10.
42
96
 
43
- The repository does not provide any concrete code that could be documented for now.
97
+ ```bash
98
+ pip install wexample-queue
99
+ ```
100
+
101
+ A worker is a class with a queue name and a `process_message()`:
102
+
103
+ ```python
104
+ from typing import Any
105
+
106
+ from wexample_helpers.classes.field import public_field
107
+ from wexample_helpers.decorator.base_class import base_class
108
+ from wexample_queue.service.abstract_queue_service import AbstractQueueService
109
+
110
+ @base_class
111
+ class HelloService(AbstractQueueService):
112
+ queue_name: str = public_field(
113
+ default="hello",
114
+ description="Name of the queue this service consumes",
115
+ )
116
+
117
+ def process_message(self, message: dict[str, Any]) -> None:
118
+ self.log(f"Got {message}")
119
+
120
+ HelloService(kernel=kernel, queue_name="hello").start()
121
+ ```
122
+
123
+ `start()` blocks. The four `RABBITMQ_*` environment keys have to be readable by
124
+ the kernel, which is where the connector goes to find the broker.
44
125
 
45
126
  ## Tests
46
127
 
@@ -91,7 +172,23 @@ To enforce a minimum coverage percentage:
91
172
 
92
173
  This will cause the test suite to fail if coverage drops below 80%.
93
174
 
94
- The repository does not provide any concrete code that could be documented for now.
175
+ ## Architecture
176
+
177
+ Two classes, and the line between them is what a broker is against what a worker is.
178
+
179
+ ### The connector
180
+
181
+ src/wexample_queue/connector/rabbitmq_external_connector.py extends `AbstractExternalConnector` from `wexample-app` and holds a `pika.BlockingConnection` and its channel. It exposes only what this package needs — `connect`, `disconnect`, `declare_queue`, `publish`, `consume`, `stop_consuming` — and reads the broker address from four environment keys it declares in `get_expected_env_keys()`, so a worker never carries one.
182
+
183
+ `publish()` goes through the default exchange with the queue name as routing key, and marks the message persistent. `declare_queue()` declares it durable, with the same arguments both ends use: neither side depends on the other having started first, and a broker restarted on its own finds its queues again.
184
+
185
+ ### The service
186
+
187
+ src/wexample_queue/service/abstract_queue_service.py extends `AbstractService` from `wexample-app`, which already brings the run loop, the signal handling and the shutdown. What this adds is the queue: `start()` opens the connection and declares the queue before handing over to the loop, `_run()` blocks consuming, and `_on_message()` decodes one delivery and answers the broker for it.
188
+
189
+ The answer is the part worth reading. A body that is not JSON, and a message `process_message()` raised on, are both rejected with `requeue=False`. Putting such a message back would be asking for the same failure immediately and for ever; dropping it silently with an `ack` would lose it without saying so. Rejecting it hands the decision to the broker, which is the only place a dead letter exchange can be configured.
190
+
191
+ Unlike the implementation this was drawn from, constructing a service does not start it. `start()` is called by whoever owns the worker, which is what makes a service testable without a broker.
95
192
 
96
193
  ## Integration in the Suite
97
194
 
@@ -103,6 +200,12 @@ The suite includes packages for configuration management, file handling, prompts
103
200
 
104
201
  Visit the [Wexample Suite documentation](https://docs.wexample.com) for the complete package ecosystem.
105
202
 
203
+ ## Dependencies
204
+
205
+ - pika: >=1.3.0
206
+ - wexample-app: >=19.3.0
207
+ - wexample-helpers: >=20.1.0
208
+
106
209
  ## Versioning & Compatibility Policy
107
210
 
108
211
  Wexample packages follow **Semantic Versioning** (SemVer):
@@ -0,0 +1,243 @@
1
+ # queue
2
+
3
+ Version: 2.0.0
4
+
5
+ `wexample-queue` is what a worker needs to live on a RabbitMQ queue and nothing more: a connector holding the broker connection, and a service base whose whole subject is one queue. A subclass writes the name of the queue and what to do with a message; connecting, declaring, decoding, acknowledging and giving up are the same for every worker and are already written.
6
+
7
+ It exists because the other end of the queue is not always Python. The body of a message is plain JSON, published on the default exchange with the queue name as routing key — which is where a Symfony Messenger transport declaring that same queue binds its own exchange, so both ends meet in the same place without either knowing the other.
8
+
9
+ ## A worker
10
+
11
+ ```python
12
+ from wexample_queue.service.abstract_queue_service import AbstractQueueService
13
+
14
+ @base_class
15
+ class ProcessRunService(AbstractQueueService):
16
+ queue_name: str = public_field(
17
+ default="process_run",
18
+ description="Name of the queue this service consumes",
19
+ )
20
+
21
+ def process_message(self, message: dict[str, Any]) -> None:
22
+ self.log(f"Run {message['id']} asked for in {message['workdir']}")
23
+ ```
24
+
25
+ Calling `start()` opens the connection, declares the queue and blocks consuming
26
+ until `stop()`. Returning from `process_message()` acknowledges the message;
27
+ raising rejects it without putting it back, because requeueing a message that
28
+ just failed is asking for the same failure at once and for ever. Where it goes
29
+ then is the broker's business — a dead letter exchange if the queue has one,
30
+ nowhere if it has not.
31
+
32
+ ## Publishing
33
+
34
+ ```python
35
+ self.connector.publish("process_run_event", {"kind": "process_run", "id": run_id})
36
+ ```
37
+
38
+ ## The broker
39
+
40
+ Where the broker is comes from the environment, under four keys the connector
41
+ declares as expected:
42
+
43
+ ```
44
+ RABBITMQ_HOST
45
+ RABBITMQ_PORT
46
+ RABBITMQ_USER
47
+ RABBITMQ_PASSWORD
48
+ ```
49
+
50
+ Reading them from there rather than from a caller is what keeps a worker and
51
+ whoever publishes to it from holding two addresses that could disagree.
52
+
53
+ ## Table of Contents
54
+
55
+ - [A worker](#a-worker)
56
+ - [Publishing](#publishing)
57
+ - [The broker](#the-broker)
58
+ - [Installation](#installation)
59
+ - [Tests](#tests)
60
+ - [Architecture](#architecture)
61
+ - [Integration in the Suite](#integration-in-the-suite)
62
+ - [Dependencies](#dependencies)
63
+ - [Versioning & Compatibility Policy](#versioning--compatibility-policy)
64
+ - [License](#license)
65
+ - [About us](#about-us)
66
+ - [Known Limitations & Roadmap](#known-limitations--roadmap)
67
+ - [Status & Compatibility](#status--compatibility)
68
+ - [Useful Links](#useful-links)
69
+ - [Migration Notes](#migration-notes)
70
+
71
+ ## Installation
72
+
73
+ ```bash
74
+ pip install wexample-queue
75
+ ```
76
+
77
+ Requires Python >=3.10.
78
+
79
+ ```bash
80
+ pip install wexample-queue
81
+ ```
82
+
83
+ A worker is a class with a queue name and a `process_message()`:
84
+
85
+ ```python
86
+ from typing import Any
87
+
88
+ from wexample_helpers.classes.field import public_field
89
+ from wexample_helpers.decorator.base_class import base_class
90
+ from wexample_queue.service.abstract_queue_service import AbstractQueueService
91
+
92
+ @base_class
93
+ class HelloService(AbstractQueueService):
94
+ queue_name: str = public_field(
95
+ default="hello",
96
+ description="Name of the queue this service consumes",
97
+ )
98
+
99
+ def process_message(self, message: dict[str, Any]) -> None:
100
+ self.log(f"Got {message}")
101
+
102
+ HelloService(kernel=kernel, queue_name="hello").start()
103
+ ```
104
+
105
+ `start()` blocks. The four `RABBITMQ_*` environment keys have to be readable by
106
+ the kernel, which is where the connector goes to find the broker.
107
+
108
+ ## Tests
109
+
110
+ This project uses `pytest` for testing and `pytest-cov` for code coverage analysis.
111
+
112
+ ### Installation
113
+
114
+ First, install the required testing dependencies:
115
+ ```bash
116
+ .venv/bin/python -m pip install pytest pytest-cov
117
+ ```
118
+
119
+ ### Basic Usage
120
+
121
+ Run all tests with coverage:
122
+ ```bash
123
+ .venv/bin/python -m pytest --cov --cov-report=html
124
+ ```
125
+
126
+ ### Common Commands
127
+ ```bash
128
+ # Run tests with coverage for a specific module
129
+ .venv/bin/python -m pytest --cov=your_module
130
+
131
+ # Show which lines are not covered
132
+ .venv/bin/python -m pytest --cov=your_module --cov-report=term-missing
133
+
134
+ # Generate an HTML coverage report
135
+ .venv/bin/python -m pytest --cov=your_module --cov-report=html
136
+
137
+ # Combine terminal and HTML reports
138
+ .venv/bin/python -m pytest --cov=your_module --cov-report=term-missing --cov-report=html
139
+
140
+ # Run specific test file with coverage
141
+ .venv/bin/python -m pytest tests/test_file.py --cov=your_module --cov-report=term-missing
142
+ ```
143
+
144
+ ### Viewing HTML Reports
145
+
146
+ After generating an HTML report, open `htmlcov/index.html` in your browser to view detailed line-by-line coverage information.
147
+
148
+ ### Coverage Threshold
149
+
150
+ To enforce a minimum coverage percentage:
151
+ ```bash
152
+ .venv/bin/python -m pytest --cov=your_module --cov-fail-under=80
153
+ ```
154
+
155
+ This will cause the test suite to fail if coverage drops below 80%.
156
+
157
+ ## Architecture
158
+
159
+ Two classes, and the line between them is what a broker is against what a worker is.
160
+
161
+ ### The connector
162
+
163
+ src/wexample_queue/connector/rabbitmq_external_connector.py extends `AbstractExternalConnector` from `wexample-app` and holds a `pika.BlockingConnection` and its channel. It exposes only what this package needs — `connect`, `disconnect`, `declare_queue`, `publish`, `consume`, `stop_consuming` — and reads the broker address from four environment keys it declares in `get_expected_env_keys()`, so a worker never carries one.
164
+
165
+ `publish()` goes through the default exchange with the queue name as routing key, and marks the message persistent. `declare_queue()` declares it durable, with the same arguments both ends use: neither side depends on the other having started first, and a broker restarted on its own finds its queues again.
166
+
167
+ ### The service
168
+
169
+ src/wexample_queue/service/abstract_queue_service.py extends `AbstractService` from `wexample-app`, which already brings the run loop, the signal handling and the shutdown. What this adds is the queue: `start()` opens the connection and declares the queue before handing over to the loop, `_run()` blocks consuming, and `_on_message()` decodes one delivery and answers the broker for it.
170
+
171
+ The answer is the part worth reading. A body that is not JSON, and a message `process_message()` raised on, are both rejected with `requeue=False`. Putting such a message back would be asking for the same failure immediately and for ever; dropping it silently with an `ack` would lose it without saying so. Rejecting it hands the decision to the broker, which is the only place a dead letter exchange can be configured.
172
+
173
+ Unlike the implementation this was drawn from, constructing a service does not start it. `start()` is called by whoever owns the worker, which is what makes a service testable without a broker.
174
+
175
+ ## Integration in the Suite
176
+
177
+ This package is part of the Wexample Suite — a collection of high-quality, modular tools designed to work seamlessly together across multiple languages and environments.
178
+
179
+ ### Related Packages
180
+
181
+ The suite includes packages for configuration management, file handling, prompts, and more. Each package can be used independently or as part of the integrated suite.
182
+
183
+ Visit the [Wexample Suite documentation](https://docs.wexample.com) for the complete package ecosystem.
184
+
185
+ ## Dependencies
186
+
187
+ - pika: >=1.3.0
188
+ - wexample-app: >=19.3.0
189
+ - wexample-helpers: >=20.1.0
190
+
191
+ ## Versioning & Compatibility Policy
192
+
193
+ Wexample packages follow **Semantic Versioning** (SemVer):
194
+
195
+ - **MAJOR**: Breaking changes
196
+ - **MINOR**: New features, backward compatible
197
+ - **PATCH**: Bug fixes, backward compatible
198
+
199
+ We maintain backward compatibility within major versions and provide clear migration guides for breaking changes.
200
+
201
+ ## License
202
+
203
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
204
+
205
+ Free to use in both personal and commercial projects.
206
+
207
+ ## About us
208
+
209
+ [Wexample](https://wexample.com) stands as a cornerstone of the digital ecosystem — a collective of seasoned engineers, researchers, and creators driven by a relentless pursuit of technological excellence. More than a media platform, it has grown into a vibrant community where innovation meets craftsmanship, and where every line of code reflects a commitment to clarity, durability, and shared intelligence.
210
+
211
+ This packages suite embodies this spirit. Trusted by professionals and enthusiasts alike, it delivers a consistent, high-quality foundation for modern development — open, elegant, and battle-tested. Its reputation is built on years of collaboration, refinement, and rigorous attention to detail, making it a natural choice for those who demand both robustness and beauty in their tools.
212
+
213
+ Wexample cultivates a culture of mastery. Each package, each contribution carries the mark of a community that values precision, ethics, and innovation — a community proud to shape the future of digital craftsmanship.
214
+
215
+ ## Known Limitations & Roadmap
216
+
217
+ Current limitations and planned features are tracked in the GitHub issues.
218
+
219
+ See the [project roadmap](https://github.com/wexample/python-queue/issues) for upcoming features and improvements.
220
+
221
+ ## Status & Compatibility
222
+
223
+ **Maturity**: Production-ready
224
+
225
+ **Python Support**: >=3.10
226
+
227
+ **OS Support**: Linux, macOS, Windows
228
+
229
+ **Status**: Actively maintained
230
+
231
+ ## Useful Links
232
+
233
+ - **Homepage**: https://github.com/wexample/python-queue
234
+ - **Documentation**: [docs.wexample.com](https://docs.wexample.com)
235
+ - **Issue Tracker**: https://github.com/wexample/python-queue/issues
236
+ - **Discussions**: https://github.com/wexample/python-queue/discussions
237
+ - **PyPI**: [pypi.org/project/wexample-queue](https://pypi.org/project/wexample-queue/)
238
+
239
+ ## Migration Notes
240
+
241
+ When upgrading between major versions, refer to the migration guides in the documentation.
242
+
243
+ Breaking changes are clearly documented with upgrade paths and examples.
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
6
6
 
7
7
  [project]
8
8
  name = "wexample-queue"
9
- version = "1.0.1"
9
+ version = "2.0.0"
10
10
  description = "Manage Queuing"
11
11
  authors = [
12
12
  { name = "weeger", email = "contact@wexample.com" },
@@ -17,7 +17,11 @@ classifiers = [
17
17
  "License :: OSI Approved :: MIT License",
18
18
  "Operating System :: OS Independent",
19
19
  ]
20
- dependencies = []
20
+ dependencies = [
21
+ "pika>=1.3.0",
22
+ "wexample-app>=19.3.0",
23
+ "wexample-helpers>=20.1.0",
24
+ ]
21
25
 
22
26
  [project.readme]
23
27
  file = "README.md"
@@ -0,0 +1,170 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ import pika
6
+ from wexample_app.connector.abstract_external_connector import (
7
+ AbstractExternalConnector,
8
+ )
9
+ from wexample_helpers.classes.field import public_field
10
+ from wexample_helpers.decorator.base_class import base_class
11
+
12
+ if TYPE_CHECKING:
13
+ from wexample_helpers.const.types import StringsList
14
+
15
+ RABBITMQ_ENV_KEY_HEARTBEAT = "RABBITMQ_HEARTBEAT"
16
+ RABBITMQ_ENV_KEY_HOST = "RABBITMQ_HOST"
17
+ RABBITMQ_ENV_KEY_PASSWORD = "RABBITMQ_PASSWORD"
18
+ RABBITMQ_ENV_KEY_PORT = "RABBITMQ_PORT"
19
+ RABBITMQ_ENV_KEY_USER = "RABBITMQ_USER"
20
+
21
+
22
+ @base_class
23
+ class RabbitmqExternalConnector(AbstractExternalConnector):
24
+ """A connection to a RabbitMQ broker, and the few things one does with it.
25
+
26
+ Where the broker is comes from the environment and not from a caller: a
27
+ worker and whoever publishes to it read the same four keys, so neither of
28
+ them holds an address the other could contradict.
29
+ """
30
+
31
+ channel: Any = public_field(
32
+ default=None,
33
+ description="The channel opened on the connection, once connected",
34
+ )
35
+ connected: bool = public_field(
36
+ default=False,
37
+ description="Whether the connection is currently open",
38
+ )
39
+ connection: Any = public_field(
40
+ default=None,
41
+ description="The blocking connection to the broker, once connected",
42
+ )
43
+ name: str = public_field(
44
+ default="rabbitmq",
45
+ description="Name this connector answers to",
46
+ )
47
+ thread_id: int | None = public_field(
48
+ default=None,
49
+ description="The thread that opened the connection, the only one allowed to use it",
50
+ )
51
+
52
+ def ack(self, delivery_tag: int) -> None:
53
+ self.channel.basic_ack(delivery_tag)
54
+
55
+ def connect(self) -> None:
56
+ if self.connected:
57
+ return
58
+
59
+ import os
60
+ import threading
61
+
62
+ parameters: dict[str, Any] = {}
63
+ # Seconds between two signs of life; the broker's proposal when unset.
64
+ heartbeat = os.environ.get(RABBITMQ_ENV_KEY_HEARTBEAT)
65
+ if heartbeat:
66
+ parameters["heartbeat"] = int(heartbeat)
67
+
68
+ self.connection = pika.BlockingConnection(
69
+ pika.ConnectionParameters(
70
+ host=self._env(RABBITMQ_ENV_KEY_HOST),
71
+ port=int(self._env(RABBITMQ_ENV_KEY_PORT)),
72
+ credentials=pika.PlainCredentials(
73
+ self._env(RABBITMQ_ENV_KEY_USER),
74
+ self._env(RABBITMQ_ENV_KEY_PASSWORD),
75
+ ),
76
+ **parameters,
77
+ )
78
+ )
79
+ self.channel = self.connection.channel()
80
+ self.connected = True
81
+ self.thread_id = threading.get_ident()
82
+
83
+ def declare_queue(self, queue_name: str) -> None:
84
+ """Makes sure the queue is there, durable, whoever gets there first.
85
+
86
+ Both ends declare it with the same arguments so that neither depends on
87
+ the other having started, and a broker restarted on its own keeps the
88
+ queue rather than the messages in it.
89
+ """
90
+ self.channel.queue_declare(queue=queue_name, durable=True)
91
+
92
+ def disconnect(self) -> None:
93
+ if not self.connected:
94
+ return
95
+
96
+ # A connection the broker already dropped cannot be closed again.
97
+ if self.connection.is_open:
98
+ self.connection.close()
99
+ self.connection = None
100
+ self.channel = None
101
+ self.connected = False
102
+
103
+ def get(self, queue_name: str) -> tuple[int, bytes] | None:
104
+ """Takes the next message off the queue, or nothing when it is empty.
105
+
106
+ Pulled one at a time rather than pushed: a message the broker pushes
107
+ while the previous one is still being worked on would wait unacknowledged
108
+ in the client, and be dropped by the broker's delivery timeout long
109
+ before its turn came.
110
+ """
111
+ method, _properties, body = self.channel.basic_get(queue=queue_name)
112
+ if method is None:
113
+ return None
114
+ return method.delivery_tag, body
115
+
116
+ def get_expected_env_keys(self) -> StringsList:
117
+ return [
118
+ RABBITMQ_ENV_KEY_HOST,
119
+ RABBITMQ_ENV_KEY_PORT,
120
+ RABBITMQ_ENV_KEY_USER,
121
+ RABBITMQ_ENV_KEY_PASSWORD,
122
+ ]
123
+
124
+ def nack(self, delivery_tag: int) -> None:
125
+ """Gives a message up, without putting it back on the queue."""
126
+ self.channel.basic_nack(delivery_tag, requeue=False)
127
+
128
+ def publish(self, queue_name: str, payload: dict[str, Any]) -> None:
129
+ """Puts one message on the queue, marked to survive a broker restart.
130
+
131
+ It goes through the default exchange, whose routing key is the queue
132
+ name — which is also where a Symfony Messenger transport declaring that
133
+ queue binds its own exchange, so both land in the same place.
134
+
135
+ Callable from any thread: a connection belongs to the thread that opened
136
+ it, so from another one the publication is handed over to that thread,
137
+ which sends it the next time it tends the connection.
138
+ """
139
+ import threading
140
+
141
+ if threading.get_ident() != self.thread_id:
142
+ self.connection.add_callback_threadsafe(
143
+ lambda: self.publish(queue_name, payload)
144
+ )
145
+ return
146
+
147
+ import json
148
+
149
+ self.channel.basic_publish(
150
+ exchange="",
151
+ routing_key=queue_name,
152
+ body=json.dumps(payload),
153
+ properties=pika.BasicProperties(delivery_mode=2),
154
+ )
155
+
156
+ def sleep(self, seconds: float) -> None:
157
+ """Waits while keeping the connection alive — heartbeats answered, and
158
+ what other threads handed over sent."""
159
+ self.connection.sleep(seconds)
160
+
161
+ def _env(self, key: str) -> str:
162
+ """One of the four keys, from the process environment or from wex's own.
163
+
164
+ Both are read because both are how a worker is configured: a container
165
+ gets its broker from its environment, and a worker started by hand gets
166
+ it from the app's env file.
167
+ """
168
+ import os
169
+
170
+ return os.environ.get(key) or self.kernel.get_env_parameter(key)
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, ClassVar
4
+
5
+ from wexample_app.service.abstract_service import AbstractService
6
+ from wexample_helpers.classes.abstract_method import abstract_method
7
+ from wexample_helpers.classes.field import public_field
8
+ from wexample_helpers.decorator.base_class import base_class
9
+
10
+ if TYPE_CHECKING:
11
+ from wexample_queue.connector.rabbitmq_external_connector import (
12
+ RabbitmqExternalConnector,
13
+ )
14
+
15
+
16
+ @base_class
17
+ class AbstractQueueService(AbstractService):
18
+ """A service whose work is whatever arrives on one queue.
19
+
20
+ All a subclass writes is the name of the queue and what to do with a
21
+ message. Connecting, taking, decoding, acknowledging, keeping the connection
22
+ alive and coming back after losing it are the same for every worker, so they
23
+ are here.
24
+
25
+ A message may take hours. It is worked on in a thread of its own while this
26
+ one tends the connection — answering the broker's heartbeats, sending what
27
+ the work publishes — so the broker never takes a long job for a dead worker.
28
+ """
29
+
30
+ ACKNOWLEDGE_ON_RECEIPT: ClassVar[bool] = False
31
+ """Acknowledge a message as soon as it is taken, rather than once done.
32
+
33
+ For work longer than the broker allows a message to stay unacknowledged
34
+ (`consumer_timeout`, 30 minutes by default), and whose outcome is recorded
35
+ somewhere else: a message acknowledged at once is never delivered twice,
36
+ and nothing the work does can make the broker put it back.
37
+ """
38
+ POLL_INTERVAL: ClassVar[float] = 1.0
39
+ """Seconds between two looks at an empty queue."""
40
+ RECONNECT_DELAY: ClassVar[float] = 5.0
41
+ """Seconds before trying again once the connection is lost."""
42
+ connector: RabbitmqExternalConnector | None = public_field(
43
+ default=None,
44
+ description="The broker connection, opened when the service starts",
45
+ )
46
+ queue_name: str = public_field(
47
+ description="Name of the queue this service consumes",
48
+ )
49
+
50
+ def on_idle(self) -> None:
51
+ """Called each time the queue is found empty. Nothing by default."""
52
+
53
+ def on_tick(self, message: dict[str, Any]) -> None:
54
+ """Called about once a second while a message is being worked on, from the
55
+ thread that tends the connection. Nothing by default."""
56
+
57
+ @abstract_method
58
+ def process_message(self, message: dict[str, Any]) -> None:
59
+ """What to do with one message, already decoded — in a thread of its own.
60
+
61
+ Returning means the message is acknowledged. Raising means it is given
62
+ up on — so a subclass that wants a message tried again has to say so by
63
+ putting it back itself. With `ACKNOWLEDGE_ON_RECEIPT`, it is acknowledged
64
+ before this is called, whatever comes of it.
65
+ """
66
+
67
+ def start(self) -> None:
68
+ self._connect()
69
+
70
+ super().start()
71
+
72
+ def stop(self) -> None:
73
+ if self.connector:
74
+ self.connector.disconnect()
75
+
76
+ super().stop()
77
+
78
+ def _connect(self) -> None:
79
+ from wexample_queue.connector.rabbitmq_external_connector import (
80
+ RabbitmqExternalConnector,
81
+ )
82
+
83
+ self.connector = RabbitmqExternalConnector(kernel=self.kernel)
84
+ self.connector.connect()
85
+ self.connector.declare_queue(self.queue_name)
86
+
87
+ def _decode(self, body: bytes) -> dict[str, Any] | None:
88
+ import json
89
+
90
+ try:
91
+ return json.loads(body.decode())
92
+ except json.JSONDecodeError as exception:
93
+ self.io.error(
94
+ f"[{self.get_snake_short_class_name()}] Message is not JSON: {exception}",
95
+ fatal=False,
96
+ )
97
+ return None
98
+
99
+ def _handle(self, delivery_tag: int, body: bytes) -> None:
100
+ """Works one message through, keeping the connection alive meanwhile.
101
+
102
+ A message that cannot be read, or that the service could not get
103
+ through, is rejected without being put back: requeueing it would be
104
+ asking for the same failure at once and for ever.
105
+ """
106
+ import threading
107
+
108
+ message = self._decode(body)
109
+ if message is None:
110
+ self.connector.nack(delivery_tag)
111
+ return
112
+
113
+ if self.ACKNOWLEDGE_ON_RECEIPT:
114
+ self.connector.ack(delivery_tag)
115
+
116
+ outcome: dict[str, Any] = {}
117
+
118
+ def work() -> None:
119
+ try:
120
+ self.process_message(message)
121
+ except Exception as exception:
122
+ import traceback
123
+
124
+ outcome["error"] = exception
125
+ self.io.debug(traceback.format_exc())
126
+
127
+ import time
128
+
129
+ from pika.exceptions import AMQPError
130
+
131
+ worker = threading.Thread(target=work, name=f"{self.queue_name}-message")
132
+ worker.start()
133
+
134
+ # Losing the connection does not stop the work: it is let finish, the
135
+ # connection being opened again afterwards, and nothing else is taken
136
+ # meanwhile.
137
+ lost: AMQPError | None = None
138
+ while worker.is_alive():
139
+ if lost is None:
140
+ try:
141
+ self.connector.sleep(self.POLL_INTERVAL)
142
+ except AMQPError as exception:
143
+ lost = exception
144
+ else:
145
+ time.sleep(self.POLL_INTERVAL)
146
+ self.on_tick(message)
147
+
148
+ if lost is not None:
149
+ # An unacknowledged message is given back by the broker with the
150
+ # connection it was delivered on; one acknowledged on receipt is done.
151
+ raise lost
152
+
153
+ if "error" in outcome:
154
+ self.io.error(
155
+ f"[{self.get_snake_short_class_name()}] Message failed: {outcome['error']}",
156
+ fatal=False,
157
+ )
158
+
159
+ if not self.ACKNOWLEDGE_ON_RECEIPT:
160
+ if "error" in outcome:
161
+ self.connector.nack(delivery_tag)
162
+ else:
163
+ self.connector.ack(delivery_tag)
164
+
165
+ def _run(self) -> None:
166
+ """One turn: the next message worked through, or a short wait.
167
+
168
+ A lost connection is opened again rather than ending the service: a
169
+ worker left running for a night outlives the odd network hiccup, and a
170
+ broker restarted under it.
171
+ """
172
+ from pika.exceptions import AMQPError
173
+
174
+ try:
175
+ if not self.connector or not self.connector.connected:
176
+ self._connect()
177
+ self.log(f"Consuming messages on queue « {self.queue_name} »...")
178
+
179
+ delivery = self.connector.get(self.queue_name)
180
+ if delivery is None:
181
+ self.on_idle()
182
+ self.connector.sleep(self.POLL_INTERVAL)
183
+ return
184
+
185
+ self._handle(*delivery)
186
+ except AMQPError as exception:
187
+ self.io.error(
188
+ f"[{self.get_snake_short_class_name()}] Connection lost: "
189
+ f"{type(exception).__name__}: {exception}. Reconnecting in "
190
+ f"{self.RECONNECT_DELAY:.0f}s.",
191
+ fatal=False,
192
+ )
193
+ if self.connector:
194
+ self.connector.connected = False
195
+ import time
196
+
197
+ time.sleep(self.RECONNECT_DELAY)
File without changes
@@ -1,143 +0,0 @@
1
- # queue
2
-
3
- Version: 1.0.1
4
-
5
- The repository does not provide any concrete code that could be documented for now.
6
-
7
- ## Table of Contents
8
-
9
- - [Installation](#installation)
10
- - [Tests](#tests)
11
- - [Integration in the Suite](#integration-in-the-suite)
12
- - [Versioning & Compatibility Policy](#versioning--compatibility-policy)
13
- - [License](#license)
14
- - [About us](#about-us)
15
- - [Known Limitations & Roadmap](#known-limitations--roadmap)
16
- - [Status & Compatibility](#status--compatibility)
17
- - [Useful Links](#useful-links)
18
- - [Migration Notes](#migration-notes)
19
-
20
- ## Installation
21
-
22
- ```bash
23
- pip install wexample-queue
24
- ```
25
-
26
- Requires Python >=3.10.
27
-
28
- The repository does not provide any concrete code that could be documented for now.
29
-
30
- ## Tests
31
-
32
- This project uses `pytest` for testing and `pytest-cov` for code coverage analysis.
33
-
34
- ### Installation
35
-
36
- First, install the required testing dependencies:
37
- ```bash
38
- .venv/bin/python -m pip install pytest pytest-cov
39
- ```
40
-
41
- ### Basic Usage
42
-
43
- Run all tests with coverage:
44
- ```bash
45
- .venv/bin/python -m pytest --cov --cov-report=html
46
- ```
47
-
48
- ### Common Commands
49
- ```bash
50
- # Run tests with coverage for a specific module
51
- .venv/bin/python -m pytest --cov=your_module
52
-
53
- # Show which lines are not covered
54
- .venv/bin/python -m pytest --cov=your_module --cov-report=term-missing
55
-
56
- # Generate an HTML coverage report
57
- .venv/bin/python -m pytest --cov=your_module --cov-report=html
58
-
59
- # Combine terminal and HTML reports
60
- .venv/bin/python -m pytest --cov=your_module --cov-report=term-missing --cov-report=html
61
-
62
- # Run specific test file with coverage
63
- .venv/bin/python -m pytest tests/test_file.py --cov=your_module --cov-report=term-missing
64
- ```
65
-
66
- ### Viewing HTML Reports
67
-
68
- After generating an HTML report, open `htmlcov/index.html` in your browser to view detailed line-by-line coverage information.
69
-
70
- ### Coverage Threshold
71
-
72
- To enforce a minimum coverage percentage:
73
- ```bash
74
- .venv/bin/python -m pytest --cov=your_module --cov-fail-under=80
75
- ```
76
-
77
- This will cause the test suite to fail if coverage drops below 80%.
78
-
79
- The repository does not provide any concrete code that could be documented for now.
80
-
81
- ## Integration in the Suite
82
-
83
- This package is part of the Wexample Suite — a collection of high-quality, modular tools designed to work seamlessly together across multiple languages and environments.
84
-
85
- ### Related Packages
86
-
87
- The suite includes packages for configuration management, file handling, prompts, and more. Each package can be used independently or as part of the integrated suite.
88
-
89
- Visit the [Wexample Suite documentation](https://docs.wexample.com) for the complete package ecosystem.
90
-
91
- ## Versioning & Compatibility Policy
92
-
93
- Wexample packages follow **Semantic Versioning** (SemVer):
94
-
95
- - **MAJOR**: Breaking changes
96
- - **MINOR**: New features, backward compatible
97
- - **PATCH**: Bug fixes, backward compatible
98
-
99
- We maintain backward compatibility within major versions and provide clear migration guides for breaking changes.
100
-
101
- ## License
102
-
103
- This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
104
-
105
- Free to use in both personal and commercial projects.
106
-
107
- ## About us
108
-
109
- [Wexample](https://wexample.com) stands as a cornerstone of the digital ecosystem — a collective of seasoned engineers, researchers, and creators driven by a relentless pursuit of technological excellence. More than a media platform, it has grown into a vibrant community where innovation meets craftsmanship, and where every line of code reflects a commitment to clarity, durability, and shared intelligence.
110
-
111
- This packages suite embodies this spirit. Trusted by professionals and enthusiasts alike, it delivers a consistent, high-quality foundation for modern development — open, elegant, and battle-tested. Its reputation is built on years of collaboration, refinement, and rigorous attention to detail, making it a natural choice for those who demand both robustness and beauty in their tools.
112
-
113
- Wexample cultivates a culture of mastery. Each package, each contribution carries the mark of a community that values precision, ethics, and innovation — a community proud to shape the future of digital craftsmanship.
114
-
115
- ## Known Limitations & Roadmap
116
-
117
- Current limitations and planned features are tracked in the GitHub issues.
118
-
119
- See the [project roadmap](https://github.com/wexample/python-queue/issues) for upcoming features and improvements.
120
-
121
- ## Status & Compatibility
122
-
123
- **Maturity**: Production-ready
124
-
125
- **Python Support**: >=3.10
126
-
127
- **OS Support**: Linux, macOS, Windows
128
-
129
- **Status**: Actively maintained
130
-
131
- ## Useful Links
132
-
133
- - **Homepage**: https://github.com/wexample/python-queue
134
- - **Documentation**: [docs.wexample.com](https://docs.wexample.com)
135
- - **Issue Tracker**: https://github.com/wexample/python-queue/issues
136
- - **Discussions**: https://github.com/wexample/python-queue/discussions
137
- - **PyPI**: [pypi.org/project/wexample-queue](https://pypi.org/project/wexample-queue/)
138
-
139
- ## Migration Notes
140
-
141
- When upgrading between major versions, refer to the migration guides in the documentation.
142
-
143
- Breaking changes are clearly documented with upgrade paths and examples.