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.
@@ -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