apppy-queues 0.1.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.
- apppy_queues-0.1.0/.gitignore +28 -0
- apppy_queues-0.1.0/PKG-INFO +11 -0
- apppy_queues-0.1.0/README.md +0 -0
- apppy_queues-0.1.0/pyproject.toml +25 -0
- apppy_queues-0.1.0/queues.mk +23 -0
- apppy_queues-0.1.0/src/apppy/queues/__init__.py +35 -0
- apppy_queues-0.1.0/src/apppy/queues/asyncio.py +92 -0
- apppy_queues-0.1.0/src/apppy/queues/asyncio_unit_test.py +50 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
__generated__/
|
|
2
|
+
dist/
|
|
3
|
+
*.egg-info
|
|
4
|
+
.env
|
|
5
|
+
.env.*
|
|
6
|
+
*.env
|
|
7
|
+
!.env.ci
|
|
8
|
+
.file_store/
|
|
9
|
+
*.pid
|
|
10
|
+
.python-version
|
|
11
|
+
*.secrets
|
|
12
|
+
.secrets
|
|
13
|
+
*.tar.gz
|
|
14
|
+
*.test_output/
|
|
15
|
+
.test_output/
|
|
16
|
+
uv.lock
|
|
17
|
+
*.whl
|
|
18
|
+
|
|
19
|
+
# System files
|
|
20
|
+
__pycache__
|
|
21
|
+
.DS_Store
|
|
22
|
+
|
|
23
|
+
# Editor files
|
|
24
|
+
*.sublime-project
|
|
25
|
+
*.sublime-workspace
|
|
26
|
+
.vscode/*
|
|
27
|
+
!.vscode/settings.json
|
|
28
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apppy-queues
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python definitions for async processing over a queue for server development
|
|
5
|
+
Project-URL: Homepage, https://github.com/spals/apppy
|
|
6
|
+
Author: Tim Kral
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: fastapi-lifespan-manager==0.1.4
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "apppy-queues"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python definitions for async processing over a queue for server development"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{ name = "Tim Kral" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"fastapi-lifespan-manager==0.1.4"
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/spals/apppy"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/apppy"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
ifndef APPPY_QUEUES_MK_INCLUDED
|
|
2
|
+
APPPY_QUEUES_MK_INCLUDED := 1
|
|
3
|
+
QUEUES_PKG_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
|
4
|
+
|
|
5
|
+
.PHONY: queues queues-dev queues/build queues/clean queues/install queues/install-dev
|
|
6
|
+
|
|
7
|
+
queues: queues/clean queues/install
|
|
8
|
+
|
|
9
|
+
queues-dev: queues/clean queues/install-dev
|
|
10
|
+
|
|
11
|
+
queues/build:
|
|
12
|
+
cd $(QUEUES_PKG_DIR) && uvx --from build pyproject-build
|
|
13
|
+
|
|
14
|
+
queues/clean:
|
|
15
|
+
cd $(QUEUES_PKG_DIR) && rm -rf dist/ *.egg-info .venv
|
|
16
|
+
|
|
17
|
+
queues/install: queues/build
|
|
18
|
+
cd $(QUEUES_PKG_DIR) && uv pip install dist/*.whl
|
|
19
|
+
|
|
20
|
+
queues/install-dev:
|
|
21
|
+
cd $(QUEUES_PKG_DIR) && uv pip install -e .
|
|
22
|
+
|
|
23
|
+
endif # APPPY_QUEUES_MK_INCLUDED
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class QueueHandler(metaclass=abc.ABCMeta):
|
|
5
|
+
@abc.abstractmethod
|
|
6
|
+
async def handle_exception(self, message, exception):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
@abc.abstractmethod
|
|
10
|
+
async def handle_message(self, message):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Queue(metaclass=abc.ABCMeta): # noqa: B024
|
|
15
|
+
# @abstractmethod
|
|
16
|
+
async def publish(self, message):
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
|
|
19
|
+
# @abstractmethod
|
|
20
|
+
def register_handler(self, handler: QueueHandler):
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
|
|
23
|
+
@abc.abstractmethod
|
|
24
|
+
def startup(self):
|
|
25
|
+
"""
|
|
26
|
+
Startup a queue implementation.
|
|
27
|
+
"""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
@abc.abstractmethod
|
|
31
|
+
def shutdown(self):
|
|
32
|
+
"""
|
|
33
|
+
Allow graceful shutdown logic for a queue implementation.
|
|
34
|
+
"""
|
|
35
|
+
pass
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import queue
|
|
3
|
+
from threading import Thread
|
|
4
|
+
|
|
5
|
+
from fastapi_lifespan_manager import LifespanManager
|
|
6
|
+
|
|
7
|
+
from apppy.logger import WithLogger
|
|
8
|
+
from apppy.queues import Queue, QueueHandler
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AsyncioQueue(Queue, WithLogger):
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
lifespan: LifespanManager | None,
|
|
15
|
+
name: str,
|
|
16
|
+
maxsize: int = -1,
|
|
17
|
+
shutdown_timeout: int = 2,
|
|
18
|
+
) -> None:
|
|
19
|
+
## Queue configuration
|
|
20
|
+
self._queue_name = name
|
|
21
|
+
self._handlers: list[QueueHandler] = []
|
|
22
|
+
self._maxsize = maxsize
|
|
23
|
+
self._shutdown_timeout = shutdown_timeout
|
|
24
|
+
|
|
25
|
+
## Queue internals
|
|
26
|
+
self._native_queue: queue.Queue = queue.Queue(maxsize=self._maxsize)
|
|
27
|
+
# Start a background thread to consume the queue
|
|
28
|
+
self._worker_thread = Thread(target=self._worker_loop, daemon=True)
|
|
29
|
+
self._logger.info("Created AsyncioQueue", extra={"queue_name": self._queue_name})
|
|
30
|
+
|
|
31
|
+
if lifespan is not None:
|
|
32
|
+
lifespan.add(self._manage_queue)
|
|
33
|
+
|
|
34
|
+
async def _manage_queue(self):
|
|
35
|
+
self.startup()
|
|
36
|
+
yield
|
|
37
|
+
self.shutdown()
|
|
38
|
+
|
|
39
|
+
async def _process_queue(self):
|
|
40
|
+
self._logger.info("Started AsyncioQueue processor", extra={"queue_name": self._queue_name})
|
|
41
|
+
|
|
42
|
+
while True:
|
|
43
|
+
try:
|
|
44
|
+
message = self._native_queue.get()
|
|
45
|
+
if message is not None:
|
|
46
|
+
for h in self._handlers:
|
|
47
|
+
try:
|
|
48
|
+
# self._logger.debug(
|
|
49
|
+
# "Dispatching to handler", extra={"handler": h.__class__.__name__}
|
|
50
|
+
# )
|
|
51
|
+
await h.handle_message(message)
|
|
52
|
+
except BaseException as e:
|
|
53
|
+
await h.handle_exception(message, e)
|
|
54
|
+
except BaseException as e:
|
|
55
|
+
self._logger.error("Unhandled exception in queue processor", exc_info=e)
|
|
56
|
+
|
|
57
|
+
def _worker_loop(self):
|
|
58
|
+
asyncio.set_event_loop(asyncio.new_event_loop())
|
|
59
|
+
loop = asyncio.get_event_loop()
|
|
60
|
+
loop.run_until_complete(self._process_queue())
|
|
61
|
+
|
|
62
|
+
def publish(self, message):
|
|
63
|
+
try:
|
|
64
|
+
self._native_queue.put_nowait(message)
|
|
65
|
+
except queue.Full:
|
|
66
|
+
self._logger.warning(
|
|
67
|
+
"Queue is full — dropping message", extra={"queue_name": self._queue_name}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def register_handler(self, handler: QueueHandler):
|
|
71
|
+
self._handlers.append(handler)
|
|
72
|
+
self._logger.info(
|
|
73
|
+
"Registered handler in asyncio queue",
|
|
74
|
+
extra={"queue_name": self._queue_name, "handler": handler.__class__.__name__},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def startup(self):
|
|
78
|
+
if self._worker_thread.is_alive():
|
|
79
|
+
self._logger.debug(
|
|
80
|
+
"AsyncioQueue already started", extra={"queue_name": self._queue_name}
|
|
81
|
+
)
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
self._worker_thread.start()
|
|
85
|
+
|
|
86
|
+
def shutdown(self):
|
|
87
|
+
self._logger.info("Shutting down AsyncioQueue", extra={"queue_name": self._queue_name})
|
|
88
|
+
if not hasattr(self, "_native_queue"):
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
self._native_queue.put(None)
|
|
92
|
+
self._worker_thread.join(timeout=self._shutdown_timeout)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from . import QueueHandler
|
|
4
|
+
from .asyncio import AsyncioQueue
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AsyncioQueueTestQueueHandler(QueueHandler):
|
|
8
|
+
def __init__(self) -> None:
|
|
9
|
+
self._exception_counter = 0
|
|
10
|
+
self._message_counter = 0
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def exception_counter(self):
|
|
14
|
+
return self._exception_counter
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def message_counter(self):
|
|
18
|
+
return self._message_counter
|
|
19
|
+
|
|
20
|
+
async def handle_exception(self, message, exception):
|
|
21
|
+
self._exception_counter = self._exception_counter + 1
|
|
22
|
+
|
|
23
|
+
async def handle_message(self, message):
|
|
24
|
+
self._message_counter = self._message_counter + 1
|
|
25
|
+
# Raise an exception on the second call
|
|
26
|
+
# to this handler
|
|
27
|
+
if self._message_counter == 2:
|
|
28
|
+
raise Exception("Mimicked QueueHandler exception")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def test_async_queue():
|
|
32
|
+
queue = AsyncioQueue(lifespan=None, name="test_direct_queue_handler_error")
|
|
33
|
+
queue_handler = AsyncioQueueTestQueueHandler()
|
|
34
|
+
queue.register_handler(queue_handler)
|
|
35
|
+
queue.startup()
|
|
36
|
+
|
|
37
|
+
queue.publish(message={})
|
|
38
|
+
await asyncio.sleep(0.1)
|
|
39
|
+
assert queue_handler.exception_counter == 0
|
|
40
|
+
assert queue_handler.message_counter == 1
|
|
41
|
+
|
|
42
|
+
queue.publish(message={})
|
|
43
|
+
await asyncio.sleep(0.1)
|
|
44
|
+
assert queue_handler.exception_counter == 1
|
|
45
|
+
assert queue_handler.message_counter == 2
|
|
46
|
+
|
|
47
|
+
queue.publish(message={})
|
|
48
|
+
await asyncio.sleep(0.1)
|
|
49
|
+
assert queue_handler.exception_counter == 1
|
|
50
|
+
assert queue_handler.message_counter == 3
|