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,304 @@
1
+ """Oracle Cloud Infrastructure: OCI Functions (container images) + API Gateway + Events.
2
+
3
+ OCI Functions only run container images. pskit writes ``Dockerfile``,
4
+ ``func.yaml`` and ``func.py`` into the bundle and either
5
+
6
+ * uses a pre-built image (``options.image``), or
7
+ * builds and pushes one with your local Docker (``options.image_repository``),
8
+ tagged with the artifact hash, from a ``terraform_data`` resource.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from ..config.schema import ServiceConfig
17
+ from .base import HTTP, STORAGE, Issue, Provider, RenderContext
18
+ from .terraform_doc import TerraformDoc, escape, ref, tf_id
19
+
20
+ FUNCTION = "oci_functions_function"
21
+ _EVENT_TYPES = {
22
+ "created": ["com.oraclecloud.objectstorage.createobject", "com.oraclecloud.objectstorage.updateobject"],
23
+ "deleted": ["com.oraclecloud.objectstorage.deleteobject"],
24
+ }
25
+
26
+
27
+ def oci_path(path: str) -> str:
28
+ """``/files/{path+}`` -> ``/files/{path*}`` (OCI's greedy wildcard)."""
29
+ return path.replace("+}", "*}")
30
+
31
+
32
+ class OracleProvider(Provider):
33
+ name = "oracle"
34
+ display_name = "Oracle Cloud Functions"
35
+ tier = "experimental"
36
+ terraform_source = "oracle/oci"
37
+ terraform_version = ">= 6.0"
38
+ terraform_local_name = "oci"
39
+ default_region = "us-ashburn-1"
40
+ capabilities = frozenset({HTTP, STORAGE})
41
+ max_name_length = 100
42
+ runtimes = {"python3.9": "3.9", "python3.11": "3.11"}
43
+ vendor_dependencies = False # dependencies are installed inside the Docker build
44
+ extra_requirements = ["fdk>=0.1.71"]
45
+ max_timeout = 300
46
+ notes = (
47
+ "Functions run as container images: set options.image (pre-built) or options.image_repository "
48
+ "(pskit runs docker build/push). Needs options.compartment_id and options.subnet_ids. HTTP via API "
49
+ "Gateway, object-storage triggers via Events rules. No schedule/queue/topic triggers. You must add IAM "
50
+ "policies letting API Gateway and Events invoke functions."
51
+ )
52
+
53
+ # ------------------------------------------------------------------ bundle
54
+ def shim_files(self, config: ServiceConfig) -> Dict[str, str]:
55
+ version = self.runtimes.get(config.provider.runtime, "3.11")
56
+ return {
57
+ "func.py": (
58
+ "# Generated by py-serverless-kit. FDK entrypoint: func.handler\n"
59
+ "from py_serverless_kit.runtime.adapters.oracle import handler # noqa: F401\n"
60
+ ),
61
+ "func.yaml": (
62
+ "schema_version: 20180708\n"
63
+ f"name: {config.service}\n"
64
+ "version: 0.0.1\n"
65
+ "runtime: python\n"
66
+ f"build_image: fnproject/python:{version}-dev\n"
67
+ f"run_image: fnproject/python:{version}\n"
68
+ "entrypoint: /python/bin/fdk /function/func.py handler\n"
69
+ f"memory: {config.provider.memory}\n"
70
+ ),
71
+ "Dockerfile": (
72
+ "# Generated by py-serverless-kit\n"
73
+ f"FROM fnproject/python:{version}-dev AS build-stage\n"
74
+ "WORKDIR /function\n"
75
+ "COPY requirements.txt /function/\n"
76
+ "RUN pip3 install --target /python/ --no-cache-dir -r requirements.txt && chmod -R o+r /python\n"
77
+ "COPY . /function/\n"
78
+ f"FROM fnproject/python:{version}\n"
79
+ "WORKDIR /function\n"
80
+ "COPY --from=build-stage /python /python\n"
81
+ "COPY --from=build-stage /function /function\n"
82
+ "RUN chmod -R o+r /function\n"
83
+ "ENV PYTHONPATH=/function:/python\n"
84
+ 'ENTRYPOINT ["/python/bin/fdk", "/function/func.py", "handler"]\n'
85
+ ),
86
+ }
87
+
88
+ def provider_block(self, ctx: RenderContext) -> Dict[str, Any]:
89
+ block: Dict[str, Any] = {"region": ctx.region}
90
+ for key in ("config_file_profile", "auth", "tenancy_ocid", "user_ocid", "fingerprint", "private_key_path"):
91
+ if ctx.opt(key):
92
+ block[key] = ctx.opt(key)
93
+ return block
94
+
95
+ def extra_validation(self, config: ServiceConfig) -> List[Issue]:
96
+ opts = config.provider.options
97
+ issues: List[Issue] = []
98
+ if not opts.get("compartment_id"):
99
+ issues.append(Issue("error", "options.compartment_id is required", "provider.options"))
100
+ if not opts.get("subnet_ids"):
101
+ issues.append(Issue("error", "options.subnet_ids (list of subnet OCIDs) is required", "provider.options"))
102
+ if not opts.get("image") and not opts.get("image_repository"):
103
+ issues.append(
104
+ Issue(
105
+ "error",
106
+ "OCI Functions need a container image. Either set options.image_repository "
107
+ "(e.g. iad.ocir.io/<namespace>/<repo>) and pskit runs `docker build` + `docker push` for you, "
108
+ "or build it yourself and set options.image:\n"
109
+ " docker login <region-key>.ocir.io\n"
110
+ " docker build -t <image> .pskit/oracle-<stage>/build\n"
111
+ " docker push <image>",
112
+ "provider.options.image",
113
+ )
114
+ )
115
+ has_http = False
116
+ for name, fn in config.functions.items():
117
+ for ev in fn.events_of("http"):
118
+ has_http = True
119
+ if ev.auth != "none":
120
+ issues.append(
121
+ Issue(
122
+ "error",
123
+ f"auth `{ev.auth}` is not wired up for OCI API Gateway yet; "
124
+ "use options to add an authentication policy",
125
+ f"functions.{name}",
126
+ )
127
+ )
128
+ for ev in fn.events_of("storage"):
129
+ if ev.prefix or ev.suffix:
130
+ issues.append(
131
+ Issue(
132
+ "warning",
133
+ "OCI Events rules cannot filter by prefix/suffix; filter in the handler",
134
+ f"functions.{name}",
135
+ )
136
+ )
137
+ if ev.existing:
138
+ issues.append(
139
+ Issue(
140
+ "warning", f"bucket {ev.bucket} must have 'Emit Object Events' enabled", f"functions.{name}"
141
+ )
142
+ )
143
+ if has_http:
144
+ issues.append(
145
+ Issue(
146
+ "warning",
147
+ "API Gateway needs an IAM policy to call functions, e.g. `ALLOW any-user to use functions-family "
148
+ "in compartment <name> where ALL {request.principal.type = 'ApiGateway'}`",
149
+ "provider",
150
+ )
151
+ )
152
+ return issues
153
+
154
+ # ------------------------------------------------------------------ render
155
+ def _image(self, doc: TerraformDoc, ctx: RenderContext) -> tuple[str, List[str]]:
156
+ if ctx.opt("image"):
157
+ return str(ctx.opt("image")), []
158
+ repo = str(ctx.opt("image_repository", "")).rstrip("/")
159
+ image = f"{repo}:{ctx.artifact.sha256[:16]}"
160
+ platform = "linux/arm64" if ctx.config.provider.architecture == "arm64" else "linux/amd64"
161
+ build = doc.resource(
162
+ "terraform_data",
163
+ "image",
164
+ {
165
+ "triggers_replace": [image],
166
+ "provisioner": [
167
+ {
168
+ "local-exec": {
169
+ "working_dir": "${path.module}",
170
+ "command": f"docker build --platform {platform} -t {image} build && docker push {image}",
171
+ }
172
+ }
173
+ ],
174
+ },
175
+ )
176
+ return image, [build]
177
+
178
+ def render_resources(self, doc: TerraformDoc, ctx: RenderContext) -> None:
179
+ cfg = ctx.config
180
+ compartment = str(ctx.opt("compartment_id", ""))
181
+ subnets = list(ctx.opt("subnet_ids") or [])
182
+ image, depends = self._image(doc, ctx)
183
+ app = doc.resource(
184
+ "oci_functions_application",
185
+ "app",
186
+ {
187
+ "compartment_id": compartment,
188
+ "display_name": ctx.prefix,
189
+ "subnet_ids": subnets,
190
+ "shape": "GENERIC_ARM" if cfg.provider.architecture == "arm64" else "GENERIC_X86",
191
+ "freeform_tags": ctx.tags(),
192
+ },
193
+ )
194
+ fns: Dict[str, str] = {}
195
+ for name, fn in ctx.function_items():
196
+ body: Dict[str, Any] = {
197
+ "application_id": ref(app, "id"),
198
+ "display_name": ctx.physical_name(name),
199
+ "image": image,
200
+ "memory_in_mbs": str(cfg.memory_of(fn)),
201
+ "timeout_in_seconds": cfg.timeout_of(fn),
202
+ "config": {k: escape(v) for k, v in ctx.environment(name, fn).items()},
203
+ "freeform_tags": ctx.tags(fn),
204
+ }
205
+ if depends:
206
+ body["depends_on"] = depends
207
+ body.update(fn.options)
208
+ fns[name] = doc.resource(FUNCTION, name, body)
209
+
210
+ deployment = self._http(doc, ctx, compartment, subnets, fns)
211
+ self._storage(doc, ctx, compartment, fns)
212
+
213
+ fn_out: Dict[str, Dict[str, Any]] = {}
214
+ for name, fn in ctx.function_items():
215
+ out: Dict[str, Any] = {
216
+ "id": ref(fns[name], "id"),
217
+ "console": f"https://cloud.oracle.com/functions/apps/{ref(app, 'id')}/fn/{ref(fns[name], 'id')}"
218
+ f"?region={ctx.region}",
219
+ }
220
+ if deployment and fn.events_of("http"):
221
+ out["base_url"] = ref(deployment, "endpoint")
222
+ fn_out[name] = out
223
+ wf_out = {n: {"id": ref(FUNCTION, tf_id(f"{n}-orchestrator"), "id")} for n in cfg.workflows}
224
+ doc.output(
225
+ "pskit",
226
+ {"functions": fn_out, "workflows": wf_out, "endpoint": ref(deployment, "endpoint") if deployment else None},
227
+ description="py-serverless-kit deployment summary",
228
+ )
229
+
230
+ def _http(
231
+ self, doc: TerraformDoc, ctx: RenderContext, compartment: str, subnets: List[str], fns: Dict[str, str]
232
+ ) -> Optional[str]:
233
+ routes = []
234
+ for name, fn in ctx.function_items():
235
+ for ev in fn.events_of("http"):
236
+ methods = [ev.method]
237
+ if ev.cors_config is not None and ev.method not in ("ANY", "OPTIONS"):
238
+ methods.append("OPTIONS")
239
+ routes.append(
240
+ {
241
+ "path": oci_path(ev.path),
242
+ "methods": methods,
243
+ "backend": {"type": "ORACLE_FUNCTIONS_BACKEND", "function_id": ref(fns[name], "id")},
244
+ }
245
+ )
246
+ if not routes:
247
+ return None
248
+ subnet = ctx.opt("gateway_subnet_id") or (subnets[0] if subnets else "")
249
+ gw = doc.resource(
250
+ "oci_apigateway_gateway",
251
+ "gateway",
252
+ {
253
+ "compartment_id": compartment,
254
+ "display_name": ctx.prefix,
255
+ "endpoint_type": str(ctx.opt("gateway_endpoint_type", "PUBLIC")),
256
+ "subnet_id": subnet,
257
+ "freeform_tags": ctx.tags(),
258
+ },
259
+ )
260
+ return doc.resource(
261
+ "oci_apigateway_deployment",
262
+ "http",
263
+ {
264
+ "compartment_id": compartment,
265
+ "gateway_id": ref(gw, "id"),
266
+ "display_name": ctx.prefix,
267
+ "path_prefix": f"/{ctx.service}",
268
+ "specification": {"routes": routes},
269
+ "freeform_tags": ctx.tags(),
270
+ },
271
+ )
272
+
273
+ def _storage(self, doc: TerraformDoc, ctx: RenderContext, compartment: str, fns: Dict[str, str]) -> None:
274
+ for name, fn in ctx.function_items():
275
+ for ev in fn.events_of("storage"):
276
+ label = f"b-{ev.bucket}"
277
+ if not ev.existing and not doc.has_resource("oci_objectstorage_bucket", label):
278
+ ns = doc.data("oci_objectstorage_namespace", "ns", {"compartment_id": compartment})
279
+ doc.resource(
280
+ "oci_objectstorage_bucket",
281
+ label,
282
+ {
283
+ "compartment_id": compartment,
284
+ "name": ev.bucket,
285
+ "namespace": ref(ns, "namespace"),
286
+ "object_events_enabled": True,
287
+ "freeform_tags": ctx.tags(),
288
+ },
289
+ )
290
+ types = [t for e in ev.events for t in _EVENT_TYPES[e]]
291
+ condition = {"eventType": types, "data": {"additionalDetails": {"bucketName": ev.bucket}}}
292
+ body: Dict[str, Any] = {
293
+ "compartment_id": compartment,
294
+ "display_name": ctx.physical_name(f"{name}-{ev.bucket}"),
295
+ "is_enabled": True,
296
+ "condition": escape(json.dumps(condition)),
297
+ "actions": {
298
+ "actions": [{"action_type": "FAAS", "is_enabled": True, "function_id": ref(fns[name], "id")}]
299
+ },
300
+ "freeform_tags": ctx.tags(),
301
+ }
302
+ if not ev.existing:
303
+ body["depends_on"] = [f"oci_objectstorage_bucket.{tf_id(label)}"]
304
+ doc.resource("oci_events_rule", f"{name}-{ev.bucket}", body)
@@ -0,0 +1,60 @@
1
+ """Provider registry. Third-party providers can register through the
2
+ ``py_serverless_kit.providers`` entry-point group."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import importlib
7
+ from typing import Dict, List, Type
8
+
9
+ from .base import Provider, ProviderError
10
+
11
+ BUILTIN: Dict[str, str] = {
12
+ "aws": "py_serverless_kit.providers.aws:AwsProvider",
13
+ "azure": "py_serverless_kit.providers.azure:AzureProvider",
14
+ "gcp": "py_serverless_kit.providers.gcp:GcpProvider",
15
+ "alibaba": "py_serverless_kit.providers.alibaba:AlibabaProvider",
16
+ "oracle": "py_serverless_kit.providers.oracle:OracleProvider",
17
+ "ibm": "py_serverless_kit.providers.ibm:IbmProvider",
18
+ "digitalocean": "py_serverless_kit.providers.digitalocean:DigitalOceanProvider",
19
+ "tencent": "py_serverless_kit.providers.tencent:TencentProvider",
20
+ "huawei": "py_serverless_kit.providers.huawei:HuaweiProvider",
21
+ "cloudflare": "py_serverless_kit.providers.cloudflare:CloudflareProvider",
22
+ }
23
+
24
+ _CACHE: Dict[str, Provider] = {}
25
+
26
+
27
+ def _load(target: str) -> Type[Provider]:
28
+ module, _, attr = target.partition(":")
29
+ return getattr(importlib.import_module(module), attr)
30
+
31
+
32
+ def _entry_points() -> Dict[str, str]:
33
+ try:
34
+ from importlib.metadata import entry_points
35
+ except ImportError: # pragma: no cover
36
+ return {}
37
+ try:
38
+ eps = entry_points(group="py_serverless_kit.providers") # type: ignore[call-arg]
39
+ except TypeError: # pragma: no cover - Python 3.9
40
+ eps = entry_points().get("py_serverless_kit.providers", []) # type: ignore[attr-defined,arg-type]
41
+ return {ep.name: ep.value for ep in eps}
42
+
43
+
44
+ def available() -> List[str]:
45
+ return sorted(set(BUILTIN) | set(_entry_points()))
46
+
47
+
48
+ def get_provider(name: str) -> Provider:
49
+ if name in _CACHE:
50
+ return _CACHE[name]
51
+ target = BUILTIN.get(name) or _entry_points().get(name)
52
+ if target is None:
53
+ raise ProviderError(f"unknown provider {name!r}; available: {', '.join(available())}")
54
+ provider = _load(target)()
55
+ _CACHE[name] = provider
56
+ return provider
57
+
58
+
59
+ def all_providers() -> List[Provider]:
60
+ return [get_provider(n) for n in available()]
@@ -0,0 +1,153 @@
1
+ """Convert the portable schedule syntax into each provider's dialect.
2
+
3
+ The config accepts:
4
+
5
+ * ``rate: "<n> minutes|hours|days"``
6
+ * ``cron: "<min> <hour> <day-of-month> <month> <day-of-week>"`` (standard 5-field unix cron)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import Tuple
13
+
14
+ from ..config.schema import ScheduleEvent
15
+
16
+ _RATE = re.compile(r"^\s*(\d+)\s+(minute|minutes|hour|hours|day|days)\s*$", re.I)
17
+
18
+
19
+ class ScheduleError(ValueError):
20
+ pass
21
+
22
+
23
+ def parse_rate(rate: str) -> Tuple[int, str]:
24
+ m = _RATE.match(rate)
25
+ if not m:
26
+ raise ScheduleError(f"invalid rate {rate!r}")
27
+ value, unit = int(m.group(1)), m.group(2).lower().rstrip("s")
28
+ if value <= 0:
29
+ raise ScheduleError("rate must be positive")
30
+ return value, unit # unit in minute|hour|day
31
+
32
+
33
+ def rate_to_cron(rate: str) -> str:
34
+ """Best-effort conversion of a rate to standard 5-field cron."""
35
+ value, unit = parse_rate(rate)
36
+ if unit == "minute":
37
+ if value < 60 and 60 % value == 0:
38
+ return "* * * * *" if value == 1 else f"*/{value} * * * *"
39
+ if value % 60 == 0:
40
+ return rate_to_cron(f"{value // 60} hours")
41
+ raise ScheduleError(f"rate {rate!r} is not expressible as cron; use a divisor of 60 minutes")
42
+ if unit == "hour":
43
+ if value < 24 and 24 % value == 0:
44
+ return "0 * * * *" if value == 1 else f"0 */{value} * * *"
45
+ if value % 24 == 0:
46
+ return rate_to_cron(f"{value // 24} days")
47
+ raise ScheduleError(f"rate {rate!r} is not expressible as cron; use a divisor of 24 hours")
48
+ if value == 1:
49
+ return "0 0 * * *"
50
+ if value <= 31:
51
+ return f"0 0 */{value} * *"
52
+ raise ScheduleError(f"rate {rate!r} is too long for cron")
53
+
54
+
55
+ def to_cron5(ev: ScheduleEvent) -> str:
56
+ return ev.cron.strip() if ev.cron else rate_to_cron(ev.rate or "")
57
+
58
+
59
+ def to_seconds(ev: ScheduleEvent) -> int:
60
+ value, unit = parse_rate(ev.rate or "")
61
+ return value * {"minute": 60, "hour": 3600, "day": 86400}[unit]
62
+
63
+
64
+ # ----------------------------------------------------------------- dialects
65
+
66
+
67
+ def _aws_dow(field: str) -> str:
68
+ """unix 0-6 (Sun=0) -> AWS 1-7 (Sun=1). Names pass through."""
69
+
70
+ def conv(token: str) -> str:
71
+ if token.isdigit():
72
+ return str(int(token) % 7 + 1)
73
+ return token
74
+
75
+ out = []
76
+ for part in field.split(","):
77
+ step = ""
78
+ if "/" in part:
79
+ part, step = part.split("/", 1)
80
+ step = "/" + step
81
+ if "-" in part:
82
+ a, b = part.split("-", 1)
83
+ out.append(f"{conv(a)}-{conv(b)}{step}")
84
+ else:
85
+ out.append(conv(part) + step)
86
+ return ",".join(out)
87
+
88
+
89
+ def aws_expression(ev: ScheduleEvent) -> str:
90
+ if ev.rate:
91
+ value, unit = parse_rate(ev.rate)
92
+ return f"rate({value} {unit}{'' if value == 1 else 's'})"
93
+ minute, hour, dom, month, dow = ev.cron.split() # type: ignore[union-attr]
94
+ if dow != "*" and dom == "*":
95
+ dom = "?"
96
+ dow = _aws_dow(dow)
97
+ elif dow != "*" and dom != "*":
98
+ raise ScheduleError("AWS cron cannot restrict both day-of-month and day-of-week")
99
+ else:
100
+ dow = "?"
101
+ return f"cron({minute} {hour} {dom} {month} {dow} *)"
102
+
103
+
104
+ def ncrontab(ev: ScheduleEvent) -> str:
105
+ """Azure Functions timer trigger: 6 fields, seconds first."""
106
+ if ev.rate:
107
+ value, unit = parse_rate(ev.rate)
108
+ if unit == "minute" and value < 60:
109
+ return f"0 */{value} * * * *"
110
+ return "0 " + to_cron5(ev)
111
+
112
+
113
+ def cron_with_seconds(ev: ScheduleEvent) -> str:
114
+ """Alibaba / Huawei style 6-field cron (seconds first)."""
115
+ return "0 " + to_cron5(ev)
116
+
117
+
118
+ def tencent_cron(ev: ScheduleEvent) -> str:
119
+ """Tencent SCF: 7 fields, seconds first, year last."""
120
+ return "0 " + to_cron5(ev) + " *"
121
+
122
+
123
+ def every_expression(ev: ScheduleEvent) -> str:
124
+ """``@every 5m`` style used by Alibaba FC and Huawei FunctionGraph."""
125
+ if not ev.rate:
126
+ raise ScheduleError("@every expressions require `rate`")
127
+ value, unit = parse_rate(ev.rate)
128
+ return f"@every {value}{unit[0]}"
129
+
130
+
131
+ _DOW_NAMES = ("SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT")
132
+
133
+
134
+ def named_cron5(ev: ScheduleEvent) -> str:
135
+ """Standard 5-field cron with numeric day-of-week replaced by SUN..SAT.
136
+
137
+ Clouds disagree on numeric weekdays (0=Sun vs 1=Sun); names are unambiguous.
138
+ """
139
+ minute, hour, dom, month, dow = to_cron5(ev).split()
140
+
141
+ def conv(token: str) -> str:
142
+ return _DOW_NAMES[int(token) % 7] if token.isdigit() else token
143
+
144
+ parts = []
145
+ for part in dow.split(","):
146
+ base, slash, step = part.partition("/")
147
+ if "-" in base:
148
+ a, b = base.split("-", 1)
149
+ base = f"{conv(a)}-{conv(b)}"
150
+ else:
151
+ base = conv(base)
152
+ parts.append(base + slash + step)
153
+ return " ".join([minute, hour, dom, month, ",".join(parts)])