Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Resolve #1252] Supporting resolvers in Hook and Resolver arguments, with new !substitute, !join, !split, and !select resolvers! #1313

Merged
merged 43 commits into from
Mar 24, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
cb5a477
creating Sub resolver
jfalkenstein Feb 25, 2023
382938f
creating nestable resolvers
jfalkenstein Feb 25, 2023
caa6d3f
testing substitute, join, and split
jfalkenstein Feb 25, 2023
f34631e
supporting resolving to nothing
jfalkenstein Feb 25, 2023
24f3333
adding docstrings and extra test
jfalkenstein Feb 25, 2023
5376462
merging nestable resolver with resolver base class
jfalkenstein Feb 25, 2023
8ba2d02
updating base resolver class
jfalkenstein Feb 26, 2023
1b56f5e
creating select resolver
jfalkenstein Feb 26, 2023
9712bfa
supporting resolvers as hook arguments
jfalkenstein Feb 26, 2023
617c37a
testing helper
jfalkenstein Feb 26, 2023
b7f4059
updating docstrings
jfalkenstein Feb 26, 2023
8ccb8bd
documenting new resolvers
jfalkenstein Feb 26, 2023
5d14ec0
adding some context to stack config docs
jfalkenstein Feb 26, 2023
a98a955
fixing docs
jfalkenstein Feb 26, 2023
c739663
docs tweak
jfalkenstein Feb 26, 2023
ee70e4e
adding docstrings
jfalkenstein Feb 26, 2023
868448b
testing for resolvers in resolvers in resolvers
jfalkenstein Feb 26, 2023
1fc438d
fixing setup
jfalkenstein Feb 26, 2023
80cb201
making an example use sceptre_user_data
jfalkenstein Mar 4, 2023
01130d4
making example a little more explicit
jfalkenstein Mar 4, 2023
ac07a92
small docs tweak
jfalkenstein Mar 4, 2023
13cf87d
improving docstring
jfalkenstein Mar 4, 2023
1502a81
improving docstring
jfalkenstein Mar 4, 2023
2c42905
improving docstring
jfalkenstein Mar 4, 2023
aa1a258
handling resolver situations
jfalkenstein Mar 4, 2023
62bcb29
documenting __repr__
jfalkenstein Mar 4, 2023
51c7079
testing placeholders with nested resolvers
jfalkenstein Mar 4, 2023
6f84e48
Merge remote-tracking branch 'sceptre/master' into 1252-sub-resolver
jfalkenstein Mar 4, 2023
c5f9c72
Apply suggestions from code review
jfalkenstein Mar 6, 2023
9fe80bc
renaming Substitute to Sub
jfalkenstein Mar 11, 2023
f75e3c3
Merge branch '1252-sub-resolver' of github.com:jfalkenstein/sceptre i…
jfalkenstein Mar 11, 2023
20ca362
fixing grammer in docs
jfalkenstein Mar 12, 2023
d7eb15f
clarifying docs
jfalkenstein Mar 12, 2023
5ad9748
making the underline shorter
jfalkenstein Mar 12, 2023
5888204
more robust joining
jfalkenstein Mar 12, 2023
d57bb3d
fixing test name
jfalkenstein Mar 12, 2023
d8cd879
adding better select tests
jfalkenstein Mar 12, 2023
9080e70
adding proper error handling around join
jfalkenstein Mar 19, 2023
26d960e
proper error handling around select
jfalkenstein Mar 19, 2023
6babfb8
testing sub
jfalkenstein Mar 19, 2023
b3d50ff
adding test on invalid argument error raising
jfalkenstein Mar 19, 2023
e1665ad
documenting resolvers in a hook argument
jfalkenstein Mar 19, 2023
fab6544
fixing typo in test name
jfalkenstein Mar 24, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion sceptre/config/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ def constructor_factory(node_class):
:rtype: func
"""

# This function signture is required by PyYAML
# This function signature is required by PyYAML
def class_constructor(loader, node):
return node_class(
loader.construct_object(self.resolve_node_tag(loader, node))
Expand Down
24 changes: 23 additions & 1 deletion sceptre/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from contextlib import contextmanager
from datetime import datetime
from os import sep
from typing import Optional, Any, List
from typing import Optional, Any, List, Tuple, Union

import dateutil.parser
import deprecation
Expand Down Expand Up @@ -68,6 +68,28 @@ def func_on_instance(key):
return attr


Container = Union[list, dict]
Key = Union[str, int]


def delete_keys_from_containers(keys_to_delete: List[Tuple[Container, Key]]):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is mostly just extracted out of the ResolvableProperty class and put here. I needed to also use this in the CustomYamlTagBase class.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm glad that this has been extracted but I'm a bit queasy about this function mutating collections it doesn't own. I got side-tracked by work but I feel this could be simplified (in the future) using list comprehensions and/or filter. But then I wasn't able to dig enough into how it's used to propose a refactor.

Copy link
Contributor Author

@jfalkenstein jfalkenstein Mar 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I posted a previous response to this and then I'm replacing it with this, because I thought you were talking about something else.

If you look at how this is used, mutation is exactly the purpose of this function. Filtered lists and dicts will not suit the purpose of what this exists for and won't work. The whole point is in-place modification of containers because of the way that resolvers operate.

"""Removes the indicated keys/indexes from their paired containers."""
list_items_to_delete = []
for container, key in keys_to_delete:
if isinstance(container, list):
# If it's a list, we want to gather up the items to remove from the list.
# We don't want to modify the list length yet, since removals will change all the other
# list indexes. Instead, we'll get the actual items at those indexes to remove later.
list_items_to_delete.append((container, container[key]))
else:
del container[key]

# Finally, now that we have all the items we want to remove the lists, we'll remove those
# items specifically from the lists.
for containing_list, item in list_items_to_delete:
containing_list.remove(item)


def normalise_path(path):
"""
Converts a path to use correct path separator.
Expand Down
26 changes: 5 additions & 21 deletions sceptre/hooks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@

