py-serverless-kit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- py_serverless_kit/__init__.py +47 -0
- py_serverless_kit/__main__.py +3 -0
- py_serverless_kit/_version.py +2 -0
- py_serverless_kit/cli/__init__.py +0 -0
- py_serverless_kit/cli/main.py +491 -0
- py_serverless_kit/cli/templates.py +212 -0
- py_serverless_kit/config/__init__.py +12 -0
- py_serverless_kit/config/loader.py +316 -0
- py_serverless_kit/config/schema.py +457 -0
- py_serverless_kit/core/__init__.py +0 -0
- py_serverless_kit/core/deployer.py +180 -0
- py_serverless_kit/core/describe.py +112 -0
- py_serverless_kit/core/project.py +147 -0
- py_serverless_kit/dashboard/__init__.py +3 -0
- py_serverless_kit/dashboard/generator.py +65 -0
- py_serverless_kit/dashboard/template.html +248 -0
- py_serverless_kit/local/__init__.py +3 -0
- py_serverless_kit/local/runner.py +234 -0
- py_serverless_kit/local/server.py +200 -0
- py_serverless_kit/packaging/__init__.py +3 -0
- py_serverless_kit/packaging/builder.py +252 -0
- py_serverless_kit/providers/__init__.py +15 -0
- py_serverless_kit/providers/alibaba.py +268 -0
- py_serverless_kit/providers/aws.py +508 -0
- py_serverless_kit/providers/azure.py +417 -0
- py_serverless_kit/providers/base.py +310 -0
- py_serverless_kit/providers/cloudflare.py +264 -0
- py_serverless_kit/providers/digitalocean.py +211 -0
- py_serverless_kit/providers/gcp.py +480 -0
- py_serverless_kit/providers/huawei.py +292 -0
- py_serverless_kit/providers/ibm.py +132 -0
- py_serverless_kit/providers/oracle.py +304 -0
- py_serverless_kit/providers/registry.py +60 -0
- py_serverless_kit/providers/schedule.py +153 -0
- py_serverless_kit/providers/tencent.py +275 -0
- py_serverless_kit/providers/terraform_doc.py +125 -0
- py_serverless_kit/runtime/__init__.py +6 -0
- py_serverless_kit/runtime/adapters/__init__.py +18 -0
- py_serverless_kit/runtime/adapters/_httpfn.py +91 -0
- py_serverless_kit/runtime/adapters/alibaba.py +147 -0
- py_serverless_kit/runtime/adapters/aws.py +201 -0
- py_serverless_kit/runtime/adapters/azure.py +185 -0
- py_serverless_kit/runtime/adapters/base.py +86 -0
- py_serverless_kit/runtime/adapters/cloudflare.py +126 -0
- py_serverless_kit/runtime/adapters/digitalocean.py +74 -0
- py_serverless_kit/runtime/adapters/gcp.py +176 -0
- py_serverless_kit/runtime/adapters/huawei.py +173 -0
- py_serverless_kit/runtime/adapters/ibm.py +91 -0
- py_serverless_kit/runtime/adapters/oracle.py +161 -0
- py_serverless_kit/runtime/adapters/tencent.py +151 -0
- py_serverless_kit/runtime/batch.py +47 -0
- py_serverless_kit/runtime/context.py +45 -0
- py_serverless_kit/runtime/dispatch.py +277 -0
- py_serverless_kit/runtime/errors.py +83 -0
- py_serverless_kit/runtime/event.py +267 -0
- py_serverless_kit/runtime/invoke.py +119 -0
- py_serverless_kit/runtime/log.py +69 -0
- py_serverless_kit/runtime/manifest.py +121 -0
- py_serverless_kit/runtime/response.py +177 -0
- py_serverless_kit/runtime/routing.py +74 -0
- py_serverless_kit/runtime/workflow.py +312 -0
- py_serverless_kit/terraform/__init__.py +3 -0
- py_serverless_kit/terraform/runner.py +104 -0
- py_serverless_kit/workflows/__init__.py +35 -0
- py_serverless_kit/workflows/asl.py +170 -0
- py_serverless_kit/workflows/gcp.py +328 -0
- py_serverless_kit-0.1.0.dist-info/METADATA +300 -0
- py_serverless_kit-0.1.0.dist-info/RECORD +71 -0
- py_serverless_kit-0.1.0.dist-info/WHEEL +4 -0
- py_serverless_kit-0.1.0.dist-info/entry_points.txt +3 -0
- py_serverless_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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)
|