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,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
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli.main import main
2
+
3
+ main()
@@ -0,0 +1,2 @@
1
+ __version__ = "0.1.0"
2
+ __author__ = "Asif Khan @ Kavion.ai"
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()