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,201 @@
1
+ """AWS Lambda adapter.
2
+
3
+ Handles API Gateway HTTP API (payload v2), REST API / ALB (payload v1),
4
+ Lambda Function URLs, SQS, SNS, S3, EventBridge, Step Functions and direct
5
+ invocation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from typing import Any, Dict, List, Optional
12
+ from urllib.parse import unquote_plus
13
+
14
+ from ..batch import BatchResult
15
+ from ..context import Context
16
+ from ..dispatch import execute
17
+ from ..event import Event, EventType, Headers, HttpRequest, Record, parse_time
18
+ from ..response import Response
19
+ from .base import action_from, function_name, maybe_json, unwrap_envelope
20
+
21
+ PROVIDER = "aws"
22
+
23
+
24
+ def _http_v2(evt: Dict[str, Any]) -> HttpRequest:
25
+ rc = evt.get("requestContext") or {}
26
+ http = rc.get("http") or {}
27
+ headers = dict(evt.get("headers") or {})
28
+ if evt.get("cookies"):
29
+ headers["cookie"] = "; ".join(evt["cookies"])
30
+ query = dict(evt.get("queryStringParameters") or {})
31
+ multi = {k: v.split(",") for k, v in query.items()}
32
+ return HttpRequest(
33
+ method=http.get("method", "GET"),
34
+ path=evt.get("rawPath") or http.get("path") or "/",
35
+ headers=Headers(headers),
36
+ query=query,
37
+ multi_query=multi,
38
+ path_params=dict(evt.get("pathParameters") or {}),
39
+ body=HttpRequest.decode_body(evt.get("body"), bool(evt.get("isBase64Encoded"))),
40
+ source_ip=http.get("sourceIp"),
41
+ route=(evt.get("routeKey") or "").split(" ", 1)[-1] or None,
42
+ )
43
+
44
+
45
+ def _http_v1(evt: Dict[str, Any]) -> HttpRequest:
46
+ rc = evt.get("requestContext") or {}
47
+ multi_headers = evt.get("multiValueHeaders") or {}
48
+ headers: Dict[str, Any] = dict(multi_headers) if multi_headers else dict(evt.get("headers") or {})
49
+ multi_query = {k: list(v) for k, v in (evt.get("multiValueQueryStringParameters") or {}).items()}
50
+ query = dict(evt.get("queryStringParameters") or {})
51
+ if not query and multi_query:
52
+ query = {k: v[-1] for k, v in multi_query.items()}
53
+ return HttpRequest(
54
+ method=evt.get("httpMethod", "GET"),
55
+ path=evt.get("path") or "/",
56
+ headers=Headers(headers),
57
+ query=query,
58
+ multi_query=multi_query or {k: [v] for k, v in query.items()},
59
+ path_params=dict(evt.get("pathParameters") or {}),
60
+ body=HttpRequest.decode_body(evt.get("body"), bool(evt.get("isBase64Encoded"))),
61
+ source_ip=(rc.get("identity") or {}).get("sourceIp"),
62
+ route=evt.get("resource"),
63
+ )
64
+
65
+
66
+ def _records(evt: Dict[str, Any]) -> Optional[Event]:
67
+ records = evt.get("Records")
68
+ if not isinstance(records, list) or not records:
69
+ return None
70
+ first = records[0]
71
+ source = first.get("eventSource") or first.get("EventSource") or ""
72
+ out: List[Record] = []
73
+ if source == "aws:sqs":
74
+ for r in records:
75
+ out.append(
76
+ Record(
77
+ id=r.get("messageId"),
78
+ body=r.get("body"),
79
+ attributes={
80
+ **(r.get("attributes") or {}),
81
+ **{k: v.get("stringValue") for k, v in (r.get("messageAttributes") or {}).items()},
82
+ },
83
+ source=r.get("eventSourceARN"),
84
+ raw=r,
85
+ )
86
+ )
87
+ return Event(type=EventType.QUEUE, provider=PROVIDER, records=out, source=first.get("eventSourceARN"), raw=evt)
88
+ if source == "aws:sns":
89
+ for r in records:
90
+ sns = r.get("Sns") or {}
91
+ out.append(
92
+ Record(
93
+ id=sns.get("MessageId"),
94
+ body=sns.get("Message"),
95
+ attributes={k: v.get("Value") for k, v in (sns.get("MessageAttributes") or {}).items()},
96
+ source=sns.get("TopicArn"),
97
+ raw=r,
98
+ )
99
+ )
100
+ return Event(
101
+ type=EventType.TOPIC,
102
+ provider=PROVIDER,
103
+ records=out,
104
+ source=(first.get("Sns") or {}).get("TopicArn"),
105
+ time=parse_time((first.get("Sns") or {}).get("Timestamp")),
106
+ raw=evt,
107
+ )
108
+ if source == "aws:s3":
109
+ for r in records:
110
+ s3 = r.get("s3") or {}
111
+ obj = s3.get("object") or {}
112
+ out.append(
113
+ Record(
114
+ id=obj.get("sequencer"),
115
+ bucket=(s3.get("bucket") or {}).get("name"),
116
+ key=unquote_plus(obj.get("key", "")),
117
+ size=obj.get("size"),
118
+ action=action_from(r.get("eventName", "")),
119
+ attributes={"etag": obj.get("eTag"), "event_name": r.get("eventName")},
120
+ source="aws:s3",
121
+ raw=r,
122
+ )
123
+ )
124
+ return Event(
125
+ type=EventType.STORAGE,
126
+ provider=PROVIDER,
127
+ records=out,
128
+ source="aws:s3",
129
+ time=parse_time(first.get("eventTime")),
130
+ raw=evt,
131
+ )
132
+ return None
133
+
134
+
135
+ def to_event(evt: Any, ctx: Any = None) -> Event:
136
+ fn = function_name(getattr(ctx, "function_name", "") or "")
137
+ wrapped = unwrap_envelope(evt)
138
+ if wrapped is not None:
139
+ kind, data, meta = wrapped
140
+ return Event(type=kind, provider=PROVIDER, function=fn, data=data, source=meta.get("source"), raw=evt)
141
+ if isinstance(evt, dict):
142
+ rc = evt.get("requestContext")
143
+ if isinstance(rc, dict) and isinstance(rc.get("http"), dict):
144
+ return Event(type=EventType.HTTP, provider=PROVIDER, function=fn, http=_http_v2(evt), raw=evt)
145
+ if "httpMethod" in evt:
146
+ return Event(type=EventType.HTTP, provider=PROVIDER, function=fn, http=_http_v1(evt), raw=evt)
147
+ batch = _records(evt)
148
+ if batch is not None:
149
+ batch.function = fn
150
+ return batch
151
+ if evt.get("source") == "aws.events" or evt.get("detail-type") == "Scheduled Event":
152
+ return Event(
153
+ type=EventType.SCHEDULE,
154
+ provider=PROVIDER,
155
+ function=fn,
156
+ data=evt.get("detail"),
157
+ time=parse_time(evt.get("time")),
158
+ source=evt.get("source"),
159
+ raw=evt,
160
+ )
161
+ return Event(type=EventType.INVOKE, provider=PROVIDER, function=fn, data=maybe_json(evt), raw=evt)
162
+
163
+
164
+ def to_context(ctx: Any, fn: str) -> Context:
165
+ if ctx is None:
166
+ return Context(function=fn, provider=PROVIDER)
167
+ remaining = getattr(ctx, "get_remaining_time_in_millis", None)
168
+ memory = getattr(ctx, "memory_limit_in_mb", None)
169
+ return Context(
170
+ request_id=getattr(ctx, "aws_request_id", "") or "",
171
+ function=fn,
172
+ provider=PROVIDER,
173
+ memory_mb=int(memory) if memory else None,
174
+ deadline=(time.time() + remaining() / 1000.0) if callable(remaining) else None,
175
+ raw=ctx,
176
+ _remaining=remaining if callable(remaining) else None,
177
+ )
178
+
179
+
180
+ def to_native(result: Any, event: Event) -> Any:
181
+ if event.http is not None:
182
+ resp: Response = result
183
+ status, headers, body, is_b64 = resp.render_for_gateway()
184
+ out: Dict[str, Any] = {"statusCode": status, "headers": headers, "body": body, "isBase64Encoded": is_b64}
185
+ if resp.cookies:
186
+ if (event.raw or {}).get("version") == "2.0":
187
+ out["cookies"] = list(resp.cookies)
188
+ else:
189
+ out["multiValueHeaders"] = {"set-cookie": list(resp.cookies)}
190
+ return out
191
+ if isinstance(result, BatchResult):
192
+ return {"batchItemFailures": [{"itemIdentifier": rid} for rid in result.failed]}
193
+ return result
194
+
195
+
196
+ def handler(evt: Any, ctx: Any = None) -> Any:
197
+ """Lambda entrypoint - the generated ``pskit_entry.handler`` points here."""
198
+ event = to_event(evt, ctx)
199
+ context = to_context(ctx, event.function)
200
+ result = execute(event, context)
201
+ return to_native(result, event)
@@ -0,0 +1,185 @@
1
+ """Azure Functions (Python v2 programming model) adapter.
2
+
3
+ The generated ``function_app.py`` registers one decorated wrapper per trigger
4
+ and calls these helpers with the *logical* function name, because one Function
5
+ App hosts every function of the service (app settings are shared, so
6
+ ``PSKIT_FUNCTION`` cannot be used).
7
+
8
+ ``azure.functions`` is imported lazily so the module can be unit-tested
9
+ without the Azure SDK installed.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import uuid
15
+ from typing import Any, Dict, Optional
16
+ from urllib.parse import urlparse
17
+
18
+ from ..context import Context
19
+ from ..dispatch import execute
20
+ from ..event import Event, EventType, Headers, HttpRequest, Record, parse_time
21
+ from ..response import Response, dumps
22
+ from .base import unwrap_envelope
23
+
24
+ PROVIDER = "azure"
25
+ _JSON = "application/json"
26
+
27
+
28
+ def _func() -> Any:
29
+ import azure.functions as func # type: ignore[import-not-found]
30
+
31
+ return func
32
+
33
+
34
+ def _context(fn: str, request_id: Optional[str] = None, raw: Any = None) -> Context:
35
+ return Context(request_id=request_id or str(uuid.uuid4()), function=fn, provider=PROVIDER, raw=raw)
36
+
37
+
38
+ def _text(value: Any) -> Any:
39
+ if isinstance(value, bytes):
40
+ try:
41
+ return value.decode("utf-8")
42
+ except UnicodeDecodeError:
43
+ return value
44
+ return value
45
+
46
+
47
+ # ------------------------------------------------------------------ HTTP
48
+
49
+
50
+ def to_http_event(req: Any, function: str) -> Event:
51
+ headers = dict(req.headers.items()) if hasattr(req.headers, "items") else dict(req.headers or {})
52
+ params = dict(req.params or {})
53
+ path = urlparse(req.url).path if getattr(req, "url", None) else "/"
54
+ request = HttpRequest(
55
+ method=req.method,
56
+ path=path or "/",
57
+ headers=Headers(headers),
58
+ query=params,
59
+ path_params={k: v for k, v in dict(getattr(req, "route_params", None) or {}).items()},
60
+ body=req.get_body() or b"",
61
+ source_ip=(headers.get("x-forwarded-for") or headers.get("X-Forwarded-For") or "").split(",")[0].split(":")[0]
62
+ or None,
63
+ )
64
+ return Event(type=EventType.HTTP, provider=PROVIDER, function=function, http=request, raw=req)
65
+
66
+
67
+ def _http_response(body: bytes, status: int, headers: Dict[str, str]) -> Any:
68
+ func = _func()
69
+ return func.HttpResponse(body=body, status_code=status, headers=headers)
70
+
71
+
72
+ def http(req: Any, function: str) -> Any:
73
+ body = req.get_body() or b""
74
+ headers = req.headers if req.headers is not None else {}
75
+ req_id = headers.get("x-ms-request-id") or headers.get("x-request-id")
76
+ wrapped = unwrap_envelope(body) if body[:1] == b"{" else None
77
+ if wrapped is not None:
78
+ kind, data, meta = wrapped
79
+ event = Event(type=kind, provider=PROVIDER, function=function, data=data, source=meta.get("source"), raw=req)
80
+ try:
81
+ result = execute(event, _context(function, req_id, req), function)
82
+ except Exception as exc: # noqa: BLE001 - reported to the caller
83
+ payload = {"errorType": type(exc).__name__, "cause": str(exc)}
84
+ return _http_response(dumps(payload).encode("utf-8"), 500, {"content-type": _JSON})
85
+ return _http_response(dumps(result).encode("utf-8"), 200, {"content-type": _JSON})
86
+ event = to_http_event(req, function)
87
+ resp: Response = execute(event, _context(function, req_id, req), function)
88
+ status, out_headers, raw = resp.render()
89
+ if resp.cookies:
90
+ # Azure's HttpResponse headers are a plain mapping: only one Set-Cookie survives.
91
+ out_headers["set-cookie"] = resp.cookies[-1]
92
+ return _http_response(raw, status, out_headers)
93
+
94
+
95
+ # ------------------------------------------------------------------ other triggers
96
+
97
+
98
+ def timer(timer_request: Any, function: str, data: Any = None) -> Any:
99
+ event = Event(
100
+ type=EventType.SCHEDULE,
101
+ provider=PROVIDER,
102
+ function=function,
103
+ data=data,
104
+ source="azure.timer",
105
+ raw=timer_request,
106
+ )
107
+ return execute(event, _context(function, raw=timer_request), function)
108
+
109
+
110
+ def to_queue_event(msg: Any, function: str) -> Event:
111
+ record = Record(
112
+ id=getattr(msg, "id", None),
113
+ body=_text(msg.get_body()),
114
+ attributes={
115
+ "dequeue_count": getattr(msg, "dequeue_count", None),
116
+ "insertion_time": str(getattr(msg, "insertion_time", "") or ""),
117
+ },
118
+ source="azure.storage.queue",
119
+ raw=msg,
120
+ )
121
+ return Event(
122
+ type=EventType.QUEUE,
123
+ provider=PROVIDER,
124
+ function=function,
125
+ records=[record],
126
+ time=parse_time(getattr(msg, "insertion_time", None)),
127
+ source="azure.storage.queue",
128
+ raw=msg,
129
+ )
130
+
131
+
132
+ def queue(msg: Any, function: str) -> Any:
133
+ return execute(to_queue_event(msg, function), _context(function, getattr(msg, "id", None), msg), function)
134
+
135
+
136
+ def to_blob_event(blob: Any, function: str, bucket: str) -> Event:
137
+ name = getattr(blob, "name", "") or ""
138
+ key = name[len(bucket) + 1 :] if name.startswith(bucket + "/") else name
139
+ record = Record(
140
+ id=getattr(blob, "uri", None) or name,
141
+ bucket=bucket,
142
+ key=key,
143
+ size=getattr(blob, "length", None),
144
+ action="created",
145
+ attributes={"uri": getattr(blob, "uri", None)},
146
+ source="azure.storage.blob",
147
+ raw=blob,
148
+ )
149
+ return Event(
150
+ type=EventType.STORAGE,
151
+ provider=PROVIDER,
152
+ function=function,
153
+ records=[record],
154
+ source="azure.storage.blob",
155
+ raw=blob,
156
+ )
157
+
158
+
159
+ def blob(blob_stream: Any, function: str, bucket: str) -> Any:
160
+ return execute(to_blob_event(blob_stream, function, bucket), _context(function, raw=blob_stream), function)
161
+
162
+
163
+ def to_service_bus_event(msg: Any, function: str) -> Event:
164
+ props = getattr(msg, "user_properties", None) or getattr(msg, "application_properties", None) or {}
165
+ record = Record(
166
+ id=getattr(msg, "message_id", None),
167
+ body=_text(msg.get_body()),
168
+ attributes={str(k): v for k, v in dict(props).items()},
169
+ source="azure.servicebus",
170
+ raw=msg,
171
+ )
172
+ return Event(
173
+ type=EventType.TOPIC,
174
+ provider=PROVIDER,
175
+ function=function,
176
+ records=[record],
177
+ time=parse_time(getattr(msg, "enqueued_time_utc", None)),
178
+ source="azure.servicebus",
179
+ raw=msg,
180
+ )
181
+
182
+
183
+ def service_bus(msg: Any, function: str) -> Any:
184
+ event = to_service_bus_event(msg, function)
185
+ return execute(event, _context(function, getattr(msg, "message_id", None), msg), function)
@@ -0,0 +1,86 @@
1
+ """Helpers shared by all adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import os
8
+ from typing import Any, Dict, Optional, Tuple
9
+
10
+ from ..event import EventType
11
+
12
+ ENVELOPE_KEY = "__pskit"
13
+
14
+
15
+ def envelope(kind: str, data: Any = None, **meta: Any) -> Dict[str, Any]:
16
+ """Wrap a payload so any adapter can recognise its event type.
17
+
18
+ The CLI uses this for schedule inputs, workflow task payloads and
19
+ function-to-function ``invoke`` calls, because those arrive on most clouds
20
+ as an opaque JSON object.
21
+ """
22
+ body: Dict[str, Any] = {ENVELOPE_KEY: kind, "data": data}
23
+ body.update(meta)
24
+ return body
25
+
26
+
27
+ def unwrap_envelope(obj: Any) -> Optional[Tuple[str, Any, Dict[str, Any]]]:
28
+ if isinstance(obj, (bytes, str)):
29
+ obj = maybe_json(obj)
30
+ if isinstance(obj, dict) and ENVELOPE_KEY in obj:
31
+ kind = obj[ENVELOPE_KEY]
32
+ if kind not in EventType.ALL:
33
+ kind = EventType.INVOKE
34
+ meta = {k: v for k, v in obj.items() if k not in (ENVELOPE_KEY, "data")}
35
+ return kind, obj.get("data"), meta
36
+ return None
37
+
38
+
39
+ def maybe_json(value: Any) -> Any:
40
+ """Decode bytes/str holding JSON; return the input unchanged otherwise."""
41
+ if isinstance(value, bytes):
42
+ try:
43
+ value = value.decode("utf-8")
44
+ except UnicodeDecodeError:
45
+ return value
46
+ if isinstance(value, str):
47
+ text = value.strip()
48
+ if text[:1] in ("{", "[") or text in ("null", "true", "false"):
49
+ try:
50
+ return json.loads(text)
51
+ except ValueError:
52
+ return value
53
+ return value
54
+
55
+
56
+ def b64_text(value: Optional[str]) -> Any:
57
+ """Decode a base64 field to text (or JSON) - used by Pub/Sub style payloads."""
58
+ if not value:
59
+ return None
60
+ try:
61
+ raw = base64.b64decode(value)
62
+ except (ValueError, TypeError):
63
+ return value
64
+ try:
65
+ return raw.decode("utf-8")
66
+ except UnicodeDecodeError:
67
+ return raw
68
+
69
+
70
+ def function_name(default: str = "") -> str:
71
+ return os.environ.get("PSKIT_FUNCTION", default)
72
+
73
+
74
+ def action_from(name: str) -> str:
75
+ """Map provider-specific object event names to ``created`` / ``deleted``."""
76
+ lowered = (name or "").lower()
77
+ if any(word in lowered for word in ("delete", "remove", "archive")):
78
+ return "deleted"
79
+ return "created"
80
+
81
+
82
+ def first(mapping: Dict[str, Any], *keys: str, default: Any = None) -> Any:
83
+ for key in keys:
84
+ if key in mapping and mapping[key] is not None:
85
+ return mapping[key]
86
+ return default
@@ -0,0 +1,126 @@
1
+ """Cloudflare Python Workers adapter (Pyodide).
2
+
3
+ The generated worker module defines ``class Default(WorkerEntrypoint)`` whose
4
+ ``fetch`` / ``scheduled`` methods call :func:`fetch` / :func:`scheduled` here.
5
+ Everything is async because the Workers runtime already owns the event loop.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
12
+ from urllib.parse import parse_qs, urlsplit
13
+
14
+ from ..context import Context
15
+ from ..dispatch import execute_async
16
+ from ..event import Event, EventType, Headers, HttpRequest, parse_time
17
+ from ._httpfn import event_from_request, run_async
18
+ from .base import function_name
19
+
20
+ PROVIDER = "cloudflare"
21
+
22
+ # Filled by the generated worker module: {cron expression: schedule input}.
23
+ SCHEDULE_INPUTS: Dict[str, Any] = {}
24
+
25
+
26
+ def _header_items(headers: Any) -> Iterable[Tuple[str, str]]:
27
+ if headers is None:
28
+ return []
29
+ items = getattr(headers, "items", None)
30
+ if callable(items):
31
+ try:
32
+ return [(str(k), str(v)) for k, v in items()]
33
+ except TypeError:
34
+ pass
35
+ try:
36
+ return [(str(k), str(v)) for k, v in headers]
37
+ except (TypeError, ValueError):
38
+ return []
39
+
40
+
41
+ async def _read_body(request: Any) -> bytes:
42
+ for name in ("bytes", "text"):
43
+ reader = getattr(request, name, None)
44
+ if not callable(reader):
45
+ continue
46
+ value = await reader()
47
+ if isinstance(value, (bytes, bytearray)):
48
+ return bytes(value)
49
+ if hasattr(value, "to_bytes"): # JsProxy of an ArrayBuffer / Uint8Array
50
+ return bytes(value.to_bytes())
51
+ if value is None:
52
+ return b""
53
+ return str(value).encode("utf-8")
54
+ return b""
55
+
56
+
57
+ async def to_request(request: Any) -> HttpRequest:
58
+ method = str(getattr(request, "method", "GET"))
59
+ parts = urlsplit(str(getattr(request, "url", "/")))
60
+ headers: List[Tuple[str, str]] = list(_header_items(getattr(request, "headers", None)))
61
+ body = await _read_body(request) if method not in ("GET", "HEAD") else b""
62
+ multi = parse_qs(parts.query, keep_blank_values=True)
63
+ lookup = {k.lower(): v for k, v in headers}
64
+ return HttpRequest(
65
+ method=method,
66
+ path=parts.path or "/",
67
+ headers=Headers(headers),
68
+ query={k: v[-1] for k, v in multi.items()},
69
+ multi_query=multi,
70
+ body=body,
71
+ source_ip=lookup.get("cf-connecting-ip"),
72
+ )
73
+
74
+
75
+ def _response(status: int, headers: Dict[str, str], body: str, is_b64: bool) -> Any:
76
+ payload: Any = body
77
+ if is_b64:
78
+ import base64
79
+
80
+ payload = base64.b64decode(body)
81
+ try:
82
+ from workers import Response as WorkerResponse # type: ignore[import-not-found]
83
+ except ImportError: # local tests
84
+ return {"status": status, "headers": headers, "body": payload}
85
+ return WorkerResponse(payload, status=status, headers=headers)
86
+
87
+
88
+ async def fetch(request: Any, env: Any = None) -> Any:
89
+ fn = function_name()
90
+ req = await to_request(request)
91
+ event = event_from_request(req, PROVIDER, fn, raw=request)
92
+ context = Context(
93
+ request_id=req.headers.get("cf-ray", "") or Context().request_id,
94
+ function=fn,
95
+ provider=PROVIDER,
96
+ raw=env,
97
+ )
98
+ status, headers, body, is_b64 = await run_async(event, context)
99
+ return _response(status, headers, body, is_b64)
100
+
101
+
102
+ async def scheduled(controller: Any, env: Any = None) -> Any:
103
+ fn = function_name()
104
+ cron = str(getattr(controller, "cron", "") or "")
105
+ event = Event(
106
+ type=EventType.SCHEDULE,
107
+ provider=PROVIDER,
108
+ function=fn,
109
+ data=SCHEDULE_INPUTS.get(cron),
110
+ time=parse_time(getattr(controller, "scheduledTime", None)),
111
+ source=cron or None,
112
+ raw=controller,
113
+ )
114
+ return await execute_async(event, Context(function=fn, provider=PROVIDER, raw=env))
115
+
116
+
117
+ def configure(
118
+ function: str,
119
+ environment: Optional[Dict[str, str]] = None,
120
+ schedules: Optional[Dict[str, Any]] = None,
121
+ ) -> None:
122
+ """Called once by the generated worker module at import time."""
123
+ os.environ.update(environment or {})
124
+ os.environ["PSKIT_FUNCTION"] = function
125
+ SCHEDULE_INPUTS.clear()
126
+ SCHEDULE_INPUTS.update(schedules or {})
@@ -0,0 +1,74 @@
1
+ """DigitalOcean Functions adapter (``main(event, context)``).
2
+
3
+ Web functions deployed with ``web: raw`` receive ``event["http"]`` with
4
+ ``method``, ``path``, ``headers``, ``queryString``, ``body`` and
5
+ ``isBase64Encoded``. Scheduled triggers deliver their ``withBody`` payload,
6
+ which the CLI fills with the ``__pskit`` envelope.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from typing import Any, Dict, Optional
13
+ from urllib.parse import parse_qs
14
+
15
+ from ..context import Context
16
+ from ..event import Event, EventType, Headers, HttpRequest
17
+ from ..response import dumps
18
+ from ._httpfn import decode_body, event_from_request, run
19
+ from .base import function_name, unwrap_envelope
20
+
21
+ PROVIDER = "digitalocean"
22
+
23
+
24
+ def to_request(event: Dict[str, Any]) -> Optional[HttpRequest]:
25
+ http = event.get("http")
26
+ if not isinstance(http, dict) or not http.get("method"):
27
+ return None
28
+ headers = dict(http.get("headers") or {})
29
+ body: Any = http.get("body")
30
+ if body is None:
31
+ # `web: true` (non-raw) merges body params into the event.
32
+ plain = {k: v for k, v in event.items() if k != "http" and not k.startswith("__ow_")}
33
+ body = dumps(plain) if plain else None
34
+ is_b64 = http.get("isBase64Encoded")
35
+ multi = parse_qs(str(http.get("queryString") or ""), keep_blank_values=True)
36
+ return HttpRequest(
37
+ method=str(http["method"]),
38
+ path=http.get("path") or "/",
39
+ headers=Headers(headers),
40
+ query={k: v[-1] for k, v in multi.items()},
41
+ multi_query=multi,
42
+ body=decode_body(body, headers, bool(is_b64) if is_b64 is not None else False),
43
+ source_ip=str(headers.get("x-forwarded-for", "")).split(",")[0].strip() or None,
44
+ )
45
+
46
+
47
+ def to_event(event: Dict[str, Any], fn: str) -> Event:
48
+ req = to_request(event)
49
+ if req is not None:
50
+ return event_from_request(req, PROVIDER, fn, raw=event)
51
+ wrapped = unwrap_envelope(event)
52
+ if wrapped is not None:
53
+ kind, data, meta = wrapped
54
+ return Event(type=kind, provider=PROVIDER, function=fn, data=data, source=meta.get("source"), raw=event)
55
+ return Event(type=EventType.INVOKE, provider=PROVIDER, function=fn, data=event, raw=event)
56
+
57
+
58
+ def to_context(ctx: Any, fn: str) -> Context:
59
+ remaining = getattr(ctx, "get_remaining_time_in_millis", None)
60
+ return Context(
61
+ request_id=str(getattr(ctx, "activation_id", "") or getattr(ctx, "request_id", "") or ""),
62
+ function=fn,
63
+ provider=PROVIDER,
64
+ deadline=(time.time() + remaining() / 1000.0) if callable(remaining) else None,
65
+ raw=ctx,
66
+ _remaining=remaining if callable(remaining) else None,
67
+ )
68
+
69
+
70
+ def main(event: Optional[Dict[str, Any]] = None, context: Any = None) -> Dict[str, Any]:
71
+ event = event or {}
72
+ fn = function_name()
73
+ status, headers, body, _ = run(to_event(event, fn), to_context(context, fn))
74
+ return {"statusCode": status, "headers": headers, "body": body}