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,212 @@
1
+ """Project scaffolds for ``pskit init``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict
6
+
7
+ DEFAULT_REGIONS = {
8
+ "aws": "us-east-1",
9
+ "azure": "eastus",
10
+ "gcp": "us-central1",
11
+ "alibaba": "cn-hangzhou",
12
+ "oracle": "us-ashburn-1",
13
+ "ibm": "us-south",
14
+ "digitalocean": "nyc1",
15
+ "tencent": "ap-guangzhou",
16
+ "huawei": "cn-north-4",
17
+ "cloudflare": "global",
18
+ }
19
+
20
+ PROVIDER_OPTIONS = {
21
+ "gcp": " options:\n project: ${env:GOOGLE_CLOUD_PROJECT}\n",
22
+ "azure": " options:\n subscription_id: ${env:ARM_SUBSCRIPTION_ID}\n",
23
+ "oracle": (
24
+ ' options:\n compartment_id: ${env:OCI_COMPARTMENT_ID}\n subnet_ids: ["${env:OCI_SUBNET_ID}"]\n'
25
+ " image: ${env:OCI_FUNCTION_IMAGE}\n"
26
+ ),
27
+ "digitalocean": " options:\n namespace: ${env:DO_FUNCTIONS_NAMESPACE}\n",
28
+ "cloudflare": " options:\n account_id: ${env:CLOUDFLARE_ACCOUNT_ID}\n",
29
+ }
30
+
31
+ HANDLERS_HTTP = '''"""HTTP handlers. This code runs unchanged on every supported cloud."""
32
+
33
+ from py_serverless_kit import Context, Event, NotFound, Response
34
+
35
+ _ITEMS = {"1": {"id": "1", "name": "first item"}}
36
+
37
+
38
+ def hello(event: Event, ctx: Context):
39
+ name = event.http.query.get("name", "world")
40
+ ctx.logger.info("saying hello", extra={"name": name})
41
+ return {"message": f"Hello, {name}!", "provider": event.provider, "stage": ctx.stage}
42
+
43
+
44
+ def get_item(event: Event, ctx: Context):
45
+ item = _ITEMS.get(event.http.path_params["id"])
46
+ if item is None:
47
+ raise NotFound(f"item {event.http.path_params['id']} does not exist")
48
+ return item
49
+
50
+
51
+ def create_item(event: Event, ctx: Context):
52
+ body = event.http.json()
53
+ item = {"id": str(len(_ITEMS) + 1), **body}
54
+ _ITEMS[item["id"]] = item
55
+ return Response.json(item, status=201)
56
+ '''
57
+
58
+ HANDLERS_EVENTS = '''
59
+
60
+ def nightly_report(event: Event, ctx: Context):
61
+ """Runs on a schedule; ``event.data`` is the optional ``input`` from the YAML."""
62
+ ctx.logger.info("nightly report", extra={"input": event.data})
63
+ return {"ok": True}
64
+
65
+
66
+ def process_jobs(event: Event, ctx: Context):
67
+ """Queue consumer: only failed records are retried where the cloud supports it."""
68
+ from py_serverless_kit import BatchResult
69
+
70
+ result = BatchResult()
71
+ for record in event.records:
72
+ try:
73
+ job = record.json()
74
+ ctx.logger.info("processing job", extra={"job": job})
75
+ except Exception: # noqa: BLE001
76
+ result.fail(record)
77
+ return result
78
+
79
+
80
+ def on_upload(event: Event, ctx: Context):
81
+ for record in event.records:
82
+ ctx.logger.info("object event", extra={"bucket": record.bucket, "key": record.key, "action": record.action})
83
+ '''
84
+
85
+ HANDLERS_WORKFLOW = """
86
+
87
+ def validate_order(event: Event, ctx: Context):
88
+ order = dict(event.data or {})
89
+ if order.get("amount", 0) <= 0:
90
+ raise ValueError("amount must be positive")
91
+ order["valid"] = True
92
+ return order
93
+
94
+
95
+ def charge_order(event: Event, ctx: Context):
96
+ order = dict(event.data)
97
+ order["charged"] = True
98
+ return order
99
+ """
100
+
101
+ CONFIG_HTTP = """service: {service}
102
+
103
+ provider:
104
+ name: {provider}
105
+ region: {region}
106
+ stage: dev
107
+ runtime: python3.12
108
+ memory: 256
109
+ timeout: 15
110
+ environment:
111
+ LOG_LEVEL: INFO
112
+ {options}
113
+ functions:
114
+ hello:
115
+ handler: app.handlers.hello
116
+ description: Says hello
117
+ events:
118
+ - http: GET /hello
119
+
120
+ get-item:
121
+ handler: app.handlers.get_item
122
+ events:
123
+ - http: GET /items/{{id}}
124
+
125
+ create-item:
126
+ handler: app.handlers.create_item
127
+ events:
128
+ - http:
129
+ method: POST
130
+ path: /items
131
+ cors: true
132
+ """
133
+
134
+ CONFIG_EVENTS = """
135
+ nightly-report:
136
+ handler: app.handlers.nightly_report
137
+ events:
138
+ - schedule:
139
+ cron: "0 2 * * *"
140
+ input: {{report: daily}}
141
+
142
+ process-jobs:
143
+ handler: app.handlers.process_jobs
144
+ events:
145
+ - queue: {{name: {service}-jobs, batch_size: 10}}
146
+
147
+ on-upload:
148
+ handler: app.handlers.on_upload
149
+ events:
150
+ - storage: {{bucket: {service}-uploads-change-me, events: [created], suffix: .csv}}
151
+ """
152
+
153
+ CONFIG_WORKFLOW = """
154
+ validate-order:
155
+ handler: app.handlers.validate_order
156
+
157
+ charge-order:
158
+ handler: app.handlers.charge_order
159
+
160
+ workflows:
161
+ order-pipeline:
162
+ description: Validate then charge an order
163
+ start_at: Validate
164
+ states:
165
+ Validate:
166
+ type: task
167
+ function: validate-order
168
+ retry: [{{errors: ["*"], max_attempts: 2}}]
169
+ catch: [{{errors: ["ValueError"], next: Rejected}}]
170
+ next: Charge
171
+ Charge:
172
+ type: task
173
+ function: charge-order
174
+ end: true
175
+ Rejected:
176
+ type: fail
177
+ error: OrderRejected
178
+ """
179
+
180
+ TEST_FILE = """from py_serverless_kit.local import LocalRunner, http_event
181
+ from py_serverless_kit.core.project import Project
182
+
183
+
184
+ def test_hello():
185
+ runner = LocalRunner(Project.load())
186
+ resp = runner.invoke("hello", http_event("GET", "/hello?name=test"))
187
+ assert resp.status == 200
188
+ """
189
+
190
+ GITIGNORE = ".pskit/\n.venv/\n__pycache__/\n*.pyc\n.env\n"
191
+
192
+
193
+ def render(template: str, service: str, provider: str) -> Dict[str, str]:
194
+ region = DEFAULT_REGIONS.get(provider, "")
195
+ config = CONFIG_HTTP
196
+ handlers = HANDLERS_HTTP
197
+ if template in ("events", "full"):
198
+ config += CONFIG_EVENTS
199
+ handlers += HANDLERS_EVENTS
200
+ if template in ("workflow", "full"):
201
+ config += CONFIG_WORKFLOW
202
+ handlers += HANDLERS_WORKFLOW
203
+ return {
204
+ "pskit.yml": config.format(
205
+ service=service, provider=provider, region=region, options=PROVIDER_OPTIONS.get(provider, "")
206
+ ),
207
+ "app/__init__.py": "",
208
+ "app/handlers.py": handlers,
209
+ "requirements.txt": "# Runtime dependencies of your functions (py-serverless-kit's runtime is vendored automatically)\n",
210
+ "tests/test_handlers.py": TEST_FILE,
211
+ ".gitignore": GITIGNORE,
212
+ }
@@ -0,0 +1,12 @@
1
+ from .loader import ConfigError, LoadedConfig, load_config
2
+ from .schema import PROVIDERS, FunctionConfig, ServiceConfig, WorkflowConfig
3
+
4
+ __all__ = [
5
+ "PROVIDERS",
6
+ "ConfigError",
7
+ "FunctionConfig",
8
+ "LoadedConfig",
9
+ "ServiceConfig",
10
+ "WorkflowConfig",
11
+ "load_config",
12
+ ]
@@ -0,0 +1,316 @@
1
+ """Load, merge and interpolate ``pskit.yml``.
2
+
3
+ Resolution order (later wins):
4
+
5
+ 1. ``extends: other.yml`` - parent file(s), deep-merged
6
+ 2. the file itself
7
+ 3. ``providers.<provider>:`` - per-cloud overrides of any top-level key
8
+ 4. ``stages.<stage>:`` - per-stage overrides of any top-level key
9
+ 5. CLI flags (``--provider``, ``--stage``, ``--region``)
10
+
11
+ Then variables are interpolated:
12
+
13
+ * ``${env:NAME}`` / ``${env:NAME, default}`` - environment variable
14
+ * ``${opt:stage}`` / ``${opt:region}`` / ``${opt:provider}`` - effective CLI values
15
+ * ``${self:dotted.path}`` - any value in the merged config
16
+ * ``${file(path/to/file.json)}`` / ``.yml`` / ``.txt`` - file content (relative to the config)
17
+
18
+ ``${tf:aws_dynamodb_table.orders.arn}`` is left for Terraform (see providers.terraform_doc.escape),
19
+ and any other ``${...}`` (e.g. IAM policy variables like ``${aws:username}``) is kept verbatim.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import copy
25
+ import json
26
+ import os
27
+ import re
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+ from typing import Any, Dict, List, Optional
31
+
32
+ import yaml
33
+ from pydantic import ValidationError
34
+
35
+ from .schema import PROVIDERS, ServiceConfig
36
+
37
+ DEFAULT_FILES = ("pskit.yml", "pskit.yaml", "serverless_kit.yml", "serverless_kit.yaml")
38
+ # Only these sources are resolved here. Anything else - IAM policy variables such
39
+ # as ${aws:username}, or ${tf:resource.attr} Terraform references - is kept as is.
40
+ _VAR = re.compile(r"\$\{\s*(env|opt|self|file)(?::([^}]*)|\(([^)]*)\))\s*\}")
41
+ MAX_PASSES = 10
42
+
43
+
44
+ class ConfigError(Exception):
45
+ """User-facing configuration problem."""
46
+
47
+
48
+ @dataclass
49
+ class LoadedConfig:
50
+ config: ServiceConfig
51
+ path: Path
52
+ root: Path
53
+ raw: Dict[str, Any]
54
+
55
+ @property
56
+ def provider(self) -> str:
57
+ return self.config.provider.name
58
+
59
+ @property
60
+ def stage(self) -> str:
61
+ return self.config.provider.stage
62
+
63
+
64
+ def deep_merge(base: Any, override: Any) -> Any:
65
+ if isinstance(base, dict) and isinstance(override, dict):
66
+ out = dict(base)
67
+ for key, value in override.items():
68
+ out[key] = deep_merge(base[key], value) if key in base else copy.deepcopy(value)
69
+ return out
70
+ return copy.deepcopy(override)
71
+
72
+
73
+ def find_config(path: Optional[str] = None, cwd: Optional[Path] = None) -> Path:
74
+ cwd = cwd or Path.cwd()
75
+ if path:
76
+ candidate = Path(path)
77
+ if not candidate.is_absolute():
78
+ candidate = cwd / candidate
79
+ if not candidate.is_file():
80
+ raise ConfigError(f"config file not found: {candidate}")
81
+ return candidate
82
+ for name in DEFAULT_FILES:
83
+ if (cwd / name).is_file():
84
+ return cwd / name
85
+ raise ConfigError(f"no config found in {cwd} (looked for {', '.join(DEFAULT_FILES)}); run `pskit init`")
86
+
87
+
88
+ def _read_layers(path: Path, chain: Optional[List[Path]] = None) -> List[Dict[str, Any]]:
89
+ """Return the file and its ``extends`` ancestors as layers, parents first."""
90
+ chain = chain or []
91
+ if path in chain:
92
+ raise ConfigError("circular `extends`: " + " -> ".join(str(p) for p in chain + [path]))
93
+ try:
94
+ data = yaml.safe_load(path.read_text("utf-8")) or {}
95
+ except yaml.YAMLError as exc:
96
+ raise ConfigError(f"invalid YAML in {path}: {exc}") from exc
97
+ if not isinstance(data, dict):
98
+ raise ConfigError(f"{path} must contain a mapping at the top level")
99
+ parents = data.pop("extends", None)
100
+ layers: List[Dict[str, Any]] = []
101
+ for parent in [parents] if isinstance(parents, str) else parents or []:
102
+ layers.extend(_read_layers((path.parent / parent).resolve(), chain + [path]))
103
+ if isinstance(data.get("provider"), str):
104
+ data["provider"] = {"name": data["provider"]}
105
+ layers.append(data)
106
+ return layers
107
+
108
+
109
+ _PROVIDER_FIELDS = {
110
+ "region",
111
+ "stage",
112
+ "runtime",
113
+ "memory",
114
+ "timeout",
115
+ "architecture",
116
+ "environment",
117
+ "tags",
118
+ "iam",
119
+ "log_retention_days",
120
+ "options",
121
+ "state",
122
+ "terraform",
123
+ }
124
+
125
+
126
+ def _known_providers() -> List[str]:
127
+ """Built-in providers plus any registered through entry points."""
128
+ try:
129
+ from ..providers.registry import available # local import: avoids an import cycle
130
+
131
+ return sorted(set(PROVIDERS) | set(available()))
132
+ except Exception: # noqa: BLE001 - a broken plug-in must not break config loading
133
+ return list(PROVIDERS)
134
+
135
+
136
+ def _provider_override(block: Dict[str, Any]) -> Dict[str, Any]:
137
+ """``providers.<cloud>`` block -> top-level override; ProviderConfig keys may be
138
+ written directly (``region: x``) as shorthand for ``provider: {region: x}``."""
139
+ block = dict(block or {})
140
+ short = {k: block.pop(k) for k in list(block) if k in _PROVIDER_FIELDS}
141
+ return deep_merge(block, {"provider": short}) if short else block
142
+
143
+
144
+ def _lookup(data: Any, dotted: str) -> Any:
145
+ current = data
146
+ for part in dotted.split("."):
147
+ if isinstance(current, dict) and part in current:
148
+ current = current[part]
149
+ elif isinstance(current, list) and part.isdigit() and int(part) < len(current):
150
+ current = current[int(part)]
151
+ else:
152
+ raise KeyError(dotted)
153
+ return current
154
+
155
+
156
+ class _Interpolator:
157
+ def __init__(self, root: Dict[str, Any], opts: Dict[str, str], base_dir: Path) -> None:
158
+ self.root = root
159
+ self.opts = opts
160
+ self.base_dir = base_dir
161
+ self.unresolved: List[str] = []
162
+
163
+ def resolve_var(self, kind: str, arg: str) -> Any:
164
+ arg = (arg or "").strip()
165
+ if kind == "env":
166
+ name, _, default = arg.partition(",")
167
+ name = name.strip()
168
+ if name in os.environ:
169
+ return os.environ[name]
170
+ if _:
171
+ return _strip_quotes(default.strip())
172
+ raise KeyError(f"env:{name}")
173
+ if kind == "opt":
174
+ name, _, default = arg.partition(",")
175
+ if name.strip() in self.opts:
176
+ return self.opts[name.strip()]
177
+ if _:
178
+ return _strip_quotes(default.strip())
179
+ raise KeyError(f"opt:{name.strip()}")
180
+ if kind == "self":
181
+ name, _, default = arg.partition(",")
182
+ try:
183
+ return _lookup(self.root, name.strip())
184
+ except KeyError:
185
+ if _:
186
+ return _strip_quotes(default.strip())
187
+ raise KeyError(f"self:{name.strip()}") from None
188
+ if kind == "file":
189
+ target = (self.base_dir / _strip_quotes(arg)).resolve()
190
+ if not target.is_file():
191
+ raise ConfigError(f"${{file({arg})}}: {target} does not exist")
192
+ text = target.read_text("utf-8")
193
+ if target.suffix == ".json":
194
+ return json.loads(text)
195
+ if target.suffix in (".yml", ".yaml"):
196
+ return yaml.safe_load(text)
197
+ return text.strip()
198
+ raise ConfigError(f"unknown variable source ${{{kind}:...}}; use env, opt, self or file")
199
+
200
+ def walk(self, value: Any) -> Any:
201
+ if isinstance(value, dict):
202
+ return {k: self.walk(v) for k, v in value.items()}
203
+ if isinstance(value, list):
204
+ return [self.walk(v) for v in value]
205
+ if not isinstance(value, str) or "${" not in value:
206
+ return value
207
+ whole = _VAR.fullmatch(value.strip())
208
+ if whole:
209
+ kind, arg1, arg2 = whole.groups()
210
+ try:
211
+ return self.resolve_var(kind, arg1 if arg1 is not None else arg2)
212
+ except KeyError as exc:
213
+ self.unresolved.append(str(exc).strip("'"))
214
+ return value
215
+
216
+ def sub(m: re.Match[str]) -> str:
217
+ kind, arg1, arg2 = m.groups()
218
+ try:
219
+ resolved = self.resolve_var(kind, arg1 if arg1 is not None else arg2)
220
+ except KeyError as exc:
221
+ self.unresolved.append(str(exc).strip("'"))
222
+ return m.group(0)
223
+ return resolved if isinstance(resolved, str) else json.dumps(resolved)
224
+
225
+ return _VAR.sub(sub, value)
226
+
227
+
228
+ def _strip_quotes(text: str) -> str:
229
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in "'\"":
230
+ return text[1:-1]
231
+ return text
232
+
233
+
234
+ def interpolate(data: Dict[str, Any], opts: Dict[str, str], base_dir: Path) -> Dict[str, Any]:
235
+ current = data
236
+ for _ in range(MAX_PASSES):
237
+ interp = _Interpolator(current, opts, base_dir)
238
+ nxt = interp.walk(current)
239
+ if nxt == current:
240
+ if interp.unresolved:
241
+ missing = ", ".join(sorted(set(interp.unresolved)))
242
+ raise ConfigError(f"unresolved variables: {missing}")
243
+ return nxt
244
+ current = nxt
245
+ raise ConfigError("variable interpolation did not converge (circular ${self:...} reference?)")
246
+
247
+
248
+ def build_raw(
249
+ path: Path,
250
+ *,
251
+ provider: Optional[str] = None,
252
+ stage: Optional[str] = None,
253
+ region: Optional[str] = None,
254
+ ) -> Dict[str, Any]:
255
+ layers = _read_layers(path.resolve())
256
+ bases = [{k: v for k, v in layer.items() if k not in ("providers", "stages")} for layer in layers]
257
+ merged_base: Dict[str, Any] = {}
258
+ for base in bases:
259
+ merged_base = deep_merge(merged_base, base)
260
+ name = provider or (merged_base.get("provider") or {}).get("name")
261
+ if not name:
262
+ raise ConfigError("provider.name is required (or pass --provider)")
263
+ known = _known_providers()
264
+ if name not in known:
265
+ raise ConfigError(f"unknown provider {name!r}; supported: {', '.join(known)}")
266
+
267
+ # Each file's `providers.<name>` override is applied right after that file's own
268
+ # values, so a child file always wins over its parent's per-provider overrides.
269
+ data: Dict[str, Any] = {}
270
+ stage_overrides: Dict[str, Any] = {}
271
+ for layer, base in zip(layers, bases):
272
+ overrides = layer.get("providers") or {}
273
+ unknown = set(overrides) - set(known)
274
+ if unknown:
275
+ raise ConfigError(f"`providers:` has unknown keys {sorted(unknown)}; supported: {', '.join(known)}")
276
+ data = deep_merge(data, base)
277
+ data = deep_merge(data, _provider_override(overrides.get(name) or {}))
278
+ stage_overrides = deep_merge(stage_overrides, layer.get("stages") or {})
279
+ data.setdefault("provider", {})["name"] = name
280
+
281
+ effective_stage = stage or data["provider"].get("stage") or "dev"
282
+ stage_override = stage_overrides.get(effective_stage) or {}
283
+ data = deep_merge(data, stage_override)
284
+ data["provider"]["stage"] = effective_stage
285
+ if region:
286
+ data["provider"]["region"] = region
287
+
288
+ opts = {"stage": effective_stage, "provider": name}
289
+ if data["provider"].get("region"):
290
+ opts["region"] = str(data["provider"]["region"])
291
+ return interpolate(data, opts, path.parent)
292
+
293
+
294
+ def format_validation_error(exc: ValidationError, path: Path) -> str:
295
+ lines = [f"{path.name} is invalid:"]
296
+ for err in exc.errors():
297
+ loc = ".".join(str(p) for p in err["loc"] if p not in ("config",)) or "<root>"
298
+ lines.append(f" - {loc}: {err['msg']}")
299
+ return "\n".join(lines)
300
+
301
+
302
+ def load_config(
303
+ path: Optional[str] = None,
304
+ *,
305
+ provider: Optional[str] = None,
306
+ stage: Optional[str] = None,
307
+ region: Optional[str] = None,
308
+ cwd: Optional[Path] = None,
309
+ ) -> LoadedConfig:
310
+ cfg_path = find_config(path, cwd)
311
+ raw = build_raw(cfg_path, provider=provider, stage=stage, region=region)
312
+ try:
313
+ config = ServiceConfig.model_validate(raw)
314
+ except ValidationError as exc:
315
+ raise ConfigError(format_validation_error(exc, cfg_path)) from None
316
+ return LoadedConfig(config=config, path=cfg_path, root=cfg_path.parent.resolve(), raw=raw)