from typing import TYPE_CHECKING, Any

from sceptre.resolvers import ResolvableArgumentBase

if TYPE_CHECKING:
from sceptre.stack import Stack


class Hook(abc.ABC):
class Hook(ResolvableArgumentBase, metaclass=abc.ABCMeta):
"""
Hook is an abstract base class that should be inherited by all hooks.

Expand All @@ -20,21 +22,12 @@ class Hook(abc.ABC):
"""

def __init__(self, argument: Any = None, stack: "Stack" = None):
super().__init__(argument, stack)
self.logger = logging.getLogger(__name__)

if stack is not None:
self.logger = StackLoggerAdapter(self.logger, stack.name)

self.argument = argument
self.stack = stack

def setup(self):
"""
setup is a method that may be overwritten by inheriting classes. Allows
hooks to run so initalisation steps when config is first read.
"""
pass # pragma: no cover

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now defined in the base class

@abc.abstractmethod
def run(self):
"""
Expand All @@ -43,15 +36,6 @@ def run(self):
"""
pass # pragma: no cover

def clone(self, stack: "Stack") -> "Hook":
"""
Produces a "fresh" copy of the Hook, with the specified stack.

:param stack: The stack to set on the cloned resolver
"""
clone = type(self)(self.argument, stack)
return clone

Comment on lines -46 to -54
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now defined on the base class


class HookProperty(object):
"""
Expand Down Expand Up @@ -84,7 +68,7 @@ def __set__(self, instance: "Stack", value):
"""

