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,177 @@
1
+ """Provider-neutral HTTP response model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ from dataclasses import dataclass, field
8
+ from datetime import date, datetime
9
+ from decimal import Decimal
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+ from uuid import UUID
12
+
13
+ _TEXT_PREFIXES = ("text/",)
14
+ _TEXT_TYPES = (
15
+ "application/json",
16
+ "application/xml",
17
+ "application/javascript",
18
+ "application/x-www-form-urlencoded",
19
+ "image/svg+xml",
20
+ )
21
+
22
+
23
+ def json_default(value: Any) -> Any:
24
+ if isinstance(value, (datetime, date)):
25
+ return value.isoformat()
26
+ if isinstance(value, Decimal):
27
+ return int(value) if value == value.to_integral_value() else float(value)
28
+ if isinstance(value, (UUID, bytes)):
29
+ return value.decode("utf-8", "replace") if isinstance(value, bytes) else str(value)
30
+ if isinstance(value, (set, frozenset)):
31
+ return sorted(value)
32
+ if hasattr(value, "model_dump"): # pydantic v2
33
+ return value.model_dump()
34
+ if hasattr(value, "to_dict"):
35
+ return value.to_dict()
36
+ if hasattr(value, "__dataclass_fields__"):
37
+ from dataclasses import asdict
38
+
39
+ return asdict(value)
40
+ raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
41
+
42
+
43
+ def dumps(value: Any) -> str:
44
+ return json.dumps(value, default=json_default, separators=(",", ":"))
45
+
46
+
47
+ def is_text_content_type(content_type: str) -> bool:
48
+ ct = content_type.split(";")[0].strip().lower()
49
+ return ct.startswith(_TEXT_PREFIXES) or ct in _TEXT_TYPES or ct.endswith("+json")
50
+
51
+
52
+ @dataclass
53
+ class Response:
54
+ status: int = 200
55
+ body: Any = None
56
+ headers: Dict[str, str] = field(default_factory=dict)
57
+ cookies: List[str] = field(default_factory=list)
58
+
59
+ # ---- constructors -------------------------------------------------
60
+ @classmethod
61
+ def json(cls, data: Any, status: int = 200, headers: Optional[Dict[str, str]] = None) -> Response:
62
+ return cls(status, dumps(data), {"content-type": "application/json", **(headers or {})})
63
+
64
+ @classmethod
65
+ def text(cls, text: str, status: int = 200, headers: Optional[Dict[str, str]] = None) -> Response:
66
+ return cls(status, text, {"content-type": "text/plain; charset=utf-8", **(headers or {})})
67
+
68
+ @classmethod
69
+ def html(cls, html: str, status: int = 200, headers: Optional[Dict[str, str]] = None) -> Response:
70
+ return cls(status, html, {"content-type": "text/html; charset=utf-8", **(headers or {})})
71
+
72
+ @classmethod
73
+ def binary(
74
+ cls,
75
+ data: bytes,
76
+ content_type: str = "application/octet-stream",
77
+ status: int = 200,
78
+ headers: Optional[Dict[str, str]] = None,
79
+ ) -> Response:
80
+ return cls(status, bytes(data), {"content-type": content_type, **(headers or {})})
81
+
82
+ @classmethod
83
+ def redirect(cls, location: str, status: int = 302) -> Response:
84
+ return cls(status, "", {"location": location})
85
+
86
+ @classmethod
87
+ def no_content(cls) -> Response:
88
+ return cls(204, "")
89
+
90
+ # ---- helpers ------------------------------------------------------
91
+ def set_cookie(
92
+ self,
93
+ name: str,
94
+ value: str,
95
+ *,
96
+ max_age: Optional[int] = None,
97
+ path: str = "/",
98
+ http_only: bool = True,
99
+ secure: bool = True,
100
+ same_site: str = "Lax",
101
+ ) -> Response:
102
+ parts = [f"{name}={value}", f"Path={path}", f"SameSite={same_site}"]
103
+ if max_age is not None:
104
+ parts.append(f"Max-Age={max_age}")
105
+ if http_only:
106
+ parts.append("HttpOnly")
107
+ if secure:
108
+ parts.append("Secure")
109
+ self.cookies.append("; ".join(parts))
110
+ return self
111
+
112
+ @property
113
+ def content_type(self) -> str:
114
+ for key, value in self.headers.items():
115
+ if key.lower() == "content-type":
116
+ return value
117
+ return ""
118
+
119
+ def render(self) -> Tuple[int, Dict[str, str], bytes]:
120
+ """Return ``(status, headers, body_bytes)`` with lower-cased header names
121
+ and a content-type guaranteed to be set when there is a body."""
122
+ headers = {k.lower(): str(v) for k, v in self.headers.items()}
123
+ body = self.body
124
+ if body is None:
125
+ raw = b""
126
+ elif isinstance(body, bytes):
127
+ raw = body
128
+ if raw:
129
+ headers.setdefault("content-type", "application/octet-stream")
130
+ elif isinstance(body, str):
131
+ raw = body.encode("utf-8")
132
+ if raw:
133
+ headers.setdefault("content-type", "text/plain; charset=utf-8")
134
+ else:
135
+ raw = dumps(body).encode("utf-8")
136
+ headers.setdefault("content-type", "application/json")
137
+ return int(self.status), headers, raw
138
+
139
+ def render_for_gateway(self) -> Tuple[int, Dict[str, str], str, bool]:
140
+ """Like :meth:`render` but returns a ``str`` body plus an ``is_base64`` flag,
141
+ which is what API-gateway style integrations expect."""
142
+ status, headers, raw = self.render()
143
+ if not raw:
144
+ return status, headers, "", False
145
+ if is_text_content_type(headers.get("content-type", "")):
146
+ try:
147
+ return status, headers, raw.decode("utf-8"), False
148
+ except UnicodeDecodeError:
149
+ pass
150
+ return status, headers, base64.b64encode(raw).decode("ascii"), True
151
+
152
+
153
+ def to_response(result: Any) -> Response:
154
+ """Coerce whatever a handler returned into a :class:`Response`.
155
+
156
+ * ``Response`` -> unchanged
157
+ * ``None`` -> 204 No Content
158
+ * ``dict`` / ``list`` / numbers -> JSON 200
159
+ * ``str`` -> text/plain 200
160
+ * ``bytes`` -> application/octet-stream 200
161
+ * ``(body, status)`` / ``(body, status, headers)`` -> Flask-style tuple
162
+ """
163
+ if isinstance(result, Response):
164
+ return result
165
+ if result is None:
166
+ return Response.no_content()
167
+ if isinstance(result, tuple) and 2 <= len(result) <= 3 and isinstance(result[1], int):
168
+ base = to_response(result[0])
169
+ base.status = result[1]
170
+ if len(result) == 3 and result[2]:
171
+ base.headers.update(result[2])
172
+ return base
173
+ if isinstance(result, bytes):
174
+ return Response.binary(result)
175
+ if isinstance(result, str):
176
+ return Response.text(result)
177
+ return Response.json(result)
@@ -0,0 +1,74 @@
1
+ """Tiny route matcher used where the provider has no API gateway in front of the
2
+ function (GCP, IBM, DigitalOcean, Cloudflare, ...) and by the local dev server.
3
+
4
+ Patterns use ``{name}`` for one segment and ``{name+}`` for a greedy tail,
5
+ the same syntax as AWS API Gateway, Azure Functions and the YAML config.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Dict, List, Optional, Sequence
11
+
12
+
13
+ def split_path(path: str) -> List[str]:
14
+ return [seg for seg in path.split("?")[0].split("/") if seg]
15
+
16
+
17
+ def _is_param(seg: str) -> bool:
18
+ return seg.startswith("{") and seg.endswith("}")
19
+
20
+
21
+ def _match_segments(pattern: Sequence[str], parts: Sequence[str]) -> Optional[Dict[str, str]]:
22
+ params: Dict[str, str] = {}
23
+ for i, seg in enumerate(pattern):
24
+ if _is_param(seg):
25
+ name = seg[1:-1]
26
+ if name.endswith("+"):
27
+ if i != len(pattern) - 1 or i >= len(parts):
28
+ return None
29
+ params[name[:-1]] = "/".join(parts[i:])
30
+ return params
31
+ if i >= len(parts):
32
+ return None
33
+ params[name] = parts[i]
34
+ elif i >= len(parts) or seg != parts[i]:
35
+ return None
36
+ return params if len(parts) == len(pattern) else None
37
+
38
+
39
+ def match_path(pattern: str, path: str, *, tolerant: bool = True) -> Optional[Dict[str, str]]:
40
+ """Return path params if ``path`` matches ``pattern`` else ``None``.
41
+
42
+ With ``tolerant=True`` two platform quirks are accepted as well:
43
+
44
+ * the platform *prepended* a fixed prefix (Azure ``/api/users/7``) - the
45
+ whole pattern must then match the tail of the path;
46
+ * the platform *stripped* the pattern's leading static segments (GCP serves
47
+ ``/users/{id}`` at ``https://.../fn-name/7``) - the remaining
48
+ pattern must then match the whole path.
49
+ """
50
+ pat = split_path(pattern)
51
+ parts = split_path(path)
52
+ exact = _match_segments(pat, parts)
53
+ if exact is not None or not tolerant:
54
+ return exact
55
+ if any(not _is_param(seg) for seg in pat):
56
+ for drop in range(1, len(parts)):
57
+ got = _match_segments(pat, parts[drop:])
58
+ if got is not None:
59
+ return got
60
+ leading_static = 0
61
+ for seg in pat:
62
+ if _is_param(seg):
63
+ break
64
+ leading_static += 1
65
+ if leading_static:
66
+ rest = pat[leading_static:]
67
+ if rest or not parts:
68
+ return _match_segments(rest, parts)
69
+ return None
70
+
71
+
72
+ def method_matches(allowed: str, method: str) -> bool:
73
+ allowed = allowed.upper()
74
+ return allowed in ("ANY", "*") or allowed == method.upper()
@@ -0,0 +1,312 @@
1
+ """Embedded workflow (state machine) engine.
2
+
3
+ The same definition the CLI compiles to AWS Step Functions (ASL) or Google
4
+ Cloud Workflows is interpreted here for:
5
+
6
+ * ``pskit workflow run --local`` - test workflows on your laptop;
7
+ * providers without a native orchestrator - the CLI deploys an extra
8
+ *orchestrator* function whose handler is :func:`orchestrate`, and which calls
9
+ task handlers in-process (they live in the same bundle).
10
+
11
+ Supported state types: task, choice, wait, pass, parallel, map, succeed, fail.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import copy
17
+ import fnmatch
18
+ import time
19
+ from concurrent.futures import ThreadPoolExecutor
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime, timezone
22
+ from typing import Any, Callable, Dict, List, Optional
23
+
24
+ from .context import Context
25
+ from .event import Event, EventType
26
+ from .response import Response
27
+
28
+ TaskRunner = Callable[[str, Any], Any]
29
+ ALL_ERRORS = ("*", "States.ALL")
30
+
31
+
32
+ class WorkflowFailed(Exception):
33
+ def __init__(self, error: str, cause: str = "", history: Optional[List[Dict[str, Any]]] = None) -> None:
34
+ self.error = error
35
+ self.cause = cause
36
+ self.history = history or []
37
+ super().__init__(f"{error}: {cause}" if cause else error)
38
+
39
+
40
+ # --------------------------------------------------------------------- JSON path
41
+
42
+
43
+ def _tokens(path: str) -> List[Any]:
44
+ if path in ("$", "", None):
45
+ return []
46
+ if not path.startswith("$"):
47
+ raise ValueError(f"JSON path must start with '$': {path!r}")
48
+ tokens: List[Any] = []
49
+ for part in path[1:].replace("[", ".[").split("."):
50
+ if not part:
51
+ continue
52
+ if part.startswith("[") and part.endswith("]"):
53
+ inner = part[1:-1].strip("'\"")
54
+ tokens.append(int(inner) if inner.lstrip("-").isdigit() else inner)
55
+ else:
56
+ tokens.append(part)
57
+ return tokens
58
+
59
+
60
+ _MISSING = object() # "no default given" marker for get_path
61
+ _ABSENT = object() # "path not present" value used by choice rules
62
+
63
+
64
+ def get_path(data: Any, path: str, default: Any = _MISSING) -> Any:
65
+ current = data
66
+ for tok in _tokens(path):
67
+ try:
68
+ current = current[tok]
69
+ except (KeyError, IndexError, TypeError):
70
+ if default is _MISSING:
71
+ raise KeyError(path) from None
72
+ return default
73
+ return current
74
+
75
+
76
+ def set_path(data: Any, path: Optional[str], value: Any) -> Any:
77
+ """Return ``data`` with ``value`` written at ``path`` (``$`` replaces; ``None`` discards)."""
78
+ if path is None:
79
+ return data
80
+ tokens = _tokens(path)
81
+ if not tokens:
82
+ return value
83
+ root = copy.deepcopy(data) if isinstance(data, (dict, list)) else {}
84
+ current = root
85
+ for tok in tokens[:-1]:
86
+ if isinstance(current, dict):
87
+ current = current.setdefault(tok, {})
88
+ else:
89
+ current = current[tok]
90
+ current[tokens[-1]] = value
91
+ return root
92
+
93
+
94
+ # --------------------------------------------------------------------- choice
95
+
96
+
97
+ _OPS: Dict[str, Callable[[Any, Any], bool]] = {
98
+ "equals": lambda a, b: a == b,
99
+ "not_equals": lambda a, b: a != b,
100
+ "gt": lambda a, b: a is not None and a > b,
101
+ "gte": lambda a, b: a is not None and a >= b,
102
+ "lt": lambda a, b: a is not None and a < b,
103
+ "lte": lambda a, b: a is not None and a <= b,
104
+ "in": lambda a, b: a in b,
105
+ "contains": lambda a, b: a is not None and b in a,
106
+ "matches": lambda a, b: isinstance(a, str) and fnmatch.fnmatchcase(a, b),
107
+ "is_null": lambda a, b: (a is None) == bool(b),
108
+ "is_present": lambda a, b: (a is not _ABSENT) == bool(b),
109
+ }
110
+ CHOICE_OPERATORS = tuple(_OPS)
111
+
112
+
113
+ def evaluate_rule(rule: Dict[str, Any], data: Any) -> bool:
114
+ if "and" in rule:
115
+ return all(evaluate_rule(r, data) for r in rule["and"])
116
+ if "or" in rule:
117
+ return any(evaluate_rule(r, data) for r in rule["or"])
118
+ if "not" in rule:
119
+ return not evaluate_rule(rule["not"], data)
120
+ variable = rule.get("variable", "$")
121
+ for op, fn in _OPS.items():
122
+ if op in rule:
123
+ value = get_path(data, variable, _ABSENT)
124
+ if op == "is_present":
125
+ return fn(value, rule[op])
126
+ if value is _ABSENT:
127
+ return False
128
+ try:
129
+ return bool(fn(value, rule[op]))
130
+ except TypeError:
131
+ return False
132
+ raise ValueError(f"Choice rule has no operator (one of {', '.join(_OPS)}): {rule}")
133
+
134
+
135
+ # --------------------------------------------------------------------- engine
136
+
137
+
138
+ def _error_name(exc: BaseException) -> str:
139
+ return getattr(exc, "error", None) or type(exc).__name__
140
+
141
+
142
+ def _matches(errors: List[str], name: str) -> bool:
143
+ return any(e in ALL_ERRORS or e == name for e in (errors or ["*"]))
144
+
145
+
146
+ @dataclass
147
+ class WorkflowEngine:
148
+ definition: Dict[str, Any]
149
+ task_runner: TaskRunner
150
+ sleep: Callable[[float], None] = time.sleep
151
+ max_steps: int = 10_000
152
+ history: List[Dict[str, Any]] = field(default_factory=list)
153
+
154
+ def run(self, data: Any = None) -> Any:
155
+ return self._run_machine(self.definition, data)
156
+
157
+ # ------------------------------------------------------------------
158
+ def _record(self, state: str, kind: str, **extra: Any) -> None:
159
+ self.history.append({"ts": datetime.now(timezone.utc).isoformat(), "state": state, "type": kind, **extra})
160
+
161
+ def _run_machine(self, machine: Dict[str, Any], data: Any) -> Any:
162
+ states: Dict[str, Dict[str, Any]] = machine["states"]
163
+ name: Optional[str] = machine["start_at"]
164
+ steps = 0
165
+ while name is not None:
166
+ steps += 1
167
+ if steps > self.max_steps:
168
+ raise WorkflowFailed("States.Runtime", f"exceeded {self.max_steps} steps", self.history)
169
+ state = states[name]
170
+ kind = state["type"]
171
+ self._record(name, kind, event="enter")
172
+ if kind == "succeed":
173
+ return data
174
+ if kind == "fail":
175
+ raise WorkflowFailed(state.get("error", "States.Fail"), state.get("cause", ""), self.history)
176
+ if kind == "choice":
177
+ name = self._choose(state, data)
178
+ continue
179
+ try:
180
+ data = self._execute(state, data)
181
+ except WorkflowFailed:
182
+ raise
183
+ except Exception as exc: # noqa: BLE001 - mapped onto catch rules
184
+ handler = next(
185
+ (c for c in state.get("catch") or [] if _matches(c.get("errors", ["*"]), _error_name(exc))),
186
+ None,
187
+ )
188
+ if handler is None:
189
+ raise WorkflowFailed(_error_name(exc), str(exc), self.history) from exc
190
+ self._record(name, kind, event="caught", error=_error_name(exc))
191
+ info = {"error": _error_name(exc), "cause": str(exc)}
192
+ data = set_path(data, handler.get("result_path", "$.error"), info)
193
+ name = handler["next"]
194
+ continue
195
+ if state.get("end") or not state.get("next"):
196
+ return data
197
+ name = state["next"]
198
+ return data
199
+
200
+ def _choose(self, state: Dict[str, Any], data: Any) -> str:
201
+ for rule in state.get("choices") or []:
202
+ if evaluate_rule(rule, data):
203
+ return rule["next"]
204
+ if state.get("default"):
205
+ return state["default"]
206
+ raise WorkflowFailed("States.NoChoiceMatched", "no choice rule matched and no default", self.history)
207
+
208
+ def _execute(self, state: Dict[str, Any], data: Any) -> Any:
209
+ kind = state["type"]
210
+ input_data = get_path(data, state["input_path"]) if state.get("input_path") else data
211
+ if kind == "task":
212
+ result = self._with_retry(state, lambda: self.task_runner(state["function"], input_data))
213
+ elif kind == "pass":
214
+ result = state.get("result", input_data)
215
+ elif kind == "wait":
216
+ if state.get("seconds_path"):
217
+ seconds = float(get_path(data, state["seconds_path"]))
218
+ else:
219
+ seconds = float(state.get("seconds", 0))
220
+ self.sleep(seconds)
221
+ result = input_data
222
+ elif kind == "parallel":
223
+ branches = state.get("branches") or []
224
+
225
+ def run_branch(branch: Dict[str, Any]) -> Any:
226
+ return self._run_machine(branch, copy.deepcopy(input_data))
227
+
228
+ with ThreadPoolExecutor(max_workers=max(1, len(branches))) as pool:
229
+ result = list(pool.map(run_branch, branches))
230
+ elif kind == "map":
231
+ items = get_path(input_data, state.get("items_path", "$"))
232
+ if not isinstance(items, list):
233
+ raise TypeError(f"map items_path did not resolve to a list: {type(items).__name__}")
234
+ iterator = state["iterator"]
235
+ workers = int(state.get("max_concurrency") or 0) or min(32, max(1, len(items)))
236
+ with ThreadPoolExecutor(max_workers=workers) as pool:
237
+ result = list(pool.map(lambda item: self._run_machine(iterator, item), items))
238
+ else:
239
+ raise WorkflowFailed("States.Runtime", f"unknown state type {kind!r}", self.history)
240
+ return set_path(data, state.get("result_path", "$"), result)
241
+
242
+ def _with_retry(self, state: Dict[str, Any], call: Callable[[], Any]) -> Any:
243
+ attempts: Dict[int, int] = {}
244
+ while True:
245
+ try:
246
+ return call()
247
+ except Exception as exc: # noqa: BLE001
248
+ name = _error_name(exc)
249
+ rules = state.get("retry") or []
250
+ # Like ASL, the first retrier whose errors match is the one used.
251
+ idx = next((i for i, r in enumerate(rules) if _matches(r.get("errors", ["*"]), name)), None)
252
+ if idx is None:
253
+ raise
254
+ rule = rules[idx]
255
+ count = attempts.get(idx, 0)
256
+ if count >= int(rule.get("max_attempts", 3)):
257
+ raise
258
+ attempts[idx] = count + 1
259
+ delay = float(rule.get("interval_seconds", 1)) * float(rule.get("backoff_rate", 2.0)) ** count
260
+ self._record(state.get("function", "?"), "task", event="retry", attempt=count + 1, error=name)
261
+ self.sleep(min(delay, float(rule.get("max_delay_seconds", 300))))
262
+
263
+
264
+ def in_process_runner(provider: str = "local") -> TaskRunner:
265
+ """Task runner that invokes handlers from the current bundle via the dispatcher."""
266
+ from .dispatch import execute
267
+
268
+ def run(function: str, data: Any) -> Any:
269
+ event = Event(type=EventType.WORKFLOW, provider=provider, function=function, data=data)
270
+ result = execute(event, Context(function=function, provider=provider), function)
271
+ return result
272
+
273
+ return run
274
+
275
+
276
+ def orchestrate(event: Event, context: Context) -> Any:
277
+ """Handler of the auto-generated ``<workflow>-orchestrator`` function."""
278
+ from .manifest import load_manifest
279
+
280
+ manifest = load_manifest()
281
+ target = next(
282
+ (
283
+ (name, wf)
284
+ for name, wf in manifest.workflows.items()
285
+ if wf.get("orchestrator") == (event.function or context.function)
286
+ ),
287
+ None,
288
+ )
289
+ if target is None:
290
+ raise LookupError(f"No workflow is orchestrated by function {event.function!r}")
291
+ name, wf = target
292
+ if event.http is not None:
293
+ data = event.http.json(default=None) if event.http.body else dict(event.http.query)
294
+ else:
295
+ data = event.data
296
+ engine = WorkflowEngine(wf["definition"], in_process_runner(event.provider))
297
+ started = time.time()
298
+ try:
299
+ output = engine.run(data)
300
+ except WorkflowFailed as exc:
301
+ failure = {"workflow": name, "status": "FAILED", "error": exc.error, "cause": exc.cause}
302
+ context.logger.error("workflow failed", extra=failure)
303
+ if event.http is not None:
304
+ return Response.json(failure, 500)
305
+ raise
306
+ body: Dict[str, Any] = {
307
+ "workflow": name,
308
+ "status": "SUCCEEDED",
309
+ "output": output,
310
+ "duration_ms": round((time.time() - started) * 1000, 2),
311
+ }
312
+ return body
@@ -0,0 +1,3 @@
1
+ from .runner import TerraformError, TerraformRunner, find_binary
2
+
3
+ __all__ = ["TerraformError", "TerraformRunner", "find_binary"]
@@ -0,0 +1,104 @@
1
+ """Thin, testable wrapper around the ``terraform`` / ``tofu`` binary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Dict, List, Optional
12
+
13
+
14
+ class TerraformError(Exception):
15
+ pass
16
+
17
+
18
+ def find_binary(preferred: Optional[str] = None) -> str:
19
+ candidates = [c for c in (preferred, os.environ.get("PSKIT_TERRAFORM_BIN"), "terraform", "tofu") if c]
20
+ for cand in candidates:
21
+ found = shutil.which(cand)
22
+ if found:
23
+ return found
24
+ raise TerraformError(
25
+ "Neither `terraform` nor `tofu` was found on PATH. Install Terraform >= 1.5 "
26
+ "(https://developer.hashicorp.com/terraform/install) or OpenTofu, "
27
+ "or set PSKIT_TERRAFORM_BIN."
28
+ )
29
+
30
+
31
+ @dataclass
32
+ class TerraformRunner:
33
+ work_dir: Path
34
+ binary: Optional[str] = None
35
+ env: Optional[Dict[str, str]] = None
36
+ echo: Callable[[str], None] = print
37
+
38
+ def _bin(self) -> str:
39
+ if self.binary is None or not Path(self.binary).is_file():
40
+ self.binary = find_binary(self.binary)
41
+ return self.binary
42
+
43
+ def _run(self, args: List[str], *, capture: bool = False, check: bool = True) -> subprocess.CompletedProcess:
44
+ env = dict(os.environ)
45
+ env.setdefault("TF_IN_AUTOMATION", "1")
46
+ env.setdefault("TF_INPUT", "0")
47
+ env.update(self.env or {})
48
+ cmd = [self._bin(), *args]
49
+ if capture:
50
+ proc = subprocess.run(cmd, cwd=self.work_dir, env=env, capture_output=True, text=True)
51
+ else:
52
+ proc = subprocess.run(cmd, cwd=self.work_dir, env=env, text=True)
53
+ if check and proc.returncode != 0:
54
+ detail = (proc.stderr or proc.stdout or "")[-6000:] if capture else ""
55
+ raise TerraformError(f"`{' '.join(cmd[1:3])}` failed with exit code {proc.returncode}\n{detail}".rstrip())
56
+ return proc
57
+
58
+ def init(self, upgrade: bool = False, reconfigure: bool = False) -> None:
59
+ args = ["init", "-input=false", "-no-color"]
60
+ if upgrade:
61
+ args.append("-upgrade")
62
+ if reconfigure:
63
+ args.append("-reconfigure")
64
+ self._run(args)
65
+
66
+ def validate(self) -> None:
67
+ self._run(["validate", "-no-color"])
68
+
69
+ def plan(self, destroy: bool = False, out: Optional[str] = "tfplan") -> bool:
70
+ """Return ``True`` when the plan has changes."""
71
+ args = ["plan", "-input=false", "-no-color", "-detailed-exitcode"]
72
+ if destroy:
73
+ args.append("-destroy")
74
+ if out:
75
+ args += ["-out", out]
76
+ proc = self._run(args, check=False)
77
+ if proc.returncode == 1:
78
+ raise TerraformError("terraform plan failed")
79
+ return proc.returncode == 2
80
+
81
+ def apply(self, plan_file: Optional[str] = None, auto_approve: bool = True) -> None:
82
+ args = ["apply", "-input=false", "-no-color"]
83
+ if plan_file:
84
+ args.append(plan_file)
85
+ elif auto_approve:
86
+ args.append("-auto-approve")
87
+ self._run(args)
88
+
89
+ def destroy(self, auto_approve: bool = True) -> None:
90
+ args = ["destroy", "-input=false", "-no-color"]
91
+ if auto_approve:
92
+ args.append("-auto-approve")
93
+ self._run(args)
94
+
95
+ def output(self) -> Dict[str, Any]:
96
+ proc = self._run(["output", "-json", "-no-color"], capture=True)
97
+ try:
98
+ raw = json.loads(proc.stdout or "{}")
99
+ except ValueError as exc:
100
+ raise TerraformError(f"could not parse terraform output: {exc}") from exc
101
+ return {k: v.get("value") for k, v in raw.items()}
102
+
103
+ def passthrough(self, args: List[str]) -> int:
104
+ return self._run(args, check=False).returncode