Skip to content

Commit 0230eb6

Browse files
committed
feat: add manifest module
1 parent eb3c087 commit 0230eb6

File tree

2 files changed

+232
-65
lines changed

2 files changed

+232
-65
lines changed

src/firebase_functions/manifest.py

+148-65
Original file line numberDiff line numberDiff line change
@@ -2,110 +2,193 @@
22

33
# We're ignoring pylint's warning about names since we want
44
# the manifest to match the container specification.
5-
65
# pylint: disable=invalid-name
76

8-
from dataclasses import dataclass
9-
from typing import TypedDict, Optional, Union
10-
from typing_extensions import NotRequired, Required
7+
import dataclasses as _dataclasses
8+
import typing as _typing
9+
import typing_extensions as _typing_extensions
1110

12-
from firebase_functions.params import Expression, Param
11+
import firebase_functions.params as _params
1312

1413

15-
class SecretEnvironmentVariable(TypedDict):
16-
key: Required[str]
17-
secret: NotRequired[str]
14+
class SecretEnvironmentVariable(_typing.TypedDict):
15+
key: _typing_extensions.Required[str]
16+
secret: _typing_extensions.NotRequired[str]
1817

1918

20-
class HttpsTrigger(TypedDict):
19+
class HttpsTrigger(_typing.TypedDict):
2120
"""
2221
Trigger definition for arbitrary HTTPS endpoints.
2322
"""
24-
invoker: NotRequired[list[str]]
23+
invoker: _typing_extensions.NotRequired[list[str]]
2524
"""
2625
Which service account should be able to trigger this function. No value means "make public"
2726
on create and don't do anything on update.
2827
"""
2928

3029

31-
class CallableTrigger(TypedDict):
30+
class CallableTrigger(_typing.TypedDict):
3231
"""
3332
Trigger definitions for RPCs servers using the HTTP protocol defined at
3433
https://firebase.google.com/docs/functions/callable-reference
3534
"""
3635

3736

38-
class EventTrigger(TypedDict):
37+
class EventTrigger(_typing.TypedDict):
3938
"""
4039
Trigger definitions for endpoints that listen to CloudEvents emitted by
4140
other systems (or legacy Google events for GCF gen 1)
4241
"""
43-
eventFilters: NotRequired[dict[str, Union[str, Expression[str]]]]
44-
eventFilterPathPatterns: NotRequired[dict[str, Union[str, Expression[str]]]]
45-
channel: NotRequired[str]
46-
eventType: Required[str]
47-
retry: Required[Union[bool, Expression[bool]]]
48-
region: NotRequired[str]
49-
serviceAccountEmail: NotRequired[str]
42+
eventFilters: _typing_extensions.NotRequired[dict[str, str |
43+
_params.Expression[str]]]
44+
eventFilterPathPatterns: _typing_extensions.NotRequired[dict[
45+
str, str | _params.Expression[str]]]
46+
channel: _typing_extensions.NotRequired[str]
47+
eventType: _typing_extensions.Required[str]
48+
retry: _typing_extensions.Required[bool | _params.Expression[bool]]
49+
region: _typing_extensions.NotRequired[str]
50+
serviceAccountEmail: _typing_extensions.NotRequired[str]
5051

5152

52-
class RetryConfig(TypedDict):
53-
retryCount: NotRequired[Union[int, Expression[int]]]
54-
maxRetrySeconds: NotRequired[Union[str, Expression[str]]]
55-
minBackoffSeconds: NotRequired[Union[str, Expression[str]]]
56-
maxBackoffSeconds: NotRequired[Union[str, Expression[str]]]
57-
maxDoublings: NotRequired[Union[int, Expression[int]]]
53+
class RetryConfig(_typing.TypedDict):
54+
retryCount: _typing_extensions.NotRequired[int | _params.Expression[int]]
55+
maxRetrySeconds: _typing_extensions.NotRequired[str |
56+
_params.Expression[str]]
57+
minBackoffSeconds: _typing_extensions.NotRequired[str |
58+
_params.Expression[str]]
59+
maxBackoffSeconds: _typing_extensions.NotRequired[str |
60+
_params.Expression[str]]
61+
maxDoublings: _typing_extensions.NotRequired[int | _params.Expression[int]]
5862

5963

60-
class ScheduleTrigger(TypedDict):
61-
schedule: NotRequired[Union[str, Expression[str]]]
62-
timeZone: NotRequired[Union[str, Expression[str]]]
63-
retryConfig: NotRequired[RetryConfig]
64+
class ScheduleTrigger(_typing.TypedDict):
65+
schedule: _typing_extensions.NotRequired[str | _params.Expression[str]]
66+
timeZone: _typing_extensions.NotRequired[str | _params.Expression[str]]
67+
retryConfig: _typing_extensions.NotRequired[RetryConfig]
6468

6569

66-
class BlockingTrigger(TypedDict):
67-
eventType: Required[str]
70+
class BlockingTrigger(_typing.TypedDict):
71+
eventType: _typing_extensions.Required[str]
6872

6973

