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,47 @@
|
|
|
1
|
+
"""py_serverless_kit - write a Python function once, deploy it to any cloud.
|
|
2
|
+
|
|
3
|
+
Only the *runtime* API is exported here. It is pure standard library so it can be
|
|
4
|
+
shipped inside every deployment bundle. The CLI, config loader and Terraform
|
|
5
|
+
renderers live in sub-packages that are never imported at function runtime.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._version import __author__, __version__
|
|
9
|
+
from .runtime.batch import BatchResult
|
|
10
|
+
from .runtime.context import Context
|
|
11
|
+
from .runtime.errors import (
|
|
12
|
+
BadRequest,
|
|
13
|
+
Conflict,
|
|
14
|
+
Forbidden,
|
|
15
|
+
HttpError,
|
|
16
|
+
MethodNotAllowed,
|
|
17
|
+
NotFound,
|
|
18
|
+
TooManyRequests,
|
|
19
|
+
Unauthorized,
|
|
20
|
+
UnprocessableEntity,
|
|
21
|
+
)
|
|
22
|
+
from .runtime.event import Event, EventType, Headers, HttpRequest, Record
|
|
23
|
+
from .runtime.invoke import invoke
|
|
24
|
+
from .runtime.response import Response
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"__author__",
|
|
28
|
+
"__version__",
|
|
29
|
+
"BadRequest",
|
|
30
|
+
"BatchResult",
|
|
31
|
+
"Conflict",
|
|
32
|
+
"Context",
|
|
33
|
+
"Event",
|
|
34
|
+
"EventType",
|
|
35
|
+
"Forbidden",
|
|
36
|
+
"Headers",
|
|
37
|
+
"HttpError",
|
|
38
|
+
"HttpRequest",
|
|
39
|
+
"MethodNotAllowed",
|
|
40
|
+
"NotFound",
|
|
41
|
+
"Record",
|
|
42
|
+
"Response",
|
|
43
|
+
"TooManyRequests",
|
|
44
|
+
"Unauthorized",
|
|
45
|
+
"UnprocessableEntity",
|
|
46
|
+
"invoke",
|
|
47
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""``pskit`` command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import traceback
|
|
10
|
+
import webbrowser
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Callable, Optional
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from .._version import __version__
|
|
17
|
+
from ..config.loader import ConfigError
|
|
18
|
+
from ..config.schema import PROVIDERS
|
|
19
|
+
|
|
20
|
+
COMMON_ERRORS: tuple = ()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _errors() -> tuple:
|
|
24
|
+
global COMMON_ERRORS
|
|
25
|
+
if not COMMON_ERRORS:
|
|
26
|
+
from ..core.deployer import DeployError
|
|
27
|
+
from ..packaging.builder import BuildError
|
|
28
|
+
from ..providers.base import ProviderError
|
|
29
|
+
from ..providers.schedule import ScheduleError
|
|
30
|
+
from ..terraform.runner import TerraformError
|
|
31
|
+
|
|
32
|
+
COMMON_ERRORS = (ConfigError, DeployError, BuildError, ProviderError, ScheduleError, TerraformError)
|
|
33
|
+
return COMMON_ERRORS
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def echo(msg: str = "") -> None:
|
|
37
|
+
click.echo(msg)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def info(msg: str) -> None:
|
|
41
|
+
click.secho(msg, fg="cyan")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ok(msg: str) -> None:
|
|
45
|
+
click.secho(msg, fg="green")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fail(msg: str) -> None:
|
|
49
|
+
click.secho(msg, fg="red", err=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def project_options(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
53
|
+
"""Options shared by every command that loads the project."""
|
|
54
|
+
|
|
55
|
+
@click.option("-c", "--config", "config_path", default=None, help="Config file (default: pskit.yml).")
|
|
56
|
+
@click.option(
|
|
57
|
+
"-p", "--provider", default=None, help=f"Override provider.name ({', '.join(PROVIDERS)}, or a plug-in)."
|
|
58
|
+
)
|
|
59
|
+
@click.option("-s", "--stage", default=None, help="Stage, e.g. dev / staging / prod.")
|
|
60
|
+
@click.option("-r", "--region", default=None, help="Override provider.region.")
|
|
61
|
+
@click.option("-v", "--verbose", is_flag=True, help="Show tracebacks on errors.")
|
|
62
|
+
@functools.wraps(fn)
|
|
63
|
+
def wrapper(
|
|
64
|
+
*args: Any,
|
|
65
|
+
config_path: Optional[str],
|
|
66
|
+
provider: Optional[str],
|
|
67
|
+
stage: Optional[str],
|
|
68
|
+
region: Optional[str],
|
|
69
|
+
verbose: bool,
|
|
70
|
+
**kwargs: Any,
|
|
71
|
+
) -> Any:
|
|
72
|
+
from ..core.project import Project
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
project = Project.load(config_path, provider=provider, stage=stage, region=region)
|
|
76
|
+
return fn(project, *args, **kwargs)
|
|
77
|
+
except click.exceptions.Exit:
|
|
78
|
+
raise
|
|
79
|
+
except click.ClickException:
|
|
80
|
+
raise
|
|
81
|
+
except _errors() as exc:
|
|
82
|
+
if verbose:
|
|
83
|
+
traceback.print_exc()
|
|
84
|
+
fail(f"Error: {exc}")
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
except KeyError as exc:
|
|
87
|
+
if verbose:
|
|
88
|
+
traceback.print_exc()
|
|
89
|
+
fail(f"Error: {exc.args[0] if exc.args else exc}")
|
|
90
|
+
sys.exit(1)
|
|
91
|
+
|
|
92
|
+
return wrapper
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
96
|
+
@click.version_option(__version__, prog_name="py-serverless-kit")
|
|
97
|
+
def cli() -> None:
|
|
98
|
+
"""py-serverless-kit: write Python functions once, deploy them to any cloud.
|
|
99
|
+
|
|
100
|
+
\b
|
|
101
|
+
Typical flow:
|
|
102
|
+
pskit init my-api --provider aws
|
|
103
|
+
pskit dev # run locally
|
|
104
|
+
pskit deploy --stage dev # terraform apply
|
|
105
|
+
pskit dashboard --open # see everything you deployed
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ----------------------------------------------------------------------------- init
|
|
110
|
+
@cli.command()
|
|
111
|
+
@click.argument("directory", default=".", type=click.Path(file_okay=False))
|
|
112
|
+
@click.option("--name", default=None, help="Service name (default: directory name).")
|
|
113
|
+
@click.option("-p", "--provider", default="aws", type=click.Choice(PROVIDERS), show_default=True)
|
|
114
|
+
@click.option(
|
|
115
|
+
"-t", "--template", default="http", type=click.Choice(["http", "events", "workflow", "full"]), show_default=True
|
|
116
|
+
)
|
|
117
|
+
@click.option("--force", is_flag=True, help="Overwrite existing files.")
|
|
118
|
+
def init(directory: str, name: Optional[str], provider: str, template: str, force: bool) -> None:
|
|
119
|
+
"""Scaffold a new project."""
|
|
120
|
+
import re
|
|
121
|
+
|
|
122
|
+
from .templates import render
|
|
123
|
+
|
|
124
|
+
target = Path(directory).resolve()
|
|
125
|
+
service = name or re.sub(r"[^a-z0-9-]", "-", target.name.lower()).strip("-") or "my-service"
|
|
126
|
+
if not re.fullmatch(r"[a-z][a-z0-9-]{1,31}", service):
|
|
127
|
+
raise click.BadParameter(f"invalid service name {service!r}; use --name", param_hint="--name")
|
|
128
|
+
files = render(template, service, provider)
|
|
129
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
for rel, content in files.items():
|
|
131
|
+
path = target / rel
|
|
132
|
+
if path.exists() and not force:
|
|
133
|
+
echo(f" skip {rel} (exists)")
|
|
134
|
+
continue
|
|
135
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
path.write_text(content, "utf-8")
|
|
137
|
+
echo(f" create {rel}")
|
|
138
|
+
ok(f"\nCreated service '{service}' for {provider} in {target}")
|
|
139
|
+
echo("Next steps:")
|
|
140
|
+
if directory != ".":
|
|
141
|
+
echo(f" cd {directory}")
|
|
142
|
+
echo(" pskit dev # local server with hot reload")
|
|
143
|
+
echo(" pskit deploy # deploy with terraform")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ----------------------------------------------------------------------------- validate
|
|
147
|
+
@cli.command()
|
|
148
|
+
@project_options
|
|
149
|
+
def validate(project) -> None: # noqa: ANN001
|
|
150
|
+
"""Validate the config against the selected provider."""
|
|
151
|
+
issues = project.validate()
|
|
152
|
+
cfg = project.config
|
|
153
|
+
info(
|
|
154
|
+
f"{cfg.service} -> {project.provider.display_name} [{project.provider.tier}] stage={cfg.provider.stage} region={project.region}"
|
|
155
|
+
)
|
|
156
|
+
echo(f" {len(cfg.functions)} function(s), {len(cfg.workflows)} workflow(s)")
|
|
157
|
+
for name, engine in project.engines.items():
|
|
158
|
+
echo(f" workflow {name}: {engine} engine")
|
|
159
|
+
for issue in issues:
|
|
160
|
+
(fail if issue.level == "error" else echo)(f" {issue}")
|
|
161
|
+
if any(i.level == "error" for i in issues):
|
|
162
|
+
sys.exit(1)
|
|
163
|
+
ok("Configuration is valid.")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ----------------------------------------------------------------------------- package / plan / deploy / remove
|
|
167
|
+
@cli.command()
|
|
168
|
+
@project_options
|
|
169
|
+
@click.option("--skip-deps", is_flag=True, help="Do not pip-install requirements into the bundle.")
|
|
170
|
+
def package(project, skip_deps: bool) -> None: # noqa: ANN001
|
|
171
|
+
"""Build the artifact and generate Terraform (no cloud calls)."""
|
|
172
|
+
from ..core.deployer import Deployer
|
|
173
|
+
|
|
174
|
+
result = Deployer(project, echo=echo).package(skip_deps=skip_deps)
|
|
175
|
+
ok(f"Packaged into {project.work_dir} ({result.resource_count} terraform resources)")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@cli.command()
|
|
179
|
+
@project_options
|
|
180
|
+
@click.option("--skip-deps", is_flag=True)
|
|
181
|
+
@click.option("--destroy", is_flag=True, help="Plan a destroy instead.")
|
|
182
|
+
def plan(project, skip_deps: bool, destroy: bool) -> None: # noqa: ANN001
|
|
183
|
+
"""Show the terraform plan."""
|
|
184
|
+
from ..core.deployer import Deployer
|
|
185
|
+
|
|
186
|
+
changed = Deployer(project, echo=echo).plan(skip_deps=skip_deps, destroy=destroy)
|
|
187
|
+
ok("Plan has changes." if changed else "No changes.")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@cli.command()
|
|
191
|
+
@project_options
|
|
192
|
+
@click.option("--skip-deps", is_flag=True, help="Do not pip-install requirements into the bundle.")
|
|
193
|
+
@click.option("--confirm", is_flag=True, help="Show the plan and apply it (instead of -auto-approve).")
|
|
194
|
+
@click.option("--upgrade", is_flag=True, help="terraform init -upgrade (update provider plugins).")
|
|
195
|
+
@click.option("--no-dashboard", is_flag=True, help="Do not regenerate the dashboard.")
|
|
196
|
+
def deploy(project, skip_deps: bool, confirm: bool, upgrade: bool, no_dashboard: bool) -> None: # noqa: ANN001
|
|
197
|
+
"""Package and deploy with terraform apply."""
|
|
198
|
+
from ..core.deployer import Deployer
|
|
199
|
+
from ..dashboard.generator import write_dashboard
|
|
200
|
+
|
|
201
|
+
record = Deployer(project, echo=echo).deploy(skip_deps=skip_deps, auto_approve=not confirm, upgrade=upgrade)
|
|
202
|
+
ok(f"\nDeployed {record['service']} ({record['stage']}) to {record['provider_display']} {record['region']}")
|
|
203
|
+
_print_record(record)
|
|
204
|
+
if not no_dashboard:
|
|
205
|
+
path = write_dashboard(project.root)
|
|
206
|
+
echo(f"\nDashboard: {path.as_uri()}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@cli.command()
|
|
210
|
+
@project_options
|
|
211
|
+
@click.option("-y", "--yes", is_flag=True, help="Do not ask for confirmation.")
|
|
212
|
+
def remove(project, yes: bool) -> None: # noqa: ANN001
|
|
213
|
+
"""Destroy every resource of this service/stage."""
|
|
214
|
+
from ..core.deployer import Deployer
|
|
215
|
+
from ..dashboard.generator import write_dashboard
|
|
216
|
+
|
|
217
|
+
cfg = project.config
|
|
218
|
+
if not yes:
|
|
219
|
+
click.confirm(
|
|
220
|
+
f"Destroy ALL resources of '{cfg.service}' stage '{cfg.provider.stage}' on {project.provider.display_name}?",
|
|
221
|
+
abort=True,
|
|
222
|
+
)
|
|
223
|
+
Deployer(project, echo=echo).remove(auto_approve=True)
|
|
224
|
+
write_dashboard(project.root)
|
|
225
|
+
ok("Removed.")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ----------------------------------------------------------------------------- info / dashboard
|
|
229
|
+
def _print_record(record: dict) -> None:
|
|
230
|
+
if record.get("endpoint"):
|
|
231
|
+
echo(f"endpoint: {record['endpoint']}")
|
|
232
|
+
echo("functions:")
|
|
233
|
+
for f in record["functions"]:
|
|
234
|
+
triggers = ", ".join(e["summary"] for e in f["events"]) or "direct invoke"
|
|
235
|
+
echo(f" {f['name']:<24} {f['kind']:<13} {triggers}")
|
|
236
|
+
for u in f.get("urls") or []:
|
|
237
|
+
echo(f" {u['method']:<6} {u['url']}")
|
|
238
|
+
if f.get("url") and not f.get("urls"):
|
|
239
|
+
echo(f" url: {f['url']}")
|
|
240
|
+
if f.get("arn") or f.get("id"):
|
|
241
|
+
echo(f" id: {f.get('arn') or f.get('id')}")
|
|
242
|
+
if record.get("workflows"):
|
|
243
|
+
echo("workflows:")
|
|
244
|
+
for w in record["workflows"]:
|
|
245
|
+
echo(f" {w['name']:<24} {w['engine']:<9} {w.get('id') or ''}")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@cli.command("info")
|
|
249
|
+
@project_options
|
|
250
|
+
@click.option("--json", "as_json", is_flag=True, help="Machine readable output.")
|
|
251
|
+
@click.option("--refresh", is_flag=True, help="Re-read terraform outputs.")
|
|
252
|
+
def info_cmd(project, as_json: bool, refresh: bool) -> None: # noqa: ANN001
|
|
253
|
+
"""Show what is deployed (functions, URLs, ids)."""
|
|
254
|
+
from ..core.deployer import Deployer
|
|
255
|
+
from ..dashboard.generator import local_record
|
|
256
|
+
|
|
257
|
+
deployer = Deployer(project, echo=echo)
|
|
258
|
+
record = deployer.refresh_info() if refresh else deployer.load_record()
|
|
259
|
+
if record is None:
|
|
260
|
+
record = local_record(project)
|
|
261
|
+
record["status"] = "not deployed"
|
|
262
|
+
if as_json:
|
|
263
|
+
click.echo(json.dumps(record, indent=2, default=str))
|
|
264
|
+
return
|
|
265
|
+
info(
|
|
266
|
+
f"{record['service']} | {record['stage']} | {record['provider_display']} | {record.get('region')} | {record['status']}"
|
|
267
|
+
)
|
|
268
|
+
_print_record(record)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@cli.command()
|
|
272
|
+
@click.option(
|
|
273
|
+
"-d", "--dir", "directory", default=".", type=click.Path(file_okay=False, exists=True), help="Project root."
|
|
274
|
+
)
|
|
275
|
+
@click.option("-o", "--output", default=None, type=click.Path(dir_okay=False), help="Write HTML here.")
|
|
276
|
+
@click.option("--serve", is_flag=True, help="Serve the dashboard over HTTP.")
|
|
277
|
+
@click.option("--port", default=8765, show_default=True)
|
|
278
|
+
@click.option("--open", "open_browser", is_flag=True, help="Open in the browser.")
|
|
279
|
+
def dashboard(directory: str, output: Optional[str], serve: bool, port: int, open_browser: bool) -> None:
|
|
280
|
+
"""Generate (and optionally serve) the deployment dashboard."""
|
|
281
|
+
from ..dashboard.generator import collect_records, local_record, write_dashboard
|
|
282
|
+
|
|
283
|
+
root = Path(directory).resolve()
|
|
284
|
+
records = collect_records(root)
|
|
285
|
+
if not records:
|
|
286
|
+
try:
|
|
287
|
+
from ..core.project import Project
|
|
288
|
+
|
|
289
|
+
records = [local_record(Project.load(cwd=root))]
|
|
290
|
+
echo("No deployments yet - showing the configured (local) view.")
|
|
291
|
+
except ConfigError:
|
|
292
|
+
pass
|
|
293
|
+
path = write_dashboard(root, records, Path(output).resolve() if output else None)
|
|
294
|
+
ok(f"Dashboard written to {path}")
|
|
295
|
+
if serve:
|
|
296
|
+
import http.server
|
|
297
|
+
import socketserver
|
|
298
|
+
|
|
299
|
+
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(path.parent))
|
|
300
|
+
with socketserver.TCPServer(("127.0.0.1", port), handler) as httpd:
|
|
301
|
+
url = f"http://127.0.0.1:{port}/{path.name}"
|
|
302
|
+
echo(f"Serving on {url} (Ctrl+C to stop)")
|
|
303
|
+
if open_browser:
|
|
304
|
+
webbrowser.open(url)
|
|
305
|
+
try:
|
|
306
|
+
httpd.serve_forever()
|
|
307
|
+
except KeyboardInterrupt:
|
|
308
|
+
pass
|
|
309
|
+
elif open_browser:
|
|
310
|
+
webbrowser.open(path.as_uri())
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ----------------------------------------------------------------------------- local
|
|
314
|
+
@cli.command()
|
|
315
|
+
@project_options
|
|
316
|
+
@click.option("--host", default="127.0.0.1", show_default=True)
|
|
317
|
+
@click.option("--port", default=3000, show_default=True)
|
|
318
|
+
@click.option("--env-file", default=".env", show_default=True)
|
|
319
|
+
def dev(project, host: str, port: int, env_file: str) -> None: # noqa: ANN001
|
|
320
|
+
"""Run a local HTTP server that routes to your handlers (hot reload)."""
|
|
321
|
+
from ..local.runner import LocalRunner
|
|
322
|
+
from ..local.server import serve
|
|
323
|
+
|
|
324
|
+
serve(LocalRunner(project, env_file=env_file), host=host, port=port, echo=echo)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@cli.command()
|
|
328
|
+
@project_options
|
|
329
|
+
@click.option("-f", "--function", "function", required=True, help="Function name from the config.")
|
|
330
|
+
@click.option(
|
|
331
|
+
"-t",
|
|
332
|
+
"--type",
|
|
333
|
+
"kind",
|
|
334
|
+
default=None,
|
|
335
|
+
type=click.Choice(["http", "schedule", "queue", "storage", "topic", "invoke", "workflow"]),
|
|
336
|
+
help="Event type (default: the function's first event, or invoke).",
|
|
337
|
+
)
|
|
338
|
+
@click.option("-d", "--data", default=None, help="JSON payload or @file.json.")
|
|
339
|
+
@click.option("--method", default=None, help="HTTP method (http events).")
|
|
340
|
+
@click.option("--path", "path", default=None, help="HTTP path incl. query string (http events).")
|
|
341
|
+
@click.option("-H", "--header", "headers", multiple=True, help="HTTP header 'Name: value' (repeatable).")
|
|
342
|
+
@click.option("--env-file", default=".env", show_default=True)
|
|
343
|
+
def invoke(
|
|
344
|
+
project,
|
|
345
|
+
function: str,
|
|
346
|
+
kind: Optional[str],
|
|
347
|
+
data: Optional[str],
|
|
348
|
+
method: Optional[str], # noqa: ANN001
|
|
349
|
+
path: Optional[str],
|
|
350
|
+
headers: tuple,
|
|
351
|
+
env_file: str,
|
|
352
|
+
) -> None:
|
|
353
|
+
"""Invoke a function locally with a synthetic event."""
|
|
354
|
+
from ..local.runner import LocalRunner, build_event, parse_data, response_to_dict
|
|
355
|
+
|
|
356
|
+
fn = project.config.functions.get(function)
|
|
357
|
+
if fn is None:
|
|
358
|
+
raise KeyError(f"unknown function {function!r}; known: {', '.join(sorted(project.config.functions))}")
|
|
359
|
+
first = fn.events[0] if fn.events else None
|
|
360
|
+
kind = kind or (first.type if first else "invoke")
|
|
361
|
+
parsed_headers = {}
|
|
362
|
+
for raw_header in headers:
|
|
363
|
+
hname, sep, hvalue = raw_header.partition(":")
|
|
364
|
+
if not sep:
|
|
365
|
+
raise click.BadParameter(f"expected 'Name: value', got {raw_header!r}", param_hint="--header")
|
|
366
|
+
parsed_headers[hname.strip()] = hvalue.strip()
|
|
367
|
+
http_kw: dict = {"headers": parsed_headers or None}
|
|
368
|
+
if kind == "http":
|
|
369
|
+
route = next(iter(fn.events_of("http")), None)
|
|
370
|
+
http_kw["method"] = method or (route.method if route and route.method != "ANY" else "GET")
|
|
371
|
+
http_kw["path"] = path or (route.path if route else "/")
|
|
372
|
+
payload = parse_data(data)
|
|
373
|
+
if kind == "schedule" and payload is None and fn.events_of("schedule"):
|
|
374
|
+
payload = fn.events_of("schedule")[0].input
|
|
375
|
+
runner = LocalRunner(project, env_file=env_file)
|
|
376
|
+
try:
|
|
377
|
+
result = runner.invoke(function, build_event(kind, payload, **http_kw))
|
|
378
|
+
except Exception as exc: # noqa: BLE001 - user code failed; show it like the cloud would
|
|
379
|
+
traceback.print_exc()
|
|
380
|
+
fail(f"{function} raised {type(exc).__name__}: {exc}")
|
|
381
|
+
sys.exit(1)
|
|
382
|
+
click.echo(json.dumps(response_to_dict(result), indent=2, default=str))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ----------------------------------------------------------------------------- workflows
|
|
386
|
+
@cli.group()
|
|
387
|
+
def workflow() -> None:
|
|
388
|
+
"""Run or inspect workflows (state machines)."""
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@workflow.command("run")
|
|
392
|
+
@project_options
|
|
393
|
+
@click.argument("name")
|
|
394
|
+
@click.option("-i", "--input", "data", default=None, help="JSON input or @file.json.")
|
|
395
|
+
@click.option("--sleep", is_flag=True, help="Honour wait states (default: skip waiting).")
|
|
396
|
+
@click.option("--history", is_flag=True, help="Print the execution history.")
|
|
397
|
+
def workflow_run(project, name: str, data: Optional[str], sleep: bool, history: bool) -> None: # noqa: ANN001
|
|
398
|
+
"""Run a workflow locally on the embedded engine."""
|
|
399
|
+
from ..local.runner import LocalRunner, parse_data
|
|
400
|
+
from ..runtime.workflow import WorkflowFailed
|
|
401
|
+
|
|
402
|
+
runner = LocalRunner(project)
|
|
403
|
+
try:
|
|
404
|
+
result = runner.run_workflow(name, parse_data(data), sleep=sleep)
|
|
405
|
+
except WorkflowFailed as exc:
|
|
406
|
+
fail(f"FAILED {exc.error}: {exc.cause}")
|
|
407
|
+
if history:
|
|
408
|
+
click.echo(json.dumps(exc.history, indent=2))
|
|
409
|
+
sys.exit(2)
|
|
410
|
+
ok("SUCCEEDED")
|
|
411
|
+
click.echo(json.dumps(result["output"], indent=2, default=str))
|
|
412
|
+
if history:
|
|
413
|
+
click.echo(json.dumps(result["history"], indent=2))
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@workflow.command("show")
|
|
417
|
+
@project_options
|
|
418
|
+
@click.argument("name")
|
|
419
|
+
@click.option("--format", "fmt", default="auto", type=click.Choice(["auto", "asl", "gcp", "json"]), show_default=True)
|
|
420
|
+
def workflow_show(project, name: str, fmt: str) -> None: # noqa: ANN001
|
|
421
|
+
"""Print the compiled workflow definition."""
|
|
422
|
+
from ..workflows import definition_dict
|
|
423
|
+
from ..workflows.asl import compile_asl
|
|
424
|
+
from ..workflows.gcp import compile_gcp
|
|
425
|
+
|
|
426
|
+
wf = project.config.workflows.get(name)
|
|
427
|
+
if wf is None:
|
|
428
|
+
raise KeyError(f"unknown workflow {name!r}")
|
|
429
|
+
definition = definition_dict(wf)
|
|
430
|
+
if fmt == "auto":
|
|
431
|
+
fmt = {"aws": "asl", "gcp": "gcp"}.get(project.config.provider.name, "json")
|
|
432
|
+
if fmt == "asl":
|
|
433
|
+
out: Any = compile_asl(definition, lambda f: f"<arn of {f}>", wf.description, wf.timeout or 0)
|
|
434
|
+
elif fmt == "gcp":
|
|
435
|
+
out, warnings = compile_gcp(definition, lambda f: f"<url of {f}>", name)
|
|
436
|
+
for w in warnings:
|
|
437
|
+
fail(f"warning: {w}")
|
|
438
|
+
else:
|
|
439
|
+
out = definition
|
|
440
|
+
click.echo(json.dumps(out, indent=2))
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# ----------------------------------------------------------------------------- providers / terraform
|
|
444
|
+
@cli.command()
|
|
445
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
446
|
+
def providers(as_json: bool) -> None:
|
|
447
|
+
"""List supported clouds and what each supports."""
|
|
448
|
+
from ..providers.registry import available, get_provider
|
|
449
|
+
|
|
450
|
+
rows = []
|
|
451
|
+
for name in available():
|
|
452
|
+
try:
|
|
453
|
+
rows.append(get_provider(name).describe())
|
|
454
|
+
except Exception as exc: # noqa: BLE001 - a broken plug-in must not hide the others
|
|
455
|
+
rows.append({"name": name, "tier": "unavailable", "error": str(exc)})
|
|
456
|
+
if as_json:
|
|
457
|
+
click.echo(json.dumps(rows, indent=2))
|
|
458
|
+
return
|
|
459
|
+
cols = ["http", "schedule", "queue", "storage", "topic", "workflow_native"]
|
|
460
|
+
echo(f"{'provider':<14}{'tier':<14}" + "".join(f"{c.replace('workflow_native', 'workflows'):<11}" for c in cols))
|
|
461
|
+
for r in rows:
|
|
462
|
+
caps = set(r.get("capabilities", []))
|
|
463
|
+
line = f"{r['name']:<14}{r.get('tier', ''):<14}"
|
|
464
|
+
for c in cols:
|
|
465
|
+
if c == "workflow_native":
|
|
466
|
+
line += f"{'native' if c in caps else 'embedded':<11}"
|
|
467
|
+
else:
|
|
468
|
+
line += f"{'yes' if c in caps else '-':<11}"
|
|
469
|
+
echo(line)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@cli.command(context_settings={"ignore_unknown_options": True, "allow_extra_args": True})
|
|
473
|
+
@project_options
|
|
474
|
+
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
|
|
475
|
+
def tf(project, args: tuple) -> None: # noqa: ANN001
|
|
476
|
+
"""Run any terraform command in the generated workspace, e.g. `pskit tf -- state list`."""
|
|
477
|
+
from ..terraform.runner import TerraformRunner
|
|
478
|
+
|
|
479
|
+
if not (project.work_dir / "main.tf.json").is_file():
|
|
480
|
+
raise click.ClickException("nothing generated yet; run `pskit package` first")
|
|
481
|
+
runner = TerraformRunner(project.work_dir, binary=project.config.provider.terraform.binary)
|
|
482
|
+
sys.exit(runner.passthrough(list(args)))
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def main(argv: Optional[list] = None) -> None:
|
|
486
|
+
os.environ.setdefault("PSKIT_LOG_FORMAT", "text")
|
|
487
|
+
cli.main(args=argv, prog_name="pskit")
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
if __name__ == "__main__": # pragma: no cover
|
|
491
|
+
main()
|