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,176 @@
1
+ """Google Cloud Functions (2nd gen) adapter.
2
+
3
+ Two entrypoints are exposed through the generated ``main.py``:
4
+
5
+ * :func:`http` - ``@functions_framework.http``; receives a Flask request. A body
6
+ carrying the ``__pskit`` envelope (Cloud Scheduler, Cloud Workflows,
7
+ ``invoke()``) is dispatched as a *non-HTTP* event and answered with the raw
8
+ JSON result, or a 500 ``{"errorType", "cause"}`` that the workflow compiler
9
+ understands.
10
+ * :func:`cloud_event` - ``@functions_framework.cloud_event``; Pub/Sub
11
+ ``messagePublished`` (queue / topic) and Cloud Storage object events.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import uuid
19
+ from typing import Any, Dict, List, Optional, Tuple
20
+
21
+ from ..context import Context
22
+ from ..dispatch import execute
23
+ from ..event import Event, EventType, Headers, HttpRequest, Record, parse_time
24
+ from ..response import Response, dumps
25
+ from .base import action_from, b64_text, function_name, unwrap_envelope
26
+
27
+ PROVIDER = "gcp"
28
+ _JSON = "application/json"
29
+
30
+
31
+ def _context(fn: str, request_id: Optional[str] = None, raw: Any = None) -> Context:
32
+ return Context(request_id=request_id or str(uuid.uuid4()), function=fn, provider=PROVIDER, raw=raw)
33
+
34
+
35
+ def _request_id(headers: Any) -> Optional[str]:
36
+ for key in ("Function-Execution-Id", "X-Cloud-Trace-Context"):
37
+ value = headers.get(key) if headers is not None else None
38
+ if value:
39
+ return str(value).split("/")[0]
40
+ return None
41
+
42
+
43
+ # ------------------------------------------------------------------ HTTP
44
+
45
+
46
+ def to_http_event(request: Any, fn: str) -> Event:
47
+ headers = dict(request.headers.items()) if hasattr(request.headers, "items") else dict(request.headers or {})
48
+ args = getattr(request, "args", None) or {}
49
+ multi: Dict[str, List[str]] = {}
50
+ if hasattr(args, "getlist"):
51
+ multi = {k: list(args.getlist(k)) for k in args.keys()}
52
+ query = {k: (v[-1] if v else "") for k, v in multi.items()} if multi else dict(args)
53
+ req = HttpRequest(
54
+ method=request.method,
55
+ path=request.path or "/",
56
+ headers=Headers(headers),
57
+ query=query,
58
+ multi_query=multi or {k: [v] for k, v in query.items()},
59
+ body=request.get_data() or b"",
60
+ source_ip=headers.get("X-Forwarded-For", "").split(",")[0].strip() or getattr(request, "remote_addr", None),
61
+ )
62
+ return Event(type=EventType.HTTP, provider=PROVIDER, function=fn, http=req, raw=request)
63
+
64
+
65
+ def _render(resp: Response) -> Tuple[bytes, int, List[Tuple[str, str]]]:
66
+ status, headers, body = resp.render()
67
+ items = list(headers.items()) + [("set-cookie", c) for c in resp.cookies]
68
+ return body, status, items
69
+
70
+
71
+ def http(request: Any) -> Tuple[bytes, int, Any]:
72
+ fn = function_name()
73
+ body = request.get_data() or b""
74
+ req_id = _request_id(request.headers)
75
+ wrapped = unwrap_envelope(body) if body[:1] == b"{" else None
76
+ if wrapped is not None:
77
+ kind, data, meta = wrapped
78
+ event = Event(type=kind, provider=PROVIDER, function=fn, data=data, source=meta.get("source"), raw=request)
79
+ try:
80
+ result = execute(event, _context(fn, req_id, request))
81
+ except Exception as exc: # noqa: BLE001 - reported to the caller (workflow / scheduler)
82
+ payload = {"errorType": type(exc).__name__, "cause": str(exc)}
83
+ return dumps(payload).encode("utf-8"), 500, [("content-type", _JSON)]
84
+ return dumps(result).encode("utf-8"), 200, [("content-type", _JSON)]
85
+ event = to_http_event(request, fn)
86
+ return _render(execute(event, _context(fn, req_id, request)))
87
+
88
+
89
+ # ------------------------------------------------------------------ CloudEvents
90
+
91
+
92
+ def _ce_attr(ce: Any, key: str, default: Any = None) -> Any:
93
+ try:
94
+ value = ce[key]
95
+ except (KeyError, TypeError, IndexError):
96
+ value = None
97
+ if value is None:
98
+ value = getattr(ce, key, None)
99
+ return default if value is None else value
100
+
101
+
102
+ def _ce_data(ce: Any) -> Any:
103
+ data = getattr(ce, "data", None)
104
+ if data is None and isinstance(ce, dict):
105
+ data = ce.get("data")
106
+ if isinstance(data, (bytes, str)):
107
+ try:
108
+ data = json.loads(data)
109
+ except ValueError:
110
+ pass
111
+ return data
112
+
113
+
114
+ def _storage_wanted(key: str) -> bool:
115
+ prefix = os.environ.get("PSKIT_STORAGE_PREFIX", "")
116
+ suffix = os.environ.get("PSKIT_STORAGE_SUFFIX", "")
117
+ return key.startswith(prefix) and key.endswith(suffix)
118
+
119
+
120
+ def to_cloud_event(ce: Any, fn: str = "") -> Optional[Event]:
121
+ """Translate a CloudEvent; returns ``None`` when filtered out (storage prefix/suffix)."""
122
+ ce_type = str(_ce_attr(ce, "type", ""))
123
+ source = _ce_attr(ce, "source")
124
+ when = parse_time(_ce_attr(ce, "time"))
125
+ data = _ce_data(ce) or {}
126
+ if ce_type.startswith("google.cloud.pubsub.topic"):
127
+ msg = data.get("message") or {}
128
+ body = b64_text(msg.get("data"))
129
+ wrapped = unwrap_envelope(body) if isinstance(body, str) else None
130
+ if wrapped is not None: # Cloud Scheduler -> Pub/Sub target
131
+ kind, payload, meta = wrapped
132
+ return Event(
133
+ type=kind,
134
+ provider=PROVIDER,
135
+ function=fn,
136
+ data=payload,
137
+ time=when,
138
+ source=meta.get("source", source),
139
+ raw=ce,
140
+ )
141
+ kind = EventType.QUEUE if os.environ.get("PSKIT_TRIGGER_KIND") == "queue" else EventType.TOPIC
142
+ record = Record(
143
+ id=msg.get("messageId") or msg.get("message_id") or _ce_attr(ce, "id"),
144
+ body=body,
145
+ attributes=dict(msg.get("attributes") or {}),
146
+ source=data.get("subscription") or source,
147
+ raw=msg,
148
+ )
149
+ return Event(type=kind, provider=PROVIDER, function=fn, records=[record], time=when, source=source, raw=ce)
150
+ if ce_type.startswith("google.cloud.storage.object"):
151
+ key = data.get("name", "")
152
+ if not _storage_wanted(key):
153
+ return None
154
+ size = data.get("size")
155
+ record = Record(
156
+ id=str(data.get("generation") or _ce_attr(ce, "id") or ""),
157
+ bucket=data.get("bucket"),
158
+ key=key,
159
+ size=int(size) if size not in (None, "") else None,
160
+ action=action_from(ce_type.rsplit(".", 1)[-1]),
161
+ attributes={"content_type": data.get("contentType"), "event_type": ce_type},
162
+ source=source,
163
+ raw=data,
164
+ )
165
+ return Event(
166
+ type=EventType.STORAGE, provider=PROVIDER, function=fn, records=[record], time=when, source=source, raw=ce
167
+ )
168
+ return Event(type=EventType.INVOKE, provider=PROVIDER, function=fn, data=data, time=when, source=source, raw=ce)
169
+
170
+
171
+ def cloud_event(ce: Any) -> Any:
172
+ fn = function_name()
173
+ event = to_cloud_event(ce, fn)
174
+ if event is None:
175
+ return None
176
+ return execute(event, _context(fn, str(_ce_attr(ce, "id", "")) or None, ce))
@@ -0,0 +1,173 @@
1
+ """Huawei Cloud FunctionGraph adapter.
2
+
3
+ Handles APIG (dedicated gateway) events, timer triggers, OBS triggers, SMN
4
+ topic triggers and direct invocation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from typing import Any, Dict
11
+ from urllib.parse import unquote_plus
12
+
13
+ from ..context import Context
14
+ from ..dispatch import execute
15
+ from ..event import Event, EventType, Headers, HttpRequest, Record, parse_time
16
+ from ..response import Response
17
+ from .base import action_from, function_name, maybe_json, unwrap_envelope
18
+
19
+ PROVIDER = "huawei"
20
+
21
+
22
+ def _call(ctx: Any, method: str, default: Any = None) -> Any:
23
+ fn = getattr(ctx, method, None)
24
+ if callable(fn):
25
+ try:
26
+ return fn()
27
+ except Exception: # noqa: BLE001 - context helpers are best effort
28
+ return default
29
+ return default
30
+
31
+
32
+ def _http(evt: Dict[str, Any]) -> HttpRequest:
33
+ rc = evt.get("requestContext") or {}
34
+ query: Dict[str, str] = {}
35
+ multi = {}
36
+ for key, value in (evt.get("queryStringParameters") or {}).items():
37
+ values = [str(v) for v in value] if isinstance(value, list) else [str(value)]
38
+ multi[key] = values
39
+ query[key] = values[-1] if values else ""
40
+ return HttpRequest(
41
+ method=evt.get("httpMethod", "GET"),
42
+ path=evt.get("path") or "/",
43
+ headers=Headers(evt.get("headers") or {}),
44
+ query=query,
45
+ multi_query=multi,
46
+ path_params={k: str(v) for k, v in (evt.get("pathParameters") or {}).items()},
47
+ body=HttpRequest.decode_body(evt.get("body"), bool(evt.get("isBase64Encoded"))),
48
+ source_ip=rc.get("sourceIp"),
49
+ )
50
+
51
+
52
+ def _obs(evt: Dict[str, Any], fn: str) -> Event:
53
+ records = []
54
+ for r in evt.get("Records") or []:
55
+ obs = r.get("obs") or r.get("s3") or {}
56
+ obj = obs.get("object") or {}
57
+ records.append(
58
+ Record(
59
+ id=obj.get("sequencer"),
60
+ bucket=(obs.get("bucket") or {}).get("name"),
61
+ key=unquote_plus(obj.get("key", "")),
62
+ size=obj.get("size"),
63
+ action=action_from(r.get("eventName", "")),
64
+ attributes={"event_name": r.get("eventName"), "etag": obj.get("eTag")},
65
+ source="obs",
66
+ raw=r,
67
+ )
68
+ )
69
+ first = (evt.get("Records") or [{}])[0]
70
+ return Event(
71
+ type=EventType.STORAGE,
72
+ provider=PROVIDER,
73
+ function=fn,
74
+ records=records,
75
+ source="obs",
76
+ time=parse_time(first.get("eventTime")),
77
+ raw=evt,
78
+ )
79
+
80
+
81
+ def _smn(evt: Dict[str, Any], fn: str) -> Event:
82
+ records = []
83
+ for r in evt.get("record") or []:
84
+ smn = r.get("smn") or {}
85
+ records.append(
86
+ Record(
87
+ id=smn.get("message_id"),
88
+ body=smn.get("message"),
89
+ attributes={"subject": smn.get("subject"), **(smn.get("message_attributes") or {})},
90
+ source=smn.get("topic_urn"),
91
+ raw=r,
92
+ )
93
+ )
94
+ first = ((evt.get("record") or [{}])[0].get("smn")) or {}
95
+ return Event(
96
+ type=EventType.TOPIC,
97
+ provider=PROVIDER,
98
+ function=fn,
99
+ records=records,
100
+ source=first.get("topic_urn"),
101
+ time=parse_time(first.get("timestamp")),
102
+ raw=evt,
103
+ )
104
+
105
+
106
+ def to_event(native: Any, ctx: Any = None) -> Event:
107
+ fn = function_name(_call(ctx, "getFunctionName", "") or "")
108
+ evt = maybe_json(native)
109
+ wrapped = unwrap_envelope(evt)
110
+ if wrapped is not None:
111
+ kind, data, meta = wrapped
112
+ return Event(type=kind, provider=PROVIDER, function=fn, data=data, source=meta.get("source"), raw=evt)
113
+ if isinstance(evt, dict):
114
+ if "httpMethod" in evt and ("requestContext" in evt or "path" in evt):
115
+ return Event(type=EventType.HTTP, provider=PROVIDER, function=fn, http=_http(evt), raw=evt)
116
+ if str(evt.get("trigger_type", "")).upper() == "TIMER":
117
+ user_event = evt.get("user_event")
118
+ inner = unwrap_envelope(user_event) if user_event else None
119
+ return Event(
120
+ type=EventType.SCHEDULE,
121
+ provider=PROVIDER,
122
+ function=fn,
123
+ data=inner[1] if inner else (maybe_json(user_event) if user_event else None),
124
+ time=parse_time(evt.get("time")),
125
+ source=evt.get("trigger_name"),
126
+ raw=evt,
127
+ )
128
+ records = evt.get("Records")
129
+ if (
130
+ isinstance(records, list)
131
+ and records
132
+ and isinstance(records[0], dict)
133
+ and ("obs" in records[0] or "s3" in records[0])
134
+ ):
135
+ return _obs(evt, fn)
136
+ smn = evt.get("record")
137
+ if isinstance(smn, list) and smn and isinstance(smn[0], dict) and "smn" in smn[0]:
138
+ return _smn(evt, fn)
139
+ return Event(type=EventType.INVOKE, provider=PROVIDER, function=fn, data=evt, raw=native)
140
+
141
+
142
+ def to_context(ctx: Any, fn: str) -> Context:
143
+ if ctx is None:
144
+ return Context(function=fn, provider=PROVIDER)
145
+ remaining = getattr(ctx, "getRemainingTimeInMilliSeconds", None)
146
+ memory = _call(ctx, "getMemorySize")
147
+ remaining_ms = _call(ctx, "getRemainingTimeInMilliSeconds")
148
+ return Context(
149
+ request_id=str(_call(ctx, "getRequestID", "") or ""),
150
+ function=fn,
151
+ provider=PROVIDER,
152
+ memory_mb=int(memory) if memory else None,
153
+ deadline=(time.time() + float(remaining_ms) / 1000.0) if remaining_ms else None,
154
+ raw=ctx,
155
+ _remaining=remaining if callable(remaining) else None,
156
+ )
157
+
158
+
159
+ def to_native(result: Any, event: Event) -> Any:
160
+ if event.http is not None:
161
+ resp: Response = result
162
+ status, headers, body, is_b64 = resp.render_for_gateway()
163
+ if resp.cookies:
164
+ headers["set-cookie"] = resp.cookies[0] if len(resp.cookies) == 1 else ", ".join(resp.cookies)
165
+ return {"statusCode": status, "isBase64Encoded": is_b64, "headers": headers, "body": body}
166
+ return result
167
+
168
+
169
+ def handler(native: Any, ctx: Any = None) -> Any:
170
+ """FunctionGraph entrypoint - the generated ``index.handler`` points here."""
171
+ event = to_event(native, ctx)
172
+ context = to_context(ctx, event.function)
173
+ return to_native(execute(event, context), event)
@@ -0,0 +1,91 @@
1
+ """IBM Cloud Code Engine functions adapter.
2
+
3
+ Code Engine calls ``main(params)``; HTTP request data arrives in ``__ce_*`` keys
4
+ (``__ow_*`` from the older Cloud Functions / OpenWhisk platform is accepted too)
5
+ and the function returns ``{"statusCode", "headers", "body"}``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Any, Dict, Optional
12
+ from urllib.parse import parse_qs
13
+
14
+ from ..context import Context
15
+ from ..event import Event, EventType, Headers, HttpRequest
16
+ from ..response import dumps
17
+ from ._httpfn import decode_body, event_from_request, run
18
+ from .base import function_name, unwrap_envelope
19
+
20
+ PROVIDER = "ibm"
21
+ _PREFIXES = ("__ce_", "__ow_")
22
+
23
+
24
+ def _get(params: Dict[str, Any], key: str) -> Any:
25
+ for prefix in _PREFIXES:
26
+ if prefix + key in params:
27
+ return params[prefix + key]
28
+ return None
29
+
30
+
31
+ def _query(raw: Any) -> Dict[str, Any]:
32
+ if isinstance(raw, dict):
33
+ return {str(k): v if isinstance(v, list) else [str(v)] for k, v in raw.items()}
34
+ if isinstance(raw, str) and raw:
35
+ return parse_qs(raw.lstrip("?"), keep_blank_values=True)
36
+ return {}
37
+
38
+
39
+ def to_request(params: Dict[str, Any]) -> Optional[HttpRequest]:
40
+ method = _get(params, "method")
41
+ if not method:
42
+ return None
43
+ headers = dict(_get(params, "headers") or {})
44
+ body = _get(params, "body")
45
+ if body is None:
46
+ # Code Engine merges JSON bodies into params; rebuild it from the plain keys.
47
+ plain = {k: v for k, v in params.items() if not k.startswith(_PREFIXES)}
48
+ body = dumps(plain) if plain else None
49
+ multi = _query(_get(params, "query"))
50
+ return HttpRequest(
51
+ method=str(method),
52
+ path=_get(params, "path") or "/",
53
+ headers=Headers(headers),
54
+ query={k: v[-1] for k, v in multi.items() if v},
55
+ multi_query=multi,
56
+ body=decode_body(body, headers),
57
+ source_ip=headers.get("x-forwarded-for", "").split(",")[0].strip() or None,
58
+ )
59
+
60
+
61
+ def main(params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
62
+ params = params or {}
63
+ fn = function_name()
64
+ req = to_request(params)
65
+ if req is None:
66
+ # Direct invocation without HTTP metadata: params are the payload.
67
+ wrapped = unwrap_envelope(params)
68
+ if wrapped is not None:
69
+ event = Event(type=wrapped[0], provider=PROVIDER, function=fn, data=wrapped[1], raw=params)
70
+ else:
71
+ event = Event(type=EventType.INVOKE, provider=PROVIDER, function=fn, data=params, raw=params)
72
+ else:
73
+ event = event_from_request(req, PROVIDER, fn, raw=params)
74
+ context = Context(
75
+ request_id=os.environ.get("CE_REQUEST_ID", "") or headers_id(params),
76
+ function=fn,
77
+ provider=PROVIDER,
78
+ raw=params,
79
+ )
80
+ status, headers, body, _is_b64 = run(event, context)
81
+ return {"statusCode": status, "headers": headers, "body": body}
82
+
83
+
84
+ def headers_id(params: Dict[str, Any]) -> str:
85
+ headers = _get(params, "headers") or {}
86
+ for key in ("x-request-id", "X-Request-Id", "x-ce-request-id"):
87
+ if key in headers:
88
+ return str(headers[key])
89
+ import uuid
90
+
91
+ return str(uuid.uuid4())
@@ -0,0 +1,161 @@
1
+ """Oracle Cloud (OCI) Functions adapter, built on the Fn Project Python FDK.
2
+
3
+ ``func.py`` exposes ``handler(ctx, data)``. Invocations come from:
4
+
5
+ * OCI API Gateway - the FDK context carries ``Fn-Http-Method``,
6
+ ``Fn-Http-Request-Url`` and ``Fn-Http-H-<name>`` headers;
7
+ * OCI Events (object storage) - a CloudEvents JSON body;
8
+ * direct invoke (``oci fn function invoke``) / the ``__pskit`` envelope.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import io
15
+ from typing import Any, Dict, Optional
16
+ from urllib.parse import parse_qs, urlsplit
17
+
18
+ from ..context import Context
19
+ from ..event import Event, EventType, Headers, HttpRequest, Record, parse_time
20
+ from ._httpfn import event_from_request, run
21
+ from .base import action_from, function_name, maybe_json, unwrap_envelope
22
+
23
+ PROVIDER = "oracle"
24
+ _HEADER_PREFIX = "fn-http-h-"
25
+
26
+
27
+ def _call(obj: Any, name: str, default: Any = None) -> Any:
28
+ fn = getattr(obj, name, None)
29
+ if callable(fn):
30
+ try:
31
+ return fn()
32
+ except Exception: # noqa: BLE001 - FDK accessors are best effort
33
+ return default
34
+ return default
35
+
36
+
37
+ def _ctx_headers(ctx: Any) -> Dict[str, Any]:
38
+ raw = _call(ctx, "Headers") or {}
39
+ return {str(k).lower(): v for k, v in dict(raw).items()}
40
+
41
+
42
+ def _first(value: Any) -> Any:
43
+ if isinstance(value, (list, tuple)):
44
+ return value[0] if value else None
45
+ return value
46
+
47
+
48
+ def _read(data: Any) -> bytes:
49
+ if data is None:
50
+ return b""
51
+ if isinstance(data, (bytes, bytearray)):
52
+ return bytes(data)
53
+ if isinstance(data, str):
54
+ return data.encode("utf-8")
55
+ if hasattr(data, "getvalue"):
56
+ return data.getvalue()
57
+ if hasattr(data, "read"):
58
+ return data.read()
59
+ return str(data).encode("utf-8")
60
+
61
+
62
+ def to_request(ctx: Any, body: bytes) -> Optional[HttpRequest]:
63
+ headers = _ctx_headers(ctx)
64
+ method = _first(headers.get("fn-http-method"))
65
+ if not method:
66
+ http_headers = _call(ctx, "HttpHeaders")
67
+ if http_headers is None:
68
+ return None
69
+ method = _call(ctx, "Method")
70
+ if not method:
71
+ return None
72
+ url = _first(headers.get("fn-http-request-url")) or _call(ctx, "RequestURL") or "/"
73
+ http_headers = _call(ctx, "HttpHeaders")
74
+ if http_headers is None:
75
+ http_headers = {k[len(_HEADER_PREFIX) :]: v for k, v in headers.items() if k.startswith(_HEADER_PREFIX)}
76
+ parts = urlsplit(str(url))
77
+ multi = parse_qs(parts.query, keep_blank_values=True)
78
+ return HttpRequest(
79
+ method=str(method),
80
+ path=parts.path or "/",
81
+ headers=Headers(dict(http_headers)),
82
+ query={k: v[-1] for k, v in multi.items()},
83
+ multi_query=multi,
84
+ body=body,
85
+ )
86
+
87
+
88
+ def _cloud_event(payload: Dict[str, Any], fn: str) -> Optional[Event]:
89
+ etype = str(payload.get("eventType") or payload.get("type") or "")
90
+ if "objectstorage" not in etype:
91
+ return None
92
+ data = payload.get("data") or {}
93
+ details = data.get("additionalDetails") or {}
94
+ record = Record(
95
+ id=str(payload.get("eventID") or payload.get("id") or ""),
96
+ bucket=details.get("bucketName"),
97
+ key=data.get("resourceName"),
98
+ action=action_from(etype),
99
+ attributes={"event_type": etype, "namespace": details.get("namespace")},
100
+ source=str(payload.get("source") or "objectstorage"),
101
+ raw=payload,
102
+ )
103
+ return Event(
104
+ type=EventType.STORAGE,
105
+ provider=PROVIDER,
106
+ function=fn,
107
+ records=[record],
108
+ source=record.source,
109
+ time=parse_time(payload.get("eventTime") or payload.get("time")),
110
+ raw=payload,
111
+ )
112
+
113
+
114
+ def to_event(ctx: Any, data: Any, fn: str) -> Event:
115
+ body = _read(data)
116
+ req = to_request(ctx, body)
117
+ if req is not None:
118
+ return event_from_request(req, PROVIDER, fn, raw=ctx)
119
+ payload = maybe_json(body) if body else None
120
+ wrapped = unwrap_envelope(payload)
121
+ if wrapped is not None:
122
+ kind, value, meta = wrapped
123
+ return Event(type=kind, provider=PROVIDER, function=fn, data=value, source=meta.get("source"), raw=payload)
124
+ if isinstance(payload, dict):
125
+ storage = _cloud_event(payload, fn)
126
+ if storage is not None:
127
+ return storage
128
+ return Event(type=EventType.INVOKE, provider=PROVIDER, function=fn, data=payload, raw=payload)
129
+
130
+
131
+ def _respond(ctx: Any, status: int, headers: Dict[str, str], body: Any, is_http: bool) -> Any:
132
+ try:
133
+ from fdk import response as fdk_response # type: ignore[import-not-found]
134
+ except ImportError: # local tests without the FDK
135
+ return {"status": status, "headers": headers, "body": body}
136
+ if is_http:
137
+ try:
138
+ from fdk import context as fdk_context # type: ignore[import-not-found]
139
+
140
+ factory = getattr(fdk_context, "GetHTTPGatewayContext", None) or getattr(
141
+ fdk_context, "HTTPGatewayContext", None
142
+ )
143
+ if factory is not None:
144
+ gw = factory(ctx)
145
+ gw.SetStatusCode(status)
146
+ gw.SetResponseHeaders(headers, status)
147
+ except Exception: # noqa: BLE001, S110 - fall back to protocol headers
148
+ pass
149
+ headers = dict(headers)
150
+ headers["Fn-Http-Status"] = str(status)
151
+ return fdk_response.Response(ctx, response_data=body, headers=headers, status_code=200 if is_http else status)
152
+
153
+
154
+ def handler(ctx: Any, data: Any = None) -> Any:
155
+ """FDK entrypoint used by the generated ``func.py``."""
156
+ fn = function_name()
157
+ event = to_event(ctx, data if data is not None else io.BytesIO(b""), fn)
158
+ context = Context(request_id=str(_call(ctx, "CallID") or ""), function=fn, provider=PROVIDER, raw=ctx)
159
+ status, headers, body, is_b64 = run(event, context)
160
+ payload: Any = base64.b64decode(body) if is_b64 else body
161
+ return _respond(ctx, status, headers, payload, event.http is not None)