70-
class VpcSettings(TypedDict):
71-
connector: Required[Union[str, Expression[str]]]
72-
egressSettings: NotRequired[str]
74+
class VpcSettings(_typing.TypedDict):
75+
connector: _typing_extensions.Required[str | _params.Expression[str]]
76+
egressSettings: _typing_extensions.NotRequired[str]
7377

7478

75-
@dataclass(frozen=True)
79+
@_dataclasses.dataclass(frozen=True)
7680
class ManifestEndpoint:
7781
"""An definition of a function as appears in the Manifest."""
7882

79-
entryPoint: Optional[str] = None
80-
region: Optional[list[str]] = None
81-
platform: Optional[str] = "gcfv2"
82-
availableMemoryMb: Union[int, Expression[int], None] = None
83-
maxInstances: Union[int, Expression[int], None] = None
84-
minInstances: Union[int, Expression[int], None] = None
85-
concurrency: Union[int, Expression[int], None] = None
86-
serviceAccountEmail: Optional[str] = None
87-
timeoutSeconds: Union[int, Expression[int], None] = None
88-
cpu: Union[int, str] = "gcf_gen1"
89-
vpc: Optional[VpcSettings] = None
90-
labels: Optional[dict[str, str]] = None
91-
ingressSettings: Optional[str] = None
92-
environmentVariables: Optional[dict[str, str]] = None
93-
secretEnvironmentVariables: Optional[list[SecretEnvironmentVariable]] = None
94-
httpsTrigger: Optional[HttpsTrigger] = None
95-
callableTrigger: Optional[CallableTrigger] = None
96-
eventTrigger: Optional[EventTrigger] = None
97-
scheduleTrigger: Optional[ScheduleTrigger] = None
98-
blockingTrigger: Optional[BlockingTrigger] = None
99-
100-
101-
class ManifestRequiredApi(TypedDict):
102-
api: Required[str]
103-
reason: Required[str]
104-
105-
106-
@dataclass(frozen=True)
83+
entryPoint: _typing.Optional[str] = None
84+
region: _typing.Optional[list[str]] = _dataclasses.field(
85+
default_factory=list[str])
86+
platform: _typing.Optional[str] = "gcfv2"
87+
availableMemoryMb: int | _params.Expression[int] | None = None
88+
maxInstances: int | _params.Expression[int] | None = None
89+
minInstances: int | _params.Expression[int] | None = None
90+
concurrency: int | _params.Expression[int] | None = None
91+
serviceAccountEmail: _typing.Optional[str] = None
92+
timeoutSeconds: int | _params.Expression[int] | None = None
93+
cpu: int | str = "gcf_gen1"
94+
vpc: _typing.Optional[VpcSettings] = None
95+
labels: _typing.Optional[dict[str, str]] = None
96+
ingressSettings: _typing.Optional[str] = None
97+
environmentVariables: _typing.Optional[dict[str, str]] = None
98+
secretEnvironmentVariables: _typing.Optional[
99+
list[SecretEnvironmentVariable]] = _dataclasses.field(
100+
default_factory=list[SecretEnvironmentVariable])
101+
httpsTrigger: _typing.Optional[HttpsTrigger] = None
102+
callableTrigger: _typing.Optional[CallableTrigger] = None
103+
eventTrigger: _typing.Optional[EventTrigger] = None
104+
scheduleTrigger: _typing.Optional[ScheduleTrigger] = None
105+
blockingTrigger: _typing.Optional[BlockingTrigger] = None
106+
107+
108+
class ManifestRequiredApi(_typing.TypedDict):
109+
api: _typing_extensions.Required[str]
110+
reason: _typing_extensions.Required[str]
111+
112+
113+
@_dataclasses.dataclass(frozen=True)
107114
class ManifestStack:
108115
endpoints: dict[str, ManifestEndpoint]
109116
specVersion: str = "v1alpha1"
110-
params: Optional[list[Param]] = None
111-
requiredApis: list[ManifestRequiredApi] = []
117+
params: _typing.Optional[list[_params.Param]] = _dataclasses.field(
118+
default_factory=list[_params.Param])
119+
requiredApis: list[ManifestRequiredApi] = _dataclasses.field(
120+
default_factory=list[ManifestRequiredApi])
121+
122+
123+
def _param_to_spec(
124+
param: _params.Param | _params.SecretParam) -> dict[str, _typing.Any]:
125+
spec_dict: dict[str, _typing.Any] = {
126+
"name": param.name,
127+
"label": param.label,
128+
"description": param.description,
129+
"immutable": param.immutable,
130+
}
131+
132+
if isinstance(param, _params.Param):
133+
spec_dict["default"] = param.default
134+
# TODO spec representation of inputs
135+
136+
if isinstance(param, _params.BoolParam):
137+
spec_dict["type"] = "boolean"
138+
elif isinstance(param, _params.IntParam):
139+
spec_dict["type"] = "int"
140+
elif isinstance(param, _params.FloatParam):
141+
spec_dict["type"] = "float"
142+
elif isinstance(param, _params.SecretParam):
143+
spec_dict["type"] = "secret"
144+
elif isinstance(param, _params.ListParam):
145+
spec_dict["type"] = "list"
146+
if spec_dict["default"] is not None:
147+
spec_dict["default"] = ",".join(spec_dict["default"])
148+
elif isinstance(param, _params.StringParam):
149+
spec_dict["type"] = "string"
150+
else:
151+
raise NotImplementedError("Unsupported param type.")
152+
153+
return _dict_to_spec(spec_dict)
154+
155+
156+
def _object_to_spec(data) -> object:
157+
if isinstance(data, _params.Expression):
158+
return data.to_cel()
159+
elif _dataclasses.is_dataclass(data):
160+
return _dataclass_to_spec(data)
161+
elif isinstance(data, list):
162+
return list(map(_object_to_spec, data))
163+
elif isinstance(data, dict):
164+
return _dict_to_spec(data)
165+
else:
166+
return data
167+
168+
169+
def _dict_factory(data: list[_typing.Tuple[str, _typing.Any]]) -> dict:
170+
out: dict = {}
171+
for key, value in data:
172+
if value is not None:
173+
out[key] = _object_to_spec(value)
174+
return out
175+
176+
177+
def _dataclass_to_spec(data) -> dict:
178+
out: dict = {}
179+
for field in _dataclasses.fields(data):
180+
value = _object_to_spec(getattr(data, field.name))
181+
if value is not None:
182+
out[field.name] = value
183+
return out
184+
185+
186+
def _dict_to_spec(data: dict) -> dict:
187+
return _dict_factory([(k, v) for k, v in data.items()])
188+
189+
190+
def _manifest_to_spec(manifest: ManifestStack) -> dict:
191+
out: dict = _dataclass_to_spec(manifest)
192+
if "params" in out:
193+
out["params"] = list(map(_param_to_spec, out["params"]))
194+
return out