def setup(attr, key, value: Hook):
attr[key] = clone = value.clone(instance)
attr[key] = clone = value.clone_for_stack(instance)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clone_for_stack (as you'll see below) is a recursive clone operation that allows for cloning resolvers inside of the arguments.

clone.setup()

_call_func_on_values(setup, value, Hook)
Expand Down
161 changes: 107 additions & 54 deletions sceptre/resolvers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from threading import RLock
from typing import Any, TYPE_CHECKING, Type, Union, TypeVar

from sceptre.helpers import _call_func_on_values
from sceptre.helpers import _call_func_on_values, delete_keys_from_containers
from sceptre.logging import StackLoggerAdapter
from sceptre.resolvers.placeholders import (
create_placeholder_value,
Expand All @@ -17,13 +17,113 @@
from sceptre import stack

T_Container = TypeVar("T_Container", bound=Union[dict, list])
Self = TypeVar("Self")


class RecursiveResolve(Exception):
pass


class Resolver(abc.ABC):
class ResolvableArgumentBase:
def __init__(self, argument: Any = None, stack: "stack.Stack" = None):
self.stack = stack

self._argument = argument
self._argument_is_resolved = False

@property
def argument(self) -> Any:
"""This is the resolver's argument.

This property will resolve all nested resolvers inside the argument, but only if this resolver
has a Stack.

Resolving nested resolvers will result in their values being replaced in the dict/list they
were in with their resolved value, so we won't have to resolve them again.

If this property is accessed BEFORE the resolver has a stack, it will return
the raw argument value. This is to safeguard any __init__() behaviors from triggering
resolution prematurely.
"""
if self.stack is not None and not self._argument_is_resolved:
self._resolve_argument()

return self._argument

@argument.setter
def argument(self, value):
self._argument = value

def _resolve_argument(self):
"""Resolves all argument resolvers recursively."""

keys_to_delete = []

def resolve(attr, key, obj: Resolver):
result = obj.resolve()
if result is None:
keys_to_delete.append((attr, key))
else:
attr[key] = result

_call_func_on_values(resolve, self._argument, Resolver)
delete_keys_from_containers(keys_to_delete)

self._argument_is_resolved = True

def _setup_nested_resolvers(self):
"""Ensures all nested resolvers in this resolver's argument are also setup when this
resolver's setup method is called.
"""

def setup_nested(attr, key, obj: Resolver):
obj.setup()

_call_func_on_values(setup_nested, self._argument, Resolver)

def _clone(self: Self, stack: "stack.Stack") -> Self:
"""Recursively clones the resolver and its arguments.

The returned resolver will have an identical argument that is a different memory reference,
so that resolvers inherited from a stack group and applied across multiple stacks are
independent of each other.

Furthermore, all nested resolvers in this resolver's argument will also be cloned to ensure
they themselves are also independent and fully configured for the current stack.
"""

def recursively_clone(obj):
if isinstance(obj, Resolver):
return obj._clone(stack)
if isinstance(obj, list):
return [recursively_clone(item) for item in obj]
elif isinstance(obj, dict):
return {key: recursively_clone(val) for key, val in obj.items()}
return obj

argument = recursively_clone(self._argument)
clone = type(self)(argument, stack)
return clone

def clone_for_stack(self: Self, stack: "stack.Stack") -> Self:
"""Obtains a clone of the current object, setup and ready for use for a given Stack
instance.
"""
clone = self._clone(stack)
clone._setup_nested_resolvers()
clone.setup()
return clone

def setup(self):
"""
This method is called at during stack initialisation.
Implementation of this method in subclasses can be used to do any
initial setup of the object.
"""
pass # pragma: no cover


class Resolver(ResolvableArgumentBase, metaclass=abc.ABCMeta):
"""
Resolver is an abstract base class that should be inherited by all
Resolvers.
Expand All @@ -33,21 +133,12 @@ class Resolver(abc.ABC):
"""

def __init__(self, argument: Any = None, stack: "stack.Stack" = None):
super().__init__(argument, stack)

self.logger = logging.getLogger(__name__)
if stack is not None:
self.logger = StackLoggerAdapter(self.logger, stack.name)

self.argument = argument
self.stack = stack

def setup(self):
"""
This method is called at during stack initialisation.
Implementation of this method in subclasses can be used to do any
initial setup of the object.
"""
pass # pragma: no cover

@abc.abstractmethod
def resolve(self):
"""
Expand All @@ -58,14 +149,6 @@ def resolve(self):
"""
pass # pragma: no cover

def clone(self, stack: "stack.Stack") -> "Resolver":
"""
Produces a "fresh" copy of the Resolver, with the specified stack.

:param stack: The stack to set on the cloned resolver
"""
return type(self)(self.argument, stack)


class ResolvableProperty(abc.ABC):
"""
Expand Down Expand Up @@ -127,23 +210,6 @@ def _no_recursive_get(self, stack: "stack.Stack"):
finally:
setattr(stack, get_status_name, False)

def get_setup_resolver_for_stack(
self, stack: "stack.Stack", resolver: Resolver
) -> Resolver:
"""Obtains a clone of the resolver with the stack set on it and the setup method having
been called on it.

:param stack: The stack to set on the Resolver
:param resolver: The Resolver to clone and set up
:return: The cloned resolver.
"""
# We clone the resolver when we assign the value so that every stack gets its own resolver
# rather than potentially having one resolver instance shared in memory across multiple
# stacks.
clone = resolver.clone(stack)
clone.setup()
return clone

Comment on lines -130 to -146
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is no longer necessary. It's all been moved into the CustomYamlTagBase's clone_for_stack method.

@abc.abstractmethod
def get_resolved_value(
self, stack: "stack.Stack", stack_class: Type["stack.Stack"]
Expand Down Expand Up @@ -254,20 +320,7 @@ def resolve(attr: Union[dict, list], key: Union[int, str], value: Resolver):

container = getattr(stack, self.name)
_call_func_on_values(resolve, container, Resolver)
# Remove keys and indexes from their containers that had resolvers resolve to None.
list_items_to_delete = []
for attr, key in keys_to_delete:
if isinstance(attr, list):
# If it's a list, we want to gather up the items to remove from the list.
# We don't want to modify the list length yet.
# Since removals will change all the other list indexes,
# we don't wan't to modify lists yet.
list_items_to_delete.append((attr, attr[key]))
else:
del attr[key]

for containing_list, item in list_items_to_delete:
containing_list.remove(item)
delete_keys_from_containers(keys_to_delete)

return container

Expand All @@ -294,7 +347,7 @@ def _clone_container_with_resolvers(

def recurse(obj):
if isinstance(obj, Resolver):
return self.get_setup_resolver_for_stack(stack, obj)
return obj.clone_for_stack(stack)
if isinstance(obj, list):
return [recurse(item) for item in obj]
elif isinstance(obj, dict):
Expand Down Expand Up @@ -389,5 +442,5 @@ def assign_value_to_stack(self, stack: "stack.Stack", value: Any):
:param value: The value to set
"""
if isinstance(value, Resolver):
value = self.get_setup_resolver_for_stack(stack, value)
value = value.clone_for_stack(stack)
setattr(stack, self.name, value)
8 changes: 8 additions & 0 deletions sceptre/resolvers/join.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from sceptre.resolvers import Resolver


class Join(Resolver):
def resolve(self):
delimiter, items_list = self.argument
joined = delimiter.join(items_list)
return joined
7 changes: 7 additions & 0 deletions sceptre/resolvers/select.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from sceptre.resolvers import Resolver


class Select(Resolver):
def resolve(self):
index, items = self.argument
return items[index]
7 changes: 7 additions & 0 deletions sceptre/resolvers/split.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from sceptre.resolvers import Resolver


class Split(Resolver):
def resolve(self):
split_on, split_string = self.argument
return split_string.split(split_on)
7 changes: 7 additions & 0 deletions sceptre/resolvers/substitute.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from sceptre.resolvers import Resolver


class Substitute(Resolver):
def resolve(self):
template, variables = self.argument
return template.format(**variables)
4 changes: 4 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,11 @@ def get_version(rel_path):
"stack_output_external ="
"sceptre.resolvers.stack_output:StackOutputExternal",
"no_value = sceptre.resolvers.no_value:NoValue",
"select = scpetre.resolvers.select:Select",
"stack_attr = sceptre.resolvers.stack_attr:StackAttr",
"substitute = sceptre.resolvers.substitute:Substitute",
"split = sceptre.resolvers.split:Split",
"join = sceptre.resolvers.join:Join",
],
"sceptre.template_handlers": [
"file = sceptre.template_handlers.file:File",
Expand Down
Loading