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,151 @@
|
|
|
1
|
+
"""Tencent Cloud SCF adapter.
|
|
2
|
+
|
|
3
|
+
Handles API Gateway (integrated response), timer triggers, COS triggers and
|
|
4
|
+
direct invocation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Dict, List, Mapping
|
|
11
|
+
from urllib.parse import parse_qs, unquote_plus
|
|
12
|
+
|
|
13
|
+
from ..context import Context
|
|
14
|
+
from ..dispatch import execute
|
|
15
|
+
from ..event import Event, EventType, Headers, HttpRequest, Record, parse_time
|
|
16
|
+
from ..response import Response
|
|
17
|
+
from .base import action_from, function_name, maybe_json, unwrap_envelope
|
|
18
|
+
|
|
19
|
+
PROVIDER = "tencent"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ctx_get(ctx: Any, key: str, default: Any = None) -> Any:
|
|
23
|
+
if isinstance(ctx, Mapping):
|
|
24
|
+
return ctx.get(key, default)
|
|
25
|
+
return getattr(ctx, key, default)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _http(evt: Dict[str, Any]) -> HttpRequest:
|
|
29
|
+
rc = evt.get("requestContext") or {}
|
|
30
|
+
query: Dict[str, str] = {}
|
|
31
|
+
multi: Dict[str, List[str]] = {}
|
|
32
|
+
raw_query = evt.get("queryString")
|
|
33
|
+
if isinstance(raw_query, dict):
|
|
34
|
+
for key, value in raw_query.items():
|
|
35
|
+
values = [str(v) for v in value] if isinstance(value, list) else [str(value)]
|
|
36
|
+
multi[key] = values
|
|
37
|
+
query[key] = values[-1] if values else ""
|
|
38
|
+
elif isinstance(raw_query, str) and raw_query:
|
|
39
|
+
multi = parse_qs(raw_query, keep_blank_values=True)
|
|
40
|
+
query = {k: v[-1] for k, v in multi.items()}
|
|
41
|
+
if not query and evt.get("queryStringParameters"):
|
|
42
|
+
query = {k: str(v) for k, v in evt["queryStringParameters"].items()}
|
|
43
|
+
return HttpRequest(
|
|
44
|
+
method=evt.get("httpMethod") or rc.get("httpMethod") or "GET",
|
|
45
|
+
path=evt.get("path") or rc.get("path") or "/",
|
|
46
|
+
headers=Headers(evt.get("headers") or {}),
|
|
47
|
+
query=query,
|
|
48
|
+
multi_query=multi,
|
|
49
|
+
path_params={k: str(v) for k, v in (evt.get("pathParameters") or {}).items()},
|
|
50
|
+
body=HttpRequest.decode_body(evt.get("body"), bool(evt.get("isBase64Encoded"))),
|
|
51
|
+
source_ip=rc.get("sourceIp"),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _cos(evt: Dict[str, Any], fn: str) -> Event:
|
|
56
|
+
records = []
|
|
57
|
+
for r in evt.get("Records") or []:
|
|
58
|
+
cos = r.get("cos") or {}
|
|
59
|
+
obj = cos.get("cosObject") or {}
|
|
60
|
+
bucket = (cos.get("cosBucket") or {}).get("name")
|
|
61
|
+
key = obj.get("key") or ""
|
|
62
|
+
# COS keys look like "/<appid>/<bucket>/<path>"; strip that prefix.
|
|
63
|
+
parts = key.lstrip("/").split("/", 2)
|
|
64
|
+
if len(parts) == 3 and parts[0].isdigit():
|
|
65
|
+
key = parts[2]
|
|
66
|
+
event_name = (r.get("event") or {}).get("eventName", "")
|
|
67
|
+
records.append(
|
|
68
|
+
Record(
|
|
69
|
+
id=(obj.get("meta") or {}).get("x-cos-request-id"),
|
|
70
|
+
bucket=bucket,
|
|
71
|
+
key=unquote_plus(key),
|
|
72
|
+
size=obj.get("size"),
|
|
73
|
+
action=action_from(event_name),
|
|
74
|
+
attributes={"event_name": event_name, "url": obj.get("url")},
|
|
75
|
+
source="cos",
|
|
76
|
+
raw=r,
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
first = (evt.get("Records") or [{}])[0]
|
|
80
|
+
return Event(
|
|
81
|
+
type=EventType.STORAGE,
|
|
82
|
+
provider=PROVIDER,
|
|
83
|
+
function=fn,
|
|
84
|
+
records=records,
|
|
85
|
+
source="cos",
|
|
86
|
+
time=parse_time((first.get("event") or {}).get("eventTime")),
|
|
87
|
+
raw=evt,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def to_event(native: Any, ctx: Any = None) -> Event:
|
|
92
|
+
fn = function_name(_ctx_get(ctx, "function_name", "") or "")
|
|
93
|
+
evt = maybe_json(native)
|
|
94
|
+
wrapped = unwrap_envelope(evt)
|
|
95
|
+
if wrapped is not None:
|
|
96
|
+
kind, data, meta = wrapped
|
|
97
|
+
return Event(type=kind, provider=PROVIDER, function=fn, data=data, source=meta.get("source"), raw=evt)
|
|
98
|
+
if isinstance(evt, dict):
|
|
99
|
+
if "httpMethod" in evt and ("requestContext" in evt or "path" in evt):
|
|
100
|
+
return Event(type=EventType.HTTP, provider=PROVIDER, function=fn, http=_http(evt), raw=evt)
|
|
101
|
+
if evt.get("Type") == "Timer":
|
|
102
|
+
message = evt.get("Message")
|
|
103
|
+
inner = unwrap_envelope(message) if message else None
|
|
104
|
+
return Event(
|
|
105
|
+
type=EventType.SCHEDULE,
|
|
106
|
+
provider=PROVIDER,
|
|
107
|
+
function=fn,
|
|
108
|
+
data=inner[1] if inner else (maybe_json(message) if message else None),
|
|
109
|
+
time=parse_time(evt.get("Time")),
|
|
110
|
+
source=evt.get("TriggerName"),
|
|
111
|
+
raw=evt,
|
|
112
|
+
)
|
|
113
|
+
records = evt.get("Records")
|
|
114
|
+
if isinstance(records, list) and records and isinstance(records[0], dict) and "cos" in records[0]:
|
|
115
|
+
return _cos(evt, fn)
|
|
116
|
+
return Event(type=EventType.INVOKE, provider=PROVIDER, function=fn, data=evt, raw=native)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def to_context(ctx: Any, fn: str) -> Context:
|
|
120
|
+
if ctx is None:
|
|
121
|
+
return Context(function=fn, provider=PROVIDER)
|
|
122
|
+
limit_ms = _ctx_get(ctx, "time_limit_in_ms")
|
|
123
|
+
memory = _ctx_get(ctx, "memory_limit_in_mb")
|
|
124
|
+
return Context(
|
|
125
|
+
request_id=str(_ctx_get(ctx, "request_id", "") or ""),
|
|
126
|
+
function=fn,
|
|
127
|
+
provider=PROVIDER,
|
|
128
|
+
memory_mb=int(memory) if memory else None,
|
|
129
|
+
deadline=(time.time() + float(limit_ms) / 1000.0) if limit_ms else None,
|
|
130
|
+
raw=ctx,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def to_native(result: Any, event: Event) -> Any:
|
|
135
|
+
if event.http is not None:
|
|
136
|
+
resp: Response = result
|
|
137
|
+
status, headers, body, is_b64 = resp.render_for_gateway()
|
|
138
|
+
if resp.cookies:
|
|
139
|
+
headers["set-cookie"] = resp.cookies[0] if len(resp.cookies) == 1 else ", ".join(resp.cookies)
|
|
140
|
+
return {"isBase64Encoded": is_b64, "statusCode": status, "headers": headers, "body": body}
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def main_handler(native: Any, ctx: Any = None) -> Any:
|
|
145
|
+
"""SCF entrypoint - the generated ``index.main_handler`` points here."""
|
|
146
|
+
event = to_event(native, ctx)
|
|
147
|
+
context = to_context(ctx, event.function)
|
|
148
|
+
return to_native(execute(event, context), event)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
handler = main_handler
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Partial batch failure reporting for queue / topic / storage events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Iterable, List, Optional, Union
|
|
7
|
+
|
|
8
|
+
from .event import Record
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BatchFailure(Exception):
|
|
12
|
+
"""Raised on providers without partial-batch support so the whole batch retries."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, failed_ids: List[str]) -> None:
|
|
15
|
+
self.failed_ids = failed_ids
|
|
16
|
+
super().__init__(f"{len(failed_ids)} record(s) failed: {', '.join(failed_ids[:10])}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class BatchResult:
|
|
21
|
+
"""Return this from a batch handler to retry only the records that failed.
|
|
22
|
+
|
|
23
|
+
On AWS SQS it becomes ``batchItemFailures``; on every other provider any
|
|
24
|
+
failure makes the whole invocation fail (and therefore retry).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
failed: List[str] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
def fail(self, record: Union[Record, str]) -> None:
|
|
30
|
+
rid: Optional[str] = record.id if isinstance(record, Record) else record
|
|
31
|
+
if rid is not None:
|
|
32
|
+
self.failed.append(rid)
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def of(cls, failed: Iterable[Union[Record, str]]) -> BatchResult:
|
|
36
|
+
result = cls()
|
|
37
|
+
for item in failed:
|
|
38
|
+
result.fail(item)
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def ok(self) -> bool:
|
|
43
|
+
return not self.failed
|
|
44
|
+
|
|
45
|
+
def raise_if_failed(self) -> None:
|
|
46
|
+
if self.failed:
|
|
47
|
+
raise BatchFailure(self.failed)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Provider-neutral invocation context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Callable, Mapping, Optional
|
|
11
|
+
|
|
12
|
+
from .log import get_logger
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Context:
|
|
17
|
+
request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
18
|
+
function: str = ""
|
|
19
|
+
provider: str = "local"
|
|
20
|
+
service: str = field(default_factory=lambda: os.environ.get("PSKIT_SERVICE", ""))
|
|
21
|
+
stage: str = field(default_factory=lambda: os.environ.get("PSKIT_STAGE", ""))
|
|
22
|
+
region: str = field(default_factory=lambda: os.environ.get("PSKIT_REGION", ""))
|
|
23
|
+
memory_mb: Optional[int] = None
|
|
24
|
+
deadline: Optional[float] = None # unix epoch seconds
|
|
25
|
+
raw: Any = None
|
|
26
|
+
_remaining: Optional[Callable[[], int]] = field(default=None, repr=False)
|
|
27
|
+
_logger: Optional[logging.LoggerAdapter] = field(default=None, repr=False)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def env(self) -> Mapping[str, str]:
|
|
31
|
+
return os.environ
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def logger(self) -> logging.LoggerAdapter:
|
|
35
|
+
if self._logger is None:
|
|
36
|
+
self._logger = get_logger(function=self.function, request_id=self.request_id, provider=self.provider)
|
|
37
|
+
return self._logger
|
|
38
|
+
|
|
39
|
+
def remaining_time_ms(self) -> Optional[int]:
|
|
40
|
+
"""Milliseconds until the platform kills this invocation, if known."""
|
|
41
|
+
if self._remaining is not None:
|
|
42
|
+
return int(self._remaining())
|
|
43
|
+
if self.deadline is not None:
|
|
44
|
+
return max(0, int((self.deadline - time.time()) * 1000))
|
|
45
|
+
return None
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Calls the user's handler with a normalized event and normalizes the result.
|
|
2
|
+
|
|
3
|
+
This is the single choke-point shared by every provider adapter, so behaviour
|
|
4
|
+
(error mapping, CORS, routing, logging) is identical on every cloud.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import importlib
|
|
11
|
+
import inspect
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from .batch import BatchResult
|
|
18
|
+
from .context import Context
|
|
19
|
+
from .errors import HandlerNotFound, HttpError
|
|
20
|
+
from .event import Event, EventType
|
|
21
|
+
from .log import get_logger
|
|
22
|
+
from .manifest import FunctionSpec, Route, resolve_function
|
|
23
|
+
from .response import Response, to_response
|
|
24
|
+
from .routing import match_path, method_matches
|
|
25
|
+
|
|
26
|
+
_HANDLERS: Dict[str, Tuple[Callable[..., Any], int]] = {}
|
|
27
|
+
_log = get_logger("pskit.runtime")
|
|
28
|
+
|
|
29
|
+
DEFAULT_CORS = {
|
|
30
|
+
"origins": ["*"],
|
|
31
|
+
"methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
32
|
+
"headers": ["Content-Type", "Authorization", "X-Requested-With"],
|
|
33
|
+
"expose_headers": [],
|
|
34
|
+
"allow_credentials": False,
|
|
35
|
+
"max_age": 600,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _ensure_bundle_on_path() -> None:
|
|
40
|
+
# <bundle>/py_serverless_kit/runtime/dispatch.py -> make <bundle> importable.
|
|
41
|
+
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
42
|
+
if root not in sys.path:
|
|
43
|
+
sys.path.insert(0, root)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_handler(path: str) -> Tuple[Callable[..., Any], int]:
|
|
47
|
+
"""Import ``package.module.function`` (or ``package.module:function``).
|
|
48
|
+
|
|
49
|
+
Returns the callable and how many positional args it accepts (1 or 2).
|
|
50
|
+
"""
|
|
51
|
+
cached = _HANDLERS.get(path)
|
|
52
|
+
if cached is not None:
|
|
53
|
+
return cached
|
|
54
|
+
if ":" in path:
|
|
55
|
+
module_name, _, attr = path.partition(":")
|
|
56
|
+
else:
|
|
57
|
+
module_name, _, attr = path.rpartition(".")
|
|
58
|
+
if not module_name or not attr:
|
|
59
|
+
raise HandlerNotFound(f"Handler {path!r} must look like 'package.module.function'")
|
|
60
|
+
_ensure_bundle_on_path()
|
|
61
|
+
try:
|
|
62
|
+
module = importlib.import_module(module_name)
|
|
63
|
+
except ImportError as exc:
|
|
64
|
+
raise HandlerNotFound(f"Cannot import module {module_name!r} for handler {path!r}: {exc}") from exc
|
|
65
|
+
target: Any = module
|
|
66
|
+
for part in attr.split("."):
|
|
67
|
+
target = getattr(target, part, None)
|
|
68
|
+
if target is None:
|
|
69
|
+
raise HandlerNotFound(f"Module {module_name!r} has no attribute {attr!r}")
|
|
70
|
+
if not callable(target):
|
|
71
|
+
raise HandlerNotFound(f"Handler {path!r} is not callable")
|
|
72
|
+
arity = 2
|
|
73
|
+
try:
|
|
74
|
+
params = [
|
|
75
|
+
p
|
|
76
|
+
for p in inspect.signature(target).parameters.values()
|
|
77
|
+
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD, p.VAR_POSITIONAL)
|
|
78
|
+
]
|
|
79
|
+
if len(params) == 1 and params[0].kind != params[0].VAR_POSITIONAL:
|
|
80
|
+
arity = 1
|
|
81
|
+
except (TypeError, ValueError):
|
|
82
|
+
pass
|
|
83
|
+
_HANDLERS[path] = (target, arity)
|
|
84
|
+
return target, arity
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------- routing / CORS
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _select_route(spec: FunctionSpec, event: Event) -> Tuple[Optional[Route], Optional[Response]]:
|
|
91
|
+
"""Match the request against configured routes, filling ``path_params``.
|
|
92
|
+
|
|
93
|
+
Returns ``(route, early_response)``; ``early_response`` is a 405 when the
|
|
94
|
+
path matched but no route allows the method.
|
|
95
|
+
"""
|
|
96
|
+
req = event.http
|
|
97
|
+
if req is None or not spec.routes:
|
|
98
|
+
return None, None
|
|
99
|
+
path_matched: Optional[Route] = None
|
|
100
|
+
# Exact matches win over tolerant ones (e.g. GET /hello vs GET /hello/{name}).
|
|
101
|
+
for tolerant in (False, True):
|
|
102
|
+
for route in spec.routes:
|
|
103
|
+
params = match_path(route.path, req.path, tolerant=tolerant)
|
|
104
|
+
if params is None:
|
|
105
|
+
continue
|
|
106
|
+
path_matched = path_matched or route
|
|
107
|
+
if method_matches(route.method, req.method) or req.method == "OPTIONS":
|
|
108
|
+
if not req.path_params:
|
|
109
|
+
req.path_params = params
|
|
110
|
+
req.route = route.path
|
|
111
|
+
return route, None
|
|
112
|
+
if path_matched is not None:
|
|
113
|
+
break
|
|
114
|
+
if path_matched is not None:
|
|
115
|
+
allowed = sorted({r.method.upper() for r in spec.routes if match_path(r.path, req.path) is not None})
|
|
116
|
+
resp = Response.json({"error": "Method Not Allowed", "status": 405}, 405, {"allow": ", ".join(allowed)})
|
|
117
|
+
return path_matched, resp
|
|
118
|
+
return None, None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _cors_config(route: Optional[Route], spec: FunctionSpec) -> Optional[Dict[str, Any]]:
|
|
122
|
+
cors = route.cors if route is not None else next((r.cors for r in spec.routes if r.cors), None)
|
|
123
|
+
if not cors:
|
|
124
|
+
return None
|
|
125
|
+
merged = dict(DEFAULT_CORS)
|
|
126
|
+
if isinstance(cors, dict):
|
|
127
|
+
merged.update({k: v for k, v in cors.items() if v is not None})
|
|
128
|
+
return merged
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _apply_cors(resp: Response, cors: Dict[str, Any], origin: Optional[str], preflight: bool) -> Response:
|
|
132
|
+
origins = cors.get("origins") or ["*"]
|
|
133
|
+
if "*" in origins and not cors.get("allow_credentials"):
|
|
134
|
+
allow_origin = "*"
|
|
135
|
+
elif origin and (origin in origins or "*" in origins):
|
|
136
|
+
allow_origin = origin
|
|
137
|
+
else:
|
|
138
|
+
return resp
|
|
139
|
+
headers = {"access-control-allow-origin": allow_origin}
|
|
140
|
+
if allow_origin != "*":
|
|
141
|
+
headers["vary"] = "Origin"
|
|
142
|
+
if cors.get("allow_credentials"):
|
|
143
|
+
headers["access-control-allow-credentials"] = "true"
|
|
144
|
+
if cors.get("expose_headers"):
|
|
145
|
+
headers["access-control-expose-headers"] = ", ".join(cors["expose_headers"])
|
|
146
|
+
if preflight:
|
|
147
|
+
headers["access-control-allow-methods"] = ", ".join(cors.get("methods") or [])
|
|
148
|
+
headers["access-control-allow-headers"] = ", ".join(cors.get("headers") or [])
|
|
149
|
+
headers["access-control-max-age"] = str(cors.get("max_age", 600))
|
|
150
|
+
for key, value in headers.items():
|
|
151
|
+
resp.headers.setdefault(key, value)
|
|
152
|
+
return resp
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------- execution
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _error_response(exc: BaseException, context: Context) -> Response:
|
|
159
|
+
if isinstance(exc, HttpError):
|
|
160
|
+
return Response.json(exc.to_body(), exc.status, exc.headers)
|
|
161
|
+
body: Dict[str, Any] = {"error": "Internal Server Error", "status": 500, "request_id": context.request_id}
|
|
162
|
+
if os.environ.get("PSKIT_DEBUG", "").lower() in ("1", "true", "yes"):
|
|
163
|
+
body["detail"] = f"{type(exc).__name__}: {exc}"
|
|
164
|
+
return Response.json(body, 500)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _Prepared:
|
|
168
|
+
__slots__ = ("spec", "route", "cors", "early", "started")
|
|
169
|
+
|
|
170
|
+
def __init__(self) -> None:
|
|
171
|
+
self.spec: Optional[FunctionSpec] = None
|
|
172
|
+
self.route: Optional[Route] = None
|
|
173
|
+
self.cors: Optional[Dict[str, Any]] = None
|
|
174
|
+
self.early: Optional[Response] = None
|
|
175
|
+
self.started = time.perf_counter()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _prepare(event: Event, context: Context, function: Optional[str]) -> _Prepared:
|
|
179
|
+
prep = _Prepared()
|
|
180
|
+
spec = resolve_function(function or event.function or None)
|
|
181
|
+
prep.spec = spec
|
|
182
|
+
event.function = event.function or spec.name
|
|
183
|
+
context.function = context.function or spec.name
|
|
184
|
+
if event.http is not None:
|
|
185
|
+
prep.route, prep.early = _select_route(spec, event)
|
|
186
|
+
prep.cors = _cors_config(prep.route, spec)
|
|
187
|
+
if prep.early is None and prep.cors and event.http.method == "OPTIONS":
|
|
188
|
+
prep.early = Response(204, "")
|
|
189
|
+
return prep
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _finish(prep: _Prepared, event: Event, context: Context, result: Any, error: Optional[BaseException]) -> Any:
|
|
193
|
+
elapsed_ms = round((time.perf_counter() - prep.started) * 1000, 2)
|
|
194
|
+
if event.http is None:
|
|
195
|
+
if error is not None:
|
|
196
|
+
context.logger.error(
|
|
197
|
+
"handler failed",
|
|
198
|
+
exc_info=(type(error), error, error.__traceback__),
|
|
199
|
+
extra={"event_type": event.type, "duration_ms": elapsed_ms},
|
|
200
|
+
)
|
|
201
|
+
raise error
|
|
202
|
+
context.logger.debug("handler ok", extra={"event_type": event.type, "duration_ms": elapsed_ms})
|
|
203
|
+
# AWS reports partial failures natively; local runs show them; elsewhere the
|
|
204
|
+
# whole batch must fail so the platform redelivers it.
|
|
205
|
+
if isinstance(result, BatchResult) and event.provider not in ("aws", "local"):
|
|
206
|
+
result.raise_if_failed()
|
|
207
|
+
return None
|
|
208
|
+
return result
|
|
209
|
+
if error is not None:
|
|
210
|
+
if not isinstance(error, HttpError) or error.status >= 500:
|
|
211
|
+
context.logger.error(
|
|
212
|
+
"handler failed",
|
|
213
|
+
exc_info=(type(error), error, error.__traceback__),
|
|
214
|
+
extra={"method": event.http.method, "path": event.http.path},
|
|
215
|
+
)
|
|
216
|
+
resp = _error_response(error, context)
|
|
217
|
+
else:
|
|
218
|
+
resp = to_response(result)
|
|
219
|
+
if prep.cors:
|
|
220
|
+
_apply_cors(resp, prep.cors, event.http.headers.get("origin"), event.http.method == "OPTIONS")
|
|
221
|
+
context.logger.info(
|
|
222
|
+
"request",
|
|
223
|
+
extra={
|
|
224
|
+
"method": event.http.method,
|
|
225
|
+
"path": event.http.path,
|
|
226
|
+
"status": resp.status,
|
|
227
|
+
"duration_ms": elapsed_ms,
|
|
228
|
+
},
|
|
229
|
+
)
|
|
230
|
+
return resp
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def execute(event: Event, context: Context, function: Optional[str] = None) -> Any:
|
|
234
|
+
"""Run the handler synchronously.
|
|
235
|
+
|
|
236
|
+
For HTTP events always returns a :class:`Response`; for everything else
|
|
237
|
+
returns the handler's raw result and lets exceptions propagate so the
|
|
238
|
+
platform's retry policy applies.
|
|
239
|
+
"""
|
|
240
|
+
prep = _prepare(event, context, function)
|
|
241
|
+
if prep.early is not None:
|
|
242
|
+
return _finish(prep, event, context, prep.early, None)
|
|
243
|
+
try:
|
|
244
|
+
fn, arity = resolve_handler(prep.spec.handler) # type: ignore[union-attr]
|
|
245
|
+
result = fn(event) if arity == 1 else fn(event, context)
|
|
246
|
+
if inspect.isawaitable(result):
|
|
247
|
+
result = asyncio.run(_await(result))
|
|
248
|
+
except BaseException as exc: # noqa: BLE001 - re-raised for non-HTTP events
|
|
249
|
+
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
|
250
|
+
raise
|
|
251
|
+
return _finish(prep, event, context, None, exc)
|
|
252
|
+
return _finish(prep, event, context, result, None)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async def execute_async(event: Event, context: Context, function: Optional[str] = None) -> Any:
|
|
256
|
+
"""Async variant for runtimes that already run an event loop (Cloudflare, Azure async)."""
|
|
257
|
+
prep = _prepare(event, context, function)
|
|
258
|
+
if prep.early is not None:
|
|
259
|
+
return _finish(prep, event, context, prep.early, None)
|
|
260
|
+
try:
|
|
261
|
+
fn, arity = resolve_handler(prep.spec.handler) # type: ignore[union-attr]
|
|
262
|
+
result = fn(event) if arity == 1 else fn(event, context)
|
|
263
|
+
if inspect.isawaitable(result):
|
|
264
|
+
result = await result
|
|
265
|
+
except BaseException as exc: # noqa: BLE001
|
|
266
|
+
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
|
267
|
+
raise
|
|
268
|
+
return _finish(prep, event, context, None, exc)
|
|
269
|
+
return _finish(prep, event, context, result, None)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
async def _await(awaitable: Any) -> Any:
|
|
273
|
+
return await awaitable
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def is_http(event: Event) -> bool:
|
|
277
|
+
return event.type == EventType.HTTP
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Exceptions a handler can raise to produce a well-formed HTTP error response.
|
|
2
|
+
|
|
3
|
+
For non-HTTP events these are re-raised so the platform's retry / dead-letter
|
|
4
|
+
semantics apply unchanged.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PskitError(Exception):
|
|
13
|
+
"""Base class for runtime errors raised by the kit itself."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HandlerNotFound(PskitError):
|
|
17
|
+
"""The configured ``module.function`` handler could not be imported."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HttpError(Exception):
|
|
21
|
+
status: int = 500
|
|
22
|
+
default_message: str = "Internal Server Error"
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
message: Optional[str] = None,
|
|
27
|
+
*,
|
|
28
|
+
status: Optional[int] = None,
|
|
29
|
+
details: Any = None,
|
|
30
|
+
headers: Optional[Dict[str, str]] = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.message = message or self.default_message
|
|
33
|
+
if status is not None:
|
|
34
|
+
self.status = status
|
|
35
|
+
self.details = details
|
|
36
|
+
self.headers = dict(headers or {})
|
|
37
|
+
super().__init__(self.message)
|
|
38
|
+
|
|
39
|
+
def to_body(self) -> Dict[str, Any]:
|
|
40
|
+
body: Dict[str, Any] = {"error": self.message, "status": self.status}
|
|
41
|
+
if self.details is not None:
|
|
42
|
+
body["details"] = self.details
|
|
43
|
+
return body
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BadRequest(HttpError):
|
|
47
|
+
status = 400
|
|
48
|
+
default_message = "Bad Request"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Unauthorized(HttpError):
|
|
52
|
+
status = 401
|
|
53
|
+
default_message = "Unauthorized"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Forbidden(HttpError):
|
|
57
|
+
status = 403
|
|
58
|
+
default_message = "Forbidden"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class NotFound(HttpError):
|
|
62
|
+
status = 404
|
|
63
|
+
default_message = "Not Found"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class MethodNotAllowed(HttpError):
|
|
67
|
+
status = 405
|
|
68
|
+
default_message = "Method Not Allowed"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Conflict(HttpError):
|
|
72
|
+
status = 409
|
|
73
|
+
default_message = "Conflict"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class UnprocessableEntity(HttpError):
|
|
77
|
+
status = 422
|
|
78
|
+
default_message = "Unprocessable Entity"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TooManyRequests(HttpError):
|
|
82
|
+
status = 429
|
|
83
|
+
default_message = "Too Many Requests"
|