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,267 @@
1
+ """Provider-neutral event model.
2
+
3
+ Every provider adapter converts its native payload into an :class:`Event`, so a
4
+ handler written against this module runs unchanged on any supported cloud.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import json
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
13
+ from typing import Any, Dict, Iterator, List, Mapping, Optional, Tuple, Union
14
+
15
+ from .errors import BadRequest
16
+
17
+ _MISSING = object()
18
+
19
+
20
+ class EventType:
21
+ HTTP = "http"
22
+ SCHEDULE = "schedule"
23
+ QUEUE = "queue"
24
+ STORAGE = "storage"
25
+ TOPIC = "topic"
26
+ INVOKE = "invoke"
27
+ WORKFLOW = "workflow"
28
+
29
+ ALL = (HTTP, SCHEDULE, QUEUE, STORAGE, TOPIC, INVOKE, WORKFLOW)
30
+
31
+
32
+ class Headers(Mapping[str, str]):
33
+ """Case-insensitive, read-only header mapping.
34
+
35
+ Multi-value headers are joined with ``", "`` (RFC 9110 section 5.3).
36
+ """
37
+
38
+ __slots__ = ("_data",)
39
+
40
+ def __init__(self, raw: Union[Mapping[str, Any], List[Tuple[str, Any]], None] = None) -> None:
41
+ self._data: Dict[str, str] = {}
42
+ items = raw.items() if isinstance(raw, Mapping) else (raw or [])
43
+ for key, value in items:
44
+ if value is None:
45
+ continue
46
+ if isinstance(value, (list, tuple)):
47
+ value = ", ".join(str(v) for v in value)
48
+ k = str(key).lower()
49
+ self._data[k] = f"{self._data[k]}, {value}" if k in self._data else str(value)
50
+
51
+ def __getitem__(self, key: str) -> str:
52
+ return self._data[key.lower()]
53
+
54
+ def __contains__(self, key: object) -> bool:
55
+ return isinstance(key, str) and key.lower() in self._data
56
+
57
+ def __iter__(self) -> Iterator[str]:
58
+ return iter(self._data)
59
+
60
+ def __len__(self) -> int:
61
+ return len(self._data)
62
+
63
+ def get(self, key: str, default: Any = None) -> Any: # type: ignore[override]
64
+ return self._data.get(key.lower(), default)
65
+
66
+ def to_dict(self) -> Dict[str, str]:
67
+ return dict(self._data)
68
+
69
+ def __repr__(self) -> str:
70
+ return f"Headers({self._data!r})"
71
+
72
+
73
+ def _decode_json(raw: Union[str, bytes], what: str) -> Any:
74
+ if isinstance(raw, bytes):
75
+ raw = raw.decode("utf-8")
76
+ if raw == "":
77
+ raise BadRequest(f"{what} is empty, expected JSON")
78
+ try:
79
+ return json.loads(raw)
80
+ except ValueError as exc:
81
+ raise BadRequest(f"{what} is not valid JSON", details=str(exc)) from exc
82
+
83
+
84
+ def parse_cookies(header: Optional[str]) -> Dict[str, str]:
85
+ cookies: Dict[str, str] = {}
86
+ if not header:
87
+ return cookies
88
+ for part in header.split(";"):
89
+ if "=" in part:
90
+ name, _, value = part.strip().partition("=")
91
+ cookies[name] = value
92
+ return cookies
93
+
94
+
95
+ @dataclass
96
+ class HttpRequest:
97
+ method: str = "GET"
98
+ path: str = "/"
99
+ headers: Headers = field(default_factory=Headers)
100
+ query: Dict[str, str] = field(default_factory=dict)
101
+ multi_query: Dict[str, List[str]] = field(default_factory=dict)
102
+ path_params: Dict[str, str] = field(default_factory=dict)
103
+ body: bytes = b""
104
+ source_ip: Optional[str] = None
105
+ route: Optional[str] = None
106
+
107
+ def __post_init__(self) -> None:
108
+ self.method = self.method.upper()
109
+ if not isinstance(self.headers, Headers):
110
+ self.headers = Headers(self.headers)
111
+ if isinstance(self.body, str):
112
+ self.body = self.body.encode("utf-8")
113
+ elif self.body is None:
114
+ self.body = b""
115
+ if self.query and not self.multi_query:
116
+ self.multi_query = {k: [v] for k, v in self.query.items()}
117
+
118
+ @property
119
+ def content_type(self) -> str:
120
+ return self.headers.get("content-type", "").split(";")[0].strip().lower()
121
+
122
+ @property
123
+ def cookies(self) -> Dict[str, str]:
124
+ return parse_cookies(self.headers.get("cookie"))
125
+
126
+ def text(self, encoding: str = "utf-8") -> str:
127
+ return self.body.decode(encoding)
128
+
129
+ def json(self, default: Any = _MISSING) -> Any:
130
+ """Parse the body as JSON. Raises :class:`BadRequest` (HTTP 400) on bad input
131
+ unless ``default`` is supplied."""
132
+ try:
133
+ return _decode_json(self.body, "Request body")
134
+ except BadRequest:
135
+ if default is not _MISSING:
136
+ return default
137
+ raise
138
+
139
+ def form(self) -> Dict[str, str]:
140
+ from urllib.parse import parse_qsl
141
+
142
+ return dict(parse_qsl(self.text(), keep_blank_values=True))
143
+
144
+ @staticmethod
145
+ def decode_body(body: Any, is_base64: bool = False) -> bytes:
146
+ if body is None:
147
+ return b""
148
+ if isinstance(body, (dict, list)):
149
+ return json.dumps(body).encode("utf-8")
150
+ if isinstance(body, bytes):
151
+ return base64.b64decode(body) if is_base64 else body
152
+ if is_base64:
153
+ return base64.b64decode(body)
154
+ return str(body).encode("utf-8")
155
+
156
+
157
+ @dataclass
158
+ class Record:
159
+ """One message / object notification inside a batch event."""
160
+
161
+ id: Optional[str] = None
162
+ body: Any = None
163
+ attributes: Dict[str, Any] = field(default_factory=dict)
164
+ source: Optional[str] = None
165
+ # Storage-only fields.
166
+ bucket: Optional[str] = None
167
+ key: Optional[str] = None
168
+ size: Optional[int] = None
169
+ action: Optional[str] = None # "created" | "deleted"
170
+ raw: Any = None
171
+
172
+ def text(self) -> str:
173
+ if isinstance(self.body, bytes):
174
+ return self.body.decode("utf-8")
175
+ if isinstance(self.body, (dict, list)):
176
+ return json.dumps(self.body)
177
+ return "" if self.body is None else str(self.body)
178
+
179
+ def json(self, default: Any = _MISSING) -> Any:
180
+ if isinstance(self.body, (dict, list)):
181
+ return self.body
182
+ try:
183
+ return _decode_json(self.text(), f"Record {self.id or ''} body".strip())
184
+ except BadRequest:
185
+ if default is not _MISSING:
186
+ return default
187
+ raise
188
+
189
+
190
+ @dataclass
191
+ class Event:
192
+ type: str
193
+ provider: str = "local"
194
+ function: str = ""
195
+ http: Optional[HttpRequest] = None
196
+ records: List[Record] = field(default_factory=list)
197
+ data: Any = None
198
+ time: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
199
+ source: Optional[str] = None
200
+ raw: Any = None
201
+
202
+ @property
203
+ def is_http(self) -> bool:
204
+ return self.type == EventType.HTTP
205
+
206
+ @property
207
+ def is_batch(self) -> bool:
208
+ return self.type in (EventType.QUEUE, EventType.TOPIC, EventType.STORAGE)
209
+
210
+ def json(self, default: Any = _MISSING) -> Any:
211
+ """Best-effort JSON payload regardless of event type.
212
+
213
+ * HTTP: the parsed request body
214
+ * queue/topic/storage: the first record's parsed body
215
+ * schedule/invoke/workflow: ``data``
216
+ """
217
+ if self.http is not None:
218
+ return self.http.json(default) if default is not _MISSING else self.http.json()
219
+ if self.records:
220
+ return self.records[0].json(default) if default is not _MISSING else self.records[0].json()
221
+ return self.data
222
+
223
+ def __iter__(self) -> Iterator[Record]:
224
+ return iter(self.records)
225
+
226
+ def to_dict(self) -> Dict[str, Any]:
227
+ """JSON-friendly summary, handy for logging."""
228
+ out: Dict[str, Any] = {
229
+ "type": self.type,
230
+ "provider": self.provider,
231
+ "function": self.function,
232
+ "time": self.time.isoformat(),
233
+ "source": self.source,
234
+ }
235
+ if self.http is not None:
236
+ out["http"] = {
237
+ "method": self.http.method,
238
+ "path": self.http.path,
239
+ "query": self.http.query,
240
+ "path_params": self.http.path_params,
241
+ }
242
+ if self.records:
243
+ out["records"] = len(self.records)
244
+ if self.data is not None:
245
+ out["data"] = self.data
246
+ return out
247
+
248
+
249
+ def parse_time(value: Any) -> datetime:
250
+ """Parse the many timestamp shapes providers emit; fall back to *now*."""
251
+ if isinstance(value, datetime):
252
+ return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
253
+ if isinstance(value, (int, float)):
254
+ seconds = value / 1000.0 if value > 1e11 else float(value)
255
+ return datetime.fromtimestamp(seconds, tz=timezone.utc)
256
+ if isinstance(value, str) and value:
257
+ text = value.strip()
258
+ if text.isdigit():
259
+ return parse_time(int(text))
260
+ if text.endswith("Z"):
261
+ text = text[:-1] + "+00:00"
262
+ try:
263
+ parsed = datetime.fromisoformat(text)
264
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
265
+ except ValueError:
266
+ pass
267
+ return datetime.now(timezone.utc)
@@ -0,0 +1,119 @@
1
+ """Portable function-to-function invocation.
2
+
3
+ ``invoke("billing", {"order": 1})`` calls another function of the *same
4
+ service* regardless of the cloud it runs on:
5
+
6
+ * ``PSKIT_ENDPOINT_<NAME>`` env var set -> HTTP POST to that URL (any provider)
7
+ * local (CLI / tests) -> in-process call through the dispatcher
8
+ * aws -> ``lambda:InvokeFunction`` via boto3
9
+ * gcp -> authenticated HTTPS call (ID token from metadata server)
10
+
11
+ Other providers need ``PSKIT_ENDPOINT_<NAME>`` in the caller's ``environment``
12
+ (for example the target's HTTP URL from ``pskit info``).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import re
20
+ import urllib.request
21
+ from typing import Any, Dict, Optional
22
+
23
+ from .adapters.base import envelope
24
+ from .manifest import load_manifest
25
+
26
+
27
+ class InvokeError(RuntimeError):
28
+ pass
29
+
30
+
31
+ def _env_key(name: str) -> str:
32
+ return "PSKIT_ENDPOINT_" + re.sub(r"[^A-Za-z0-9]", "_", name).upper()
33
+
34
+
35
+ def physical_name(function: str) -> str:
36
+ manifest = load_manifest()
37
+ spec = manifest.functions.get(function)
38
+ if spec is not None and spec.resource_name:
39
+ return spec.resource_name
40
+ service = manifest.service or os.environ.get("PSKIT_SERVICE", "")
41
+ stage = manifest.stage or os.environ.get("PSKIT_STAGE", "")
42
+ return "-".join(p for p in (service, stage, function) if p)
43
+
44
+
45
+ def _http_post(url: str, payload: Any, timeout: float, headers: Optional[Dict[str, str]] = None) -> Any:
46
+ if not url.lower().startswith(("https://", "http://")):
47
+ raise InvokeError(f"invoke endpoint must be an http(s) URL, got {url!r}")
48
+ data = json.dumps(envelope("invoke", payload)).encode("utf-8")
49
+ req = urllib.request.Request(url, data=data, method="POST") # noqa: S310 - scheme checked above
50
+ req.add_header("content-type", "application/json")
51
+ for key, value in (headers or {}).items():
52
+ req.add_header(key, value)
53
+ try:
54
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 - URL is operator supplied
55
+ raw = resp.read()
56
+ except Exception as exc: # pragma: no cover - network
57
+ raise InvokeError(f"HTTP invoke of {url} failed: {exc}") from exc
58
+ if not raw:
59
+ return None
60
+ try:
61
+ return json.loads(raw)
62
+ except ValueError:
63
+ return raw.decode("utf-8", "replace")
64
+
65
+
66
+ def _gcp_id_token(audience: str) -> str: # pragma: no cover - needs GCP metadata server
67
+ url = (
68
+ "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity"
69
+ f"?audience={audience}"
70
+ )
71
+ req = urllib.request.Request(url, headers={"Metadata-Flavor": "Google"}) # noqa: S310 - fixed http URL
72
+ with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310
73
+ return resp.read().decode("utf-8")
74
+
75
+
76
+ def invoke(function: str, payload: Any = None, *, asynchronous: bool = False, timeout: float = 30.0) -> Any:
77
+ """Invoke another function of this service and return its result."""
78
+ override = os.environ.get(_env_key(function))
79
+ if override:
80
+ return _http_post(override, payload, timeout)
81
+
82
+ provider = os.environ.get("PSKIT_PROVIDER", "local")
83
+ if provider == "local":
84
+ from .context import Context
85
+ from .dispatch import execute
86
+ from .event import Event, EventType
87
+
88
+ event = Event(type=EventType.INVOKE, provider="local", function=function, data=payload)
89
+ return execute(event, Context(function=function), function)
90
+
91
+ if provider == "aws":
92
+ try:
93
+ import boto3 # type: ignore[import-not-found]
94
+ except ImportError as exc: # pragma: no cover
95
+ raise InvokeError("boto3 is required for invoke() on AWS") from exc
96
+ client = boto3.client("lambda")
97
+ resp = client.invoke(
98
+ FunctionName=physical_name(function),
99
+ InvocationType="Event" if asynchronous else "RequestResponse",
100
+ Payload=json.dumps(envelope("invoke", payload)).encode("utf-8"),
101
+ )
102
+ if asynchronous:
103
+ return None
104
+ body = resp["Payload"].read()
105
+ if resp.get("FunctionError"):
106
+ raise InvokeError(f"{function} failed: {body.decode('utf-8', 'replace')}")
107
+ return json.loads(body) if body else None
108
+
109
+ if provider == "gcp": # pragma: no cover - needs GCP
110
+ project = os.environ.get("PSKIT_GCP_PROJECT") or os.environ.get("GOOGLE_CLOUD_PROJECT", "")
111
+ region = os.environ.get("PSKIT_REGION", "")
112
+ url = f"https://{region}-{project}.cloudfunctions.net/{physical_name(function)}"
113
+ token = _gcp_id_token(url)
114
+ return _http_post(url, payload, timeout, {"authorization": f"Bearer {token}"})
115
+
116
+ raise InvokeError(
117
+ f"invoke() on provider {provider!r} needs the target URL in ${_env_key(function)}. "
118
+ "Give the target function an http event and set that URL in the caller's environment."
119
+ )
@@ -0,0 +1,69 @@
1
+ """Structured JSON logging that every provider's log pipeline can ingest.
2
+
3
+ Set ``PSKIT_LOG_FORMAT=text`` for human readable output (the local CLI does this)
4
+ and ``LOG_LEVEL`` / ``PSKIT_LOG_LEVEL`` to control verbosity.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import sys
13
+ from datetime import datetime, timezone
14
+ from typing import Any, Dict
15
+
16
+ _CONFIGURED = False
17
+ _RESERVED = set(vars(logging.LogRecord("", 0, "", 0, "", (), None))) | {"message", "asctime"}
18
+
19
+
20
+ class JsonFormatter(logging.Formatter):
21
+ def format(self, record: logging.LogRecord) -> str:
22
+ payload: Dict[str, Any] = {
23
+ "ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
24
+ "level": record.levelname,
25
+ "logger": record.name,
26
+ "message": record.getMessage(),
27
+ }
28
+ for key, value in record.__dict__.items():
29
+ if key not in _RESERVED and not key.startswith("_"):
30
+ payload[key] = value
31
+ if record.exc_info:
32
+ payload["exception"] = self.formatException(record.exc_info)
33
+ return json.dumps(payload, default=str)
34
+
35
+
36
+ def _configure() -> None:
37
+ global _CONFIGURED
38
+ if _CONFIGURED:
39
+ return
40
+ _CONFIGURED = True
41
+ logger = logging.getLogger("pskit")
42
+ level = os.environ.get("PSKIT_LOG_LEVEL") or os.environ.get("LOG_LEVEL") or "INFO"
43
+ logger.setLevel(level.upper() if isinstance(level, str) else level)
44
+ handler = logging.StreamHandler(sys.stdout)
45
+ if os.environ.get("PSKIT_LOG_FORMAT", "json").lower() == "text":
46
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-7s %(name)s: %(message)s"))
47
+ else:
48
+ handler.setFormatter(JsonFormatter())
49
+ logger.addHandler(handler)
50
+ logger.propagate = False
51
+
52
+
53
+ class _FieldsAdapter(logging.LoggerAdapter):
54
+ """Like LoggerAdapter, but merges call-site ``extra=`` with the bound fields
55
+ (the stdlib adapter replaces them before Python 3.13)."""
56
+
57
+ def process(self, msg: Any, kwargs: Any) -> Any:
58
+ merged = {**(self.extra or {}), **(kwargs.get("extra") or {})}
59
+ # Keys such as "name" or "message" are reserved by LogRecord and would make
60
+ # logging raise KeyError - a log call must never crash a handler.
61
+ kwargs["extra"] = {(f"{k}_" if k in _RESERVED else k): v for k, v in merged.items()}
62
+ return msg, kwargs
63
+
64
+
65
+ def get_logger(name: str = "pskit.function", **fields: Any) -> logging.LoggerAdapter:
66
+ _configure()
67
+ if not name.startswith("pskit"):
68
+ name = f"pskit.{name}"
69
+ return _FieldsAdapter(logging.getLogger(name), fields)
@@ -0,0 +1,121 @@
1
+ """Reads ``pskit_manifest.json`` - the per-bundle description of every function.
2
+
3
+ The CLI writes this file into the root of each deployment artifact. It lets a
4
+ single shared bundle serve many functions (Azure Function Apps, the embedded
5
+ workflow engine, ...) without depending on per-function environment variables.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ MANIFEST_FILE = "pskit_manifest.json"
17
+
18
+
19
+ @dataclass
20
+ class Route:
21
+ method: str
22
+ path: str
23
+ cors: Optional[Dict[str, Any]] = None
24
+
25
+
26
+ @dataclass
27
+ class FunctionSpec:
28
+ name: str
29
+ handler: str
30
+ routes: List[Route] = field(default_factory=list)
31
+ timeout: Optional[int] = None
32
+ memory: Optional[int] = None
33
+ resource_name: Optional[str] = None
34
+
35
+
36
+ @dataclass
37
+ class BundleManifest:
38
+ service: str = ""
39
+ stage: str = ""
40
+ provider: str = "local"
41
+ region: str = ""
42
+ functions: Dict[str, FunctionSpec] = field(default_factory=dict)
43
+ workflows: Dict[str, Any] = field(default_factory=dict)
44
+
45
+ @classmethod
46
+ def from_dict(cls, raw: Dict[str, Any]) -> BundleManifest:
47
+ functions = {}
48
+ for name, spec in (raw.get("functions") or {}).items():
49
+ routes = [Route(r["method"], r["path"], r.get("cors")) for r in spec.get("routes", [])]
50
+ functions[name] = FunctionSpec(
51
+ name=name,
52
+ handler=spec["handler"],
53
+ routes=routes,
54
+ timeout=spec.get("timeout"),
55
+ memory=spec.get("memory"),
56
+ resource_name=spec.get("resource_name"),
57
+ )
58
+ return cls(
59
+ service=raw.get("service", ""),
60
+ stage=raw.get("stage", ""),
61
+ provider=raw.get("provider", "local"),
62
+ region=raw.get("region", ""),
63
+ functions=functions,
64
+ workflows=raw.get("workflows") or {},
65
+ )
66
+
67
+
68
+ _CACHE: Optional[BundleManifest] = None
69
+
70
+
71
+ def _candidates() -> List[Path]:
72
+ here = Path(__file__).resolve()
73
+ paths = []
74
+ if os.environ.get("PSKIT_MANIFEST"):
75
+ paths.append(Path(os.environ["PSKIT_MANIFEST"]))
76
+ paths.append(here.parents[2] / MANIFEST_FILE) # <bundle>/py_serverless_kit/runtime/manifest.py
77
+ for env in ("LAMBDA_TASK_ROOT", "FC_FUNC_CODE_PATH", "RUNTIME_CODE_ROOT"):
78
+ if os.environ.get(env):
79
+ paths.append(Path(os.environ[env]) / MANIFEST_FILE)
80
+ paths.append(Path.cwd() / MANIFEST_FILE)
81
+ return paths
82
+
83
+
84
+ def load_manifest(reload: bool = False) -> BundleManifest:
85
+ global _CACHE
86
+ if _CACHE is not None and not reload:
87
+ return _CACHE
88
+ for path in _candidates():
89
+ if path.is_file():
90
+ _CACHE = BundleManifest.from_dict(json.loads(path.read_text("utf-8")))
91
+ return _CACHE
92
+ _CACHE = BundleManifest(
93
+ service=os.environ.get("PSKIT_SERVICE", ""),
94
+ stage=os.environ.get("PSKIT_STAGE", ""),
95
+ provider=os.environ.get("PSKIT_PROVIDER", "local"),
96
+ )
97
+ return _CACHE
98
+
99
+
100
+ def set_manifest(manifest: Optional[BundleManifest]) -> None:
101
+ """Install a manifest programmatically (local runner and tests)."""
102
+ global _CACHE
103
+ _CACHE = manifest
104
+
105
+
106
+ def resolve_function(name: Optional[str] = None) -> FunctionSpec:
107
+ """Find the spec for ``name`` (default: ``$PSKIT_FUNCTION``)."""
108
+ manifest = load_manifest()
109
+ name = name or os.environ.get("PSKIT_FUNCTION", "")
110
+ if name and name in manifest.functions:
111
+ return manifest.functions[name]
112
+ handler = os.environ.get("PSKIT_HANDLER")
113
+ if handler:
114
+ return FunctionSpec(name=name or handler, handler=handler)
115
+ if len(manifest.functions) == 1 and not name:
116
+ return next(iter(manifest.functions.values()))
117
+ known = ", ".join(sorted(manifest.functions)) or "<none>"
118
+ raise LookupError(
119
+ f"Cannot resolve function {name!r}. Set PSKIT_FUNCTION/PSKIT_HANDLER or check "
120
+ f"{MANIFEST_FILE}. Known functions: {known}"
121
+ )