pgtask 0.1.3__cp310-abi3-win_amd64.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.
pgtask/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from pgtask.client import (
4
+ Client,
5
+ EnqueueRequest,
6
+ JSONValue,
7
+ Task,
8
+ TaskDefinition,
9
+ TaskHandle,
10
+ TaskHandler,
11
+ TaskRegistry,
12
+ TaskResult,
13
+ TaskState,
14
+ TransactionConnection,
15
+ Worker,
16
+ )
17
+
18
+ __all__ = [
19
+ "Client",
20
+ "EnqueueRequest",
21
+ "JSONValue",
22
+ "Task",
23
+ "TaskDefinition",
24
+ "TaskHandle",
25
+ "TaskHandler",
26
+ "TaskRegistry",
27
+ "TaskResult",
28
+ "TaskState",
29
+ "TransactionConnection",
30
+ "Worker",
31
+ ]
pgtask/_native.pyd ADDED
Binary file
pgtask/_native.pyi ADDED
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ class Client:
6
+ @staticmethod
7
+ async def connect(
8
+ database_url: str,
9
+ *,
10
+ listener_url: str | None = None,
11
+ max_query_connections: int = 10,
12
+ max_listener_connections: int = 1,
13
+ ) -> Client: ...
14
+ async def migrate(self) -> None: ...
15
+ async def enqueue(self, request: dict[str, Any]) -> tuple[str, bool]: ...
16
+ async def task_result(self, task_id: str) -> dict[str, Any] | None: ...
17
+ async def wait_result(self, task_id: str, timeout: float | None) -> dict[str, Any] | None: ...
18
+ async def emit_signal(self, task_id: str, name: str, occurrence: int, value: Any) -> Any: ...
19
+ async def cancel(self, task_id: str) -> bool: ...
20
+
21
+ class TaskContext:
22
+ async def step(self, name: str, occurrence: int, operation: Any) -> Any: ...
23
+ async def sleep_for(self, name: str, occurrence: int, seconds: float) -> None: ...
24
+ async def sleep_until(self, name: str, occurrence: int, wake_at: str) -> None: ...
25
+ async def wait_for_signal(
26
+ self,
27
+ step_name: str,
28
+ occurrence: int,
29
+ signal_name: str,
30
+ signal_occurrence: int = 0,
31
+ timeout: float | None = None,
32
+ ) -> Any: ...
33
+ async def spawn(self, step_name: str, occurrence: int, request: dict[str, Any]) -> str: ...
34
+ async def wait_for_result(
35
+ self, step_name: str, occurrence: int, task_id: str, timeout: float | None = None
36
+ ) -> Any: ...
37
+
38
+ class Worker:
39
+ def __init__(self, database_url: str, queue_names: list[str], options: dict[str, Any]) -> None: ...
40
+ def register(
41
+ self,
42
+ name: str,
43
+ function: Any,
44
+ handler_version: int = 1,
45
+ retry_delay: float | None = 1.0,
46
+ ) -> None: ...
47
+ async def run(self) -> None: ...
48
+ def shutdown(self) -> None: ...
pgtask/client.py ADDED
@@ -0,0 +1,367 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable, Sequence
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime
6
+ from typing import Any, Generic, TypeVar, cast
7
+
8
+ from opentelemetry.context import attach, detach
9
+ from opentelemetry.propagate import extract, inject
10
+ from typing_extensions import Literal, Protocol, TypeAlias # noqa: UP035
11
+
12
+ from pgtask import _native
13
+
14
+ JSONValue: TypeAlias = None | bool | int | float | str | list["JSONValue"] | dict[str, "JSONValue"]
15
+ TaskState: TypeAlias = Literal["pending", "running", "waiting", "succeeded", "failed", "cancelled"]
16
+ PayloadT = TypeVar("PayloadT")
17
+ ResultT = TypeVar("ResultT")
18
+ StepT = TypeVar("StepT")
19
+ TaskHandler: TypeAlias = Callable[["Task", PayloadT], Awaitable[ResultT]]
20
+
21
+
22
+ class TransactionCursor(Protocol):
23
+ async def fetchone(self) -> tuple[str, bool] | None: ...
24
+
25
+
26
+ class TransactionConnection(Protocol):
27
+ async def execute(self, query: str, params: tuple[Any, ...]) -> TransactionCursor: ...
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class EnqueueRequest(Generic[ResultT]):
32
+ task_name: str
33
+ payload: JSONValue
34
+ queue_name: str = "default"
35
+ handler_version: int = 1
36
+ run_at: datetime | None = None
37
+ priority: int = 0
38
+ max_attempts: int = 5
39
+ idempotency_key: str | None = None
40
+ headers: dict[str, JSONValue] = field(default_factory=dict)
41
+
42
+
43
+ def _request_value(request: EnqueueRequest[Any]) -> dict[str, Any]:
44
+ headers = dict(request.headers)
45
+ inject(cast(dict[str, str], headers))
46
+ return {
47
+ "task_name": request.task_name,
48
+ "payload": request.payload,
49
+ "queue_name": request.queue_name,
50
+ "handler_version": request.handler_version,
51
+ "run_at": request.run_at.isoformat() if request.run_at is not None else None,
52
+ "priority": request.priority,
53
+ "max_attempts": request.max_attempts,
54
+ "idempotency_key": request.idempotency_key,
55
+ "headers": headers,
56
+ }
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class TaskDefinition(Generic[PayloadT, ResultT]):
61
+ name: str
62
+ queue_name: str
63
+ handler: TaskHandler[PayloadT, ResultT] = field(repr=False)
64
+ handler_version: int = 1
65
+ retry_delay: float | None = 1.0
66
+
67
+ def request(
68
+ self,
69
+ payload: PayloadT,
70
+ *,
71
+ run_at: datetime | None = None,
72
+ priority: int = 0,
73
+ max_attempts: int = 5,
74
+ idempotency_key: str | None = None,
75
+ headers: dict[str, JSONValue] | None = None,
76
+ ) -> EnqueueRequest[ResultT]:
77
+ return EnqueueRequest(
78
+ task_name=self.name,
79
+ payload=cast(JSONValue, payload),
80
+ queue_name=self.queue_name,
81
+ handler_version=self.handler_version,
82
+ run_at=run_at,
83
+ priority=priority,
84
+ max_attempts=max_attempts,
85
+ idempotency_key=idempotency_key,
86
+ headers={} if headers is None else headers,
87
+ )
88
+
89
+
90
+ class TaskRegistry:
91
+ def __init__(self, queue_name: str = "default") -> None:
92
+ self.queue_name = queue_name
93
+ self._definitions: dict[tuple[str, int], TaskDefinition[Any, Any]] = {}
94
+
95
+ def task(
96
+ self,
97
+ name: str,
98
+ *,
99
+ handler_version: int = 1,
100
+ retry_delay: float | None = 1.0,
101
+ ) -> Callable[[TaskHandler[PayloadT, ResultT]], TaskDefinition[PayloadT, ResultT]]:
102
+ def decorator(handler: TaskHandler[PayloadT, ResultT]) -> TaskDefinition[PayloadT, ResultT]:
103
+ definition = TaskDefinition(name, self.queue_name, handler, handler_version, retry_delay)
104
+ key = (definition.name, definition.handler_version)
105
+ if key in self._definitions:
106
+ raise ValueError(f"task {definition.name!r} version {definition.handler_version} is already registered")
107
+ self._definitions[key] = definition
108
+ return definition
109
+
110
+ return decorator
111
+
112
+ @property
113
+ def definitions(self) -> tuple[TaskDefinition[Any, Any], ...]:
114
+ return tuple(self._definitions.values())
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class TaskResult(Generic[ResultT]):
119
+ state: TaskState
120
+ result: ResultT | None
121
+ error: JSONValue
122
+ completed_at: datetime | None
123
+
124
+ @classmethod
125
+ def from_native(cls, value: dict[str, Any]) -> TaskResult[ResultT]:
126
+ completed_at = value["completed_at"]
127
+ return cls(
128
+ state=cast(TaskState, value["state"]),
129
+ result=cast(ResultT | None, value["result"]),
130
+ error=value["error"],
131
+ completed_at=datetime.fromisoformat(completed_at.replace("Z", "+00:00"))
132
+ if completed_at is not None
133
+ else None,
134
+ )
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class Task:
139
+ id: str
140
+ parent_task_id: str | None
141
+ queue_name: str
142
+ task_name: str
143
+ handler_version: int
144
+ payload: JSONValue
145
+ headers: dict[str, JSONValue]
146
+ state: TaskState
147
+ attempt: int
148
+ max_attempts: int
149
+ run_at: datetime
150
+ created_at: datetime
151
+ _context: _native.TaskContext = field(repr=False, compare=False)
152
+
153
+ @classmethod
154
+ def from_native(cls, value: dict[str, Any], context: _native.TaskContext) -> Task:
155
+ return cls(
156
+ id=str(value["id"]),
157
+ parent_task_id=str(value["parent_task_id"]) if value["parent_task_id"] is not None else None,
158
+ queue_name=str(value["queue_name"]),
159
+ task_name=str(value["task_name"]),
160
+ handler_version=int(value["handler_version"]),
161
+ payload=cast(JSONValue, value["payload"]),
162
+ headers=cast(dict[str, JSONValue], value["headers"]),
163
+ state=cast(TaskState, value["state"]),
164
+ attempt=int(value["attempt"]),
165
+ max_attempts=int(value["max_attempts"]),
166
+ run_at=datetime.fromisoformat(value["run_at"].replace("Z", "+00:00")),
167
+ created_at=datetime.fromisoformat(value["created_at"].replace("Z", "+00:00")),
168
+ _context=context,
169
+ )
170
+
171
+ async def step(self, name: str, operation: Callable[[], Awaitable[StepT]], occurrence: int = 0) -> StepT:
172
+ return cast(StepT, await self._context.step(name, occurrence, operation))
173
+
174
+ async def sleep_for(self, name: str, seconds: float, occurrence: int = 0) -> None:
175
+ await self._context.sleep_for(name, occurrence, seconds)
176
+
177
+ async def sleep_until(self, name: str, wake_at: datetime, occurrence: int = 0) -> None:
178
+ await self._context.sleep_until(name, occurrence, wake_at.isoformat())
179
+
180
+ async def wait_for_signal(
181
+ self,
182
+ step_name: str,
183
+ signal_name: str,
184
+ *,
185
+ occurrence: int = 0,
186
+ signal_occurrence: int = 0,
187
+ timeout: float | None = None,
188
+ ) -> JSONValue:
189
+ return cast(
190
+ JSONValue,
191
+ await self._context.wait_for_signal(
192
+ step_name,
193
+ occurrence,
194
+ signal_name,
195
+ signal_occurrence,
196
+ timeout,
197
+ ),
198
+ )
199
+
200
+ async def spawn(self, step_name: str, request: EnqueueRequest[Any], occurrence: int = 0) -> str:
201
+ return await self._context.spawn(step_name, occurrence, _request_value(request))
202
+
203
+ async def wait_for_result(
204
+ self,
205
+ step_name: str,
206
+ task_id: str,
207
+ *,
208
+ occurrence: int = 0,
209
+ timeout: float | None = None,
210
+ ) -> JSONValue:
211
+ return cast(JSONValue, await self._context.wait_for_result(step_name, occurrence, task_id, timeout))
212
+
213
+
214
+ @dataclass(frozen=True)
215
+ class TaskHandle(Generic[ResultT]):
216
+ id: str
217
+ _client: Client = field(repr=False, compare=False)
218
+
219
+ async def inspect(self) -> TaskResult[ResultT] | None:
220
+ return await self._client.task_result(self.id)
221
+
222
+ async def result(self, timeout: float | None = None) -> TaskResult[ResultT] | None:
223
+ return await self._client.wait_result(self.id, timeout)
224
+
225
+ async def signal(self, name: str, value: JSONValue, occurrence: int = 0) -> JSONValue:
226
+ return await self._client.emit_signal(self.id, name, value, occurrence)
227
+
228
+ async def cancel(self) -> bool:
229
+ return await self._client.cancel(self.id)
230
+
231
+
232
+ class Client:
233
+ def __init__(self, native: _native.Client) -> None:
234
+ self._native = native
235
+
236
+ @classmethod
237
+ async def connect(
238
+ cls,
239
+ database_url: str,
240
+ *,
241
+ listener_url: str | None = None,
242
+ max_query_connections: int = 10,
243
+ max_listener_connections: int = 1,
244
+ ) -> Client:
245
+ return cls(
246
+ await _native.Client.connect(
247
+ database_url,
248
+ listener_url=listener_url,
249
+ max_query_connections=max_query_connections,
250
+ max_listener_connections=max_listener_connections,
251
+ )
252
+ )
253
+
254
+ async def migrate(self) -> None:
255
+ await self._native.migrate()
256
+
257
+ async def enqueue(self, request: EnqueueRequest[ResultT]) -> TaskHandle[ResultT]:
258
+ task_id, _ = await self._enqueue(request)
259
+ return TaskHandle(task_id, self)
260
+
261
+ def task(self, task_id: str) -> TaskHandle[JSONValue]:
262
+ return TaskHandle(task_id, self)
263
+
264
+ async def _enqueue(self, request: EnqueueRequest[Any]) -> tuple[str, bool]:
265
+ return await self._native.enqueue(_request_value(request))
266
+
267
+ async def task_result(self, task_id: str) -> TaskResult[Any] | None:
268
+ result = await self._native.task_result(task_id)
269
+ return TaskResult.from_native(result) if result is not None else None
270
+
271
+ async def wait_result(self, task_id: str, timeout: float | None = None) -> TaskResult[Any] | None:
272
+ result = await self._native.wait_result(task_id, timeout)
273
+ return TaskResult.from_native(result) if result is not None else None
274
+
275
+ async def emit_signal(self, task_id: str, name: str, value: JSONValue, occurrence: int = 0) -> JSONValue:
276
+ return cast(JSONValue, await self._native.emit_signal(task_id, name, occurrence, value))
277
+
278
+ async def cancel(self, task_id: str) -> bool:
279
+ return await self._native.cancel(task_id)
280
+
281
+ @staticmethod
282
+ async def enqueue_on(connection: TransactionConnection, request: EnqueueRequest[Any]) -> tuple[str, bool]:
283
+ from psycopg.types.json import Jsonb
284
+
285
+ cursor = await connection.execute(
286
+ """
287
+ SELECT task_id::text, created
288
+ FROM pgtask.enqueue(%s, %s, %s, %s, %s, %s, %s, %s, %s)
289
+ """,
290
+ (
291
+ request.task_name,
292
+ Jsonb(request.payload),
293
+ request.queue_name,
294
+ request.handler_version,
295
+ request.run_at,
296
+ request.priority,
297
+ request.max_attempts,
298
+ request.idempotency_key,
299
+ Jsonb(request.headers),
300
+ ),
301
+ )
302
+ row = await cursor.fetchone()
303
+ if row is None:
304
+ raise RuntimeError("pgtask.enqueue returned no result")
305
+ return row
306
+
307
+
308
+ class Worker:
309
+ def __init__(
310
+ self,
311
+ database_url: str,
312
+ registry: TaskRegistry | Sequence[TaskRegistry],
313
+ *,
314
+ concurrency: int = 10,
315
+ poll_interval: float = 30.0,
316
+ lease_duration: float = 30.0,
317
+ health_address: str | None = None,
318
+ listener_url: str | None = None,
319
+ max_query_connections: int = 10,
320
+ max_listener_connections: int = 1,
321
+ ) -> None:
322
+ registries = [registry] if isinstance(registry, TaskRegistry) else list(registry)
323
+ if not registries:
324
+ raise ValueError("at least one registry is required")
325
+ queue_names = [entry.queue_name for entry in registries]
326
+ if len(set(queue_names)) != len(queue_names):
327
+ raise ValueError("registries must target distinct queues")
328
+ self._native = _native.Worker(
329
+ database_url,
330
+ queue_names,
331
+ {
332
+ "concurrency": concurrency,
333
+ "poll_interval": poll_interval,
334
+ "lease_duration": lease_duration,
335
+ "health_address": health_address,
336
+ "listener_url": listener_url,
337
+ "max_query_connections": max_query_connections,
338
+ "max_listener_connections": max_listener_connections,
339
+ },
340
+ )
341
+ definitions = [definition for entry in registries for definition in entry.definitions]
342
+ for definition in definitions:
343
+
344
+ async def adapter(
345
+ value: dict[str, Any],
346
+ context: _native.TaskContext,
347
+ registered: TaskDefinition[Any, Any] = definition,
348
+ ) -> JSONValue:
349
+ task = Task.from_native(value, context)
350
+ token = attach(extract(cast(dict[str, str], task.headers)))
351
+ try:
352
+ return cast(JSONValue, await registered.handler(task, task.payload))
353
+ finally:
354
+ detach(token)
355
+
356
+ self._native.register(
357
+ definition.name,
358
+ adapter,
359
+ definition.handler_version,
360
+ definition.retry_delay,
361
+ )
362
+
363
+ async def run(self) -> None:
364
+ await self._native.run()
365
+
366
+ def shutdown(self) -> None:
367
+ self._native.shutdown()
pgtask/py.typed ADDED
File without changes
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.4
2
+ Name: pgtask
3
+ Version: 0.1.3
4
+ Classifier: Development Status :: 2 - Pre-Alpha
5
+ Classifier: Programming Language :: Python :: 3 :: Only
6
+ Classifier: Programming Language :: Python :: 3.10
7
+ Classifier: Programming Language :: Python :: 3.11
8
+ Classifier: Programming Language :: Python :: 3.12
9
+ Classifier: Programming Language :: Python :: 3.13
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Classifier: Programming Language :: Rust
12
+ Requires-Dist: opentelemetry-api>=1.30
13
+ Requires-Dist: psycopg>=3.2
14
+ Summary: PostgreSQL-native durable tasks and workflows
15
+ License-Expression: MIT
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
18
+
19
+ # `pgtask`
20
+
21
+ ```python
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import os
26
+
27
+ from pgtask import Client, EnqueueRequest
28
+
29
+
30
+ async def main() -> None:
31
+ client = await Client.connect(os.environ["PGTASK_DATABASE_URL"])
32
+ task = await client.enqueue(EnqueueRequest("reports.render", {"report_id": "report-123"}))
33
+ print(await task.result(timeout=30.0))
34
+
35
+
36
+ asyncio.run(main())
37
+ ```
38
+
39
+ This package provides the Python producer client and worker runtime for `pgtask`. See the
40
+ [Python SDK documentation](https://github.com/Kludex/pgtask/blob/main/docs/sdk/python.md) for workers, transactions,
41
+ durable execution, signals, cancellation, and OpenTelemetry propagation.
42
+
@@ -0,0 +1,8 @@
1
+ pgtask\__init__.py,sha256=A2W2jVkCoQQURuItH-QA7BN37mS_OIzrZFPX5GWmHYg,532
2
+ pgtask\_native.pyd,sha256=eZuS1ODh-b_EQTgy9Xoasi0RT7RPi6SGCHwGSNFkvGE,9856512
3
+ pgtask\_native.pyi,sha256=vS90dUh2gybk3Fs-29ZEug0JBru1K4iJkDBB9qoXS-M,1875
4
+ pgtask\client.py,sha256=r_9faRvXN39H8losqpfrYFxE3mHmYu80Bj6ZK0MIAGk,13457
5
+ pgtask\py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ pgtask-0.1.3.dist-info\METADATA,sha256=VKaFqCTF_6b3OeRpHa4PtcBnkFMMY_CQCo6IU3NKZ44,1381
7
+ pgtask-0.1.3.dist-info\WHEEL,sha256=ZMDDxh9OPoaLQ4P2dJmgI1XsENYSzjzq8fErKKVw5iE,96
8
+ pgtask-0.1.3.dist-info\RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: maturin (1.11.5)
3
+ Root-Is-Purelib: false
4
+ Tag: cp310-abi3-win_amd64