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,275 @@
1
+ """Tencent Cloud: SCF + API Gateway + timer / COS triggers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import shutil
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from ..config.schema import FunctionConfig, ServiceConfig
11
+ from ..runtime.adapters.base import envelope
12
+ from . import schedule as sched
13
+ from .base import HTTP, SCHEDULE, STORAGE, Issue, Provider, RenderContext
14
+ from .terraform_doc import TerraformDoc, escape, ref, tf_id
15
+
16
+ FUNCTION = "tencentcloud_scf_function"
17
+ _BUCKET_RE = re.compile(r"^[a-z0-9][a-z0-9-]*-\d+$")
18
+
19
+
20
+ def _gateway_path(path: str) -> str:
21
+ """API Gateway has no greedy params; ``/files/{p+}`` becomes prefix ``/files``."""
22
+ segments = [s for s in path.split("/") if s]
23
+ if segments and segments[-1].endswith("+}"):
24
+ segments = segments[:-1]
25
+ return "/" + "/".join(segments)
26
+
27
+
28
+ class TencentProvider(Provider):
29
+ name = "tencent"
30
+ display_name = "Tencent Cloud SCF"
31
+ tier = "beta"
32
+ terraform_source = "tencentcloudstack/tencentcloud"
33
+ terraform_version = ">= 1.81.0"
34
+ terraform_local_name = "tencentcloud"
35
+ default_region = "ap-guangzhou"
36
+ capabilities = frozenset({HTTP, SCHEDULE, STORAGE})
37
+ max_name_length = 60
38
+ name_pattern = r"[^a-z0-9_-]"
39
+ runtimes = {"python3.9": "Python3.9", "python3.10": "Python3.10"}
40
+ vendor_dependencies = True
41
+ max_timeout = 900
42
+ notes = (
43
+ "HTTP via an API Gateway service (one API per route, released to `release`); "
44
+ "timer and COS triggers; COS bucket names must include the APPID suffix (name-1250000000)."
45
+ )
46
+
47
+ # ------------------------------------------------------------------ bundle
48
+ def shim_files(self, config: ServiceConfig) -> Dict[str, str]:
49
+ return {
50
+ "index.py": (
51
+ "# Generated by py-serverless-kit. SCF handler: index.main_handler\n"
52
+ "from py_serverless_kit.runtime.adapters.tencent import main_handler # noqa: F401\n"
53
+ )
54
+ }
55
+
56
+ def provider_block(self, ctx: RenderContext) -> Dict[str, Any]:
57
+ block: Dict[str, Any] = {"region": ctx.region}
58
+ if ctx.opt("profile"):
59
+ block["profile"] = ctx.opt("profile")
60
+ return block
61
+
62
+ def physical_name(self, service: str, stage: str, logical: str) -> str:
63
+ name = super().physical_name(service, stage, logical)
64
+ # SCF names must start with a letter and must not end with '-' or '_'.
65
+ return name.rstrip("-_") or "fn"
66
+
67
+ def extra_validation(self, config: ServiceConfig) -> List[Issue]:
68
+ issues = []
69
+ if config.provider.iam.statements:
70
+ issues.append(
71
+ Issue(
72
+ "warning",
73
+ "iam.statements are not translated to CAM policies on Tencent; set iam.role to an existing "
74
+ "CAM role name",
75
+ "provider.iam",
76
+ )
77
+ )
78
+ for name, fn in config.functions.items():
79
+ where = f"functions.{name}"
80
+ for ev in fn.events_of("http"):
81
+ if ev.auth != "none":
82
+ issues.append(
83
+ Issue("error", f"http auth `{ev.auth}` is not supported on Tencent (use none)", where)
84
+ )
85
+ if ev.method == "PATCH":
86
+ issues.append(Issue("error", "Tencent API Gateway does not support PATCH; use ANY", where))
87
+ for ev in fn.events_of("storage"):
88
+ if not _BUCKET_RE.match(ev.bucket):
89
+ issues.append(
90
+ Issue(
91
+ "error",
92
+ f"COS bucket {ev.bucket!r} must be '<name>-<APPID>', e.g. uploads-1250000000",
93
+ where,
94
+ )
95
+ )
96
+ memory = config.memory_of(fn)
97
+ if memory not in (64, 128) and memory % 128 != 0:
98
+ issues.append(
99
+ Issue("error", f"SCF memory must be 64, 128 or a multiple of 128 MB (got {memory})", where)
100
+ )
101
+ return issues
102
+
103
+ # ------------------------------------------------------------------ render
104
+ def render_resources(self, doc: TerraformDoc, ctx: RenderContext) -> None:
105
+ cfg = ctx.config
106
+ zip_name = self._artifact_copy(ctx)
107
+ fn_out: Dict[str, Dict[str, Any]] = {}
108
+ service: Optional[str] = None
109
+ apis: List[str] = []
110
+ for name, fn in ctx.function_items():
111
+ res = self._function(doc, ctx, name, fn, zip_name)
112
+ namespace = ctx.opt("namespace", "default")
113
+ out: Dict[str, Any] = {
114
+ "id": ref(res, "name"),
115
+ "console": "https://console.cloud.tencent.com/scf/list-detail"
116
+ f"?ns={namespace}&id={ctx.physical_name(name)}",
117
+ }
118
+ http_events = fn.events_of("http")
119
+ if http_events:
120
+ service = service or self._service(doc, ctx)
121
+ apis.extend(self._apis(doc, ctx, service, name, res, fn, http_events))
122
+ out["base_url"] = "https://" + ref(service, "outer_sub_domain") + "/release"
123
+ fn_out[name] = out
124
+
125
+ endpoint = None
126
+ if service:
127
+ doc.resource(
128
+ "tencentcloud_api_gateway_service_release",
129
+ "release",
130
+ {
131
+ "service_id": ref(service, "id"),
132
+ "environment_name": "release",
133
+ "release_desc": f"py-serverless-kit {ctx.artifact.sha256[:12]}",
134
+ "depends_on": apis,
135
+ },
136
+ )
137
+ endpoint = "https://" + ref(service, "outer_sub_domain") + "/release"
138
+
139
+ wf_out = {n: {"id": ref(FUNCTION, tf_id(f"{n}-orchestrator"), "name")} for n in sorted(cfg.workflows)}
140
+ doc.output(
141
+ "pskit",
142
+ {"functions": fn_out, "workflows": wf_out, "endpoint": endpoint},
143
+ description="py-serverless-kit deployment summary",
144
+ )
145
+
146
+ # ------------------------------------------------------------------ pieces
147
+ @staticmethod
148
+ def _artifact_copy(ctx: RenderContext) -> str:
149
+ """SCF only re-uploads when ``zip_file`` *path* changes, so use a content-addressed copy."""
150
+ name = f"artifact-{ctx.artifact.sha256[:16]}.zip"
151
+ target = ctx.work_dir / name
152
+ for old in ctx.work_dir.glob("artifact-*.zip"):
153
+ if old.name != name:
154
+ old.unlink()
155
+ if not target.exists() and ctx.artifact.path.exists():
156
+ shutil.copyfile(ctx.artifact.path, target)
157
+ return name
158
+
159
+ def _function(self, doc: TerraformDoc, ctx: RenderContext, name: str, fn: FunctionConfig, zip_name: str) -> str:
160
+ cfg = ctx.config
161
+ triggers: List[Dict[str, Any]] = []
162
+ configs: List[Dict[str, Any]] = []
163
+ for i, ev in enumerate(fn.events_of("schedule")):
164
+ tname = re.sub(r"[^A-Za-z0-9_-]", "-", ev.name or f"timer-{i}")[:100]
165
+ desc = "0 " + sched.named_cron5(ev) + " *"
166
+ triggers.append({"name": tname, "type": "timer", "trigger_desc": desc})
167
+ if ev.input is not None or not ev.enabled:
168
+ configs.append(
169
+ {
170
+ "trigger_name": tname,
171
+ "type": "timer",
172
+ "trigger_desc": desc,
173
+ "enable": "OPEN" if ev.enabled else "CLOSE",
174
+ "custom_argument": escape(json.dumps(envelope("schedule", ev.input, source="tencent.timer"))),
175
+ }
176
+ )
177
+ for ev in fn.events_of("storage"):
178
+ label = f"b-{ev.bucket}"
179
+ if not ev.existing and not doc.has_resource("tencentcloud_cos_bucket", label):
180
+ doc.resource(
181
+ "tencentcloud_cos_bucket", label, {"bucket": ev.bucket, "acl": "private", "tags": ctx.tags()}
182
+ )
183
+ for action in ev.events:
184
+ desc_obj: Dict[str, Any] = {
185
+ "event": {"created": "cos:ObjectCreated:*", "deleted": "cos:ObjectRemove:*"}[action]
186
+ }
187
+ if ev.prefix or ev.suffix:
188
+ desc_obj["filter"] = {"Prefix": ev.prefix or "", "Suffix": ev.suffix or ""}
189
+ triggers.append(
190
+ {
191
+ "name": f"{ev.bucket}.cos.{ctx.region}.myqcloud.com",
192
+ "type": "cos",
193
+ "cos_region": ctx.region,
194
+ "trigger_desc": json.dumps(desc_obj),
195
+ }
196
+ )
197
+ body: Dict[str, Any] = {
198
+ "name": ctx.physical_name(name),
199
+ "description": re.sub(r"[^\w\s,.一-鿿]", " ", fn.description)[:1000],
200
+ "handler": "index.main_handler",
201
+ "runtime": self.runtime_id(cfg.runtime_of(fn)),
202
+ "mem_size": cfg.memory_of(fn),
203
+ "timeout": cfg.timeout_of(fn),
204
+ "namespace": ctx.opt("namespace", "default"),
205
+ "zip_file": "${path.module}/" + zip_name,
206
+ "enable_public_net": True,
207
+ "environment": {k: escape(v) for k, v in ctx.environment(name, fn).items()},
208
+ "tags": ctx.tags(fn),
209
+ }
210
+ if triggers:
211
+ body["triggers"] = triggers
212
+ role = fn.iam.role or cfg.provider.iam.role
213
+ if role:
214
+ body["role"] = role
215
+ body.update(fn.options) # escape hatch: vpc_id, subnet_id, layers, cls_*...
216
+ res = doc.resource(FUNCTION, name, body)
217
+ for c in configs:
218
+ doc.resource(
219
+ "tencentcloud_scf_trigger_config",
220
+ f"{name}-{c['trigger_name']}",
221
+ {"function_name": ref(res, "name"), "namespace": ref(res, "namespace"), **c},
222
+ )
223
+ return res
224
+
225
+ def _service(self, doc: TerraformDoc, ctx: RenderContext) -> str:
226
+ return doc.resource(
227
+ "tencentcloud_api_gateway_service",
228
+ "http",
229
+ {
230
+ "service_name": re.sub(r"[^A-Za-z0-9_]", "_", ctx.prefix)[:50],
231
+ "protocol": "http&https",
232
+ "service_desc": f"py-serverless-kit {ctx.service} ({ctx.stage})",
233
+ "net_type": ["OUTER"],
234
+ "ip_version": "IPv4",
235
+ "tags": ctx.tags(),
236
+ },
237
+ )
238
+
239
+ def _apis(
240
+ self,
241
+ doc: TerraformDoc,
242
+ ctx: RenderContext,
243
+ service: str,
244
+ name: str,
245
+ fn_res: str,
246
+ fn: FunctionConfig,
247
+ events: List[Any],
248
+ ) -> List[str]:
249
+ refs = []
250
+ for i, ev in enumerate(events):
251
+ path = _gateway_path(ev.path)
252
+ params = re.findall(r"\{([A-Za-z_]\w*)\}", path)
253
+ body: Dict[str, Any] = {
254
+ "service_id": ref(service, "id"),
255
+ "api_name": re.sub(r"[^A-Za-z0-9_]", "_", f"{name}_{i}")[:60],
256
+ "api_desc": f"{ev.method} {ev.path} -> {name}",
257
+ "auth_type": "NONE",
258
+ "protocol": "HTTP",
259
+ "enable_cors": ev.cors_config is not None,
260
+ "request_config_path": path,
261
+ "request_config_method": ev.method,
262
+ "service_config_type": "SCF",
263
+ "service_config_scf_function_name": ref(fn_res, "name"),
264
+ "service_config_scf_function_namespace": ref(fn_res, "namespace"),
265
+ "service_config_scf_function_qualifier": "$LATEST",
266
+ "service_config_scf_is_integrated_response": True,
267
+ "service_config_timeout": min(ctx.config.timeout_of(fn), 1800),
268
+ "response_type": "JSON",
269
+ }
270
+ if params:
271
+ body["request_parameters"] = [
272
+ {"name": p, "position": "PATH", "type": "string", "required": True, "desc": p} for p in params
273
+ ]
274
+ refs.append(doc.resource("tencentcloud_api_gateway_api", f"{name}-{i}", body))
275
+ return refs
@@ -0,0 +1,125 @@
1
+ """Builder for Terraform JSON configuration (``main.tf.json``).
2
+
3
+ Terraform reads ``*.tf.json`` natively. Generating JSON (instead of HCL text)
4
+ removes a whole class of quoting / escaping bugs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from ..config.loader import deep_merge
14
+
15
+ _TF_REF = re.compile(r"\$\$\{\s*tf:([^}]+)\}")
16
+
17
+
18
+ def tf_id(name: str) -> str:
19
+ """Make a valid Terraform block label from an arbitrary logical name."""
20
+ ident = re.sub(r"[^A-Za-z0-9_]", "_", name)
21
+ if not re.match(r"[A-Za-z_]", ident):
22
+ ident = "_" + ident
23
+ return ident
24
+
25
+
26
+ def ref(*parts: str) -> str:
27
+ """``ref("aws_lambda_function", "hello", "arn")`` -> ``${aws_lambda_function.hello.arn}``."""
28
+ return "${" + ".".join(parts) + "}"
29
+
30
+
31
+ def escape(value: str) -> str:
32
+ """Escape literal ``${`` / ``%{`` so Terraform does not treat them as templates.
33
+
34
+ The explicit ``${tf:<expression>}`` form is the one way to reference Terraform
35
+ from user values, e.g. ``${tf:aws_dynamodb_table.orders.arn}`` in an IAM
36
+ statement or environment variable.
37
+ """
38
+ escaped = value.replace("${", "$${").replace("%{", "%%{")
39
+ return _TF_REF.sub(lambda m: "${" + m.group(1).strip() + "}", escaped)
40
+
41
+
42
+ class TerraformDoc:
43
+ def __init__(self) -> None:
44
+ self.terraform: Dict[str, Any] = {"required_providers": {}}
45
+ self.providers: Dict[str, Any] = {}
46
+ self.resources: Dict[str, Dict[str, Any]] = {}
47
+ self.data_sources: Dict[str, Dict[str, Any]] = {}
48
+ self.outputs: Dict[str, Any] = {}
49
+ self.locals: Dict[str, Any] = {}
50
+ self.variables: Dict[str, Any] = {}
51
+ self._extra: Dict[str, Any] = {}
52
+ self.warnings: List[str] = []
53
+
54
+ # ----------------------------------------------------------------- blocks
55
+ def required_provider(self, name: str, source: str, version: Optional[str] = None) -> None:
56
+ spec: Dict[str, Any] = {"source": source}
57
+ if version:
58
+ spec["version"] = version
59
+ self.terraform["required_providers"][name] = spec
60
+
61
+ def required_version(self, constraint: str) -> None:
62
+ self.terraform["required_version"] = constraint
63
+
64
+ def backend(self, kind: str, config: Dict[str, Any]) -> None:
65
+ self.terraform["backend"] = {kind: config}
66
+
67
+ def provider(self, name: str, body: Dict[str, Any]) -> None:
68
+ self.providers.setdefault(name, []).append(body)
69
+
70
+ def resource(self, rtype: str, name: str, body: Dict[str, Any]) -> str:
71
+ bucket = self.resources.setdefault(rtype, {})
72
+ label = tf_id(name)
73
+ if label in bucket:
74
+ raise ValueError(f"duplicate terraform resource {rtype}.{label}")
75
+ bucket[label] = body
76
+ return f"{rtype}.{label}"
77
+
78
+ def has_resource(self, rtype: str, name: str) -> bool:
79
+ return tf_id(name) in self.resources.get(rtype, {})
80
+
81
+ def data(self, dtype: str, name: str, body: Dict[str, Any]) -> str:
82
+ bucket = self.data_sources.setdefault(dtype, {})
83
+ label = tf_id(name)
84
+ bucket.setdefault(label, body)
85
+ return f"data.{dtype}.{label}"
86
+
87
+ def output(self, name: str, value: Any, *, sensitive: bool = False, description: str = "") -> None:
88
+ body: Dict[str, Any] = {"value": value}
89
+ if sensitive:
90
+ body["sensitive"] = True
91
+ if description:
92
+ body["description"] = description
93
+ self.outputs[tf_id(name)] = body
94
+
95
+ def local(self, name: str, value: Any) -> None:
96
+ self.locals[name] = value
97
+
98
+ def merge(self, extra: Dict[str, Any]) -> None:
99
+ """Deep-merge user supplied raw Terraform JSON (escape hatch)."""
100
+ self._extra = deep_merge(self._extra, extra)
101
+
102
+ # ----------------------------------------------------------------- output
103
+ def to_dict(self) -> Dict[str, Any]:
104
+ doc: Dict[str, Any] = {"terraform": self.terraform}
105
+ if self.providers:
106
+ doc["provider"] = self.providers
107
+ if self.variables:
108
+ doc["variable"] = self.variables
109
+ if self.locals:
110
+ doc["locals"] = self.locals
111
+ if self.data_sources:
112
+ doc["data"] = self.data_sources
113
+ if self.resources:
114
+ doc["resource"] = self.resources
115
+ if self.outputs:
116
+ doc["output"] = self.outputs
117
+ if self._extra:
118
+ doc = deep_merge(doc, self._extra)
119
+ return doc
120
+
121
+ def to_json(self) -> str:
122
+ return json.dumps(self.to_dict(), indent=2, sort_keys=False) + "\n"
123
+
124
+ def count(self) -> int:
125
+ return sum(len(v) for v in self.resources.values())
@@ -0,0 +1,6 @@
1
+ """Portable function runtime.
2
+
3
+ Everything in this package MUST stay standard-library only and compatible with
4
+ Python 3.9+, because it is vendored into every deployment artifact and executed
5
+ on runtimes we do not control (Tencent / Huawei still default to 3.9).
6
+ """
@@ -0,0 +1,18 @@
1
+ """Per-provider translation between native payloads and the portable model.
2
+
3
+ Each module exposes at least ``to_event(native, native_context)`` and a
4
+ provider-specific entrypoint used by the generated shim in the bundle.
5
+ """
6
+
7
+ ADAPTERS = (
8
+ "aws",
9
+ "azure",
10
+ "gcp",
11
+ "alibaba",
12
+ "oracle",
13
+ "ibm",
14
+ "digitalocean",
15
+ "tencent",
16
+ "huawei",
17
+ "cloudflare",
18
+ )
@@ -0,0 +1,91 @@
1
+ """Shared logic for providers where *every* invocation arrives as an HTTP-ish
2
+ request (IBM Code Engine, DigitalOcean web functions, Cloudflare Workers,
3
+ OCI behind API Gateway).
4
+
5
+ A request whose JSON body carries the ``__pskit`` envelope is treated as a
6
+ non-HTTP event (``invoke()``, workflow task, scheduler with input): the handler
7
+ result is returned as JSON 200, and failures as JSON 500
8
+ ``{"errorType", "cause"}`` - the same contract the GCP adapter uses.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ from typing import Any, Dict, Optional, Tuple
15
+
16
+ from ..context import Context
17
+ from ..dispatch import execute, execute_async
18
+ from ..event import Event, EventType, HttpRequest
19
+ from ..response import Response, dumps
20
+ from .base import maybe_json, unwrap_envelope
21
+
22
+ NativeHttp = Tuple[int, Dict[str, str], str, bool] # status, headers, body, is_base64
23
+
24
+
25
+ def event_from_request(req: HttpRequest, provider: str, function: str, raw: Any = None) -> Event:
26
+ if req.method == "POST" and req.body[:1] == b"{":
27
+ wrapped = unwrap_envelope(req.body)
28
+ if wrapped is not None:
29
+ kind, data, meta = wrapped
30
+ return Event(type=kind, provider=provider, function=function, data=data, source=meta.get("source"), raw=raw)
31
+ return Event(type=EventType.HTTP, provider=provider, function=function, http=req, raw=raw)
32
+
33
+
34
+ def error_body(exc: BaseException) -> Dict[str, Any]:
35
+ return {"errorType": getattr(exc, "error", None) or type(exc).__name__, "cause": str(exc)}
36
+
37
+
38
+ def result_to_http(event: Event, result: Any) -> NativeHttp:
39
+ if isinstance(result, Response):
40
+ return result.render_for_gateway()
41
+ return 200, {"content-type": "application/json"}, dumps(result), False
42
+
43
+
44
+ def run(event: Event, context: Context) -> NativeHttp:
45
+ """Execute and always return an HTTP-shaped result."""
46
+ if event.http is not None:
47
+ return execute(event, context).render_for_gateway()
48
+ try:
49
+ result = execute(event, context)
50
+ except Exception as exc: # noqa: BLE001 - reported to the caller as JSON
51
+ return 500, {"content-type": "application/json"}, dumps(error_body(exc)), False
52
+ return result_to_http(event, result)
53
+
54
+
55
+ async def run_async(event: Event, context: Context) -> NativeHttp:
56
+ if event.http is not None:
57
+ resp = await execute_async(event, context)
58
+ return resp.render_for_gateway()
59
+ try:
60
+ result = await execute_async(event, context)
61
+ except Exception as exc: # noqa: BLE001
62
+ return 500, {"content-type": "application/json"}, dumps(error_body(exc)), False
63
+ return result_to_http(event, result)
64
+
65
+
66
+ def decode_body(body: Any, headers: Dict[str, Any], is_base64: Optional[bool] = None) -> bytes:
67
+ """Decode a body that may be base64 when the content type is binary."""
68
+ if body is None:
69
+ return b""
70
+ if isinstance(body, (dict, list)):
71
+ return dumps(body).encode("utf-8")
72
+ if isinstance(body, bytes):
73
+ return body
74
+ text = str(body)
75
+ if is_base64 is None:
76
+ ctype = ""
77
+ for k, v in headers.items():
78
+ if str(k).lower() == "content-type":
79
+ ctype = str(v).lower()
80
+ textual = (not ctype) or ctype.startswith("text/") or "json" in ctype or "xml" in ctype or "form" in ctype
81
+ is_base64 = not textual
82
+ if is_base64:
83
+ try:
84
+ return base64.b64decode(text, validate=True)
85
+ except (ValueError, TypeError):
86
+ return text.encode("utf-8")
87
+ return text.encode("utf-8")
88
+
89
+
90
+ def json_or_text(text: str) -> Any:
91
+ return maybe_json(text)
@@ -0,0 +1,147 @@
1
+ """Alibaba Cloud Function Compute 3.0 adapter.
2
+
3
+ Handles HTTP triggers (event handler mode), timer triggers, OSS triggers,
4
+ MNS topic triggers and direct invocation. FC passes the event as ``bytes``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from typing import Any, Dict, List
11
+
12
+ from ..context import Context
13
+ from ..dispatch import execute
14
+ from ..event import Event, EventType, Headers, HttpRequest, Record, parse_time
15
+ from ..response import Response
16
+ from .base import action_from, function_name, maybe_json, unwrap_envelope
17
+
18
+ PROVIDER = "alibaba"
19
+
20
+
21
+ def _http(evt: Dict[str, Any]) -> HttpRequest:
22
+ rc = evt.get("requestContext") or {}
23
+ http = rc.get("http") or {}
24
+ query: Dict[str, Any] = {}
25
+ multi: Dict[str, List[str]] = {}
26
+ for key, value in (evt.get("queryParameters") or {}).items():
27
+ values = [str(v) for v in value] if isinstance(value, list) else [str(value)]
28
+ multi[key] = values
29
+ query[key] = values[-1] if values else ""
30
+ return HttpRequest(
31
+ method=http.get("method", "GET"),
32
+ path=evt.get("rawPath") or http.get("path") or "/",
33
+ headers=Headers(evt.get("headers") or {}),
34
+ query=query,
35
+ multi_query=multi,
36
+ body=HttpRequest.decode_body(evt.get("body"), bool(evt.get("isBase64Encoded"))),
37
+ source_ip=http.get("sourceIp"),
38
+ )
39
+
40
+
41
+ def _oss(evt: Dict[str, Any], fn: str) -> Event:
42
+ records = []
43
+ for r in evt.get("events") or []:
44
+ oss = r.get("oss") or {}
45
+ obj = oss.get("object") or {}
46
+ records.append(
47
+ Record(
48
+ id=(r.get("responseElements") or {}).get("requestId"),
49
+ bucket=(oss.get("bucket") or {}).get("name"),
50
+ key=obj.get("key"),
51
+ size=obj.get("size"),
52
+ action=action_from(r.get("eventName", "")),
53
+ attributes={"event_name": r.get("eventName"), "etag": obj.get("eTag")},
54
+ source="acs:oss",
55
+ raw=r,
56
+ )
57
+ )
58
+ first = (evt.get("events") or [{}])[0]
59
+ return Event(
60
+ type=EventType.STORAGE,
61
+ provider=PROVIDER,
62
+ function=fn,
63
+ records=records,
64
+ source="acs:oss",
65
+ time=parse_time(first.get("eventTime")),
66
+ raw=evt,
67
+ )
68
+
69
+
70
+ def to_event(native: Any, ctx: Any = None) -> Event:
71
+ fn = function_name(getattr(getattr(ctx, "function", None), "name", "") or "")
72
+ evt = maybe_json(native)
73
+ wrapped = unwrap_envelope(evt)
74
+ if wrapped is not None:
75
+ kind, data, meta = wrapped
76
+ return Event(type=kind, provider=PROVIDER, function=fn, data=data, source=meta.get("source"), raw=evt)
77
+ if isinstance(evt, dict):
78
+ rc = evt.get("requestContext")
79
+ if isinstance(rc, dict) and isinstance(rc.get("http"), dict):
80
+ return Event(type=EventType.HTTP, provider=PROVIDER, function=fn, http=_http(evt), raw=evt)
81
+ if "triggerTime" in evt and "triggerName" in evt:
82
+ payload = evt.get("payload")
83
+ inner = unwrap_envelope(payload) if payload else None
84
+ return Event(
85
+ type=EventType.SCHEDULE,
86
+ provider=PROVIDER,
87
+ function=fn,
88
+ data=inner[1] if inner else maybe_json(payload) if payload else None,
89
+ time=parse_time(evt.get("triggerTime")),
90
+ source=evt.get("triggerName"),
91
+ raw=evt,
92
+ )
93
+ events = evt.get("events")
94
+ if isinstance(events, list) and events and isinstance(events[0], dict) and "oss" in events[0]:
95
+ return _oss(evt, fn)
96
+ if "TopicName" in evt and "Message" in evt: # MNS topic, JSON notify format
97
+ record = Record(
98
+ id=evt.get("MessageId"),
99
+ body=evt.get("Message"),
100
+ attributes={k: v for k, v in evt.items() if k not in ("Message",)},
101
+ source=evt.get("TopicOwner", "") + ":" + evt.get("TopicName", ""),
102
+ raw=evt,
103
+ )
104
+ return Event(
105
+ type=EventType.TOPIC,
106
+ provider=PROVIDER,
107
+ function=fn,
108
+ records=[record],
109
+ source=evt.get("TopicName"),
110
+ time=parse_time(evt.get("PublishTime")),
111
+ raw=evt,
112
+ )
113
+ return Event(type=EventType.INVOKE, provider=PROVIDER, function=fn, data=evt, raw=native)
114
+
115
+
116
+ def to_context(ctx: Any, fn: str) -> Context:
117
+ if ctx is None:
118
+ return Context(function=fn, provider=PROVIDER)
119
+ func = getattr(ctx, "function", None)
120
+ memory = getattr(func, "memory", None)
121
+ timeout = getattr(func, "timeout", None)
122
+ return Context(
123
+ request_id=getattr(ctx, "request_id", "") or "",
124
+ function=fn,
125
+ provider=PROVIDER,
126
+ region=getattr(ctx, "region", "") or "",
127
+ memory_mb=int(memory) if memory else None,
128
+ deadline=(time.time() + float(timeout)) if timeout else None,
129
+ raw=ctx,
130
+ )
131
+
132
+
133
+ def to_native(result: Any, event: Event) -> Any:
134
+ if event.http is not None:
135
+ resp: Response = result
136
+ status, headers, body, is_b64 = resp.render_for_gateway()
137
+ if resp.cookies:
138
+ headers["set-cookie"] = resp.cookies[0] if len(resp.cookies) == 1 else ", ".join(resp.cookies)
139
+ return {"statusCode": status, "headers": headers, "body": body, "isBase64Encoded": is_b64}
140
+ return result
141
+
142
+
143
+ def handler(native: Any, ctx: Any = None) -> Any:
144
+ """FC 3.0 entrypoint - the generated ``index.handler`` points here."""
145
+ event = to_event(native, ctx)
146
+ context = to_context(ctx, event.function)
147
+ return to_native(execute(event, context), event)