py-serverless-kit 0.1.0__py3-none-any.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.
Files changed (71) hide show
  1. py_serverless_kit/__init__.py +47 -0
  2. py_serverless_kit/__main__.py +3 -0
  3. py_serverless_kit/_version.py +2 -0
  4. py_serverless_kit/cli/__init__.py +0 -0
  5. py_serverless_kit/cli/main.py +491 -0
  6. py_serverless_kit/cli/templates.py +212 -0
  7. py_serverless_kit/config/__init__.py +12 -0
  8. py_serverless_kit/config/loader.py +316 -0
  9. py_serverless_kit/config/schema.py +457 -0
  10. py_serverless_kit/core/__init__.py +0 -0
  11. py_serverless_kit/core/deployer.py +180 -0
  12. py_serverless_kit/core/describe.py +112 -0
  13. py_serverless_kit/core/project.py +147 -0
  14. py_serverless_kit/dashboard/__init__.py +3 -0
  15. py_serverless_kit/dashboard/generator.py +65 -0
  16. py_serverless_kit/dashboard/template.html +248 -0
  17. py_serverless_kit/local/__init__.py +3 -0
  18. py_serverless_kit/local/runner.py +234 -0
  19. py_serverless_kit/local/server.py +200 -0
  20. py_serverless_kit/packaging/__init__.py +3 -0
  21. py_serverless_kit/packaging/builder.py +252 -0
  22. py_serverless_kit/providers/__init__.py +15 -0
  23. py_serverless_kit/providers/alibaba.py +268 -0
  24. py_serverless_kit/providers/aws.py +508 -0
  25. py_serverless_kit/providers/azure.py +417 -0
  26. py_serverless_kit/providers/base.py +310 -0
  27. py_serverless_kit/providers/cloudflare.py +264 -0
  28. py_serverless_kit/providers/digitalocean.py +211 -0
  29. py_serverless_kit/providers/gcp.py +480 -0
  30. py_serverless_kit/providers/huawei.py +292 -0
  31. py_serverless_kit/providers/ibm.py +132 -0
  32. py_serverless_kit/providers/oracle.py +304 -0
  33. py_serverless_kit/providers/registry.py +60 -0
  34. py_serverless_kit/providers/schedule.py +153 -0
  35. py_serverless_kit/providers/tencent.py +275 -0
  36. py_serverless_kit/providers/terraform_doc.py +125 -0
  37. py_serverless_kit/runtime/__init__.py +6 -0
  38. py_serverless_kit/runtime/adapters/__init__.py +18 -0
  39. py_serverless_kit/runtime/adapters/_httpfn.py +91 -0
  40. py_serverless_kit/runtime/adapters/alibaba.py +147 -0
  41. py_serverless_kit/runtime/adapters/aws.py +201 -0
  42. py_serverless_kit/runtime/adapters/azure.py +185 -0
  43. py_serverless_kit/runtime/adapters/base.py +86 -0
  44. py_serverless_kit/runtime/adapters/cloudflare.py +126 -0
  45. py_serverless_kit/runtime/adapters/digitalocean.py +74 -0
  46. py_serverless_kit/runtime/adapters/gcp.py +176 -0
  47. py_serverless_kit/runtime/adapters/huawei.py +173 -0
  48. py_serverless_kit/runtime/adapters/ibm.py +91 -0
  49. py_serverless_kit/runtime/adapters/oracle.py +161 -0
  50. py_serverless_kit/runtime/adapters/tencent.py +151 -0
  51. py_serverless_kit/runtime/batch.py +47 -0
  52. py_serverless_kit/runtime/context.py +45 -0
  53. py_serverless_kit/runtime/dispatch.py +277 -0
  54. py_serverless_kit/runtime/errors.py +83 -0
  55. py_serverless_kit/runtime/event.py +267 -0
  56. py_serverless_kit/runtime/invoke.py +119 -0
  57. py_serverless_kit/runtime/log.py +69 -0
  58. py_serverless_kit/runtime/manifest.py +121 -0
  59. py_serverless_kit/runtime/response.py +177 -0
  60. py_serverless_kit/runtime/routing.py +74 -0
  61. py_serverless_kit/runtime/workflow.py +312 -0
  62. py_serverless_kit/terraform/__init__.py +3 -0
  63. py_serverless_kit/terraform/runner.py +104 -0
  64. py_serverless_kit/workflows/__init__.py +35 -0
  65. py_serverless_kit/workflows/asl.py +170 -0
  66. py_serverless_kit/workflows/gcp.py +328 -0
  67. py_serverless_kit-0.1.0.dist-info/METADATA +300 -0
  68. py_serverless_kit-0.1.0.dist-info/RECORD +71 -0
  69. py_serverless_kit-0.1.0.dist-info/WHEEL +4 -0
  70. py_serverless_kit-0.1.0.dist-info/entry_points.txt +3 -0
  71. py_serverless_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,310 @@
