taskferry-cloudtasks 0.2.0__tar.gz → 0.3.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.
- {taskferry_cloudtasks-0.2.0 → taskferry_cloudtasks-0.3.0}/CHANGELOG.md +9 -0
- {taskferry_cloudtasks-0.2.0 → taskferry_cloudtasks-0.3.0}/PKG-INFO +1 -1
- {taskferry_cloudtasks-0.2.0 → taskferry_cloudtasks-0.3.0}/pyproject.toml +1 -1
- {taskferry_cloudtasks-0.2.0 → taskferry_cloudtasks-0.3.0}/src/taskferry_cloudtasks/backend.py +95 -15
- {taskferry_cloudtasks-0.2.0 → taskferry_cloudtasks-0.3.0}/.gitignore +0 -0
- {taskferry_cloudtasks-0.2.0 → taskferry_cloudtasks-0.3.0}/LICENSE +0 -0
- {taskferry_cloudtasks-0.2.0 → taskferry_cloudtasks-0.3.0}/README.md +0 -0
- {taskferry_cloudtasks-0.2.0 → taskferry_cloudtasks-0.3.0}/src/taskferry_cloudtasks/__init__.py +0 -0
- {taskferry_cloudtasks-0.2.0 → taskferry_cloudtasks-0.3.0}/src/taskferry_cloudtasks/py.typed +0 -0
- {taskferry_cloudtasks-0.2.0 → taskferry_cloudtasks-0.3.0}/src/taskferry_cloudtasks/receiver.py +0 -0
|
@@ -4,6 +4,15 @@ All notable changes to `taskferry-cloudtasks` are documented here.
|
|
|
4
4
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
|
5
5
|
this project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.3.0] — 2026-09-19
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Native async submit** via `CloudTasksAsyncClient`. `asubmit` creates the task
|
|
12
|
+
through the async client (injected, or built from the existing `gcp` extra),
|
|
13
|
+
skipping the worker thread, and falls back to the thread path when the SDK is
|
|
14
|
+
absent. No new dependency — the async client ships with `google-cloud-tasks`.
|
|
15
|
+
|
|
7
16
|
## [0.2.0] — 2026-07-26
|
|
8
17
|
|
|
9
18
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: taskferry-cloudtasks
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Google Cloud Tasks backend for Taskferry — push-based serverless tasks.
|
|
5
5
|
Project-URL: Homepage, https://github.com/xiidigital/taskferry
|
|
6
6
|
Project-URL: Documentation, https://taskferry.dev
|
{taskferry_cloudtasks-0.2.0 → taskferry_cloudtasks-0.3.0}/src/taskferry_cloudtasks/backend.py
RENAMED
|
@@ -24,6 +24,8 @@ by accident.
|
|
|
24
24
|
from __future__ import annotations
|
|
25
25
|
|
|
26
26
|
import json
|
|
27
|
+
from collections.abc import AsyncIterator
|
|
28
|
+
from contextlib import asynccontextmanager
|
|
27
29
|
from datetime import UTC, datetime
|
|
28
30
|
from typing import Any
|
|
29
31
|
|
|
@@ -90,6 +92,7 @@ class CloudTasksBackend(BaseBackend):
|
|
|
90
92
|
service_account_email: str | None = None,
|
|
91
93
|
audience: str | None = None,
|
|
92
94
|
client: Any = None,
|
|
95
|
+
async_client: Any = None,
|
|
93
96
|
name: str = "cloudtasks",
|
|
94
97
|
) -> None:
|
|
95
98
|
missing = [
|
|
@@ -110,6 +113,7 @@ class CloudTasksBackend(BaseBackend):
|
|
|
110
113
|
self._service_account_email = service_account_email
|
|
111
114
|
self._audience = audience
|
|
112
115
|
self._client = client
|
|
116
|
+
self._async_client = async_client
|
|
113
117
|
self._name = name
|
|
114
118
|
|
|
115
119
|
@property
|
|
@@ -143,12 +147,18 @@ class CloudTasksBackend(BaseBackend):
|
|
|
143
147
|
return str(builder(self._project, self._location, queue))
|
|
144
148
|
return f"projects/{self._project}/locations/{self._location}/queues/{queue}"
|
|
145
149
|
|
|
150
|
+
def _queue_path_str(self, queue: str) -> str:
|
|
151
|
+
"""Fully-qualified queue path without needing a client (async path)."""
|
|
152
|
+
return f"projects/{self._project}/locations/{self._location}/queues/{queue}"
|
|
153
|
+
|
|
146
154
|
# -- submission -------------------------------------------------------------- #
|
|
147
|
-
def
|
|
148
|
-
assert isinstance(spec, TaskSpec)
|
|
155
|
+
def _queue_for(self, spec: TaskSpec) -> str:
|
|
149
156
|
options = spec.options_for("cloudtasks")
|
|
150
|
-
|
|
157
|
+
return str(options.get("queue") or spec.queue or self._queue)
|
|
151
158
|
|
|
159
|
+
def _build_task(self, spec: TaskSpec, queue_path: str) -> dict[str, Any]:
|
|
160
|
+
"""The Cloud Tasks task body. Pure, shared by the sync and async paths."""
|
|
161
|
+
options = spec.options_for("cloudtasks")
|
|
152
162
|
http_request: dict[str, Any] = {
|
|
153
163
|
"http_method": "POST",
|
|
154
164
|
"url": self._url,
|
|
@@ -175,18 +185,12 @@ class CloudTasksBackend(BaseBackend):
|
|
|
175
185
|
# A named task is refused if the name was used recently — Cloud Tasks'
|
|
176
186
|
# own, real, time-bounded deduplication. The window is Google's, not
|
|
177
187
|
# ours, and this is not an exactly-once promise.
|
|
178
|
-
task["name"] = f"{
|
|
179
|
-
|
|
180
|
-
try:
|
|
181
|
-
created = self._tasks().create_task(
|
|
182
|
-
request={"parent": self.queue_path(queue), "task": task}
|
|
183
|
-
)
|
|
184
|
-
except Exception as exc:
|
|
185
|
-
raise SubmissionError(
|
|
186
|
-
f"Cloud Tasks could not create a task for {spec.task!r} on queue {queue!r}: {exc}",
|
|
187
|
-
backend=self._name,
|
|
188
|
-
) from exc
|
|
188
|
+
task["name"] = f"{queue_path}/tasks/{_safe_name(spec.idempotency_key)}"
|
|
189
|
+
return task
|
|
189
190
|
|
|
191
|
+
def _execution_from(
|
|
192
|
+
self, spec: TaskSpec, created: Any, queue_path: str, queue: str
|
|
193
|
+
) -> Execution:
|
|
190
194
|
external_id = getattr(created, "name", None)
|
|
191
195
|
return Execution(
|
|
192
196
|
id=new_execution_id(ExecutionKind.TASK),
|
|
@@ -203,12 +207,64 @@ class CloudTasksBackend(BaseBackend):
|
|
|
203
207
|
provider="gcp",
|
|
204
208
|
provider_id=str(external_id) if external_id else None,
|
|
205
209
|
region=self._location,
|
|
206
|
-
resource=
|
|
210
|
+
resource=queue_path,
|
|
207
211
|
labels=dict(spec.labels),
|
|
208
212
|
),
|
|
209
213
|
metadata={"queue": queue, "url": self._url},
|
|
210
214
|
)
|
|
211
215
|
|
|
216
|
+
def _submit(self, spec: ExecutionSpec) -> Execution:
|
|
217
|
+
assert isinstance(spec, TaskSpec)
|
|
218
|
+
queue = self._queue_for(spec)
|
|
219
|
+
queue_path = self.queue_path(queue)
|
|
220
|
+
task = self._build_task(spec, queue_path)
|
|
221
|
+
try:
|
|
222
|
+
created = self._tasks().create_task(request={"parent": queue_path, "task": task})
|
|
223
|
+
except Exception as exc:
|
|
224
|
+
raise SubmissionError(
|
|
225
|
+
f"Cloud Tasks could not create a task for {spec.task!r} on queue {queue!r}: {exc}",
|
|
226
|
+
backend=self._name,
|
|
227
|
+
) from exc
|
|
228
|
+
return self._execution_from(spec, created, queue_path, queue)
|
|
229
|
+
|
|
230
|
+
# -- native async (CloudTasksAsyncClient) ------------------------------------- #
|
|
231
|
+
@asynccontextmanager
|
|
232
|
+
async def _async_tasks(self) -> AsyncIterator[Any]:
|
|
233
|
+
if self._async_client is not None:
|
|
234
|
+
yield self._async_client
|
|
235
|
+
return
|
|
236
|
+
client = _async_tasks_client()
|
|
237
|
+
try:
|
|
238
|
+
yield client
|
|
239
|
+
finally: # pragma: no cover - real gRPC client only
|
|
240
|
+
close = getattr(getattr(client, "transport", None), "close", None)
|
|
241
|
+
if callable(close):
|
|
242
|
+
await close()
|
|
243
|
+
|
|
244
|
+
async def asubmit(self, spec: ExecutionSpec) -> Execution:
|
|
245
|
+
"""Native async submit via ``CloudTasksAsyncClient``, else the thread path."""
|
|
246
|
+
if self._async_client is None and not _has_cloud_tasks():
|
|
247
|
+
return await super().asubmit(spec)
|
|
248
|
+
assert isinstance(spec, TaskSpec)
|
|
249
|
+
self.validate(spec)
|
|
250
|
+
self.hooks.before_submit(spec, self.name)
|
|
251
|
+
queue = self._queue_for(spec)
|
|
252
|
+
queue_path = self._queue_path_str(queue)
|
|
253
|
+
task = self._build_task(spec, queue_path)
|
|
254
|
+
try:
|
|
255
|
+
async with self._async_tasks() as client:
|
|
256
|
+
created = await client.create_task(request={"parent": queue_path, "task": task})
|
|
257
|
+
execution = self._execution_from(spec, created, queue_path, queue)
|
|
258
|
+
except Exception as exc:
|
|
259
|
+
error = SubmissionError(
|
|
260
|
+
f"Cloud Tasks could not create a task for {spec.task!r} on queue {queue!r}: {exc}",
|
|
261
|
+
backend=self._name,
|
|
262
|
+
)
|
|
263
|
+
self.hooks.on_submit_error(spec, self.name, error)
|
|
264
|
+
raise error from exc
|
|
265
|
+
self.hooks.after_submit(spec, execution)
|
|
266
|
+
return execution
|
|
267
|
+
|
|
212
268
|
@staticmethod
|
|
213
269
|
def _correlation_headers(correlation: Correlation | None) -> dict[str, str]:
|
|
214
270
|
"""Propagate correlation and trace context as HTTP headers.
|
|
@@ -226,6 +282,29 @@ def _safe_name(key: str) -> str:
|
|
|
226
282
|
return cleaned[:500] or "taskferry"
|
|
227
283
|
|
|
228
284
|
|
|
285
|
+
def _has_cloud_tasks() -> bool:
|
|
286
|
+
"""Whether the Google Cloud Tasks SDK is importable, without importing it."""
|
|
287
|
+
from importlib.util import find_spec
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
# A dotted name raises (not returns None) when a parent package like
|
|
291
|
+
# ``google`` is absent, so both outcomes mean "not installed".
|
|
292
|
+
return find_spec("google.cloud.tasks_v2") is not None
|
|
293
|
+
except ModuleNotFoundError:
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _async_tasks_client() -> Any:
|
|
298
|
+
"""A lazily-built ``CloudTasksAsyncClient``, with an actionable error."""
|
|
299
|
+
try:
|
|
300
|
+
from google.cloud import tasks_v2
|
|
301
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
302
|
+
raise ConfigurationError(
|
|
303
|
+
"the Cloud Tasks backend needs the Google SDK: pip install 'taskferry-cloudtasks[gcp]'"
|
|
304
|
+
) from exc
|
|
305
|
+
return tasks_v2.CloudTasksAsyncClient()
|
|
306
|
+
|
|
307
|
+
|
|
229
308
|
def make_backend(**options: Any) -> CloudTasksBackend:
|
|
230
309
|
"""Entry point for ``{"factory": "cloudtasks", ...}`` configuration."""
|
|
231
310
|
return CloudTasksBackend(
|
|
@@ -236,6 +315,7 @@ def make_backend(**options: Any) -> CloudTasksBackend:
|
|
|
236
315
|
service_account_email=options.get("service_account_email"),
|
|
237
316
|
audience=options.get("audience"),
|
|
238
317
|
client=options.get("client"),
|
|
318
|
+
async_client=options.get("async_client"),
|
|
239
319
|
name=str(options.get("name", "cloudtasks")),
|
|
240
320
|
)
|
|
241
321
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{taskferry_cloudtasks-0.2.0 → taskferry_cloudtasks-0.3.0}/src/taskferry_cloudtasks/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{taskferry_cloudtasks-0.2.0 → taskferry_cloudtasks-0.3.0}/src/taskferry_cloudtasks/receiver.py
RENAMED
|
File without changes
|