Skip to content

Commit 1ff68f9

Browse files
Add support for Hooks (#169)
* Added support for Hooks * Version bump
1 parent 386dbb4 commit 1ff68f9

26 files changed

+2352
-69
lines changed

.pylintrc

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[MASTER]
22

3-
ignore=CVS,models.py,handlers.py
3+
ignore=CVS,models.py,handlers.py,hook_models.py,hook_handlers.py,target_model.py
44
jobs=1
55
persistent=yes
66

python/rpdk/python/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import logging
22

3-
__version__ = "2.1.4"
3+
__version__ = "2.1.5"
44

55
logging.getLogger(__name__).addHandler(logging.NullHandler())

python/rpdk/python/codegen.py

+37-15
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from rpdk.core.init import input_with_validation
1616
from rpdk.core.jsonutils.resolver import ContainerType, resolve_models
1717
from rpdk.core.plugin_base import LanguagePlugin
18+
from rpdk.core.project import ARTIFACT_TYPE_HOOK
1819

1920
from . import __version__
2021
from .resolver import contains_model, translate_type
@@ -38,7 +39,9 @@ class Python36LanguagePlugin(LanguagePlugin):
3839
MODULE_NAME = __name__
3940
NAME = "python36"
4041
RUNTIME = "python3.6"
41-
ENTRY_POINT = "{}.handlers.resource"
42+
HOOK_ENTRY_POINT = "{}.handlers.hook"
43+
RESOURCE_ENTRY_POINT = "{}.handlers.resource"
44+
TEST_ENTRY_POINT = "{}.handlers.test_entrypoint"
4245
CODE_URI = "build/"
4346

4447
def __init__(self):
@@ -63,6 +66,18 @@ def _init_from_project(self, project):
6366
def _init_settings(self, project):
6467
LOG.debug("Writing settings")
6568

69+
project.runtime = self.RUNTIME
70+
if project.artifact_type == ARTIFACT_TYPE_HOOK:
71+
project.entrypoint = self.HOOK_ENTRY_POINT.format(self.package_name)
72+
project.test_entrypoint = project.entrypoint.replace(
73+
".hook", ".test_entrypoint"
74+
)
75+
else:
76+
project.entrypoint = self.RESOURCE_ENTRY_POINT.format(self.package_name)
77+
project.test_entrypoint = project.entrypoint.replace(
78+
".resource", ".test_entrypoint"
79+
)
80+
6681
self._use_docker = self._use_docker or input_with_validation(
6782
"Use docker for platform-independent packaging (Y/n)?\n",
6883
validate_no,
@@ -79,12 +94,6 @@ def init(self, project):
7994
self._init_from_project(project)
8095
self._init_settings(project)
8196

82-
project.runtime = self.RUNTIME
83-
project.entrypoint = self.ENTRY_POINT.format(self.package_name)
84-
project.test_entrypoint = project.entrypoint.replace(
85-
".resource", ".test_entrypoint"
86-
)
87-
8897
def _render_template(path, **kwargs):
8998
LOG.debug("Writing '%s'", path)
9099
template = self.env.get_template(path.name)
@@ -103,11 +112,7 @@ def _copy_resource(path, resource_name=None):
103112
LOG.debug("Making folder '%s'", handler_package_path)
104113
handler_package_path.mkdir(parents=True, exist_ok=True)
105114
_copy_resource(handler_package_path / "__init__.py")
106-
_render_template(
107-
handler_package_path / "handlers.py",
108-
support_lib_pkg=SUPPORT_LIB_PKG,
109-
type_name=project.type_name,
110-
)
115+
self.init_handlers(project, handler_package_path)
111116
# models.py produced by generate
112117

113118
# project support files
@@ -144,12 +149,25 @@ def _copy_resource(path, resource_name=None):
144149

145150
LOG.debug("Init complete")
146151

152+
def init_handlers(self, project, handler_package_path):
153+
path = handler_package_path / "handlers.py"
154+
if project.artifact_type == ARTIFACT_TYPE_HOOK:
155+
template = self.env.get_template("hook_handlers.py")
156+
else:
157+
template = self.env.get_template("handlers.py")
158+
contents = template.render(
159+
support_lib_pkg=SUPPORT_LIB_PKG, type_name=project.type_name
160+
)
161+
project.safewrite(path, contents)
162+
147163
def generate(self, project):
148164
LOG.debug("Generate started")
149165

150166
self._init_from_project(project)
151-
152-
models = resolve_models(project.schema)
167+
if project.artifact_type == ARTIFACT_TYPE_HOOK:
168+
models = resolve_models(project.schema, "HookInputModel")
169+
else:
170+
models = resolve_models(project.schema)
153171

154172
if project.configuration_schema:
155173
configuration_schema_path = (
@@ -165,7 +183,11 @@ def generate(self, project):
165183

166184
path = self.package_root / self.package_name / "models.py"
167185
LOG.debug("Writing file: %s", path)
168-
template = self.env.get_template("models.py")
186+
if project.artifact_type == ARTIFACT_TYPE_HOOK:
187+
template = self.env.get_template("hook_models.py")
188+
else:
189+
template = self.env.get_template("models.py")
190+
169191
contents = template.render(support_lib_pkg=SUPPORT_LIB_PKG, models=models)
170192
project.overwrite(path, contents)
171193

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import logging
2+
from typing import Any, MutableMapping, Optional
3+
4+
from {{support_lib_pkg}} import (
5+
BaseHookHandlerRequest,
6+
HandlerErrorCode,
7+
Hook,
8+
HookInvocationPoint,
9+
OperationStatus,
10+
ProgressEvent,
11+
SessionProxy,
12+
exceptions,
13+
)
14+
15+
from .models import HookHandlerRequest, TypeConfigurationModel
16+
17+
# Use this logger to forward log messages to CloudWatch Logs.
18+
LOG = logging.getLogger(__name__)
19+
TYPE_NAME = "{{ type_name }}"
20+
21+
hook = Hook(TYPE_NAME, TypeConfigurationModel)
22+
test_entrypoint = hook.test_entrypoint
23+
24+
25+
@hook.handler(HookInvocationPoint.CREATE_PRE_PROVISION)
26+
def pre_create_handler(
27+
session: Optional[SessionProxy],
28+
request: HookHandlerRequest,
29+
callback_context: MutableMapping[str, Any],
30+
type_configuration: TypeConfigurationModel
31+
) -> ProgressEvent:
32+
target_model = request.hookContext.targetModel
33+
progress: ProgressEvent = ProgressEvent(
34+
status=OperationStatus.IN_PROGRESS
35+
)
36+
# TODO: put code here
37+
38+
# Example:
39+
try:
40+
# Reading the Resource Hook's target properties
41+
resource_properties = target_model.get("resourceProperties")
42+
43+
if isinstance(session, SessionProxy):
44+
client = session.client("s3")
45+
# Setting Status to success will signal to cfn that the hook operation is complete
46+
progress.status = OperationStatus.SUCCESS
47+
except TypeError as e:
48+
# exceptions module lets CloudFormation know the type of failure that occurred
49+
raise exceptions.InternalFailure(f"was not expecting type {e}")
50+
# this can also be done by returning a failed progress event
51+
# return ProgressEvent.failed(HandlerErrorCode.InternalFailure, f"was not expecting type {e}")
52+
53+
return progress
54+
55+
56+
@hook.handler(HookInvocationPoint.UPDATE_PRE_PROVISION)
57+
def pre_update_handler(
58+
session: Optional[SessionProxy],
59+
request: BaseHookHandlerRequest,
60+
callback_context: MutableMapping[str, Any],
61+
type_configuration: TypeConfigurationModel
62+
) -> ProgressEvent:
63+
target_model = request.hookContext.targetModel
64+
progress: ProgressEvent = ProgressEvent(
65+
status=OperationStatus.IN_PROGRESS
66+
)
67+
# TODO: put code here
68+
69+
# Example:
70+
try:
71+
# A Hook that does not allow a resource's encryption algorithm to be modified
72+
73+
# Reading the Resource Hook's target current properties and previous properties
74+
resource_properties = target_model.get("resourceProperties")
75+
previous_properties = target_model.get("previousResourceProperties")
76+
77+
if resource_properties.get("encryptionAlgorithm") != previous_properties.get("encryptionAlgorithm"):
78+
progress.status = OperationStatus.FAILED
79+
progress.message = "Encryption algorithm can not be changed"
80+
else:
81+
progress.status = OperationStatus.SUCCESS
82+
except TypeError as e:
83+
progress = ProgressEvent.failed(HandlerErrorCode.InternalFailure, f"was not expecting type {e}")
84+
85+
return progress
86+
87+
88+
@hook.handler(HookInvocationPoint.DELETE_PRE_PROVISION)
89+
def pre_delete_handler(
90+
session: Optional[SessionProxy],
91+
request: BaseHookHandlerRequest,
92+
callback_context: MutableMapping[str, Any],
93+
type_configuration: TypeConfigurationModel
94+
) -> ProgressEvent:
95+
# TODO: put code here
96+
return ProgressEvent(
97+
status=OperationStatus.SUCCESS
98+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# DO NOT modify this file by hand, changes will be overwritten
2+
import sys
3+
from dataclasses import dataclass
4+
from inspect import getmembers, isclass
5+
from typing import (
6+
AbstractSet,
7+
Any,
8+
Generic,
9+
Mapping,
10+
MutableMapping,
11+
Optional,
12+
Sequence,
13+
Type,
14+
TypeVar,
15+
)
16+
17+
from cloudformation_cli_python_lib.interface import BaseHookHandlerRequest, BaseModel
18+
from cloudformation_cli_python_lib.recast import recast_object
19+
from cloudformation_cli_python_lib.utils import deserialize_list
20+
21+
T = TypeVar("T")
22+
23+
24+
def set_or_none(value: Optional[Sequence[T]]) -> Optional[AbstractSet[T]]:
25+
if value:
26+
return set(value)
27+
return None
28+
29+
30+
@dataclass
31+
class HookHandlerRequest(BaseHookHandlerRequest):
32+
pass
33+
34+
35+
{% for model, properties in models.items() %}
36+
@dataclass
37+
class {{ model }}(BaseModel):
38+
{% for name, type in properties.items() %}
39+
{{ name }}: Optional[{{ type|translate_type }}]
40+
{% endfor %}
41+
42+
@classmethod
43+
def _deserialize(
44+
cls: Type["_{{ model }}"],
45+
json_data: Optional[Mapping[str, Any]],
46+
) -> Optional["_{{ model }}"]:
47+
if not json_data:
48+
return None
49+
{% if model.endswith("ResourceModel") %}
50+
dataclasses = {n: o for n, o in getmembers(sys.modules[__name__]) if isclass(o)}
51+
recast_object(cls, json_data, dataclasses)
52+
{% endif %}
53+
return cls(
54+
{% for name, type in properties.items() %}
55+
{% set container = type.container %}
56+
{% set resolved_type = type.type %}
57+
{% if container == ContainerType.MODEL %}
58+
{{ name }}={{ resolved_type }}._deserialize(json_data.get("{{ name }}")),
59+
{% elif container == ContainerType.SET %}
60+
{{ name }}=set_or_none(json_data.get("{{ name }}")),
61+
{% elif container == ContainerType.LIST %}
62+
{% if type | contains_model %}
63+
{{name}}=deserialize_list(json_data.get("{{ name }}"), {{resolved_type.type}}),
64+
{% else %}
65+
{{ name }}=json_data.get("{{ name }}"),
66+
{% endif %}
67+
{% else %}
68+
{{ name }}=json_data.get("{{ name }}"),
69+
{% endif %}
70+
{% endfor %}
71+
)
72+
73+
74+
# work around possible type aliasing issues when variable has same name as a model
75+
_{{ model }} = {{ model }}
76+
77+
78+
{% endfor -%}
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{{ support_lib_name }}>=2.1.3
1+
{{ support_lib_name }}>=2.1.9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# DO NOT modify this file by hand, changes will be overwritten
2+
import sys
3+
from dataclasses import dataclass
4+
from inspect import getmembers, isclass
5+
from typing import (
6+
AbstractSet,
7+
Any,
8+
Generic,
9+
Mapping,
10+
MutableMapping,
11+
Optional,
12+
Sequence,
13+
Type,
14+
TypeVar,
15+
)
16+
17+
from cloudformation_cli_python_lib.interface import BaseModel
18+
from cloudformation_cli_python_lib.recast import recast_object
19+
from cloudformation_cli_python_lib.utils import deserialize_list
20+
21+
T = TypeVar("T")
22+
23+
24+
def set_or_none(value: Optional[Sequence[T]]) -> Optional[AbstractSet[T]]:
25+
if value:
26+
return set(value)
27+
return None
28+
29+
30+
{% for model, properties in models.items() %}
31+
@dataclass
32+
class {{ model }}(BaseModel):
33+
{% for name, type in properties.items() %}
34+
{{ name }}: Optional[{{ type|translate_type }}]
35+
{% endfor %}
36+
37+
@classmethod
38+
def _deserialize(
39+
cls: Type["_{{ model }}"],
40+
json_data: Optional[Mapping[str, Any]],
41+
) -> Optional["_{{ model }}"]:
42+
if not json_data:
43+
return None
44+
{% if model == (target_name) %}
45+
data = dict(filter(lambda e: e[0] in cls.__dataclass_fields__, json_data.items()))
46+
if not data:
47+
return None
48+
49+
dataclasses = {n: o for n, o in getmembers(sys.modules[__name__]) if isclass(o)}
50+
recast_object(cls, data, dataclasses)
51+
{% endif %}
52+
return cls(
53+
{% for name, type in properties.items() %}
54+
{% set container = type.container %}
55+
{% set resolved_type = type.type %}
56+
{% if container == ContainerType.MODEL %}
57+
{{ name }}={{ resolved_type }}._deserialize(json_data.get("{{ name }}")),
58+
{% elif container == ContainerType.SET %}
59+
{{ name }}=set_or_none(json_data.get("{{ name }}")),
60+
{% elif container == ContainerType.LIST %}
61+
{% if type | contains_model %}
62+
{{name}}=deserialize_list(json_data.get("{{ name }}"), {{resolved_type.type}}),
63+
{% else %}
64+
{{ name }}=json_data.get("{{ name }}"),
65+
{% endif %}
66+
{% else %}
67+
{{ name }}=json_data.get("{{ name }}"),
68+
{% endif %}
69+
{% endfor %}
70+
)
71+
72+
73+
# work around possible type aliasing issues when variable has same name as a model
74+
_{{ model }} = {{ model }}
75+
76+
77+
{% endfor -%}

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def find_version(*file_paths):
3838
include_package_data=True,
3939
zip_safe=True,
4040
python_requires=">=3.6",
41-
install_requires=["cloudformation-cli>=0.2.13", "types-dataclasses>=0.1.5"],
41+
install_requires=["cloudformation-cli>=0.2.23", "types-dataclasses>=0.1.5"],
4242
entry_points={
4343
"rpdk.v1.languages": [
4444
"python37 = rpdk.python.codegen:Python37LanguagePlugin",

0 commit comments

Comments
 (0)