taraqueue 0.0.1.dev0__py3-none-any.whl

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.
taraqueue/__init__.py ADDED
File without changes
taraqueue/queue.py ADDED
@@ -0,0 +1,149 @@
1
+ """Queue abstraction and implementation."""
2
+
3
+ import os
4
+ from abc import ABC, abstractmethod
5
+ from collections import defaultdict
6
+ from contextlib import asynccontextmanager, suppress
7
+ from time import time
8
+
9
+ from attrs import define, field
10
+ from yarl import URL
11
+
12
+ from taraqueue.registry import registry_load
13
+
14
+
15
+ class QueueEmpty(Exception):
16
+ """Raised when the queue is empty."""
17
+
18
+
19
+ @define
20
+ class Queue(ABC):
21
+ """Base queue class."""
22
+
23
+ @classmethod
24
+ def from_url(cls, url: URL | str, registry=None) -> "Queue":
25
+ if registry is None:
26
+ registry = registry_load("taraqueue")
27
+ scheme = URL(url).scheme
28
+ queue_cls = registry["taraqueue"][scheme]
29
+ return queue_cls.from_url(url)
30
+
31
+ @abstractmethod
32
+ async def subscribe(self, topic: str) -> None:
33
+ """Subscribe to a topic before receiving messages."""
34
+
35
+ @abstractmethod
36
+ async def unsubscribe(self, topic: str) -> None:
37
+ """Unsubscribe from a topic after receiving messages."""
38
+
39
+ @abstractmethod
40
+ async def receive(self, timeout=None) -> str:
41
+ """Listen for messages on the subscribed topics."""
42
+
43
+ @abstractmethod
44
+ async def publish(self, topic: str, message: str) -> None:
45
+ """Publish a message to a topic."""
46
+
47
+ @asynccontextmanager
48
+ async def connect(self, topic: str):
49
+ """Context manager that subscribes on entry and unsubscribes on exit."""
50
+ await self.subscribe(topic)
51
+ try:
52
+ yield self
53
+ finally:
54
+ await self.unsubscribe(topic)
55
+
56
+
57
+ _global_memory_queues = defaultdict(list)
58
+
59
+
60
+ @define
61
+ class MemoryQueue(Queue):
62
+
63
+ topics = field(factory=list)
64
+ queues = field(default=_global_memory_queues)
65
+
66
+ @classmethod
67
+ def from_url(cls, url: URL) -> "MemoryQueue":
68
+ return cls()
69
+
70
+ async def subscribe(self, topic: str) -> None:
71
+ """See `Queue.subscribe`."""
72
+ self.topics.append(topic)
73
+
74
+ async def unsubscribe(self, topic: str) -> None:
75
+ """See `Queue.unsubscribe`."""
76
+ with suppress(ValueError):
77
+ self.topics.remove(topic)
78
+
79
+ async def receive(self, timeout=None) -> str:
80
+ """See `Queue.receive`."""
81
+ for topic in self.topics[:]:
82
+ # Cycle through topics.
83
+ self.topics.append(self.topics.pop(0))
84
+ queue = self.queues[topic]
85
+ with suppress(IndexError):
86
+ return queue.pop(0)
87
+
88
+ raise QueueEmpty("Queue is empty")
89
+
90
+ async def publish(self, topic: str, message: str) -> None:
91
+ """See `Queue.publish`."""
92
+ queue = self.queues[topic]
93
+ queue.append(message)
94
+
95
+
96
+ @define
97
+ class RedisQueue(Queue):
98
+
99
+ client = field()
100
+ pubsub = field()
101
+
102
+ @classmethod
103
+ def from_env(cls, env=os.environ) -> "RedisQueue":
104
+ host = env.get("REDIS_SLAVEOF_IP", "") or env.get("IPV4_NETWORK", "172.22.1") + ".249"
105
+ port = int(env.get("REDIS_SLAVEOF_PORT", "") or "6379")
106
+ password = env.get("REDISPASS")
107
+ return cls.from_host(host, port, password=password)
108
+
109
+ @classmethod
110
+ def from_host(cls, host: str, port: int = 6379, password: str | None = None) -> "RedisQueue":
111
+ from redis.asyncio import StrictRedis
112
+
113
+ client = StrictRedis(
114
+ host=host,
115
+ port=port,
116
+ decode_responses=True,
117
+ db=0,
118
+ password=password,
119
+ )
120
+ pubsub = client.pubsub(ignore_subscribe_messages=True)
121
+ return cls(client, pubsub)
122
+
123
+ @classmethod
124
+ def from_url(cls, url: URL | str) -> "RedisQueue":
125
+ url = URL(url)
126
+ return cls.from_host(url.host, url.port, password=url.password)
127
+
128
+ async def subscribe(self, topic: str) -> None:
129
+ """See `Queue.subscribe`."""
130
+ await self.pubsub.subscribe(topic)
131
+
132
+ async def unsubscribe(self, topic: str) -> None:
133
+ """See `Queue.unsubscribe`."""
134
+ await self.pubsub.unsubscribe(topic)
135
+
136
+ async def receive(self, timeout=0) -> str:
137
+ """See `Queue.receive`."""
138
+ stop_time = time() + timeout
139
+ while True:
140
+ remaining_timeout = max(0.0, stop_time - time())
141
+ message = await self.pubsub.get_message(ignore_subscribe_messages=True, timeout=remaining_timeout)
142
+ if message:
143
+ return message["data"]
144
+ if time() >= stop_time:
145
+ raise QueueEmpty("Queue is empty")
146
+
147
+ async def publish(self, topic: str, message: str) -> None:
148
+ """See `Queue.publish`."""
149
+ await self.client.publish(topic, message)
taraqueue/registry.py ADDED
@@ -0,0 +1,76 @@
1
+ """Entry points based registry management."""
2
+
3
+ import contextlib
4
+ from importlib.metadata import entry_points
5
+
6
+
7
+ def get_entry_points(group):
8
+ """Get the list of pytest_unique entry points."""
9
+ try:
10
+ return entry_points().select(group=group)
11
+ except AttributeError:
12
+ # Backward compatibility with Python 3.9.
13
+ return entry_points().get(group, [])
14
+
15
+
16
+ def registry_load(group, registry=None):
17
+ """Find all installed entry points."""
18
+ if registry is None:
19
+ registry = {}
20
+
21
+ for entry_point in get_entry_points(group):
22
+ entry = entry_point.load()
23
+ registry_add(group, entry_point.name, entry, registry)
24
+
25
+ return registry
26
+
27
+
28
+ def registry_add(group, name, entry, registry=None):
29
+ """Add an entry to a registry.
30
+
31
+ :param group: Group of the entry.
32
+ :param name: Name of the entry.
33
+ :param entry: Entry to add.
34
+ :param registry: Optional registry to update.
35
+ :return: A registry with the entry.
36
+ """
37
+ if registry is None:
38
+ registry = {
39
+ group: {},
40
+ }
41
+ else:
42
+ registry.setdefault(group, {})
43
+
44
+ registry[group][name] = entry
45
+ return registry
46
+
47
+
48
+ def registry_remove(group, name, registry=None):
49
+ """Remove an entry from a registry.
50
+
51
+ If the entry doesn't exist, return silently.
52
+
53
+ :param group: Group of the entry.
54
+ :param name: Name of the entry.
55
+ :param registry: Optional registry to update.
56
+ """
57
+ if registry is not None:
58
+ with contextlib.suppress(KeyError):
59
+ del registry[group][name]
60
+
61
+
62
+ def registry_get(group, name, registry=None):
63
+ """Get an entry from a registry.
64
+
65
+ If the registry is not defined or the group is not in the registry,
66
+ the registry is loaded again.
67
+
68
+ :param group: Group of the entry.
69
+ :param name: Name of the entry.
70
+ :param registry: Optional registry to get from.
71
+ :raises KeyError: If not found.
72
+ """
73
+ if registry is None or group not in registry:
74
+ registry = registry_load(group, registry)
75
+
76
+ return registry[group][name]
File without changes
@@ -0,0 +1,92 @@
1
+ """Compose server module."""
2
+
3
+ from contextlib import contextmanager
4
+ from datetime import datetime
5
+
6
+ from attrs import define
7
+ from more_itertools import only
8
+ from pytest_xdocker.docker import DockerContainer
9
+ from pytest_xdocker.process import ProcessData, ProcessServer
10
+ from pytest_xdocker.xdocker import xdocker
11
+
12
+
13
+ @define(frozen=True)
14
+ class ComposeService:
15
+ """Compose service.
16
+
17
+ :param name: Name of the compose service container.
18
+ :param network: Network name to use when resolving the IP address.
19
+ """
20
+
21
+ name: str
22
+ network: str | None = None
23
+
24
+ @property
25
+ def container(self):
26
+ return DockerContainer(self.name)
27
+
28
+ @property
29
+ def container_id(self):
30
+ return self.container.inspect["Id"]
31
+
32
+ @property
33
+ def env(self):
34
+ env = self.container.inspect["Config"]["Env"]
35
+ return dict(e.split("=", 1) for e in env)
36
+
37
+ @property
38
+ def ip(self):
39
+ network_settings = self.container.inspect["NetworkSettings"]
40
+ networks = network_settings["Networks"]
41
+ if self.network and self.network in networks:
42
+ return networks[self.network]["IPAddress"]
43
+ return only(networks.values())["IPAddress"]
44
+
45
+ @property
46
+ def started_at(self):
47
+ started_at = self.container.inspect["State"]["StartedAt"]
48
+ return datetime.fromisoformat(started_at)
49
+
50
+
51
+ class ComposeServer(ProcessServer):
52
+ def __init__(self, pattern, project="test", env_file=None, compose_files=None, timeout=180, **kwargs):
53
+ """Initilize a compose server."""
54
+ super().__init__(**kwargs)
55
+ self.pattern = pattern
56
+ self.project = project
57
+ self.env_file = env_file
58
+ self.compose_files = compose_files
59
+ self.timeout = timeout
60
+
61
+ def __repr__(self):
62
+ return f"{self.__class__.__name__}(pattern={self.pattern!r}, project={self.project!r})"
63
+
64
+ def full_name(self, name):
65
+ return f"{self.project}-{name}-1"
66
+
67
+ def prepare_func(self, controldir):
68
+ """Prepare the function to run the compose service."""
69
+ full_name = self.full_name(controldir.basename)
70
+ compose = xdocker.compose().with_project_name(self.project)
71
+ if env_file := self.env_file:
72
+ compose = compose.with_env_file(env_file)
73
+ for file in self.compose_files or []:
74
+ compose = compose.with_file(file)
75
+
76
+ command = (
77
+ compose
78
+ .run(controldir.basename)
79
+ .with_name(full_name)
80
+ .with_build()
81
+ .with_remove()
82
+ .with_optionals("--use-aliases")
83
+ )
84
+
85
+ return ProcessData(self.pattern, command, timeout=self.timeout)
86
+
87
+ @contextmanager
88
+ def run(self, name):
89
+ """Return an `ComposeService` to the running service."""
90
+ with super().run(name):
91
+ full_name = self.full_name(name)
92
+ yield ComposeService(full_name, network=f"{self.project}_default")
@@ -0,0 +1,36 @@
1
+ """Queue fixtures."""
2
+
3
+ import pytest
4
+ from yarl import URL
5
+
6
+ from taraqueue.queue import Queue
7
+
8
+
9
+ @pytest.fixture
10
+ def memory_queue():
11
+ """Memory queue fixture."""
12
+ url = URL.build(scheme="memory")
13
+ return Queue.from_url(url)
14
+
15
+
16
+ @pytest.fixture
17
+ def redis_queue(redis_service, env_vars):
18
+ """Redis queue fixture."""
19
+ url = URL.build(
20
+ scheme="redis",
21
+ host=redis_service.ip,
22
+ port=6379,
23
+ password=env_vars["REDISPASS"],
24
+ )
25
+ return Queue.from_url(url)
26
+
27
+
28
+ @pytest.fixture(
29
+ params=[
30
+ "memory_queue",
31
+ "redis_queue",
32
+ ],
33
+ )
34
+ def queue(request):
35
+ """Queue fixture."""
36
+ return request.getfixturevalue(request.param)
@@ -0,0 +1,87 @@
1
+ """Service fixtures."""
2
+
3
+ from functools import partial
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from taraqueue.testing.compose import ComposeServer
9
+
10
+
11
+ @pytest.fixture(scope="session")
12
+ def project():
13
+ return "test"
14
+
15
+
16
+ @pytest.fixture(scope="session")
17
+ def env_vars(project):
18
+ """Environment variables for the services."""
19
+ return {
20
+ "COMPOSE_PROJECT_NAME": project,
21
+ "REDISPASS": "test",
22
+ }
23
+
24
+
25
+ @pytest.fixture(scope="session")
26
+ def env_file(env_vars, request):
27
+ """Environment file containing `env_vars`.
28
+
29
+ Cached for troubleshooting purposes.
30
+ """
31
+ env_file = request.config.cache.makedir("compose") / "env"
32
+ with env_file.open("w") as f:
33
+ for k, v in env_vars.items():
34
+ f.write(f"{k}={v}\n")
35
+
36
+ return env_file
37
+
38
+
39
+ @pytest.fixture(scope="session")
40
+ def compose_files(request):
41
+ directory = Path(request.config.rootdir)
42
+ filenames = ["docker-compose.yml", "compose.yaml", "compose.yml"]
43
+ while True:
44
+ for filename in filenames:
45
+ path = directory / filename
46
+ if path.exists():
47
+ all_files = directory.glob(f"{path.stem}.*")
48
+ ordered_files = sorted(all_files, key=lambda p: len(p.name))
49
+ return list(ordered_files)
50
+
51
+ if directory == directory.parent:
52
+ raise FileNotFoundError("Docker compose file not found")
53
+
54
+ directory = directory.parent
55
+
56
+
57
+ @pytest.fixture(scope="session")
58
+ def compose_server(project, env_file, compose_files, process):
59
+ return partial(
60
+ ComposeServer,
61
+ project=project,
62
+ env_file=env_file,
63
+ compose_files=compose_files,
64
+ process=process,
65
+ )
66
+
67
+
68
+ @pytest.fixture(scope="session")
69
+ def redis_service(compose_server):
70
+ """Redis service fixture."""
71
+ server = compose_server("Ready to accept connections tcp")
72
+ with server.run("redis") as service:
73
+ yield service
74
+
75
+
76
+ @pytest.fixture(scope="session")
77
+ def redis_client(redis_service, env_vars):
78
+ """Redis client to the service fixture."""
79
+ from redis import StrictRedis
80
+
81
+ return StrictRedis(
82
+ host=redis_service.ip,
83
+ port=6379,
84
+ decode_responses=True,
85
+ db=0,
86
+ password=env_vars["REDISPASS"],
87
+ )
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: taraqueue
3
+ Version: 0.0.1.dev0
4
+ Summary: Python queue abstraction layer
5
+ Project-URL: Repository, https://github.com/taradix/taraqueue
6
+ Author-email: Marc Tardif <marc@taram.ca>
7
+ License-File: LICENSE.rst
8
+ Requires-Python: <4.0,>=3.12
9
+ Requires-Dist: attrs>=26.1.0
10
+ Requires-Dist: yarl>=1.23.0
11
+ Provides-Extra: check
12
+ Requires-Dist: ruff<1.0.0,>=0.15.0; extra == 'check'
13
+ Provides-Extra: docs
14
+ Requires-Dist: sphinx-rtd-theme<3.2.0,>=3.1.0; extra == 'docs'
15
+ Requires-Dist: sphinx<10.0.0,>=9.0.0; extra == 'docs'
16
+ Requires-Dist: sphinxcontrib-log-cabinet<2.0.0,>=1.0.1; extra == 'docs'
17
+ Provides-Extra: redis
18
+ Requires-Dist: redis>=7.4.0; extra == 'redis'
19
+ Provides-Extra: test
20
+ Requires-Dist: coverage<8.0.0,>=7.2.3; extra == 'test'
21
+ Requires-Dist: more-itertools<11.1.0,>=11.0.1; extra == 'test'
22
+ Requires-Dist: pytest-asyncio<2.0.0,>=1.0.0; extra == 'test'
23
+ Requires-Dist: pytest-unique<1.0.0,>=0.1.8; extra == 'test'
24
+ Requires-Dist: pytest-xdocker<1.0.0,>=0.2.8; extra == 'test'
25
+ Requires-Dist: pytest<10.0.0,>=9.0.0; extra == 'test'
26
+ Requires-Dist: redis>=7.4.0; extra == 'test'
27
+ Description-Content-Type: text/x-rst
28
+
29
+ TaraQueue
30
+ =========
31
+
32
+ Python queue abstraction layer.
33
+
34
+ .. image:: https://img.shields.io/badge/license-MIT-blue.svg
35
+ :target: https://github.com/taradix/taraqueue/blob/master/LICENSE
36
+ :alt: License
37
+ .. image:: https://img.shields.io/pypi/v/taraqueue.svg
38
+ :target: https://pypi.python.org/pypi/taraqueue/
39
+ :alt: PyPI
40
+ .. image:: https://img.shields.io/github/issues-raw/taradix/taraqueue.svg
41
+ :target: https://github.com/taradix/taraqueue/issues
42
+ :alt: Issues
43
+
44
+ Requirements
45
+ ------------
46
+
47
+ You will need the following prerequisites to use taraqueue:
48
+
49
+ - Python 3.12, 3.13, 3.14
50
+
51
+ Installation
52
+ ------------
53
+
54
+ To install taraqueue:
55
+
56
+ .. code-block:: bash
57
+
58
+ $ pip install taraqueue
59
+
60
+ Resources
61
+ ---------
62
+
63
+ - `Documentation <https://taradix.github.io/taraqueue/>`_
64
+ - `Release Notes <http://github.com/taradix/taraqueue/blob/master/CHANGES.rst>`_
65
+ - `Issue Tracker <http://github.com/taradix/taraqueue/issues>`_
66
+ - `Source Code <http://github.com/taradix/taraqueue/>`_
67
+ - `PyPi <https://pypi.org/project/taraqueue/>`_
@@ -0,0 +1,12 @@
1
+ taraqueue/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ taraqueue/queue.py,sha256=-ya3DVSLTjNQ1nmL1ltpJgPmTkdmQe2W2UHvhUGKfzI,4490
3
+ taraqueue/registry.py,sha256=YcF9FE21HWfzoFbGGtC4GBozj-g5YbQuWdOor9XoPrU,2036
4
+ taraqueue/testing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ taraqueue/testing/compose.py,sha256=wyI7dkhdXgQra0RcrYqye7RnYSv4WRSOMUVJRloKfDk,2896
6
+ taraqueue/testing/queue.py,sha256=ryIcwE7Txeybairo6HkW9bO9mH7A6Qj9RU3YDiUpG8M,677
7
+ taraqueue/testing/services.py,sha256=YmbsbAiX_kBVziwnNwkuvxn-82zhNE926oxF6V0SfzU,2213
8
+ taraqueue-0.0.1.dev0.dist-info/METADATA,sha256=Bd4BaAxkjIJJ5aJFwOK8gZdAfMjX4JbEw2OUAy-Llqk,2136
9
+ taraqueue-0.0.1.dev0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ taraqueue-0.0.1.dev0.dist-info/entry_points.txt,sha256=IDp7ErMVJHagqZeDuawQyIxa6ZmLF9Kh3Wv3is1up6c,186
11
+ taraqueue-0.0.1.dev0.dist-info/licenses/LICENSE.rst,sha256=aBUUonOC9jBjhQb0VIObBylPnlIVph0P0QnlnZ8z4BM,1068
12
+ taraqueue-0.0.1.dev0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,7 @@
1
+ [pytest11]
2
+ taraqueue-queue = taraqueue.testing.queue
3
+ taraqueue-services = taraqueue.testing.services
4
+
5
+ [taraqueue]
6
+ memory = taraqueue.queue:MemoryQueue
7
+ redis = taraqueue.queue:RedisQueue
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Marc Tardif
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.