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,417 @@
1
+ """Azure: Linux Function App (Consumption plan, Python v2 programming model).
2
+
3
+ One Function App hosts every function of a service. The bundle carries a
4
+ generated ``function_app.py`` that registers a decorated wrapper per trigger:
5
+
6
+ * ``http`` -> ``@app.route`` (``host.json`` sets ``routePrefix`` to ``""`` so the
7
+ configured paths are served as-is, without Azure's default ``/api`` prefix);
8
+ * ``schedule`` -> ``@app.timer_trigger`` (NCRONTAB, always UTC on Linux Consumption);
9
+ * ``queue`` -> Storage Queue in the app's storage account;
10
+ * ``storage`` -> Blob trigger on a container in the app's storage account (created only);
11
+ * ``topic`` -> Service Bus topic + one subscription per function.
12
+
13
+ Dependencies are installed by the remote Oryx build (``requirements.txt``).
14
+ Workflows run on the embedded engine (an ``<name>-orchestrator`` HTTP function).
15
+
16
+ ``azurerm`` >= 4 requires a subscription id: set ``provider.options.subscription_id``
17
+ or the ``ARM_SUBSCRIPTION_ID`` environment variable.
18
+
19
+ Options: ``subscription_id``, ``resource_group`` (existing), ``plan_sku`` (default Y1),
20
+ ``app_insights`` (default true), ``storage_connection`` (app-setting name for
21
+ ``existing`` queues / buckets in another account), ``servicebus_namespace_id`` and
22
+ ``servicebus_connection`` (for ``existing`` topics).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import re
29
+ from typing import Any, Dict, List, Optional
30
+
31
+ from ..config.schema import ServiceConfig
32
+ from . import schedule as sched
33
+ from .base import HTTP, QUEUE, SCHEDULE, STORAGE, TOPIC, Issue, Provider, RenderContext, stable_suffix
34
+ from .terraform_doc import TerraformDoc, escape, ref, tf_id
35
+
36
+ APP = "azurerm_linux_function_app"
37
+ _PER_FUNCTION_ENV = ("PSKIT_FUNCTION", "PSKIT_HANDLER")
38
+ _STORAGE_NAME = re.compile(r"^[a-z0-9](?!.*--)[a-z0-9-]{1,61}[a-z0-9]$")
39
+ DEFAULT_CONNECTION = "AzureWebJobsStorage"
40
+ SERVICEBUS_SETTING = "PSKIT_SERVICEBUS"
41
+
42
+
43
+ def py_ident(text: str) -> str:
44
+ ident = re.sub(r"\W", "_", text)
45
+ return ident if re.match(r"[A-Za-z]", ident) else "f_" + ident
46
+
47
+
48
+ def azure_route(path: str) -> str:
49
+ route = path.strip("/")
50
+ return re.sub(r"\{(\w+)\+\}", r"{*\1}", route)
51
+
52
+
53
+ def _hms(seconds: int) -> str:
54
+ return f"{seconds // 3600:02d}:{seconds % 3600 // 60:02d}:{seconds % 60:02d}"
55
+
56
+
57
+ class AzureProvider(Provider):
58
+ name = "azure"
59
+ display_name = "Azure Functions"
60
+ tier = "stable"
61
+ terraform_source = "hashicorp/azurerm"
62
+ terraform_version = ">= 4.9"
63
+ terraform_local_name = "azurerm"
64
+ default_region = "eastus"
65
+ capabilities = frozenset({HTTP, SCHEDULE, QUEUE, STORAGE, TOPIC})
66
+ max_name_length = 60
67
+ name_pattern = r"[^a-z0-9-]"
68
+ runtimes = {f"python3.{v}": f"3.{v}" for v in (9, 10, 11, 12)}
69
+ vendor_dependencies = False
70
+ extra_requirements = ["azure-functions"]
71
+ max_timeout = 600
72
+ notes = "Linux Consumption Function App, Python v2 model. Workflows use the embedded engine."
73
+
74
+ # ------------------------------------------------------------------ naming
75
+ def app_name(self, service: str, stage: str) -> str:
76
+ base = self.physical_name(service, stage, "")
77
+ return f"{base[:51].rstrip('-')}-{stable_suffix(service, stage, 'func', length=8)}"
78
+
79
+ # ------------------------------------------------------------------ bundle
80
+ def shim_files(self, config: ServiceConfig) -> Dict[str, str]:
81
+ opts = config.provider.options
82
+ lines = [
83
+ "# Generated by py-serverless-kit. Do not edit - regenerate with `pskit package`.",
84
+ "import azure.functions as func",
85
+ "",
86
+ "from py_serverless_kit.runtime.adapters import azure as _pskit",
87
+ "",
88
+ "app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)",
89
+ "",
90
+ ]
91
+ used: set = set()
92
+
93
+ def unique(base: str) -> str:
94
+ ident = py_ident(base)
95
+ candidate, n = ident, 1
96
+ while candidate.lower() in used:
97
+ n += 1
98
+ candidate = f"{ident}_{n}"
99
+ used.add(candidate.lower())
100
+ return candidate
101
+
102
+ for name, fn in sorted(config.functions.items()):
103
+ for i, ev in enumerate(fn.events):
104
+ c = ev.config
105
+ ident = unique(f"{name}_{ev.type}_{i}")
106
+ lines.append("")
107
+ lines.append(f'@app.function_name(name="{ident}")')
108
+ if ev.type == "http":
109
+ auth = "FUNCTION" if c.auth == "key" else "ANONYMOUS"
110
+ methods = "" if c.method == "ANY" else f", methods=[{c.method!r}]"
111
+ lines.append(
112
+ f"@app.route(route={azure_route(c.path)!r}{methods}, auth_level=func.AuthLevel.{auth})"
113
+ )
114
+ lines.append(f"def {ident}(req: func.HttpRequest) -> func.HttpResponse:")
115
+ lines.append(f" return _pskit.http(req, {name!r})")
116
+ elif ev.type == "schedule":
117
+ lines.append(
118
+ f"@app.timer_trigger(schedule={sched.ncrontab(c)!r}, arg_name='timer', run_on_startup=False)"
119
+ )
120
+ lines.append(f"def {ident}(timer: func.TimerRequest) -> None:")
121
+ lines.append(f" _pskit.timer(timer, {name!r}, {c.input!r})")
122
+ elif ev.type == "queue":
123
+ conn = opts.get("storage_connection", DEFAULT_CONNECTION) if c.existing else DEFAULT_CONNECTION
124
+ lines.append(f"@app.queue_trigger(arg_name='msg', queue_name={c.name!r}, connection={conn!r})")
125
+ lines.append(f"def {ident}(msg: func.QueueMessage) -> None:")
126
+ lines.append(f" _pskit.queue(msg, {name!r})")
127
+ elif ev.type == "storage":
128
+ conn = opts.get("storage_connection", DEFAULT_CONNECTION) if c.existing else DEFAULT_CONNECTION
129
+ path = f"{c.bucket}/{c.prefix or ''}{{name}}{c.suffix or ''}"
130
+ lines.append(f"@app.blob_trigger(arg_name='blob', path={path!r}, connection={conn!r})")
131
+ lines.append(f"def {ident}(blob: func.InputStream) -> None:")
132
+ lines.append(f" _pskit.blob(blob, {name!r}, {c.bucket!r})")
133
+ elif ev.type == "topic":
134
+ conn = opts.get("servicebus_connection", SERVICEBUS_SETTING) if c.existing else SERVICEBUS_SETTING
135
+ lines.append(
136
+ f"@app.service_bus_topic_trigger(arg_name='msg', topic_name={c.name!r}, "
137
+ f"subscription_name={self.subscription_name(name)!r}, connection={conn!r})"
138
+ )
139
+ lines.append(f"def {ident}(msg: func.ServiceBusMessage) -> None:")
140
+ lines.append(f" _pskit.service_bus(msg, {name!r})")
141
+ if not fn.events:
142
+ # Invoke-only functions (workflow tasks, invoke()) get a private POST route.
143
+ ident = unique(f"{name}_invoke")
144
+ lines.append("")
145
+ lines.append(f'@app.function_name(name="{ident}")')
146
+ route = "_pskit/invoke/" + name
147
+ lines.append(f"@app.route(route={route!r}, methods=['POST'], auth_level=func.AuthLevel.FUNCTION)")
148
+ lines.append(f"def {ident}(req: func.HttpRequest) -> func.HttpResponse:")
149
+ lines.append(f" return _pskit.http(req, {name!r})")
150
+ timeout = max([config.timeout_of(fn) for fn in config.functions.values()] or [config.provider.timeout])
151
+ host = {
152
+ "version": "2.0",
153
+ "extensionBundle": {"id": "Microsoft.Azure.Functions.ExtensionBundle", "version": "[4.*, 5.0.0)"},
154
+ "extensions": {"http": {"routePrefix": ""}},
155
+ "functionTimeout": _hms(min(timeout, self.max_timeout)),
156
+ "logging": {"logLevel": {"default": "Information"}},
157
+ }
158
+ return {"function_app.py": "\n".join(lines) + "\n", "host.json": json.dumps(host, indent=2) + "\n"}
159
+
160
+ @staticmethod
161
+ def subscription_name(function: str) -> str:
162
+ return re.sub(r"[^A-Za-z0-9_.-]", "-", function)[:50]
163
+
164
+ # ------------------------------------------------------------------ validation
165
+ def provider_block(self, ctx: RenderContext) -> Dict[str, Any]:
166
+ block: Dict[str, Any] = {"features": {}}
167
+ if ctx.opt("subscription_id"):
168
+ block["subscription_id"] = ctx.opt("subscription_id")
169
+ return block
170
+
171
+ def extra_validation(self, config: ServiceConfig) -> List[Issue]:
172
+ issues: List[Issue] = []
173
+ opts = config.provider.options
174
+ env_seen: Dict[str, str] = {}
175
+ if config.provider.iam.statements:
176
+ issues.append(Issue("warning", "iam.statements are AWS-style and ignored on Azure", "provider.iam"))
177
+ for name, fn in config.functions.items():
178
+ where = f"functions.{name}"
179
+ for key, value in fn.environment.items():
180
+ if key in env_seen and env_seen[key] != value:
181
+ issues.append(
182
+ Issue(
183
+ "warning",
184
+ f"environment {key} differs between functions; Azure app "
185
+ "settings are shared by the whole Function App (last one wins)",
186
+ where,
187
+ )
188
+ )
189
+ env_seen[key] = value
190
+ for ev in fn.events_of("http"):
191
+ if ev.auth in ("iam", "jwt"):
192
+ issues.append(
193
+ Issue(
194
+ "error",
195
+ f"http auth {ev.auth!r} is not supported on Azure; use none or key "
196
+ "(or put API Management / Easy Auth in front)",
197
+ where,
198
+ )
199
+ )
200
+ for ev in fn.events_of("schedule"):
201
+ if ev.timezone.upper() != "UTC":
202
+ issues.append(Issue("warning", "Linux Consumption timers run in UTC; timezone is ignored", where))
203
+ for ev in fn.events_of("queue"):
204
+ if not _STORAGE_NAME.match(ev.name):
205
+ issues.append(
206
+ Issue("error", f"queue name {ev.name!r} must be 3-63 lowercase letters, digits, dashes", where)
207
+ )
208
+ if ev.fifo:
209
+ issues.append(Issue("warning", "Storage Queues are not FIFO; queue.fifo is ignored", where))
210
+ if ev.dead_letter:
211
+ issues.append(
212
+ Issue(
213
+ "warning",
214
+ "Storage Queues move poison messages to '<queue>-poison'; dead_letter is ignored",
215
+ where,
216
+ )
217
+ )
218
+ for ev in fn.events_of("storage"):
219
+ if not _STORAGE_NAME.match(ev.bucket):
220
+ issues.append(
221
+ Issue(
222
+ "error",
223
+ f"container name {ev.bucket!r} must be 3-63 lowercase letters, digits, dashes",
224
+ where,
225
+ )
226
+ )
227
+ if "deleted" in ev.events:
228
+ issues.append(Issue("error", "Azure blob triggers only fire on created/updated blobs", where))
229
+ for ev in fn.events_of("topic"):
230
+ if ev.existing and not (opts.get("servicebus_namespace_id") and opts.get("servicebus_connection")):
231
+ issues.append(
232
+ Issue(
233
+ "error",
234
+ "existing topics need options.servicebus_namespace_id and "
235
+ "options.servicebus_connection (name of an app setting)",
236
+ where,
237
+ )
238
+ )
239
+ if ev.filter:
240
+ issues.append(
241
+ Issue("warning", "topic.filter is not applied on Azure Service Bus subscriptions", where)
242
+ )
243
+ return issues
244
+
245
+ # ------------------------------------------------------------------ render
246
+ def render_resources(self, doc: TerraformDoc, ctx: RenderContext) -> None:
247
+ cfg = ctx.config
248
+ loc = ctx.region
249
+ tags = ctx.tags()
250
+ if ctx.opt("resource_group"):
251
+ rg = doc.data("azurerm_resource_group", "rg", {"name": ctx.opt("resource_group")})
252
+ else:
253
+ rg = doc.resource(
254
+ "azurerm_resource_group", "rg", {"name": f"{ctx.prefix}-rg", "location": loc, "tags": tags}
255
+ )
256
+ rg_name = ref(rg, "name")
257
+
258
+ sa_base = re.sub(r"[^a-z0-9]", "", ctx.prefix.lower())[:16] or "pskit"
259
+ sa = doc.resource(
260
+ "azurerm_storage_account",
261
+ "app",
262
+ {
263
+ "name": sa_base + stable_suffix(ctx.prefix, loc, length=8),
264
+ "resource_group_name": rg_name,
265
+ "location": loc,
266
+ "account_tier": "Standard",
267
+ "account_replication_type": "LRS",
268
+ "min_tls_version": "TLS1_2",
269
+ "tags": tags,
270
+ },
271
+ )
272
+ plan = doc.resource(
273
+ "azurerm_service_plan",
274
+ "plan",
275
+ {
276
+ "name": f"{ctx.prefix}-plan",
277
+ "resource_group_name": rg_name,
278
+ "location": loc,
279
+ "os_type": "Linux",
280
+ "sku_name": ctx.opt("plan_sku", "Y1"),
281
+ "tags": tags,
282
+ },
283
+ )
284
+
285
+ site_config: Dict[str, Any] = {
286
+ "application_stack": {"python_version": self.runtime_id(cfg.provider.runtime)},
287
+ }
288
+ if ctx.opt("app_insights", True):
289
+ law = doc.resource(
290
+ "azurerm_log_analytics_workspace",
291
+ "logs",
292
+ {
293
+ "name": f"{ctx.prefix}-logs",
294
+ "resource_group_name": rg_name,
295
+ "location": loc,
296
+ "sku": "PerGB2018",
297
+ "retention_in_days": max(30, cfg.provider.log_retention_days or 30),
298
+ "tags": tags,
299
+ },
300
+ )
301
+ ai = doc.resource(
302
+ "azurerm_application_insights",
303
+ "ai",
304
+ {
305
+ "name": f"{ctx.prefix}-ai",
306
+ "resource_group_name": rg_name,
307
+ "location": loc,
308
+ "workspace_id": ref(law, "id"),
309
+ "application_type": "web",
310
+ "tags": tags,
311
+ },
312
+ )
313
+ site_config["application_insights_connection_string"] = ref(ai, "connection_string")
314
+ cors = [ev.cors_config for fn in cfg.functions.values() for ev in fn.events_of("http") if ev.cors_config]
315
+ if cors:
316
+ site_config["cors"] = {
317
+ "allowed_origins": sorted({o for c in cors for o in c.origins}),
318
+ "support_credentials": any(c.allow_credentials for c in cors),
319
+ }
320
+
321
+ settings: Dict[str, str] = {
322
+ "SCM_DO_BUILD_DURING_DEPLOYMENT": "true",
323
+ "ENABLE_ORYX_BUILD": "true",
324
+ "AzureWebJobsFeatureFlags": "EnableWorkerIndexing",
325
+ }
326
+ for name, fn in ctx.function_items():
327
+ for key, value in ctx.environment(name, fn).items():
328
+ if key not in _PER_FUNCTION_ENV:
329
+ settings[key] = escape(value)
330
+
331
+ # queues / containers / service bus
332
+ for _name, fn in ctx.function_items():
333
+ for ev in fn.events_of("queue"):
334
+ if not ev.existing and not doc.has_resource("azurerm_storage_queue", f"q-{ev.name}"):
335
+ doc.resource(
336
+ "azurerm_storage_queue", f"q-{ev.name}", {"name": ev.name, "storage_account_id": ref(sa, "id")}
337
+ )
338
+ for ev in fn.events_of("storage"):
339
+ if not ev.existing and not doc.has_resource("azurerm_storage_container", f"b-{ev.bucket}"):
340
+ doc.resource(
341
+ "azurerm_storage_container",
342
+ f"b-{ev.bucket}",
343
+ {"name": ev.bucket, "storage_account_id": ref(sa, "id"), "container_access_type": "private"},
344
+ )
345
+ ns: Optional[str] = None
346
+ for name, fn in ctx.function_items():
347
+ for ev in fn.events_of("topic"):
348
+ label = f"t-{ev.name}"
349
+ if ev.existing:
350
+ topic = doc.data(
351
+ "azurerm_servicebus_topic",
352
+ label,
353
+ {"name": ev.name, "namespace_id": ctx.opt("servicebus_namespace_id")},
354
+ )
355
+ else:
356
+ if ns is None:
357
+ ns = doc.resource(
358
+ "azurerm_servicebus_namespace",
359
+ "bus",
360
+ {
361
+ "name": f"{ctx.prefix[:40].rstrip('-')}-{stable_suffix(ctx.prefix, 'bus', length=6)}",
362
+ "resource_group_name": rg_name,
363
+ "location": loc,
364
+ "sku": "Standard",
365
+ "tags": tags,
366
+ },
367
+ )
368
+ settings[SERVICEBUS_SETTING] = ref(ns, "default_primary_connection_string")
369
+ if not doc.has_resource("azurerm_servicebus_topic", label):
370
+ doc.resource(
371
+ "azurerm_servicebus_topic", label, {"name": ev.name, "namespace_id": ref(ns, "id")}
372
+ )
373
+ topic = f"azurerm_servicebus_topic.{tf_id(label)}"
374
+ doc.resource(
375
+ "azurerm_servicebus_subscription",
376
+ f"{name}-{ev.name}",
377
+ {"name": self.subscription_name(name), "topic_id": ref(topic, "id"), "max_delivery_count": 10},
378
+ )
379
+
380
+ app_name = self.app_name(ctx.service, ctx.stage)
381
+ body: Dict[str, Any] = {
382
+ "name": app_name,
383
+ "resource_group_name": rg_name,
384
+ "location": loc,
385
+ "service_plan_id": ref(plan, "id"),
386
+ "storage_account_name": ref(sa, "name"),
387
+ "storage_account_access_key": ref(sa, "primary_access_key"),
388
+ "functions_extension_version": "~4",
389
+ "https_only": True,
390
+ "zip_deploy_file": "${path.module}/artifact.zip",
391
+ "app_settings": settings,
392
+ "site_config": site_config,
393
+ "tags": tags,
394
+ }
395
+ app = doc.resource(APP, "app", body)
396
+ host = "https://" + ref(app, "default_hostname")
397
+
398
+ fn_out: Dict[str, Dict[str, Any]] = {}
399
+ for name, fn in ctx.function_items():
400
+ out: Dict[str, Any] = {
401
+ "id": f"{app_name}/{name}",
402
+ "console": "https://portal.azure.com/#resource" + ref(app, "id") + "/functions",
403
+ }
404
+ http_events = fn.events_of("http")
405
+ if http_events:
406
+ out["base_url"] = host
407
+ out["url"] = host + "/" + azure_route(http_events[0].path)
408
+ fn_out[name] = out
409
+ wf_out = {
410
+ name: {"id": f"{app_name}/{name}-orchestrator", "url": host + f"/workflows/{name}"}
411
+ for name in sorted(cfg.workflows)
412
+ }
413
+ doc.output(
414
+ "pskit",
415
+ {"functions": fn_out, "workflows": wf_out, "endpoint": host},
416
+ description="py-serverless-kit deployment summary",
417
+ )