tests/test_manifest.py

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Manifest unit tests."""
2+
3+
import firebase_functions.manifest as _manifest
4+
import firebase_functions.params as _params
5+
6+
full_endpoint = _manifest.ManifestEndpoint(
7+
platform="gcfv2",
8+
region=["us-west1"],
9+
availableMemoryMb=512,
10+
timeoutSeconds=60,
11+
minInstances=1,
12+
maxInstances=3,
13+
concurrency=20,
14+
vpc={
15+
"connector": "aConnector",
16+
"egressSettings": "ALL_TRAFFIC",
17+
},
18+
serviceAccountEmail="root@",
19+
ingressSettings="ALLOW_ALL",
20+
cpu="gcf_gen1",
21+
labels={
22+
"hello": "world",
23+
},
24+
secretEnvironmentVariables=[{
25+
"key": "MY_SECRET"
26+
}],
27+
)
28+
29+
full_endpoint_dict = {
30+
"platform": "gcfv2",
31+
"region": ["us-west1"],
32+
"availableMemoryMb": 512,
33+
"timeoutSeconds": 60,
34+
"minInstances": 1,
35+
"maxInstances": 3,
36+
"concurrency": 20,
37+
"vpc": {
38+
"connector": "aConnector",
39+
"egressSettings": "ALL_TRAFFIC",
40+
},
41+
"serviceAccountEmail": "root@",
42+
"ingressSettings": "ALLOW_ALL",
43+
"cpu": "gcf_gen1",
44+
"labels": {
45+
"hello": "world",
46+
},
47+
"secretEnvironmentVariables": [{
48+
"key": "MY_SECRET"
49+
}],
50+
}
51+
52+
53+
class TestManifestEndpoint:
54+
"""Manifest unit tests."""
55+
56+
def test_endpoint_to_dict(self):
57+
"""Generic test to check all ManifestEndpoint values convert to dict."""
58+
# pylint: disable=protected-access
59+
endpoint_dict = _manifest._dataclass_to_spec(full_endpoint)
60+
assert (endpoint_dict == full_endpoint_dict
61+
), "Generated endpoint spec dict does not match expected dict."
62+
63+
def test_endpoint_expressions(self):
64+
"""Generic test to check all ManifestEndpoint values convert to dict."""
65+
expressions_test = _manifest.ManifestEndpoint(
66+
timeoutSeconds=_params.IntParam("hello"),
67+
minInstances=_params.IntParam("world"),
68+
maxInstances=_params.IntParam("foo"),
69+
concurrency=_params.IntParam("bar"),
70+
)
71+
expressions_expected_dict = {
72+
"platform": "gcfv2",
73+
"cpu": "gcf_gen1",
74+
"region": [],
75+
"secretEnvironmentVariables": [],
76+
"timeoutSeconds": "{{ params.hello }}",
77+
"minInstances": "{{ params.world }}",
78+
"maxInstances": "{{ params.foo }}",
79+
"concurrency": "{{ params.bar }}",
80+
}
81+
# pylint: disable=protected-access
82+
expressions_actual_dict = _manifest._dataclass_to_spec(expressions_test)
83+
assert (expressions_actual_dict == expressions_expected_dict
84+
), "Generated endpoint spec dict does not match expected dict."

0 commit comments

Comments
 (0)