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.
@@ -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.2.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "taskferry-cloudtasks"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Google Cloud Tasks backend for Taskferry — push-based serverless tasks."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -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 _submit(self, spec: ExecutionSpec) -> Execution:
148
- assert isinstance(spec, TaskSpec)
155
+ def _queue_for(self, spec: TaskSpec) -> str:
149
156
  options = spec.options_for("cloudtasks")
150
- queue = str(options.get("queue") or spec.queue or self._queue)
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"{self.queue_path(queue)}/tasks/{_safe_name(spec.idempotency_key)}"
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=self.queue_path(queue),
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