taskferry-cloudtasks 0.2.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.
@@ -0,0 +1,34 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+
7
+ # Build artifacts
8
+ dist/
9
+ build/
10
+ *.whl
11
+ *.tar.gz
12
+
13
+ # Environments
14
+ .venv/
15
+ .venv-*/
16
+ .env
17
+
18
+ # Tooling caches
19
+ .mypy_cache/
20
+ .ruff_cache/
21
+ .pytest_cache/
22
+ .coverage
23
+ htmlcov/
24
+ coverage.xml
25
+ node_modules/
26
+
27
+ # uv
28
+ uv.lock
29
+
30
+ # OS / editors
31
+ .DS_Store
32
+ .idea/
33
+ .vscode/
34
+ site/
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ All notable changes to `taskferry-cloudtasks` are documented here.
4
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
5
+ this project adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.2.0] — 2026-07-26
8
+
9
+ ### Added
10
+
11
+ - Initial release, extracted from the Django-coupled backends of `taskferry-django` 0.1
12
+ and rebuilt against the framework-agnostic Taskferry ports.
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or Derivative
95
+ Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.5
2
+ Name: taskferry-cloudtasks
3
+ Version: 0.2.0
4
+ Summary: Google Cloud Tasks backend for Taskferry — push-based serverless tasks.
5
+ Project-URL: Homepage, https://github.com/xiidigital/taskferry
6
+ Project-URL: Documentation, https://taskferry.dev
7
+ Project-URL: Source, https://github.com/xiidigital/taskferry
8
+ Author: Taskferry authors
9
+ License-Expression: Apache-2.0
10
+ License-File: LICENSE
11
+ Keywords: cloud-tasks,gcp,serverless,taskferry,tasks
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: taskferry<0.3,>=0.2
21
+ Provides-Extra: gcp
22
+ Requires-Dist: google-cloud-tasks>=2.16; extra == 'gcp'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # taskferry-cloudtasks
26
+
27
+ Run [Taskferry](https://github.com/xiidigital/taskferry) tasks on **Google Cloud
28
+ Tasks** — push-based, serverless, no worker process.
29
+
30
+ ```mermaid
31
+ sequenceDiagram
32
+ participant App
33
+ participant TP as Taskferry
34
+ participant CT as Cloud Tasks
35
+ participant SVC as your service
36
+
37
+ App->>TP: tasks.submit("myapp:send_email", 42)
38
+ TP->>CT: create_task(http_request)
39
+ CT->>SVC: POST /_taskferry/execute
40
+ SVC->>SVC: handle_request(body)
41
+ ```
42
+
43
+ Cloud Tasks is architecturally the opposite of Procrastinate — nothing polls, no
44
+ worker exists, Google pushes an HTTP request at your service. The application
45
+ code does not change:
46
+
47
+ ```python
48
+ runtime.tasks.submit("myapp.tasks:send_email", 42, queue="email")
49
+ ```
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install 'taskferry-cloudtasks[gcp]'
55
+ ```
56
+
57
+ ## Configure
58
+
59
+ ```python
60
+ runtime = Taskferry.from_mapping(
61
+ {
62
+ "backends": {
63
+ "push": {
64
+ "factory": "cloudtasks",
65
+ "project": "my-project",
66
+ "location": "europe-west1",
67
+ "url": "https://my-service.run.app/_taskferry/execute",
68
+ "service_account_email": "runner@my-project.iam.gserviceaccount.com",
69
+ }
70
+ },
71
+ "defaults": {"task": "push"},
72
+ }
73
+ )
74
+ ```
75
+
76
+ ## Receive
77
+
78
+ `handle_request` takes bytes, so it works with any framework:
79
+
80
+ ```python
81
+ # Django
82
+ from taskferry import FunctionRegistry
83
+ from taskferry_cloudtasks import handle_request
84
+
85
+ REGISTRY = FunctionRegistry(allowed_modules=["myapp"])
86
+
87
+
88
+ def taskferry_execute(request):
89
+ handle_request(request.body, dict(request.headers), registry=REGISTRY)
90
+ return HttpResponse(status=204)
91
+ ```
92
+
93
+ ```python
94
+ # FastAPI
95
+ @app.post("/_taskferry/execute")
96
+ async def execute(request: Request):
97
+ handle_request(await request.body(), dict(request.headers), registry=REGISTRY)
98
+ return Response(status_code=204)
99
+ ```
100
+
101
+ **Authenticate the endpoint.** Verify the OIDC token Cloud Tasks sends, or put
102
+ the service behind IAM. Taskferry never sees your framework's request object —
103
+ that is what makes it portable, and it is why this part is yours.
104
+
105
+ **Be idempotent.** Cloud Tasks delivery is at-least-once.
106
+
107
+ ## Capabilities
108
+
109
+ | Capability | Supported | Why |
110
+ | ---------- | :-------: | --- |
111
+ | `SUBMIT` · `DELAY` · `RETRY` | yes | `create_task`, `schedule_time`, queue retry config |
112
+ | `DEDUPLICATION` | yes | task names are unique per queue for a bounded window |
113
+ | `STATE` · `RESULT` | **no** | Cloud Tasks reports nothing per task after creation |
114
+
115
+ A handle refuses `status()` here instead of returning a plausible `UNKNOWN`
116
+ forever. Record what you need in your own database, where it is actually true.
117
+
118
+ ## License
119
+
120
+ Apache-2.0.
@@ -0,0 +1,96 @@
1
+ # taskferry-cloudtasks
2
+
3
+ Run [Taskferry](https://github.com/xiidigital/taskferry) tasks on **Google Cloud
4
+ Tasks** — push-based, serverless, no worker process.
5
+
6
+ ```mermaid
7
+ sequenceDiagram
8
+ participant App
9
+ participant TP as Taskferry
10
+ participant CT as Cloud Tasks
11
+ participant SVC as your service
12
+
13
+ App->>TP: tasks.submit("myapp:send_email", 42)
14
+ TP->>CT: create_task(http_request)
15
+ CT->>SVC: POST /_taskferry/execute
16
+ SVC->>SVC: handle_request(body)
17
+ ```
18
+
19
+ Cloud Tasks is architecturally the opposite of Procrastinate — nothing polls, no
20
+ worker exists, Google pushes an HTTP request at your service. The application
21
+ code does not change:
22
+
23
+ ```python
24
+ runtime.tasks.submit("myapp.tasks:send_email", 42, queue="email")
25
+ ```
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install 'taskferry-cloudtasks[gcp]'
31
+ ```
32
+
33
+ ## Configure
34
+
35
+ ```python
36
+ runtime = Taskferry.from_mapping(
37
+ {
38
+ "backends": {
39
+ "push": {
40
+ "factory": "cloudtasks",
41
+ "project": "my-project",
42
+ "location": "europe-west1",
43
+ "url": "https://my-service.run.app/_taskferry/execute",
44
+ "service_account_email": "runner@my-project.iam.gserviceaccount.com",
45
+ }
46
+ },
47
+ "defaults": {"task": "push"},
48
+ }
49
+ )
50
+ ```
51
+
52
+ ## Receive
53
+
54
+ `handle_request` takes bytes, so it works with any framework:
55
+
56
+ ```python
57
+ # Django
58
+ from taskferry import FunctionRegistry
59
+ from taskferry_cloudtasks import handle_request
60
+
61
+ REGISTRY = FunctionRegistry(allowed_modules=["myapp"])
62
+
63
+
64
+ def taskferry_execute(request):
65
+ handle_request(request.body, dict(request.headers), registry=REGISTRY)
66
+ return HttpResponse(status=204)
67
+ ```
68
+
69
+ ```python
70
+ # FastAPI
71
+ @app.post("/_taskferry/execute")
72
+ async def execute(request: Request):
73
+ handle_request(await request.body(), dict(request.headers), registry=REGISTRY)
74
+ return Response(status_code=204)
75
+ ```
76
+
77
+ **Authenticate the endpoint.** Verify the OIDC token Cloud Tasks sends, or put
78
+ the service behind IAM. Taskferry never sees your framework's request object —
79
+ that is what makes it portable, and it is why this part is yours.
80
+
81
+ **Be idempotent.** Cloud Tasks delivery is at-least-once.
82
+
83
+ ## Capabilities
84
+
85
+ | Capability | Supported | Why |
86
+ | ---------- | :-------: | --- |
87
+ | `SUBMIT` · `DELAY` · `RETRY` | yes | `create_task`, `schedule_time`, queue retry config |
88
+ | `DEDUPLICATION` | yes | task names are unique per queue for a bounded window |
89
+ | `STATE` · `RESULT` | **no** | Cloud Tasks reports nothing per task after creation |
90
+
91
+ A handle refuses `status()` here instead of returning a plausible `UNKNOWN`
92
+ forever. Record what you need in your own database, where it is actually true.
93
+
94
+ ## License
95
+
96
+ Apache-2.0.
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "taskferry-cloudtasks"
7
+ version = "0.2.0"
8
+ description = "Google Cloud Tasks backend for Taskferry — push-based serverless tasks."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "Apache-2.0"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Taskferry authors" }]
14
+ keywords = ["taskferry", "gcp", "cloud-tasks", "tasks", "serverless"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: Apache Software License",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Typing :: Typed",
23
+ ]
24
+ dependencies = ["taskferry>=0.2,<0.3"]
25
+
26
+ [project.optional-dependencies]
27
+ gcp = ["google-cloud-tasks>=2.16"]
28
+
29
+ [project.entry-points."taskferry.backends"]
30
+ cloudtasks = "taskferry_cloudtasks:make_backend"
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/xiidigital/taskferry"
34
+ Documentation = "https://taskferry.dev"
35
+ Source = "https://github.com/xiidigital/taskferry"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/taskferry_cloudtasks"]
39
+
40
+ [tool.hatch.build.targets.sdist]
41
+ include = ["src", "README.md", "CHANGELOG.md", "LICENSE"]
@@ -0,0 +1,54 @@
1
+ """Taskferry on Google Cloud Tasks — push-based, serverless task execution.
2
+
3
+ ```mermaid
4
+ sequenceDiagram
5
+ participant App
6
+ participant TP as Taskferry
7
+ participant AD as taskferry_cloudtasks
8
+ participant CT as Cloud Tasks
9
+ participant SVC as your HTTP service
10
+
11
+ App->>TP: tasks.submit("myapp:send_email", 42)
12
+ TP->>AD: TaskSpec
13
+ AD->>CT: create_task(http_request)
14
+ CT->>SVC: POST /_taskferry/execute
15
+ SVC->>AD: handle_request(body)
16
+ AD->>SVC: run the function
17
+ ```
18
+
19
+ The second task engine, and the reason the first one had to be an adapter. Cloud
20
+ Tasks is architecturally the opposite of Procrastinate — nothing polls, no worker
21
+ process exists, Google pushes an HTTP request at your service and scale-to-zero
22
+ is the normal state — and the *application code does not change*:
23
+
24
+ runtime.tasks.submit("myapp.tasks:send_email", 42, queue="email")
25
+
26
+ The queue moves from PostgreSQL to Cloud Tasks by editing configuration. That is
27
+ the entire promise of a portability layer, and this package is where it either
28
+ holds or does not.
29
+
30
+ What genuinely differs, and is therefore visible in the capabilities: Cloud Tasks
31
+ does not report per-task state after creation and stores no results, so ``STATE``
32
+ and ``RESULT`` are not advertised and a handle refuses to fake them. It does
33
+ support scheduling and native retries, and those are advertised.
34
+
35
+ The receiving side is framework-agnostic — :func:`handle_request` takes bytes and
36
+ returns whatever the task returned, so it plugs into Django, FastAPI, Flask or a
37
+ bare WSGI app in four lines. See :mod:`taskferry_cloudtasks.receiver`.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from .backend import CLOUD_TASKS_CAPABILITIES, CloudTasksBackend, make_backend
43
+ from .receiver import handle_request, parse_request
44
+
45
+ __version__ = "0.2.0"
46
+
47
+ __all__ = [
48
+ "CLOUD_TASKS_CAPABILITIES",
49
+ "CloudTasksBackend",
50
+ "__version__",
51
+ "handle_request",
52
+ "make_backend",
53
+ "parse_request",
54
+ ]
@@ -0,0 +1,250 @@
1
+ """The Cloud Tasks `TaskBackend`.
2
+
3
+ Capabilities, and the two that are missing
4
+ ------------------------------------------
5
+
6
+ Advertised: ``SUBMIT``, ``DELAY`` (``schedule_time``), ``RETRY`` (the queue's own
7
+ retry configuration), ``DEDUPLICATION`` (a task *name* is unique per queue, which
8
+ is real deduplication with a documented window).
9
+
10
+ Not advertised: ``STATE`` and ``RESULT``. Cloud Tasks is fire-and-forget — once a
11
+ task is created there is no per-task status to read and no place a return value
12
+ is kept. A handle from this backend therefore raises
13
+ :class:`~taskferry.errors.UnsupportedCapability` on ``status()`` rather than
14
+ returning a plausible-looking ``UNKNOWN`` forever, and an application that needs
15
+ to know whether the work happened records that itself, in its own database, where
16
+ it is actually true.
17
+
18
+ That asymmetry with Procrastinate is not a defect in the abstraction; it is the
19
+ abstraction working. Both engines run the same task code, and the difference in
20
+ what you can *ask* afterwards is visible, checkable and impossible to trip over
21
+ by accident.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from datetime import UTC, datetime
28
+ from typing import Any
29
+
30
+ from taskferry.capabilities import Capability, CapabilitySet
31
+ from taskferry.core.correlation import Correlation
32
+ from taskferry.core.provider import ProviderMetadata
33
+ from taskferry.envelope import ENVELOPE_VERSION, Envelope, build_envelope
34
+ from taskferry.errors import ConfigurationError, SubmissionError
35
+ from taskferry.execution import (
36
+ Execution,
37
+ ExecutionKind,
38
+ ExecutionState,
39
+ new_execution_id,
40
+ )
41
+ from taskferry.ports import BaseBackend
42
+ from taskferry.specs import ExecutionSpec, TaskSpec
43
+
44
+ MESSAGE_VERSION = ENVELOPE_VERSION
45
+ """Alias of the shared envelope version; the format is the core's, not ours."""
46
+
47
+ CLOUD_TASKS_CAPABILITIES = frozenset(
48
+ {
49
+ Capability.SUBMIT,
50
+ Capability.DELAY,
51
+ Capability.RETRY,
52
+ Capability.DEDUPLICATION,
53
+ }
54
+ )
55
+
56
+
57
+ CloudTasksMessage = Envelope
58
+ """The JSON body Cloud Tasks POSTs to your service — the shared envelope."""
59
+
60
+ build_message = build_envelope
61
+ """Serialize a spec into the HTTP body the receiver will decode."""
62
+
63
+
64
+ class CloudTasksBackend(BaseBackend):
65
+ """Creates HTTP-target tasks on a Cloud Tasks queue.
66
+
67
+ Args:
68
+ project: GCP project id.
69
+ location: Queue region.
70
+ queue: Default queue name. ``TaskSpec.queue`` overrides it, which is what
71
+ makes ``queue="email"`` mean a real Cloud Tasks queue.
72
+ url: The endpoint Cloud Tasks will POST to. Your service routes it to
73
+ :func:`taskferry_cloudtasks.handle_request`.
74
+ service_account_email: Identity for the OIDC token, so the endpoint can
75
+ require authentication instead of being open to the internet.
76
+ audience: OIDC audience, when it differs from ``url``.
77
+ client: Injected ``tasks_v2.CloudTasksClient`` for testing.
78
+
79
+ Backend options, under the ``"cloudtasks"`` namespace: ``dispatch_deadline``,
80
+ ``headers``, and ``queue`` for a one-off override.
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ *,
86
+ project: str | None = None,
87
+ location: str | None = None,
88
+ queue: str = "default",
89
+ url: str | None = None,
90
+ service_account_email: str | None = None,
91
+ audience: str | None = None,
92
+ client: Any = None,
93
+ name: str = "cloudtasks",
94
+ ) -> None:
95
+ missing = [
96
+ key
97
+ for key, value in (("project", project), ("location", location), ("url", url))
98
+ if not value
99
+ ]
100
+ if missing:
101
+ raise ConfigurationError(
102
+ f"CloudTasksBackend needs {', '.join(missing)}; 'url' is the endpoint in your "
103
+ "service that Cloud Tasks will POST each task to"
104
+ )
105
+ assert project and location and url # narrowed by the check above
106
+ self._project = project
107
+ self._location = location
108
+ self._queue = queue
109
+ self._url = url
110
+ self._service_account_email = service_account_email
111
+ self._audience = audience
112
+ self._client = client
113
+ self._name = name
114
+
115
+ @property
116
+ def name(self) -> str:
117
+ return self._name
118
+
119
+ @property
120
+ def kind(self) -> ExecutionKind:
121
+ return ExecutionKind.TASK
122
+
123
+ @property
124
+ def capabilities(self) -> CapabilitySet:
125
+ return CapabilitySet(CLOUD_TASKS_CAPABILITIES, provider=self._name)
126
+
127
+ def _tasks(self) -> Any:
128
+ if self._client is None:
129
+ try:
130
+ from google.cloud import tasks_v2
131
+ except ImportError as exc: # pragma: no cover - depends on the environment
132
+ raise ConfigurationError(
133
+ "the Cloud Tasks backend needs the Google SDK: "
134
+ "pip install 'taskferry-cloudtasks[gcp]'"
135
+ ) from exc
136
+ self._client = tasks_v2.CloudTasksClient()
137
+ return self._client
138
+
139
+ def queue_path(self, queue: str) -> str:
140
+ client = self._tasks()
141
+ builder = getattr(client, "queue_path", None)
142
+ if callable(builder):
143
+ return str(builder(self._project, self._location, queue))
144
+ return f"projects/{self._project}/locations/{self._location}/queues/{queue}"
145
+
146
+ # -- submission -------------------------------------------------------------- #
147
+ def _submit(self, spec: ExecutionSpec) -> Execution:
148
+ assert isinstance(spec, TaskSpec)
149
+ options = spec.options_for("cloudtasks")
150
+ queue = str(options.get("queue") or spec.queue or self._queue)
151
+
152
+ http_request: dict[str, Any] = {
153
+ "http_method": "POST",
154
+ "url": self._url,
155
+ "headers": {
156
+ "Content-Type": "application/json",
157
+ **self._correlation_headers(spec.correlation),
158
+ **dict(options.get("headers") or {}), # type: ignore[arg-type]
159
+ },
160
+ "body": json.dumps(build_message(spec)).encode("utf-8"),
161
+ }
162
+ if self._service_account_email:
163
+ oidc: dict[str, str] = {"service_account_email": self._service_account_email}
164
+ if self._audience or self._url:
165
+ oidc["audience"] = self._audience or self._url
166
+ http_request["oidc_token"] = oidc
167
+
168
+ task: dict[str, Any] = {"http_request": http_request}
169
+ scheduled_for = spec.scheduled_for()
170
+ if scheduled_for is not None:
171
+ task["schedule_time"] = scheduled_for
172
+ if "dispatch_deadline" in options:
173
+ task["dispatch_deadline"] = options["dispatch_deadline"]
174
+ if spec.idempotency_key is not None:
175
+ # A named task is refused if the name was used recently — Cloud Tasks'
176
+ # own, real, time-bounded deduplication. The window is Google's, not
177
+ # 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
189
+
190
+ external_id = getattr(created, "name", None)
191
+ return Execution(
192
+ id=new_execution_id(ExecutionKind.TASK),
193
+ kind=ExecutionKind.TASK,
194
+ backend=self._name,
195
+ # QUEUED is the last thing this backend can honestly observe: Cloud
196
+ # Tasks will not tell us anything after creation.
197
+ state=ExecutionState.QUEUED,
198
+ name=spec.name,
199
+ created_at=datetime.now(UTC),
200
+ external_id=str(external_id) if external_id else None,
201
+ correlation=spec.correlation,
202
+ provider_metadata=ProviderMetadata(
203
+ provider="gcp",
204
+ provider_id=str(external_id) if external_id else None,
205
+ region=self._location,
206
+ resource=self.queue_path(queue),
207
+ labels=dict(spec.labels),
208
+ ),
209
+ metadata={"queue": queue, "url": self._url},
210
+ )
211
+
212
+ @staticmethod
213
+ def _correlation_headers(correlation: Correlation | None) -> dict[str, str]:
214
+ """Propagate correlation and trace context as HTTP headers.
215
+
216
+ This is how a trace survives the hop through Google's infrastructure: the
217
+ receiver rebuilds the correlation from these headers, so one flow stays
218
+ followable from the web request through the queue into the task.
219
+ """
220
+ return correlation.to_headers() if correlation is not None else {}
221
+
222
+
223
+ def _safe_name(key: str) -> str:
224
+ """Reduce an idempotency key to the characters Cloud Tasks allows in a name."""
225
+ cleaned = "".join(char if char.isalnum() or char in "-_" else "-" for char in key)
226
+ return cleaned[:500] or "taskferry"
227
+
228
+
229
+ def make_backend(**options: Any) -> CloudTasksBackend:
230
+ """Entry point for ``{"factory": "cloudtasks", ...}`` configuration."""
231
+ return CloudTasksBackend(
232
+ project=options.get("project"),
233
+ location=options.get("location"),
234
+ queue=str(options.get("queue", "default")),
235
+ url=options.get("url"),
236
+ service_account_email=options.get("service_account_email"),
237
+ audience=options.get("audience"),
238
+ client=options.get("client"),
239
+ name=str(options.get("name", "cloudtasks")),
240
+ )
241
+
242
+
243
+ __all__ = [
244
+ "CLOUD_TASKS_CAPABILITIES",
245
+ "MESSAGE_VERSION",
246
+ "CloudTasksBackend",
247
+ "CloudTasksMessage",
248
+ "build_message",
249
+ "make_backend",
250
+ ]
@@ -0,0 +1,126 @@
1
+ """The receiving side — bytes in, task executed.
2
+
3
+ Cloud Tasks pushes an HTTP request; something in your service has to turn that
4
+ back into a function call. That something is :func:`handle_request`, and it takes
5
+ **bytes**, not a Django ``HttpRequest``, not a Starlette ``Request``.
6
+
7
+ ```mermaid
8
+ flowchart LR
9
+ CT["Cloud Tasks"]
10
+ V["your view / route<br/>(4 lines, any framework)"]
11
+ H["handle_request(body, headers)"]
12
+ FN["package.module:function"]
13
+
14
+ CT -->|"POST JSON"| V --> H --> FN
15
+ ```
16
+
17
+ Django::
18
+
19
+ from django.http import HttpResponse
20
+ from taskferry_cloudtasks import handle_request
21
+
22
+ def taskferry_execute(request):
23
+ handle_request(request.body, dict(request.headers), registry=REGISTRY)
24
+ return HttpResponse(status=204)
25
+
26
+ FastAPI::
27
+
28
+ @app.post("/_taskferry/execute")
29
+ async def execute(request: Request):
30
+ handle_request(await request.body(), dict(request.headers), registry=REGISTRY)
31
+ return Response(status_code=204)
32
+
33
+ Keeping the framework out of this module is what lets one adapter serve all of
34
+ them, and is why ``taskferry-cloudtasks`` depends on neither Django nor FastAPI.
35
+
36
+ Two things your endpoint must do
37
+ --------------------------------
38
+
39
+ **Authenticate.** Anyone who finds the URL can POST to it. Require the OIDC token
40
+ Cloud Tasks sends (verify it with ``google.oauth2.id_token``), or put the service
41
+ behind IAM. Taskferry cannot do this for you: it never sees your framework's
42
+ request object, which is the same reason it is portable.
43
+
44
+ **Be idempotent.** Cloud Tasks retries, so a task can arrive more than once.
45
+ Delivery is at-least-once; nothing here changes that and nothing can.
46
+
47
+ Returning 2xx acknowledges the task; raising (or returning 5xx) tells Cloud Tasks
48
+ to retry according to the queue's retry configuration. Let exceptions propagate
49
+ and the queue does the right thing.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import json
55
+ from collections.abc import Mapping
56
+ from typing import Any
57
+
58
+ from taskferry.core.correlation import Correlation, use_correlation
59
+ from taskferry.envelope import read_envelope
60
+ from taskferry.errors import SerializationError
61
+ from taskferry.functions import FunctionRegistry, is_async_callable
62
+
63
+ from .backend import CloudTasksMessage
64
+
65
+
66
+ def parse_request(body: bytes | str) -> CloudTasksMessage:
67
+ """Decode and validate the request body.
68
+
69
+ Raises:
70
+ SerializationError: when the body is not a Taskferry message of a version
71
+ this code understands. Failing loudly is deliberate: a malformed body
72
+ means a misconfigured endpoint or a version skew, and quietly doing
73
+ nothing would hide both.
74
+ """
75
+ try:
76
+ payload = json.loads(body)
77
+ except (ValueError, UnicodeDecodeError) as exc:
78
+ raise SerializationError(f"Cloud Tasks body is not valid JSON: {exc}") from exc
79
+ return read_envelope(payload)
80
+
81
+
82
+ def handle_request(
83
+ body: bytes | str,
84
+ headers: Mapping[str, str] | None = None,
85
+ *,
86
+ registry: FunctionRegistry | None = None,
87
+ ) -> Any:
88
+ """Run the task described by a Cloud Tasks push request.
89
+
90
+ Args:
91
+ body: The raw request body.
92
+ headers: Request headers, used to restore correlation and trace context
93
+ so the task's spans join the flow that enqueued it.
94
+ registry: Where task names resolve. **Give it an allowlist**: the task
95
+ name arrives over the network, and an unrestricted resolver on a
96
+ public endpoint is a remote-import primitive.
97
+
98
+ Returns:
99
+ Whatever the task returned. Cloud Tasks discards it; it is returned so
100
+ the value is available to your view for logging or a response body.
101
+ """
102
+ message = parse_request(body)
103
+ resolver = registry if registry is not None else FunctionRegistry()
104
+ func = resolver.resolve(str(message["task"]))
105
+ args = list(message.get("args") or [])
106
+ kwargs = dict(message.get("kwargs") or {})
107
+
108
+ with use_correlation(_correlation_from(message, headers)):
109
+ if is_async_callable(func):
110
+ import asyncio
111
+
112
+ return asyncio.run(func(*args, **kwargs))
113
+ return func(*args, **kwargs)
114
+
115
+
116
+ def _correlation_from(message: CloudTasksMessage, headers: Mapping[str, str] | None) -> Correlation:
117
+ """Prefer the headers (they carry live trace context), fall back to the body."""
118
+ if headers:
119
+ normalised = {key.lower(): value for key, value in headers.items()}
120
+ if "taskferry-correlation-id" in normalised:
121
+ return Correlation.from_headers(normalised)
122
+ embedded = message.get("correlation")
123
+ return Correlation.from_headers(embedded) if embedded else Correlation.start()
124
+
125
+
126
+ __all__ = ["handle_request", "parse_request"]