entroq 0.11.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.
entroq-0.11.0/PKG-INFO ADDED
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: entroq
3
+ Version: 0.11.0
4
+ Summary: Python client for the EntroQ task queue.
5
+ Author-email: Chris Monson <shiblon@gmail.com>
6
+ Project-URL: Homepage, https://github.com/shiblon/entroq
7
+ Project-URL: Repository, https://github.com/shiblon/entroq
8
+ Project-URL: Bug Tracker, https://github.com/shiblon/entroq/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: httpx
15
+ Provides-Extra: pg
16
+ Requires-Dist: psycopg>=3.1.8; extra == "pg"
17
+ Provides-Extra: dev
18
+ Requires-Dist: entroq[pg]; extra == "dev"
19
+ Requires-Dist: psycopg[binary]; extra == "dev"
20
+ Requires-Dist: pytest; extra == "dev"
21
+ Requires-Dist: pytest-asyncio; extra == "dev"
22
+ Requires-Dist: testcontainers[postgres]; extra == "dev"
23
+
24
+ # EntroQ (Python client)
25
+
26
+ Python client for [EntroQ](https://github.com/shiblon/entroq), a fault-tolerant,
27
+ competing-consumer task queue with exactly-once semantics and an atomic
28
+ `Modify` operation over tasks and a companion key/value doc store.
29
+
30
+ This package provides an async client and a small worker framework. It speaks to
31
+ EntroQ two ways:
32
+
33
+ - **`EntroQJSON`** — talks to a Go EntroQ server over its HTTP/Connect API. Use
34
+ this when a server is already running (`eqpg`/`eqmem`/`eqredis serve`).
35
+ - **`EntroQ`** (in `entroq.pg`) — talks **directly to PostgreSQL**, no Go server
36
+ in the path. It uses the same stored procedures the Go `eqpg` backend does, and
37
+ bundles the canonical schema so a pip-only environment can initialize a database
38
+ without a Go toolchain. Requires the `pg` extra.
39
+
40
+ ## Install
41
+
42
+ ```sh
43
+ pip install entroq # client + worker (JSON/HTTP)
44
+ pip install "entroq[pg]" # adds the direct-PostgreSQL backend (psycopg 3)
45
+ ```
46
+
47
+ ## Quick start
48
+
49
+ A worker claims tasks from one or more queues and dispatches them to a handler:
50
+
51
+ ```python
52
+ import asyncio
53
+ from entroq import EntroQWorker, Modification
54
+ from entroq.json import EntroQJSON
55
+
56
+ eq = EntroQJSON("http://localhost:8080")
57
+
58
+ @EntroQWorker.handler
59
+ async def process(task, docs):
60
+ # ... do the work ...
61
+ return Modification(Modification.deleting(task)) # ack by deleting
62
+
63
+ asyncio.run(EntroQWorker(eq, "my-queue").run(process))
64
+ ```
65
+
66
+ Talking straight to PostgreSQL instead of a server:
67
+
68
+ ```python
69
+ from entroq.pg import EntroQ
70
+
71
+ eq = EntroQ("host=localhost dbname=entroq user=entroq password=secret")
72
+ ```
73
+
74
+ A worker backed by the direct-PostgreSQL client also carries EntroQ's built-in,
75
+ always-on garbage collection for `/gc=`-marked queues on the backend's behalf; a
76
+ worker talking to a Go server does not, since the server collects for itself.
77
+
78
+ ## Documentation
79
+
80
+ See the [main repository](https://github.com/shiblon/entroq) for the concepts
81
+ (tasks, claims, the atomic `Modify`, the doc store, and the `/gc=` naming
82
+ convention) and the server/backends the Go side provides.
@@ -0,0 +1,59 @@
1
+ # EntroQ (Python client)
2
+
3
+ Python client for [EntroQ](https://github.com/shiblon/entroq), a fault-tolerant,
4
+ competing-consumer task queue with exactly-once semantics and an atomic
5
+ `Modify` operation over tasks and a companion key/value doc store.
6
+
7
+ This package provides an async client and a small worker framework. It speaks to
8
+ EntroQ two ways:
9
+
10
+ - **`EntroQJSON`** — talks to a Go EntroQ server over its HTTP/Connect API. Use
11
+ this when a server is already running (`eqpg`/`eqmem`/`eqredis serve`).
12
+ - **`EntroQ`** (in `entroq.pg`) — talks **directly to PostgreSQL**, no Go server
13
+ in the path. It uses the same stored procedures the Go `eqpg` backend does, and
14
+ bundles the canonical schema so a pip-only environment can initialize a database
15
+ without a Go toolchain. Requires the `pg` extra.
16
+
17
+ ## Install
18
+
19
+ ```sh
20
+ pip install entroq # client + worker (JSON/HTTP)
21
+ pip install "entroq[pg]" # adds the direct-PostgreSQL backend (psycopg 3)
22
+ ```
23
+
24
+ ## Quick start
25
+
26
+ A worker claims tasks from one or more queues and dispatches them to a handler:
27
+
28
+ ```python
29
+ import asyncio
30
+ from entroq import EntroQWorker, Modification
31
+ from entroq.json import EntroQJSON
32
+
33
+ eq = EntroQJSON("http://localhost:8080")
34
+
35
+ @EntroQWorker.handler
36
+ async def process(task, docs):
37
+ # ... do the work ...
38
+ return Modification(Modification.deleting(task)) # ack by deleting
39
+
40
+ asyncio.run(EntroQWorker(eq, "my-queue").run(process))
41
+ ```
42
+
43
+ Talking straight to PostgreSQL instead of a server:
44
+
45
+ ```python
46
+ from entroq.pg import EntroQ
47
+
48
+ eq = EntroQ("host=localhost dbname=entroq user=entroq password=secret")
49
+ ```
50
+
51
+ A worker backed by the direct-PostgreSQL client also carries EntroQ's built-in,
52
+ always-on garbage collection for `/gc=`-marked queues on the backend's behalf; a
53
+ worker talking to a Go server does not, since the server collects for itself.
54
+
55
+ ## Documentation
56
+
57
+ See the [main repository](https://github.com/shiblon/entroq) for the concepts
58
+ (tasks, claims, the atomic `Modify`, the doc store, and the `/gc=` naming
59
+ convention) and the server/backends the Go side provides.
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "entroq"
7
+ version = "0.11.0"
8
+ authors = [
9
+ { name="Chris Monson", email="shiblon@gmail.com" },
10
+ ]
11
+ description = "Python client for the EntroQ task queue."
12
+ readme = "README.md"
13
+ requires-python = ">=3.10"
14
+ dependencies = [
15
+ "httpx",
16
+ ]
17
+ classifiers = [
18
+ "Programming Language :: Python :: 3",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ ]
22
+
23
+ [project.urls]
24
+ "Homepage" = "https://github.com/shiblon/entroq"
25
+ "Repository" = "https://github.com/shiblon/entroq"
26
+ "Bug Tracker" = "https://github.com/shiblon/entroq/issues"
27
+
28
+ [project.optional-dependencies]
29
+ pg = [
30
+ "psycopg>=3.1.8",
31
+ ]
32
+ dev = [
33
+ "entroq[pg]",
34
+ "psycopg[binary]",
35
+ "pytest",
36
+ "pytest-asyncio",
37
+ "testcontainers[postgres]",
38
+ ]
39
+
40
+ [tool.pytest.ini_options]
41
+ asyncio_mode = "auto"
42
+
43
+ [tool.setuptools]
44
+ include-package-data = true
45
+
46
+ [tool.setuptools.packages.find]
47
+ where = ["src"]
48
+
49
+ [tool.setuptools.package-data]
50
+ "*" = ["*.sql"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,8 @@
1
+ from .types import (
2
+ Task, TaskID, TaskData, TaskChange,
3
+ Doc, DocID, DocData, DocChange,
4
+ Modification, ModifyResult,
5
+ DependencyError,
6
+ )
7
+ from .worker import EntroQWorker, Handler, StopWorker, RetryError, MoveError, DocClaim
8
+ from .base import EntroQBase
@@ -0,0 +1,53 @@
1
+ from abc import ABC, abstractmethod
2
+ from datetime import datetime
3
+ from typing import List, Optional, Sequence, Union
4
+
5
+ from .types import Task, Doc, Modification, ModifyResult
6
+
7
+
8
+ class EntroQBase(ABC):
9
+ """Abstract base class for EntroQ clients. All methods are async."""
10
+
11
+ @abstractmethod
12
+ async def time(self) -> datetime:
13
+ """Return the current time according to the backend."""
14
+
15
+ @abstractmethod
16
+ async def queues(self, prefix: str = '', exact: Sequence[str] = (), limit: int = 0) -> List[dict]:
17
+ """Return queue statistics."""
18
+
19
+ @abstractmethod
20
+ async def tasks(self, queue: str = '', limit: int = 0, omit_values: bool = False) -> List[Task]:
21
+ """Return a list of tasks in a queue."""
22
+
23
+ @abstractmethod
24
+ async def try_claim(self, queue: Union[str, List[str]], duration_ms: int = 30000) -> Optional[Task]:
25
+ """Attempt to claim a task; returns None immediately if none available."""
26
+
27
+ @abstractmethod
28
+ async def claim(self, queue: Union[str, List[str]], duration_ms: int = 30000, poll_ms: int = 5000, timeout_s: Optional[float] = None) -> Task:
29
+ """Block until a task is available, then claim it."""
30
+
31
+ @abstractmethod
32
+ async def modify(self, modification: Modification, *, unsafe_claimant_id: str | None = None) -> ModifyResult:
33
+ """Atomically apply task and doc modifications in a single operation."""
34
+
35
+ @abstractmethod
36
+ async def docs(
37
+ self,
38
+ namespace: str = '',
39
+ key_start: str = '',
40
+ key_end: str = '',
41
+ limit: int = 0,
42
+ omit_values: bool = False,
43
+ ) -> List[Doc]:
44
+ """Return docs in a namespace, optionally filtered by key range [key_start, key_end)."""
45
+
46
+ @abstractmethod
47
+ async def claim_docs(
48
+ self,
49
+ namespace: str,
50
+ key: str,
51
+ duration_ms: int = 30000,
52
+ ) -> List[Doc]:
53
+ """Atomically claim all docs sharing key in namespace."""
@@ -0,0 +1,254 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from datetime import datetime, timezone
5
+
6
+ import httpx
7
+
8
+ from .types import (
9
+ Task, TaskData, TaskChange, TaskID,
10
+ Doc, DocData, DocChange, DocID,
11
+ DependencyError, Modification, ModifyResult,
12
+ )
13
+ from .base import EntroQBase
14
+
15
+
16
+ def _parse_ms(ms: int | str) -> datetime:
17
+ return datetime.fromtimestamp(int(ms) / 1000.0, tz=timezone.utc)
18
+
19
+
20
+ def _to_ms(dt: datetime | None) -> int:
21
+ return 0 if dt is None else int(dt.timestamp() * 1000)
22
+
23
+
24
+ def _task_from_json(obj: dict) -> Task:
25
+ return Task(
26
+ id=obj.get("id", ""),
27
+ version=int(obj.get("version", 0)),
28
+ queue=obj.get("queue", ""),
29
+ at=_parse_ms(obj.get("atMs", 0)),
30
+ claimant=obj.get("claimantId", ""),
31
+ value=obj.get("value"),
32
+ created=_parse_ms(obj.get("createdMs", 0)),
33
+ modified=_parse_ms(obj.get("modifiedMs", 0)),
34
+ claims=int(obj.get("claims", 0)),
35
+ attempt=int(obj.get("attempt", 0)),
36
+ err=obj.get("err", ""),
37
+ )
38
+
39
+
40
+ def _doc_from_json(obj: dict) -> Doc:
41
+ return Doc(
42
+ namespace=obj.get("namespace", ""),
43
+ id=obj.get("id", ""),
44
+ version=int(obj.get("version", 0)),
45
+ key=obj.get("key", ""),
46
+ secondary_key=obj.get("secondaryKey", ""),
47
+ content=obj.get("content"),
48
+ claimant=obj.get("claimant", ""),
49
+ at=_parse_ms(obj["atMs"]) if obj.get("atMs") else None,
50
+ created=_parse_ms(obj["createdMs"]) if obj.get("createdMs") else None,
51
+ modified=_parse_ms(obj["modifiedMs"]) if obj.get("modifiedMs") else None,
52
+ )
53
+
54
+
55
+ def _task_id_json(t: Task | TaskID) -> dict:
56
+ return {"id": t.id, "version": t.version, "queue": getattr(t, "queue", "")}
57
+
58
+
59
+ def _doc_id_json(d: Doc | DocID) -> dict:
60
+ return {"namespace": d.namespace, "id": d.id, "version": d.version}
61
+
62
+
63
+ def _task_insert_json(i: TaskData) -> dict:
64
+ return {k: v for k, v in {
65
+ "queue": i.queue,
66
+ "atMs": _to_ms(i.at) or None,
67
+ "value": i.value,
68
+ "id": i.id or None,
69
+ "attempt": i.attempt or None,
70
+ "err": i.err or None,
71
+ }.items() if v is not None}
72
+
73
+
74
+ def _task_change_json(c: TaskChange) -> dict:
75
+ return {
76
+ "oldId": {"id": c.id, "version": c.version, "queue": c.queue},
77
+ "newData": {
78
+ "queue": c.queue,
79
+ "atMs": _to_ms(c.at),
80
+ "value": c.value,
81
+ "attempt": c.attempt,
82
+ "err": c.err,
83
+ },
84
+ }
85
+
86
+
87
+ def _doc_insert_json(d: DocData) -> dict:
88
+ return {k: v for k, v in {
89
+ "namespace": d.namespace,
90
+ "id": d.id or None,
91
+ "key": d.key,
92
+ "secondaryKey": d.secondary_key or None,
93
+ "content": d.content,
94
+ }.items() if v is not None}
95
+
96
+
97
+ def _doc_change_json(c: DocChange) -> dict:
98
+ return {
99
+ "oldId": {"namespace": c.namespace, "id": c.id, "version": c.version},
100
+ "newData": {
101
+ "namespace": c.namespace,
102
+ "key": c.key,
103
+ "secondaryKey": c.secondary_key,
104
+ "content": c.content,
105
+ "atMs": _to_ms(c.at),
106
+ },
107
+ }
108
+
109
+
110
+ class EntroQJSON(EntroQBase):
111
+ """EntroQ client talking to the REST/gRPC-gateway API at /api/v0."""
112
+
113
+ def __init__(self, base_url: str, claimant_id: str | None = None) -> None:
114
+ self._base_url = base_url.rstrip("/")
115
+ self.claimant_id = claimant_id or secrets.token_hex(8)
116
+ self._http = httpx.AsyncClient()
117
+
118
+ async def _request(self, method: str, path: str, *, json=None, params=None) -> dict:
119
+ resp = await self._http.request(method, f"{self._base_url}{path}", json=json, params=params)
120
+ if not resp.is_success:
121
+ self._raise_for_error(resp)
122
+ if resp.status_code == 204:
123
+ return {}
124
+ return resp.json()
125
+
126
+ def _raise_for_error(self, resp: httpx.Response) -> None:
127
+ # Dependency errors arrive as 409 Conflict (Aborted). 404 is also
128
+ # accepted for tolerance; the dependency-detail check below keeps an
129
+ # ordinary 404 from being misread as a dependency error.
130
+ if resp.status_code in (409, 404):
131
+ try:
132
+ body = resp.json()
133
+ details = body.get("details", [])
134
+ _DEP_TYPES = {"INSERT", "CHANGE", "DELETE", "DEPEND", "CLAIM", "DETAIL"}
135
+ if any(d.get("type") in _DEP_TYPES for d in details):
136
+ kwargs: dict = {"message": body.get("message", "")}
137
+ for d in details:
138
+ dtype = d.get("type")
139
+ tid_raw = d.get("id")
140
+ tid = (
141
+ TaskID(id=tid_raw["id"], version=int(tid_raw.get("version", 0)), queue=tid_raw.get("queue", ""))
142
+ if tid_raw else None
143
+ )
144
+ if dtype == "INSERT": kwargs.setdefault("inserts", []).append(tid)
145
+ elif dtype == "CHANGE": kwargs.setdefault("changes", []).append(tid)
146
+ elif dtype == "DELETE": kwargs.setdefault("deletes", []).append(tid)
147
+ elif dtype == "DEPEND": kwargs.setdefault("depends", []).append(tid)
148
+ elif dtype == "CLAIM": kwargs.setdefault("claims", []).append(tid)
149
+ elif dtype == "DETAIL": kwargs["message"] = d.get("msg", kwargs["message"])
150
+ kwargs["missing"] = kwargs.get("depends", []) + kwargs.get("deletes", [])
151
+ kwargs["collisions"] = kwargs.get("inserts", [])
152
+ raise DependencyError(**kwargs)
153
+ except (ValueError, KeyError):
154
+ pass
155
+ resp.raise_for_status()
156
+
157
+ async def time(self) -> datetime:
158
+ data = await self._request("GET", "/api/v0/time")
159
+ return _parse_ms(data.get("timeMs", 0))
160
+
161
+ async def queues(self, prefix: str = "", exact=(), limit: int = 0) -> list[dict]:
162
+ params: dict = {}
163
+ if prefix: params["matchPrefix"] = prefix
164
+ if exact: params["matchExact"] = list(exact)
165
+ if limit: params["limit"] = limit
166
+ data = await self._request("GET", "/api/v0/queues", params=params)
167
+ return [
168
+ {
169
+ "name": q.get("name", ""),
170
+ "num_tasks": q.get("numTasks", 0),
171
+ "num_claimed": q.get("numClaimed", 0),
172
+ "num_available": q.get("numAvailable", 0),
173
+ "num_future": q.get("numFuture", 0),
174
+ }
175
+ for q in data.get("queues", [])
176
+ ]
177
+
178
+ async def tasks(self, queue: str = "", limit: int = 0, omit_values: bool = False) -> list[Task]:
179
+ params: dict = {}
180
+ if queue: params["queue"] = queue
181
+ if limit: params["limit"] = limit
182
+ if omit_values: params["omitValues"] = "true"
183
+ data = await self._request("GET", "/api/v0/tasks", params=params)
184
+ return [_task_from_json(t) for t in data.get("tasks", [])]
185
+
186
+ async def try_claim(self, queue: str | list[str], duration_ms: int = 30000) -> Task | None:
187
+ queues = [queue] if isinstance(queue, str) else list(queue)
188
+ data = await self._request("POST", "/api/v0/claim", json={
189
+ "claimantId": self.claimant_id,
190
+ "queues": queues,
191
+ "durationMs": str(duration_ms),
192
+ "pollMs": "0",
193
+ })
194
+ # The server may emit an explicit "task": null when nothing is available
195
+ # (zero-valued fields are not omitted), so check the value, not the key.
196
+ return _task_from_json(data["task"]) if data.get("task") is not None else None
197
+
198
+ async def claim(self, queue: str | list[str], duration_ms: int = 30000, poll_ms: int = 5000, timeout_s: float | None = None) -> Task:
199
+ queues = [queue] if isinstance(queue, str) else list(queue)
200
+ data = await self._request("POST", "/api/v0/claim/wait", json={
201
+ "claimantId": self.claimant_id,
202
+ "queues": queues,
203
+ "durationMs": str(duration_ms),
204
+ "pollMs": str(poll_ms),
205
+ })
206
+ if data.get("task") is not None:
207
+ return _task_from_json(data["task"])
208
+ raise TimeoutError("claim timed out")
209
+
210
+ async def modify(self, modification: Modification, *, unsafe_claimant_id: str | None = None) -> ModifyResult:
211
+ data = await self._request("POST", "/api/v0/modify", json={
212
+ "claimantId": unsafe_claimant_id or self.claimant_id,
213
+ "inserts": [_task_insert_json(i) for i in modification.task_inserts],
214
+ "changes": [_task_change_json(c) for c in modification.task_changes],
215
+ "deletes": [_task_id_json(d) for d in modification.task_deletes],
216
+ "depends": [_task_id_json(d) for d in modification.task_depends],
217
+ "docInserts": [_doc_insert_json(i) for i in modification.doc_inserts],
218
+ "docChanges": [_doc_change_json(c) for c in modification.doc_changes],
219
+ "docDeletes": [_doc_id_json(d) for d in modification.doc_deletes],
220
+ "docDepends": [_doc_id_json(d) for d in modification.doc_depends],
221
+ })
222
+ return ModifyResult(
223
+ tasks_inserted=[_task_from_json(t) for t in data.get("inserted", [])],
224
+ tasks_changed=[_task_from_json(t) for t in data.get("changed", [])],
225
+ docs_inserted=[_doc_from_json(d) for d in data.get("insertedDocs", [])],
226
+ docs_changed=[_doc_from_json(d) for d in data.get("changedDocs", [])],
227
+ )
228
+
229
+ async def docs(
230
+ self,
231
+ namespace: str = "",
232
+ key_start: str = "",
233
+ key_end: str = "",
234
+ limit: int = 0,
235
+ omit_values: bool = False,
236
+ ) -> list[Doc]:
237
+ params: dict = {"namespace": namespace}
238
+ if key_start: params["keyStart"] = key_start
239
+ if key_end: params["keyEnd"] = key_end
240
+ if limit: params["limit"] = limit
241
+ if omit_values: params["omitValues"] = "true"
242
+ data = await self._request("GET", "/api/v0/docs", params=params)
243
+ return [_doc_from_json(d) for d in data.get("docs", [])]
244
+
245
+ async def claim_docs(self, namespace: str, key: str, duration_ms: int = 30000) -> list[Doc]:
246
+ data = await self._request("POST", "/api/v0/docs/claim", json={
247
+ "claimQuery": {
248
+ "namespace": namespace,
249
+ "claimant": self.claimant_id,
250
+ "key": key,
251
+ "durationMs": duration_ms,
252
+ },
253
+ })
254
+ return [_doc_from_json(d) for d in data.get("docs", [])]