1
+ """Provider plug-in contract.
2
+
3
+ A provider turns a validated :class:`ServiceConfig` plus a built artifact into
4
+ a :class:`TerraformDoc`, and supplies the small *shim* files that sit at the
5
+ root of the bundle and forward the platform's native entrypoint to
6
+ ``py_serverless_kit.runtime.adapters.<provider>``.
7
+
8
+ Adding a new cloud = subclass :class:`Provider`, write an adapter module, and
9
+ register it in ``providers/registry.py``. See CONTRIBUTING.md.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import re
16
+ from abc import ABC, abstractmethod
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any, ClassVar, Dict, FrozenSet, List, Optional
20
+
21
+ from ..config.schema import FunctionConfig, ServiceConfig, WorkflowConfig
22
+ from .terraform_doc import TerraformDoc
23
+
24
+ # Capability flags ------------------------------------------------------------
25
+ HTTP = "http"
26
+ SCHEDULE = "schedule"
27
+ QUEUE = "queue"
28
+ STORAGE = "storage"
29
+ TOPIC = "topic"
30
+ WORKFLOW_NATIVE = "workflow_native"
31
+ ALL_CAPABILITIES = (HTTP, SCHEDULE, QUEUE, STORAGE, TOPIC, WORKFLOW_NATIVE)
32
+ EVENT_CAPABILITIES = frozenset({HTTP, SCHEDULE, QUEUE, STORAGE, TOPIC})
33
+
34
+ ORCHESTRATOR_HANDLER = "py_serverless_kit.runtime.workflow.orchestrate"
35
+ PREFERRED_RUNTIME = "python3.12"
36
+
37
+
38
+ class ProviderError(Exception):
39
+ pass
40
+
41
+
42
+ @dataclass
43
+ class Artifact:
44
+ path: Path
45
+ sha256: str
46
+ sha256_b64: str
47
+ size: int
48
+ file_count: int = 0
49
+ build_dir: Optional[Path] = None
50
+
51
+
52
+ @dataclass
53
+ class Issue:
54
+ level: str # "error" | "warning"
55
+ message: str
56
+ where: str = ""
57
+
58
+ def __str__(self) -> str:
59
+ loc = f"[{self.where}] " if self.where else ""
60
+ return f"{self.level.upper()}: {loc}{self.message}"
61
+
62
+
63
+ @dataclass
64
+ class RenderContext:
65
+ config: ServiceConfig
66
+ provider: Provider
67
+ root: Path
68
+ work_dir: Path
69
+ artifact: Artifact
70
+ region: str
71
+ workflow_engines: Dict[str, str] = field(default_factory=dict)
72
+
73
+ @property
74
+ def service(self) -> str:
75
+ return self.config.service
76
+
77
+ @property
78
+ def stage(self) -> str:
79
+ return self.config.provider.stage
80
+
81
+ @property
82
+ def options(self) -> Dict[str, Any]:
83
+ return self.config.provider.options
84
+
85
+ def opt(self, key: str, default: Any = None) -> Any:
86
+ return self.config.provider.options.get(key, default)
87
+
88
+ def physical_name(self, logical: str) -> str:
89
+ return self.provider.physical_name(self.service, self.stage, logical)
90
+
91
+ @property
92
+ def prefix(self) -> str:
93
+ return self.provider.physical_name(self.service, self.stage, "")
94
+
95
+ def tags(self, fn: Optional[FunctionConfig] = None) -> Dict[str, str]:
96
+ base = {
97
+ "pskit-service": self.service,
98
+ "pskit-stage": self.stage,
99
+ "managed-by": "py-serverless-kit",
100
+ }
101
+ base.update(self.config.provider.tags)
102
+ if fn is not None:
103
+ base.update(fn.tags)
104
+ return base
105
+
106
+ def environment(self, name: str, fn: FunctionConfig) -> Dict[str, str]:
107
+ env = {
108
+ "PSKIT_PROVIDER": self.provider.name,
109
+ "PSKIT_SERVICE": self.service,
110
+ "PSKIT_STAGE": self.stage,
111
+ "PSKIT_REGION": self.region,
112
+ "PSKIT_FUNCTION": name,
113
+ "PSKIT_HANDLER": fn.handler,
114
+ }
115
+ env.update(self.config.provider.environment)
116
+ env.update(fn.environment)
117
+ return env
118
+
119
+ def function_items(self) -> List[tuple]:
120
+ return sorted(self.config.functions.items())
121
+
122
+
123
+ def stable_suffix(*parts: str, length: int = 6) -> str:
124
+ # Naming only, not security: keeps generated names short, stable and unique.
125
+ return hashlib.sha1("/".join(parts).encode(), usedforsecurity=False).hexdigest()[:length]
126
+
127
+
128
+ class Provider(ABC):
129
+ name: ClassVar[str]
130
+ display_name: ClassVar[str]
131
+ tier: ClassVar[str] = "beta" # stable | beta | experimental
132
+ terraform_source: ClassVar[str]
133
+ terraform_version: ClassVar[Optional[str]] = None
134
+ terraform_local_name: ClassVar[Optional[str]] = None
135
+ default_region: ClassVar[str] = ""
136
+ capabilities: ClassVar[FrozenSet[str]] = frozenset({HTTP})
137
+ max_name_length: ClassVar[int] = 64
138
+ name_pattern: ClassVar[str] = r"[^a-z0-9-]"
139
+ runtimes: ClassVar[Dict[str, str]] = {}
140
+ vendor_dependencies: ClassVar[bool] = True
141
+ extra_requirements: ClassVar[List[str]] = []
142
+ max_timeout: ClassVar[int] = 900
143
+ notes: ClassVar[str] = ""
144
+
145
+ # ------------------------------------------------------------ naming
146
+ def physical_name(self, service: str, stage: str, logical: str) -> str:
147
+ raw = "-".join(p for p in (service, stage, logical) if p)
148
+ name = re.sub(self.name_pattern, "-", raw.lower()).strip("-")
149
+ name = re.sub(r"-{2,}", "-", name)
150
+ if len(name) > self.max_name_length:
151
+ digest = stable_suffix(raw)
152
+ name = name[: self.max_name_length - len(digest) - 1].rstrip("-") + "-" + digest
153
+ return name
154
+
155
+ @property
156
+ def default_runtime(self) -> str:
157
+ """Runtime used when the config does not pin one: python3.12, or the newest
158
+ version this provider supports below it (so switching clouds just works)."""
159
+ if not self.runtimes or PREFERRED_RUNTIME in self.runtimes:
160
+ return PREFERRED_RUNTIME
161
+
162
+ def minor(rt: str) -> int:
163
+ return int(rt.split(".")[1])
164
+
165
+ older = [rt for rt in self.runtimes if minor(rt) <= minor(PREFERRED_RUNTIME)]
166
+ return max(older or list(self.runtimes), key=minor)
167
+
168
+ def runtime_id(self, runtime: str) -> str:
169
+ if self.runtimes and runtime not in self.runtimes:
170
+ supported = ", ".join(sorted(self.runtimes))
171
+ raise ProviderError(f"{self.display_name} does not support {runtime}; use one of: {supported}")
172
+ return self.runtimes.get(runtime, runtime)
173
+
174
+ def region(self, config: ServiceConfig) -> str:
175
+ return config.provider.region or self.default_region
176
+
177
+ def pip_platform(self, runtime: str, architecture: str) -> List[str]:
178
+ """pip flags that download Linux wheels matching the target runtime."""
179
+ version = runtime.replace("python", "")
180
+ machine = "aarch64" if architecture == "arm64" else "x86_64"
181
+ return [
182
+ "--platform",
183
+ f"manylinux2014_{machine}",
184
+ "--implementation",
185
+ "cp",
186
+ "--python-version",
187
+ version,
188
+ "--only-binary=:all:",
189
+ ]
190
+
191
+ # ------------------------------------------------------------ validation
192
+ def workflow_engine(self, wf: WorkflowConfig) -> str:
193
+ if wf.engine == "native":
194
+ if WORKFLOW_NATIVE not in self.capabilities:
195
+ raise ProviderError(
196
+ f"{self.display_name} has no native workflow service supported by pskit; "
197
+ "use `engine: embedded` or `auto`"
198
+ )
199
+ return "native"
200
+ if wf.engine == "embedded":
201
+ return "embedded"
202
+ return "native" if WORKFLOW_NATIVE in self.capabilities else "embedded"
203
+
204
+ def validate(self, config: ServiceConfig) -> List[Issue]:
205
+ issues: List[Issue] = []
206
+ try:
207
+ self.runtime_id(config.provider.runtime)
208
+ except ProviderError as exc:
209
+ issues.append(Issue("error", str(exc), "provider.runtime"))
210
+ for name, fn in config.functions.items():
211
+ where = f"functions.{name}"
212
+ if fn.runtime:
213
+ try:
214
+ self.runtime_id(fn.runtime)
215
+ except ProviderError as exc:
216
+ issues.append(Issue("error", str(exc), where + ".runtime"))
217
+ for ev in fn.events:
218
+ if ev.type not in self.capabilities:
219
+ issues.append(
220
+ Issue(
221
+ "error",
222
+ f"{self.display_name} does not support `{ev.type}` events in pskit "
223
+ f"(supported: {', '.join(sorted(self.capabilities & EVENT_CAPABILITIES))})",
224
+ where,
225
+ )
226
+ )
227
+ if config.timeout_of(fn) > self.max_timeout:
228
+ issues.append(
229
+ Issue(
230
+ "error",
231
+ f"timeout {config.timeout_of(fn)}s exceeds {self.display_name} max {self.max_timeout}s",
232
+ where,
233
+ )
234
+ )
235
+ for name, wf in config.workflows.items():
236
+ try:
237
+ engine = self.workflow_engine(wf)
238
+ except ProviderError as exc:
239
+ issues.append(Issue("error", str(exc), f"workflows.{name}"))
240
+ continue
241
+ if engine == "embedded":
242
+ issues.append(
243
+ Issue(
244
+ "warning",
245
+ "runs on the embedded engine inside one function: total runtime is capped by the "
246
+ f"function timeout ({self.max_timeout}s max on {self.display_name})",
247
+ f"workflows.{name}",
248
+ )
249
+ )
250
+ issues.extend(self.extra_validation(config))
251
+ return issues
252
+
253
+ def extra_validation(self, config: ServiceConfig) -> List[Issue]:
254
+ return []
255
+
256
+ # ------------------------------------------------------------ terraform
257
+ def init_doc(self, ctx: RenderContext) -> TerraformDoc:
258
+ doc = TerraformDoc()
259
+ tf = ctx.config.provider.terraform
260
+ doc.required_version(tf.required_version)
261
+ local = self.terraform_local_name or self.name
262
+ doc.required_provider(local, self.terraform_source, tf.provider_version or self.terraform_version)
263
+ state = ctx.config.provider.state
264
+ if state.backend:
265
+ doc.backend(state.backend, state.config)
266
+ block = self.provider_block(ctx)
267
+ if block is not None:
268
+ doc.provider(local, block)
269
+ return doc
270
+
271
+ def provider_block(self, ctx: RenderContext) -> Optional[Dict[str, Any]]:
272
+ return {"region": ctx.region} if ctx.region else {}
273
+
274
+ def render(self, ctx: RenderContext) -> TerraformDoc:
275
+ doc = self.init_doc(ctx)
276
+ self.render_resources(doc, ctx)
277
+ extra = ctx.config.provider.terraform.extra
278
+ if extra:
279
+ doc.merge(extra)
280
+ return doc
281
+
282
+ @abstractmethod
283
+ def render_resources(self, doc: TerraformDoc, ctx: RenderContext) -> None:
284
+ """Add every resource for functions, triggers and workflows to ``doc``.
285
+
286
+ Must emit an output named ``pskit`` shaped like::
287
+
288
+ {"functions": {"<name>": {"id": ..., "url": ...}}, "workflows": {...}, "endpoint": ...}
289
+ """
290
+
291
+ # ------------------------------------------------------------ bundle
292
+ @abstractmethod
293
+ def shim_files(self, config: ServiceConfig) -> Dict[str, str]:
294
+ """Files written at the bundle root that forward to the adapter."""
295
+
296
+ def bundle_requirements(self, user_requirements: List[str]) -> List[str]:
297
+ return list(user_requirements) + [r for r in self.extra_requirements if r not in user_requirements]
298
+
299
+ def describe(self) -> Dict[str, Any]:
300
+ return {
301
+ "name": self.name,
302
+ "display_name": self.display_name,
303
+ "tier": self.tier,
304
+ "default_region": self.default_region,
305
+ "capabilities": sorted(self.capabilities),
306
+ "runtimes": sorted(self.runtimes),
307
+ "max_timeout": self.max_timeout,
308
+ "terraform_provider": self.terraform_source,
309
+ "notes": self.notes,
310
+ }
@@ -0,0 +1,264 @@
1
+ """Cloudflare Python Workers (Pyodide), one Worker script per function.
2
+
3
+ Each Worker is uploaded as a single Python module. pskit generates that
4
+ module (``.pskit/cloudflare-<stage>/cloudflare/<fn>.py``) by embedding every
5
+ ``.py`` file of the bundle - your code plus the pskit runtime - and installing
6
+ an import hook that serves them from memory. That keeps your project layout
7
+ (packages, relative imports) working without a multi-module upload.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from ..config.schema import ServiceConfig
18
+ from . import schedule as sched
19
+ from .base import HTTP, SCHEDULE, Issue, Provider, RenderContext
20
+ from .terraform_doc import TerraformDoc, ref
21
+
22
+ SCRIPT = "cloudflare_workers_script"
23
+ WORKER_DIR = "cloudflare"
24
+ DEFAULT_COMPAT_DATE = "2025-09-01"
25
+
26
+ _LOADER = '''\
27
+ import importlib.abc
28
+ import importlib.util
29
+ import json
30
+ import sys
31
+
32
+ _PSKIT_ROOT = "/pskit_bundle"
33
+
34
+
35
+ class _PskitImporter(importlib.abc.MetaPathFinder, importlib.abc.Loader):
36
+ """Serves the embedded bundle modules from memory."""
37
+
38
+ def __init__(self, files):
39
+ self._modules = {}
40
+ for path, source in files.items():
41
+ if not path.endswith(".py"):
42
+ continue
43
+ name = path[:-3].replace("/", ".")
44
+ is_pkg = name == "__init__" or name.endswith(".__init__")
45
+ if is_pkg:
46
+ name = name[: -len(".__init__")] if "." in name else ""
47
+ if name:
48
+ self._modules[name] = (path, source, is_pkg)
49
+ for name in list(self._modules):
50
+ parts = name.split(".")
51
+ for i in range(1, len(parts)):
52
+ parent = ".".join(parts[:i])
53
+ if parent not in self._modules: # implicit namespace package
54
+ self._modules[parent] = ("/".join(parts[:i]) + "/__init__.py", "", True)
55
+
56
+ def find_spec(self, fullname, path=None, target=None):
57
+ entry = self._modules.get(fullname)
58
+ if entry is None:
59
+ return None
60
+ return importlib.util.spec_from_loader(
61
+ fullname, self, origin=_PSKIT_ROOT + "/" + entry[0], is_package=entry[2]
62
+ )
63
+
64
+ def create_module(self, spec):
65
+ return None
66
+
67
+ def exec_module(self, module):
68
+ path, source, is_pkg = self._modules[module.__spec__.name]
69
+ module.__file__ = _PSKIT_ROOT + "/" + path
70
+ if is_pkg:
71
+ module.__path__ = [_PSKIT_ROOT + "/" + path.rsplit("/", 1)[0]]
72
+ exec(compile(source, module.__file__, "exec"), module.__dict__)
73
+
74
+
75
+ sys.meta_path.insert(0, _PskitImporter(_PSKIT_FILES))
76
+
77
+ from py_serverless_kit.runtime import manifest as _pskit_manifest # noqa: E402
78
+ from py_serverless_kit.runtime.adapters import cloudflare as _pskit_cf # noqa: E402
79
+
80
+ _pskit_manifest.set_manifest(
81
+ _pskit_manifest.BundleManifest.from_dict(json.loads(_PSKIT_FILES["pskit_manifest.json"]))
82
+ )
83
+ _pskit_cf.configure(_PSKIT_FUNCTION, _PSKIT_ENV, _PSKIT_SCHEDULES)
84
+
85
+ from workers import WorkerEntrypoint # noqa: E402
86
+
87
+
88
+ class Default(WorkerEntrypoint):
89
+ async def fetch(self, request):
90
+ return await _pskit_cf.fetch(request, getattr(self, "env", None))
91
+
92
+ async def scheduled(self, controller, env=None, ctx=None):
93
+ return await _pskit_cf.scheduled(controller, env if env is not None else getattr(self, "env", None))
94
+ '''
95
+
96
+
97
+ def embedded_files(build_dir: Path) -> tuple[Dict[str, str], List[str]]:
98
+ """Collect ``.py`` files and the manifest from the bundle; report skipped files."""
99
+ files: Dict[str, str] = {}
100
+ skipped: List[str] = []
101
+ for path in sorted(p for p in build_dir.rglob("*") if p.is_file()):
102
+ rel = path.relative_to(build_dir).as_posix()
103
+ if "__pycache__" in rel or rel == "requirements.txt":
104
+ continue
105
+ if rel.endswith(".py") or rel == "pskit_manifest.json":
106
+ files[rel] = path.read_text("utf-8")
107
+ else:
108
+ skipped.append(rel)
109
+ return files, skipped
110
+
111
+
112
+ def worker_source(files: Dict[str, str], function: str, env: Dict[str, str], schedules: Dict[str, Any]) -> str:
113
+ lines = [
114
+ "# Generated by py-serverless-kit - do not edit. Cloudflare Python Worker entry module.",
115
+ f"_PSKIT_FUNCTION = {function!r}",
116
+ f"_PSKIT_ENV = {dict(sorted(env.items()))!r}",
117
+ f"_PSKIT_SCHEDULES = {json.loads(json.dumps(schedules))!r}",
118
+ "_PSKIT_FILES = {",
119
+ ]
120
+ for rel, source in files.items():
121
+ lines.append(f" {rel!r}: {source!r},")
122
+ lines.append("}")
123
+ return "\n".join(lines) + "\n\n" + _LOADER
124
+
125
+
126
+ class CloudflareProvider(Provider):
127
+ name = "cloudflare"
128
+ display_name = "Cloudflare Workers (Python)"
129
+ tier = "experimental"
130
+ terraform_source = "cloudflare/cloudflare"
131
+ terraform_version = ">= 5.28, < 6.0"
132
+ default_region = ""
133
+ capabilities = frozenset({HTTP, SCHEDULE})
134
+ max_name_length = 63
135
+ name_pattern = r"[^a-z0-9_-]"
136
+ runtimes = {f"python3.{v}": f"python3.{v}" for v in (11, 12, 13)}
137
+ vendor_dependencies = False
138
+ max_timeout = 900
139
+ notes = (
140
+ "Python Workers run on Pyodide: only the standard library and Pyodide built-in packages are available "
141
+ "(requirements.txt is NOT bundled). One Worker per function; http via workers.dev (set "
142
+ "options.workers_subdomain for URLs) or options.zone_id + functions.<fn>.options.route; schedules via "
143
+ "Cron Triggers. Needs options.account_id and CLOUDFLARE_API_TOKEN."
144
+ )
145
+
146
+ # ------------------------------------------------------------------ bundle
147
+ def shim_files(self, config: ServiceConfig) -> Dict[str, str]:
148
+ return {} # the entry module is generated per function in render_resources
149
+
150
+ def provider_block(self, ctx: RenderContext) -> Dict[str, Any]:
151
+ return {}
152
+
153
+ def region(self, config: ServiceConfig) -> str:
154
+ return config.provider.region or ""
155
+
156
+ def extra_validation(self, config: ServiceConfig) -> List[Issue]:
157
+ issues: List[Issue] = []
158
+ if not config.provider.options.get("account_id"):
159
+ issues.append(Issue("error", "options.account_id is required", "provider.options.account_id"))
160
+ issues.append(
161
+ Issue(
162
+ "warning",
163
+ "Cloudflare Python Workers only provide the standard library and Pyodide built-in packages; "
164
+ "dependencies from requirements.txt are not bundled",
165
+ "package",
166
+ )
167
+ )
168
+ for name, fn in config.functions.items():
169
+ for ev in fn.events_of("http"):
170
+ if ev.auth != "none":
171
+ issues.append(
172
+ Issue(
173
+ "error",
174
+ f"auth `{ev.auth}` is not supported on Workers; validate tokens "
175
+ "in the handler or use Cloudflare Access",
176
+ f"functions.{name}",
177
+ )
178
+ )
179
+ if fn.options.get("route") and not config.provider.options.get("zone_id"):
180
+ issues.append(Issue("error", "options.route needs provider.options.zone_id", f"functions.{name}"))
181
+ return issues
182
+
183
+ # ------------------------------------------------------------------ render
184
+ def render_resources(self, doc: TerraformDoc, ctx: RenderContext) -> None:
185
+ cfg = ctx.config
186
+ account = str(ctx.opt("account_id", ""))
187
+ subdomain = ctx.opt("workers_subdomain")
188
+ workers_dev = bool(ctx.opt("workers_dev", True))
189
+ build = ctx.artifact.build_dir
190
+ files, skipped = embedded_files(Path(build)) if build is not None else ({}, [])
191
+ if skipped:
192
+ doc.warnings.append(
193
+ "Cloudflare: non-Python files are not uploaded: "
194
+ + ", ".join(skipped[:10])
195
+ + (" ..." if len(skipped) > 10 else "")
196
+ )
197
+ out_dir = ctx.work_dir / WORKER_DIR
198
+ out_dir.mkdir(parents=True, exist_ok=True)
199
+
200
+ fn_out: Dict[str, Dict[str, Any]] = {}
201
+ endpoint: Optional[str] = None
202
+ for name, fn in ctx.function_items():
203
+ script_name = ctx.physical_name(name)
204
+ schedules = {sched.to_cron5(ev): ev.input for ev in fn.events_of("schedule") if ev.enabled}
205
+ source = worker_source(files, name, ctx.environment(name, fn), schedules)
206
+ path = out_dir / f"{name}.py"
207
+ path.write_text(source, "utf-8", newline="\n")
208
+ opts = dict(fn.options)
209
+ route = opts.pop("route", None)
210
+ body: Dict[str, Any] = {
211
+ "account_id": account,
212
+ "script_name": script_name,
213
+ "content_file": f"${{path.module}}/{WORKER_DIR}/{name}.py",
214
+ "content_sha256": hashlib.sha256(source.encode("utf-8")).hexdigest(),
215
+ "content_type": "text/x-python",
216
+ "main_module": "worker.py",
217
+ "compatibility_date": str(ctx.opt("compatibility_date", DEFAULT_COMPAT_DATE)),
218
+ "compatibility_flags": ["python_workers"],
219
+ }
220
+ body.update(opts)
221
+ script = doc.resource(SCRIPT, name, body)
222
+ http = bool(fn.events_of("http"))
223
+ if http and workers_dev:
224
+ doc.resource(
225
+ "cloudflare_workers_script_subdomain",
226
+ name,
227
+ {"account_id": account, "script_name": ref(script, "script_name"), "enabled": True},
228
+ )
229
+ if route:
230
+ doc.resource(
231
+ "cloudflare_workers_route",
232
+ name,
233
+ {"zone_id": str(ctx.opt("zone_id", "")), "pattern": route, "script": ref(script, "script_name")},
234
+ )
235
+ if schedules:
236
+ doc.resource(
237
+ "cloudflare_workers_cron_trigger",
238
+ name,
239
+ {
240
+ "account_id": account,
241
+ "script_name": ref(script, "script_name"),
242
+ "schedules": [{"cron": c} for c in schedules],
243
+ },
244
+ )
245
+ out: Dict[str, Any] = {
246
+ "id": ref(script, "script_name"),
247
+ "console": f"https://dash.cloudflare.com/{account}/workers/services/view/{script_name}/production",
248
+ }
249
+ url = None
250
+ if http and workers_dev and subdomain:
251
+ url = f"https://{script_name}.{subdomain}.workers.dev"
252
+ elif http and route:
253
+ url = "https://" + str(route).rstrip("*").rstrip("/")
254
+ if url:
255
+ out["url"] = url
256
+ out["base_url"] = url
257
+ endpoint = endpoint or url
258
+ fn_out[name] = out
259
+ wf_out = {n: {"id": ctx.physical_name(f"{n}-orchestrator")} for n in cfg.workflows}
260
+ doc.output(
261
+ "pskit",
262
+ {"functions": fn_out, "workflows": wf_out, "endpoint": endpoint},
263
+ description="py-serverless-kit deployment summary",
264
+ )