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,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}
|