@fireemu/darwin-arm64 0.42.0 → 0.44.0

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.
@@ -0,0 +1,370 @@
1
+ """The endpoints the Python SDK declares, as fireemu's canonical manifest.
2
+
3
+ `firebase_functions.private.serving.get_functions()` returns the decorated functions, each with a
4
+ `__firebase_endpoint__` (the SDK's `ManifestEndpoint`, the same v1alpha1 endpoint schema the Node
5
+ SDK produces). This module is the Python counterpart of `describe()` in
6
+ `tools/runner-node/index.mjs`: it resolves option expressions, treats `None` and the SDK's reset
7
+ sentinel as unset, and maps each trigger to the daemon's `functions[]` entry
8
+ (`crates/fireemu-adapter-functions/src/manifest_json.rs`). A function it cannot serve is reported
9
+ under `ignored[]`, never dropped.
10
+ """
11
+
12
+ import enum
13
+ import math
14
+ from typing import Any
15
+
16
+ from firebase_functions.params import Expression
17
+ from firebase_functions.private.util import Sentinel
18
+
19
+ DEFAULT_REGION = "us-central1"
20
+
21
+ _FIRESTORE_PREFIX = "google.cloud.firestore."
22
+ _PUBSUB_EVENT = "google.cloud.pubsub.topic.v1.messagePublished"
23
+ _STORAGE_EVENTS = frozenset(
24
+ "google.cloud.storage.object.v1." + kind for kind in ("finalized", "deleted", "archived", "metadataUpdated")
25
+ )
26
+ _DATABASE_EVENTS = frozenset("google.firebase.database.ref.v1." + kind for kind in ("created", "updated", "deleted", "written"))
27
+ # Products the official emulator has a trigger service for and fireemu does not serve, matched on a substring of
28
+ # the event type as the Node runner does (`DEFERRED_TRIGGER_PRODUCTS` in `tools/runner-node/index.mjs`).
29
+ _UNSERVED_PRODUCTS = (
30
+ ("remoteconfig", "remoteConfig", "deferred", "deferred: Remote Config has no emulator in the active supported surface"),
31
+ ("dataconnect", "dataconnect", "deferred", "deferred: the Data Connect emulator is not in the active supported surface"),
32
+ ("ailogic", "ai", "notPlanned", "not planned: Firebase AI Logic blocking triggers have no local emulator"),
33
+ ("analytics", "analytics", "notPlanned", "not planned: Google Analytics triggers have no local emulator"),
34
+ ("testing", "testLab", "notPlanned", "not planned: Test Lab triggers have no local emulator"),
35
+ )
36
+ _SCHEDULE_RETRY_FIELDS = frozenset(("retryCount", "maxRetrySeconds", "minBackoffSeconds", "maxBackoffSeconds", "maxDoublings"))
37
+ _TASK_RETRY_FIELDS = frozenset(("maxAttempts", "maxRetrySeconds", "minBackoffSeconds", "maxBackoffSeconds", "maxDoublings"))
38
+ _TASK_RATE_FIELDS = frozenset(("maxConcurrentDispatches", "maxDispatchesPerSecond"))
39
+ _BLOCKING_AUTH_EVENTS = ("beforeCreate", "beforeSignIn", "beforeSendEmail", "beforeSendSms")
40
+
41
+
42
+ class Unserved(Exception):
43
+ """A function the runner reports under `ignored[]`."""
44
+
45
+ def __init__(self, trigger_type: str, scope: str, reason: str):
46
+ super().__init__(reason)
47
+ self.trigger_type = trigger_type
48
+ self.scope = scope
49
+ self.reason = reason
50
+
51
+
52
+ def _unset(value: Any) -> bool:
53
+ return value is None or isinstance(value, Sentinel)
54
+
55
+
56
+ def _resolved(value: Any, field: str) -> Any:
57
+ """An option's concrete value, or None when it is unset. Expressions read their parameter."""
58
+ if _unset(value):
59
+ return None
60
+ if isinstance(value, enum.Enum):
61
+ return value.value
62
+ if isinstance(value, Expression):
63
+ resolved = value.value
64
+ if resolved is None:
65
+ raise Unserved("unknown", "unsupported", f"{field} expression did not resolve to a value")
66
+ return resolved
67
+ return value
68
+
69
+
70
+ def _non_negative_integer(value: Any, field: str):
71
+ resolved = _resolved(value, field)
72
+ if resolved is None:
73
+ return None
74
+ if isinstance(resolved, bool) or not isinstance(resolved, int) or resolved < 0:
75
+ raise Unserved("unknown", "unsupported", f"{field} did not resolve to a non-negative integer")
76
+ return resolved
77
+
78
+
79
+ def _first_region(endpoint) -> str | None:
80
+ regions = _resolved(endpoint.region, "region")
81
+ if regions is None:
82
+ return None
83
+ values = [_resolved(region, "region") for region in (regions if isinstance(regions, list) else [regions])]
84
+ for value in values:
85
+ if not isinstance(value, str) or not value:
86
+ raise Unserved("unknown", "unsupported", "region did not resolve to a non-empty string")
87
+ return values[0] if values else None
88
+
89
+
90
+ def _platform_options(endpoint) -> dict | None:
91
+ options: dict[str, Any] = {}
92
+ for field, key in (
93
+ ("availableMemoryMb", "availableMemoryMb"),
94
+ ("minInstances", "minInstances"),
95
+ ("maxInstances", "maxInstances"),
96
+ ):
97
+ value = _non_negative_integer(getattr(endpoint, field), field)
98
+ if value is not None:
99
+ options[key] = value
100
+ for field, key in (("ingressSettings", "ingressSettings"), ("serviceAccountEmail", "serviceAccountEmail")):
101
+ value = _resolved(getattr(endpoint, field), field)
102
+ if value is not None:
103
+ options[key] = value
104
+ invoker = (endpoint.httpsTrigger or {}).get("invoker") if isinstance(endpoint.httpsTrigger, dict) else None
105
+ if invoker:
106
+ options["invoker"] = invoker
107
+ vpc = endpoint.vpc if isinstance(endpoint.vpc, dict) else None
108
+ if vpc:
109
+ if not _unset(vpc.get("connector")):
110
+ options["vpcConnector"] = vpc["connector"]
111
+ egress = _resolved(vpc.get("egressSettings"), "vpc.egressSettings")
112
+ if egress is not None:
113
+ options["vpcEgressSettings"] = egress
114
+ if isinstance(vpc.get("networkInterfaces"), list):
115
+ options["networkInterfaces"] = vpc["networkInterfaces"]
116
+ if isinstance(endpoint.labels, dict) and endpoint.labels:
117
+ options["labels"] = endpoint.labels
118
+ cpu = _resolved(endpoint.cpu, "cpu")
119
+ if cpu is not None:
120
+ options["cpu"] = str(cpu)
121
+ if isinstance(endpoint.secretEnvironmentVariables, list):
122
+ secrets = [secret["key"] for secret in endpoint.secretEnvironmentVariables if secret.get("key")]
123
+ if secrets:
124
+ options["secrets"] = secrets
125
+ return options or None
126
+
127
+
128
+ def _mapping(value) -> dict:
129
+ return value if isinstance(value, dict) else {}
130
+
131
+
132
+ def _text(filters: dict, key: str, field: str, family: str, *, required: bool = False, allow_empty: bool = True):
133
+ """A string filter, resolved. Absent is None (or an error when required); another type is an error."""
134
+ value = _resolved(filters.get(key), field)
135
+ if value is None and not required:
136
+ return None
137
+ if not isinstance(value, str) or (not allow_empty and not value):
138
+ what = "a string" if allow_empty else "a non-empty string"
139
+ raise Unserved(family, "unsupported", f"{field} did not resolve to {what}")
140
+ return value
141
+
142
+
143
+ def _firestore_trigger(event_trigger: dict) -> dict:
144
+ filters = _mapping(event_trigger.get("eventFilters"))
145
+ patterns = _mapping(event_trigger.get("eventFilterPathPatterns"))
146
+ # The SDK keeps a document path without wildcards in `eventFilters.document` and one with
147
+ # wildcards in `eventFilterPathPatterns.document` (`options.py:1065-1075`).
148
+ document = _text(patterns, "document", "eventFilterPathPatterns.document", "firestore") or _text(
149
+ filters, "document", "eventFilters.document", "firestore"
150
+ )
151
+ if not document:
152
+ raise Unserved("firestore", "unsupported", "the Firestore trigger names no document (eventFilters.document)")
153
+ return {
154
+ "type": "firestore",
155
+ "eventType": event_trigger["eventType"],
156
+ "database": _text(filters, "database", "eventFilters.database", "firestore") or "(default)",
157
+ "document": document,
158
+ }
159
+
160
+
161
+ def _pubsub_trigger(event_trigger: dict) -> dict:
162
+ topic = _text(_mapping(event_trigger.get("eventFilters")), "topic", "eventFilters.topic", "pubsub", required=True, allow_empty=False)
163
+ # A full resource name is reduced to the short name the daemon matches on.
164
+ short = topic.rsplit("/topics/", 1)[-1] if "/topics/" in topic else topic
165
+ if not short:
166
+ raise Unserved("pubsub", "unsupported", "eventFilters.topic resolved to an empty topic")
167
+ return {"type": "pubsub", "topic": short}
168
+
169
+
170
+ def _storage_trigger(event_trigger: dict, event_type: str) -> dict:
171
+ bucket = _text(_mapping(event_trigger.get("eventFilters")), "bucket", "eventFilters.bucket", "storage")
172
+ trigger = {"type": "storage", "eventType": event_type}
173
+ if bucket is not None:
174
+ trigger["bucket"] = bucket
175
+ return trigger
176
+
177
+
178
+ def _database_trigger(event_trigger: dict, event_type: str) -> dict:
179
+ filters = _mapping(event_trigger.get("eventFilters"))
180
+ patterns = _mapping(event_trigger.get("eventFilterPathPatterns"))
181
+ # The SDK keeps a reference without wildcards in `eventFilters` and one with wildcards in
182
+ # `eventFilterPathPatterns`; the Node runner reads the patterns first as well.
183
+ reference = _text(patterns, "ref", "eventFilterPathPatterns.ref", "database")
184
+ if reference is None:
185
+ reference = _text(filters, "ref", "eventFilters.ref", "database", required=True)
186
+ instance = _text(patterns, "instance", "eventFilterPathPatterns.instance", "database")
187
+ if instance is None:
188
+ instance = _text(filters, "instance", "eventFilters.instance", "database")
189
+ return {
190
+ "type": "database",
191
+ "eventType": event_type,
192
+ "ref": "/" + reference.strip("/"),
193
+ "instance": instance if instance is not None else "*",
194
+ }
195
+
196
+
197
+ def _eventarc_trigger(event_trigger: dict, event_type: str, channel: str) -> dict:
198
+ filters = _mapping(event_trigger.get("eventFilters"))
199
+ return {
200
+ "type": "eventarc",
201
+ "eventType": event_type,
202
+ "channel": channel,
203
+ "filters": {key: _text(filters, key, f"eventFilters.{key}", "eventarc", required=True) for key in filters},
204
+ }
205
+
206
+
207
+ def _event_trigger(event_trigger: dict) -> dict:
208
+ """The canonical trigger of an `eventTrigger`, or `Unserved` for an event type fireemu does not serve."""
209
+ event_type = str(_resolved(event_trigger.get("eventType"), "eventType") or "")
210
+ if event_type.startswith(_FIRESTORE_PREFIX):
211
+ return _firestore_trigger({**event_trigger, "eventType": event_type})
212
+ if event_type == _PUBSUB_EVENT:
213
+ return _pubsub_trigger(event_trigger)
214
+ if event_type in _STORAGE_EVENTS:
215
+ return _storage_trigger(event_trigger, event_type)
216
+ if event_type in _DATABASE_EVENTS:
217
+ return _database_trigger(event_trigger, event_type)
218
+ # `onCustomEventPublished` is the only SDK trigger that names a channel.
219
+ channel = _text(event_trigger, "channel", "eventTrigger.channel", "eventarc")
220
+ if channel is not None:
221
+ return _eventarc_trigger(event_trigger, event_type, channel)
222
+ for fragment, trigger_type, scope, reason in _UNSERVED_PRODUCTS:
223
+ if fragment in event_type:
224
+ raise Unserved(trigger_type, scope, reason)
225
+ family = "storage" if event_type.startswith("google.cloud.storage.") else (
226
+ "database" if event_type.startswith("google.firebase.database.") else "unknown"
227
+ )
228
+ raise Unserved(family, "unsupported", f"event type {event_type} is not recognised")
229
+
230
+
231
+ def _record(value, field: str, family: str) -> dict:
232
+ """An options record: absent is empty, an object is itself, anything else is an error."""
233
+ if _unset(value):
234
+ return {}
235
+ if not isinstance(value, dict):
236
+ raise Unserved(family, "unsupported", f"{field} must be an object")
237
+ return value
238
+
239
+
240
+ def _numbers(value, field: str, allowed: frozenset, family: str) -> dict:
241
+ """The numbers of a retry or rate record. A member the SDK left unset is null on the wire."""
242
+ record = _record(value, field, family)
243
+ numbers: dict[str, Any] = {}
244
+ for key, original in record.items():
245
+ if key not in allowed:
246
+ raise Unserved(family, "unsupported", f"{field}.{key} is not supported")
247
+ resolved = _resolved(original, f"{field}.{key}")
248
+ if resolved is None:
249
+ numbers[key] = None
250
+ continue
251
+ if isinstance(resolved, bool) or not isinstance(resolved, (int, float)) or not math.isfinite(resolved):
252
+ raise Unserved(family, "unsupported", f"{field}.{key} did not resolve to a finite number")
253
+ numbers[key] = resolved
254
+ return numbers
255
+
256
+
257
+ def _schedule_trigger(trigger) -> tuple[dict, bool]:
258
+ """The canonical trigger of a `scheduleTrigger` and whether the function retries."""
259
+ record = _record(trigger, "scheduleTrigger", "schedule")
260
+ schedule = _text(record, "schedule", "scheduleTrigger.schedule", "schedule", required=True, allow_empty=False)
261
+ time_zone = _text(record, "timeZone", "scheduleTrigger.timeZone", "schedule")
262
+ # The SDK keeps a deployment expression as braced CEL; this runner resolves values, it does not evaluate CEL.
263
+ if "{{" in schedule or (time_zone is not None and "{{" in time_zone):
264
+ raise Unserved("schedule", "unsupported", "scheduleTrigger contains an unresolved deployment expression")
265
+ retry_config = _numbers(record.get("retryConfig"), "scheduleTrigger.retryConfig", _SCHEDULE_RETRY_FIELDS, "schedule")
266
+ # A Gen2 job with a retry window and no count is retried until the window ends (run 156715222b86ea44), so a
267
+ # window alone declares a retry too.
268
+ retry = (retry_config.get("retryCount") or 0) > 0 or (retry_config.get("maxRetrySeconds") or 0) > 0
269
+ described: dict[str, Any] = {"type": "schedule", "schedule": schedule}
270
+ if time_zone is not None:
271
+ described["timeZone"] = time_zone
272
+ described["retryConfig"] = retry_config
273
+ return described, retry
274
+
275
+
276
+ def _task_queue_trigger(queue) -> dict:
277
+ record = _record(queue, "taskQueueTrigger", "tasks")
278
+ return {
279
+ "type": "tasks",
280
+ "retryConfig": _numbers(record.get("retryConfig"), "taskQueueTrigger.retryConfig", _TASK_RETRY_FIELDS, "tasks"),
281
+ "rateLimits": _numbers(record.get("rateLimits"), "taskQueueTrigger.rateLimits", _TASK_RATE_FIELDS, "tasks"),
282
+ }
283
+
284
+
285
+ def _blocking_trigger(blocking) -> dict:
286
+ record = _record(blocking, "blockingTrigger", "blocking")
287
+ event_type = str(_resolved(record.get("eventType"), "blockingTrigger.eventType") or "")
288
+ if not any(event_type.endswith(event) for event in _BLOCKING_AUTH_EVENTS):
289
+ for fragment, trigger_type, scope, reason in _UNSERVED_PRODUCTS:
290
+ if fragment in event_type:
291
+ raise Unserved(trigger_type, scope, reason)
292
+ raise Unserved("blocking", "unsupported", f"blocking identity event {event_type} is not served")
293
+ options = record.get("options") if isinstance(record.get("options"), dict) else {}
294
+ return {
295
+ "type": "blockingAuth",
296
+ "eventType": event_type,
297
+ "accessToken": options.get("accessToken") is True,
298
+ "idToken": options.get("idToken") is True,
299
+ "refreshToken": options.get("refreshToken") is True,
300
+ }
301
+
302
+
303
+ def describe_endpoint(name: str, endpoint, app_check_verified: bool = False) -> dict:
304
+ """The canonical `functions[]` entry of one endpoint, or `Unserved` when it cannot be served."""
305
+ if endpoint.platform != "gcfv2":
306
+ raise Unserved("unknown", "unsupported", "endpoint platform is not recognised")
307
+ described: dict[str, Any] = {"name": name, "entryPoint": name, "generation": 2}
308
+ region = _first_region(endpoint)
309
+ if region:
310
+ described["region"] = region
311
+ timeout = _non_negative_integer(endpoint.timeoutSeconds, "timeoutSeconds")
312
+ if timeout:
313
+ described["timeoutSeconds"] = timeout
314
+ concurrency = _non_negative_integer(endpoint.concurrency, "concurrency")
315
+ if concurrency is not None:
316
+ described["concurrency"] = concurrency
317
+ options = _platform_options(endpoint)
318
+ if options:
319
+ described["platformOptions"] = options
320
+ if endpoint.taskQueueTrigger is not None:
321
+ described["trigger"] = _task_queue_trigger(endpoint.taskQueueTrigger)
322
+ return described
323
+ if endpoint.httpsTrigger is not None:
324
+ described["trigger"] = {"type": "http", "callable": False}
325
+ elif endpoint.callableTrigger is not None:
326
+ described["trigger"] = {"type": "http", "callable": True}
327
+ if app_check_verified:
328
+ # The SDK verifies the token itself, against the App Check emulator's keys, and never consumes it.
329
+ described["trigger"]["consumeAppCheckToken"] = "disabled"
330
+ elif endpoint.scheduleTrigger is not None:
331
+ described["trigger"], described["retry"] = _schedule_trigger(endpoint.scheduleTrigger)
332
+ elif endpoint.blockingTrigger is not None:
333
+ described["trigger"] = _blocking_trigger(endpoint.blockingTrigger)
334
+ elif endpoint.eventTrigger is not None:
335
+ described["trigger"] = _event_trigger(endpoint.eventTrigger)
336
+ retry = _resolved(endpoint.eventTrigger.get("retry"), "eventTrigger.retry")
337
+ if retry is not None and not isinstance(retry, bool):
338
+ raise Unserved(described["trigger"]["type"], "unsupported", "eventTrigger.retry did not resolve to a boolean")
339
+ described["retry"] = retry is True
340
+ else:
341
+ raise Unserved("unknown", "unsupported", "the endpoint declares no trigger")
342
+ return described
343
+
344
+
345
+ def build_manifest(functions: dict, app_check_verified: bool = False) -> dict:
346
+ """`{"functions": [...], "ignored": [...]}` for the decorated functions of a codebase.
347
+
348
+ `app_check_verified` says the Admin SDK verifies App Check tokens against the emulator's keys (`appcheck.py`).
349
+ """
350
+ served: list[dict] = []
351
+ ignored: list[dict] = []
352
+ for name, function in functions.items():
353
+ endpoint = function.__firebase_endpoint__
354
+ try:
355
+ served.append(describe_endpoint(name, endpoint, app_check_verified))
356
+ except Unserved as unserved:
357
+ try:
358
+ region = _first_region(endpoint) or DEFAULT_REGION
359
+ except Unserved:
360
+ region = DEFAULT_REGION
361
+ ignored.append(
362
+ {
363
+ "name": name,
364
+ "region": region,
365
+ "triggerType": unserved.trigger_type,
366
+ "scope": unserved.scope,
367
+ "reason": unserved.reason,
368
+ }
369
+ )
370
+ return {"functions": served, "ignored": ignored}
@@ -0,0 +1,65 @@
1
+ """The HTTP request a CloudEvent function runs inside.
2
+
3
+ functions-framework serves a CloudEvent function from a Flask view and calls the function inside the request context, so
4
+ `flask.request` shows the delivered request. Production's raw twins were recorded on 2026-11-04 (sandbox run
5
+ PYCHECK-EVENTS-20261104-01): `POST /` with the binary-mode `ce-*` headers, `content-type`, `content-length` and, in the strict
6
+ profile, constants every recorded frame carries (the sender's user agent, the encodings, the protocol) plus Cloud Run's plain
7
+ `traceparent` header. The body is the text the delivery had, not a re-encoding of the event: the layouts recorded per family are in
8
+ `bodies.py` (strict Pub/Sub and Storage; Eventarc keeps the publisher's text), and the emulator and unset profiles carry the framed JSON value text
9
+ of the data as the daemon sent it (a publisher's surrounding white space is trimmed upstream; a Pub/Sub body is the exception, it gets the SDK's snake-case aliases and is rewritten compactly). A Realtime Database body was not recorded. Header policy: a name that is not an HTTP token (or has an underscore) is
10
+ left out of the request, a control character other than TAB in a value becomes a space, and of two names that differ only in case the first one
11
+ is the header; the event the function receives is never changed by the view. The OIDC `Authorization`, `Forwarded`, `X-Forwarded-For`, `Function-Execution-Id`, `X-Cloud-Trace-Context`,
12
+ `From`, `Accept` and the Cloud Run host have no local source and are not invented.
13
+ """
14
+
15
+ import contextlib
16
+ import re
17
+
18
+ import flask
19
+ from werkzeug.test import EnvironBuilder
20
+
21
+ from .events import is_strict
22
+
23
+ _APP = flask.Flask("fireemu-cloudevent")
24
+ # What an HTTP header may be called: a token without an underscore. Header names with an underscore are dropped by the usual
25
+ # servers, and Werkzeug folds `foo_bar` into `foo-bar`; both are UNRECORDED, so such an attribute stays out of the request
26
+ # (the event keeps it).
27
+ _HEADER_NAME = re.compile(r"[!#$%&'*+.^`|~0-9A-Za-z-]+")
28
+ # A control character other than the tab cannot be in a header value; it becomes a space, as a line break does.
29
+ _CONTROL = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]")
30
+ # Constant in every recorded Eventarc delivery (the four raw twins of run 57ab27296dedaedc).
31
+ _STRICT_CONSTANTS = {
32
+ "user-agent": "APIs-Google; (+https://developers.google.com/webmasters/APIs-Google.html)",
33
+ "accept-encoding": "gzip, deflate, br",
34
+ "x-forwarded-proto": "https",
35
+ }
36
+
37
+
38
+ def request_headers(headers: dict, event: dict) -> dict:
39
+ """The headers of the request: the event's binary-mode headers, and in the strict profile the recorded constants and the
40
+ trace context as the plain `traceparent` header.
41
+
42
+ The request is what a server would have received, so a name that is not a header name is left out, and when two names
43
+ differ only in case the first one stays. A control character in a value other than the tab, a line break included, is
44
+ replaced by a space. The event the function receives keeps every attribute and its text.
45
+ """
46
+ view = {}
47
+ taken = set()
48
+ for name, value in headers.items():
49
+ if _HEADER_NAME.fullmatch(name) and name.lower() not in taken:
50
+ taken.add(name.lower())
51
+ view[name] = _CONTROL.sub(" ", value)
52
+ if is_strict():
53
+ view.update(_STRICT_CONSTANTS)
54
+ traceparent = event.get("traceparent")
55
+ if isinstance(traceparent, str):
56
+ view["traceparent"] = _CONTROL.sub(" ", traceparent)
57
+ return view
58
+
59
+
60
+ @contextlib.contextmanager
61
+ def delivery_request(headers: dict, body: bytes, event: dict):
62
+ """Runs the block inside the Flask request context of the delivery described by `headers` and `body`."""
63
+ environ = EnvironBuilder(method="POST", path="/", headers=request_headers(headers, event), data=body).get_environ()
64
+ with _APP.request_context(environ):
65
+ yield
@@ -0,0 +1,87 @@
1
+ """Scheduled runs: the request Cloud Scheduler sends a Gen2 function, built in process.
2
+
3
+ A Gen2 `on_schedule` function is an HTTP function. Production's Scheduler calls it with `POST /`, the job id in
4
+ `X-CloudScheduler-JobName` and the schedule time in `X-CloudScheduler-ScheduleTime`, and judges the attempt by the
5
+ status the SDK wrapper answers: 200, or 500 when the handler raised (the wrapper logs the error and answers its
6
+ message, `scheduler_fn.py:99-137`). The Node runner does the same in the strict profile
7
+ (`tools/runner-node/schedule-delivery.mjs`, recorded in run 156715222b86ea44).
8
+ """
9
+
10
+ import datetime
11
+ import os
12
+ import re
13
+ from zoneinfo import ZoneInfo
14
+
15
+ import flask
16
+ from werkzeug.test import EnvironBuilder
17
+
18
+ _INSTANT = re.compile(r"^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(?:\.(\d{1,9}))?Z$")
19
+ _LOS_ANGELES = ZoneInfo("America/Los_Angeles")
20
+ _JOB_NAME = re.compile(r"projects/([^/]*)/locations/([^/]*)/jobs/")
21
+ _NAME = re.compile(r"[A-Za-z0-9-]+")
22
+ _MAX_ANSWER_CHARS = 256
23
+
24
+
25
+ class SchedulerFailure(Exception):
26
+ """A scheduled attempt that failed: the run was malformed or the function answered 400 or more."""
27
+
28
+
29
+ def _schedule_time(instant) -> str:
30
+ """The schedule time as the header carries it: Los Angeles with its offset in the strict profile (whatever the
31
+ job's own zone, as recorded), the daemon's UTC instant otherwise."""
32
+ match = _INSTANT.match(instant) if isinstance(instant, str) else None
33
+ if match is None:
34
+ raise SchedulerFailure(f"the scheduled time is not an RFC 3339 UTC instant: {instant!r}")
35
+ year, month, day, hour, minute, second = (int(part) for part in match.groups()[:6])
36
+ try:
37
+ moment = datetime.datetime(year, month, day, hour, minute, second, tzinfo=datetime.timezone.utc)
38
+ except ValueError:
39
+ raise SchedulerFailure(f"the scheduled time is not an RFC 3339 UTC instant: {instant!r}") from None
40
+ if os.environ.get("FIREEMU_HTTP_PROFILE", "emulator") != "strict":
41
+ return instant
42
+ local = moment.astimezone(_LOS_ANGELES)
43
+ fraction = (match.group(7) or "").rstrip("0")
44
+ offset = local.strftime("%z")
45
+ return local.strftime("%Y-%m-%dT%H:%M:%S") + (f".{fraction}" if fraction else "") + f"{offset[:3]}:{offset[3:]}"
46
+
47
+
48
+ def _function_host(job: str) -> str | None:
49
+ """The public host of a function, `<region>-<project>.cloudfunctions.net`, from a job's resource name.
50
+
51
+ Recorded for us-central1 only (all 76 distinct Gen2 frames of run 156715222b86ea44); another region follows the
52
+ same rule, unrecorded. A name without a project and a location in this form invents no host (the Node runner's
53
+ `functionHost`).
54
+ """
55
+ match = _JOB_NAME.match(job)
56
+ if match is None or not _NAME.fullmatch(match.group(1)) or not _NAME.fullmatch(match.group(2)):
57
+ return None
58
+ return f"{match.group(2)}-{match.group(1)}.cloudfunctions.net"
59
+
60
+
61
+ def scheduler_headers(data) -> dict:
62
+ """The headers of the request for one scheduled run (`{jobName, scheduleTime}`)."""
63
+ job = data.get("jobName") if isinstance(data, dict) else None
64
+ if not isinstance(job, str) or not job:
65
+ raise SchedulerFailure("the scheduled run names no job name")
66
+ host = _function_host(job)
67
+ return {
68
+ "x-cloudscheduler": "true",
69
+ "x-cloudscheduler-jobname": job.rsplit("/", 1)[-1],
70
+ "x-cloudscheduler-scheduletime": _schedule_time(data.get("scheduleTime")),
71
+ "user-agent": "Google-Cloud-Scheduler",
72
+ "content-length": "0",
73
+ # Constant in every recorded frame: the front end's encodings and protocol.
74
+ "accept-encoding": "gzip, deflate, br",
75
+ "x-forwarded-proto": "https",
76
+ **({"host": host} if host else {}),
77
+ }
78
+
79
+
80
+ def deliver_schedule(function, data) -> None:
81
+ """Calls the SDK wrapper with the scheduler's request. Returns when it answers below 400; raises otherwise."""
82
+ environ = EnvironBuilder(method="POST", path="/", headers=scheduler_headers(data)).get_environ()
83
+ with flask.Flask("fireemu-scheduler").request_context(environ):
84
+ response = function(flask.request)
85
+ if response.status_code >= 400:
86
+ body = response.get_data(as_text=True)[:_MAX_ANSWER_CHARS]
87
+ raise SchedulerFailure(f"the scheduled function answered {response.status_code}" + (f": {body}" if body else ""))