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