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.
- py_serverless_kit/__init__.py +47 -0
- py_serverless_kit/__main__.py +3 -0
- py_serverless_kit/_version.py +2 -0
- py_serverless_kit/cli/__init__.py +0 -0
- py_serverless_kit/cli/main.py +491 -0
- py_serverless_kit/cli/templates.py +212 -0
- py_serverless_kit/config/__init__.py +12 -0
- py_serverless_kit/config/loader.py +316 -0
- py_serverless_kit/config/schema.py +457 -0
- py_serverless_kit/core/__init__.py +0 -0
- py_serverless_kit/core/deployer.py +180 -0
- py_serverless_kit/core/describe.py +112 -0
- py_serverless_kit/core/project.py +147 -0
- py_serverless_kit/dashboard/__init__.py +3 -0
- py_serverless_kit/dashboard/generator.py +65 -0
- py_serverless_kit/dashboard/template.html +248 -0
- py_serverless_kit/local/__init__.py +3 -0
- py_serverless_kit/local/runner.py +234 -0
- py_serverless_kit/local/server.py +200 -0
- py_serverless_kit/packaging/__init__.py +3 -0
- py_serverless_kit/packaging/builder.py +252 -0
- py_serverless_kit/providers/__init__.py +15 -0
- py_serverless_kit/providers/alibaba.py +268 -0
- py_serverless_kit/providers/aws.py +508 -0
- py_serverless_kit/providers/azure.py +417 -0
- py_serverless_kit/providers/base.py +310 -0
- py_serverless_kit/providers/cloudflare.py +264 -0
- py_serverless_kit/providers/digitalocean.py +211 -0
- py_serverless_kit/providers/gcp.py +480 -0
- py_serverless_kit/providers/huawei.py +292 -0
- py_serverless_kit/providers/ibm.py +132 -0
- py_serverless_kit/providers/oracle.py +304 -0
- py_serverless_kit/providers/registry.py +60 -0
- py_serverless_kit/providers/schedule.py +153 -0
- py_serverless_kit/providers/tencent.py +275 -0
- py_serverless_kit/providers/terraform_doc.py +125 -0
- py_serverless_kit/runtime/__init__.py +6 -0
- py_serverless_kit/runtime/adapters/__init__.py +18 -0
- py_serverless_kit/runtime/adapters/_httpfn.py +91 -0
- py_serverless_kit/runtime/adapters/alibaba.py +147 -0
- py_serverless_kit/runtime/adapters/aws.py +201 -0
- py_serverless_kit/runtime/adapters/azure.py +185 -0
- py_serverless_kit/runtime/adapters/base.py +86 -0
- py_serverless_kit/runtime/adapters/cloudflare.py +126 -0
- py_serverless_kit/runtime/adapters/digitalocean.py +74 -0
- py_serverless_kit/runtime/adapters/gcp.py +176 -0
- py_serverless_kit/runtime/adapters/huawei.py +173 -0
- py_serverless_kit/runtime/adapters/ibm.py +91 -0
- py_serverless_kit/runtime/adapters/oracle.py +161 -0
- py_serverless_kit/runtime/adapters/tencent.py +151 -0
- py_serverless_kit/runtime/batch.py +47 -0
- py_serverless_kit/runtime/context.py +45 -0
- py_serverless_kit/runtime/dispatch.py +277 -0
- py_serverless_kit/runtime/errors.py +83 -0
- py_serverless_kit/runtime/event.py +267 -0
- py_serverless_kit/runtime/invoke.py +119 -0
- py_serverless_kit/runtime/log.py +69 -0
- py_serverless_kit/runtime/manifest.py +121 -0
- py_serverless_kit/runtime/response.py +177 -0
- py_serverless_kit/runtime/routing.py +74 -0
- py_serverless_kit/runtime/workflow.py +312 -0
- py_serverless_kit/terraform/__init__.py +3 -0
- py_serverless_kit/terraform/runner.py +104 -0
- py_serverless_kit/workflows/__init__.py +35 -0
- py_serverless_kit/workflows/asl.py +170 -0
- py_serverless_kit/workflows/gcp.py +328 -0
- py_serverless_kit-0.1.0.dist-info/METADATA +300 -0
- py_serverless_kit-0.1.0.dist-info/RECORD +71 -0
- py_serverless_kit-0.1.0.dist-info/WHEEL +4 -0
- py_serverless_kit-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|
+
)
|