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

Conversation

jfalkenstein
Copy link
Contributor

@jfalkenstein jfalkenstein commented Feb 25, 2023

This pull request brings the ability for Hooks and Resolvers to have resolvers in their arguments and have those resolvers automatically resolve when self.argument is accessed. When a resolver or hook is setup, it will also setup all nested resolvers in its argument. And when it is attached to a Stack, it and its argument will be recursively cloned so that no resolver is associated with a different stack. This will let resolvers be "inherited" from parent stack configurations.

PR Checklist

  • Wrote a good commit message & description [see guide below].
  • Commit message starts with [Resolve #issue-number].
  • Added/Updated unit tests.
  • Added/Updated integration tests (if applicable).
  • All unit tests (make test) are passing.
  • Used the same coding conventions as the rest of the project.
  • The new code passes pre-commit validations (pre-commit run --all-files).
  • The PR relates to only one subject with a clear title.
    and description in grammatically correct, complete sentences.

Approver/Reviewer Checklist

  • Before merge squash related commits.

Other Information

Guide to writing a good commit

@jfalkenstein jfalkenstein changed the title [Resolve #1252] Supporting Nestable resolvers and introducing !substitute, !join, and !split [Resolve #1252] Supporting resolvers in Hook and Resolver arguments, with new !substitute, !join, !split, and !select resolvers! Feb 26, 2023
Comment on lines 28 to 37
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

Comment on lines -46 to -54
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

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


if TYPE_CHECKING:
from sceptre.stack import Stack


class Hook(abc.ABC):
class Hook(CustomYamlTagBase, metaclass=abc.ABCMeta):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The big change here is that, since there's very little that differentiates a hook from a resolver, other than the "resolve" vs "run" public method, they now share a common base class. A lot of the functionality on Hooks has now been moved up into that base class since it was shared with Resolvers.

@@ -84,7 +57,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.

@jfalkenstein jfalkenstein marked this pull request as ready for review March 4, 2023 15:41
Copy link
Contributor

@dboitnot dboitnot left a comment

Choose a reason for hiding this comment

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

I think this is great and will be a boon to users!

Looking at how resolvers work, though, I'm wondering if a more functional approach might drastically improve the code. There's a lot of mutation and behavior that, while correct, is not obvious and might be harder to reason about or troubleshoot.

It feels like resolvers (and maybe hooks?) could be evaluated as a series of transformations that act on data and their invocation is basically an abstract syntax tree.

The correct reply to the above is, of course, "Put your PR where your mouth is Dan." :)

This resolver allows you to select a specific index of a list of items. This is great for combining
with the ``!split`` resolver to obtain part of a string. This function works almost the same as
CloudFormation's ``!Select`` intrinsic function, **except you can use this with negative indexes to
select with a reverse index**.
Copy link
Contributor

Choose a reason for hiding this comment

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

What about "except you can use this with negative indices to select from the end of the list." ?

@@ -181,6 +248,32 @@ Example:
parameters:
VpcIdParameter: !stack_output_external prj-network-vpc::VpcIdOutput prod


substitute
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't have super-strong feelings about this, but why "substitute" and not "sub" as in CF? I might argue in favor of "sub" for consistency with CF.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't know. I guess I just don't like the abbreviation. It doesn't seem as clear as substitute. @zaro0508 , thoughts on this?

Copy link
Contributor

Choose a reason for hiding this comment

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

i agree with @dboitnot. Since you are making references to cloudformation intrinsic functions I prefer sub for consistency.

Copy link
Contributor

Choose a reason for hiding this comment

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

I agree "substitute" has more intrinsic clarity, but since most of our users are going to be pretty familiar with CF and sub I think we could avoid the mental dichotomy by sticking to CF's nomenclature. But I would like @zaro0508's thoughts as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've changed this to sub

Key = Union[str, int]


def delete_keys_from_containers(keys_to_delete: List[Tuple[Container, Key]]):
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.

@jfalkenstein
Copy link
Contributor Author

On the subject of in-place mutation...

  1. Because of the cloning employed when a resolver is associated with a stack, it effectively owns the data at that point. The only collections that are mutated are ones that are effectively deep-copied when they are assigned to the stack.
  2. This is generally the way resolvers have always worked. The basic idea is that it prevents us from ever needing to resolve the same resolver twice for the same stack. This can be important, since the resolvable attribute might be accessed a number of times over the course of execution.
  3. Once we've resolved the resolver, there's no sense in keeping it around; its purpose has been fulfilled.

@quinnjr
Copy link

quinnjr commented Mar 11, 2023

I'm really looking forward to this PR being merged

~~~~~~~~~~

This resolver allows you to create a string using Python string format syntax. This functions as a
great way to combine together a number of resolver outputs into a single string. This functions very
Copy link
Contributor

Choose a reason for hiding this comment

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

very/is very

@@ -181,6 +248,32 @@ Example:
parameters:
VpcIdParameter: !stack_output_external prj-network-vpc::VpcIdOutput prod


substitute
Copy link
Contributor

Choose a reason for hiding this comment

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

i agree with @dboitnot. Since you are making references to cloudformation intrinsic functions I prefer sub for consistency.

Comment on lines +146 to +148
notifications: !split
- ";"
- !stack_output my/sns/topics.yaml::SemicolonDelimitedArns
Copy link
Contributor

Choose a reason for hiding this comment

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

do you think it would make it easier for users to understand if these examples just used direct values? something like this..

   notifications: !split
     - ";"
     - "note1; note2; note3"

result: the list ["note1", "note2", "note3"] is passed into the notifications parameter

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually, I don't really. !sub, !join, !select, and !split are ONLY useful when you have resolvers to string together; They're clumsy if you don't. It's better to use Jinja to do these things, if you can. But if you have resolvers whose resolved values you need to manipulate/interpolate/select from, they're really the only way to do that.

As such, If we don't show how these resolvers can be used with other resolvers, we're not really showing how to use them well.

Copy link
Contributor

Choose a reason for hiding this comment

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

¿Por qué no los dos? :)

   notifications: !split
     - ";"
     - "note1; note2; note3"

Resolves to:

   notifications: [ "note1", "note2", "note3" ]

As a more practical example, the following will split a semicolon-delimited list exported by another stack:

   notifications: !split
     - ";"
     - !stack_output my/sns/topics.yaml::SemicolonDelimitedArns

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@dboitnot, sure, that works. But If you wanted to split a simple string, you'd just go like this:

notifications: {{ "note1; note2; note3".split(";") }}

I fear that if we show people examples of using these resolvers in cases where you don't actually need to use them (and where you really shouldn't use them), it will be more confusing. The only times you need to use these resolvers is when you need to combine/manipulate the outputs of other resolvers. I actually added documentation to that effect to make it absolutely clear.


parameters:
ConnectionString: !sub
- "postgres://{username}:{password}@{hostname}:{port}/{database}"
Copy link
Contributor

Choose a reason for hiding this comment

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

can the sub reference vars outside of it's scope? Can you set these to build-in vars like account id/region/etc.. What about references to vars from config.yaml If possible it might help users if there was an example or two of those use cases.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

!sub needs a second argument that defines the variable values. It's not the same as CloudFormation's sub that lets you just resolve values without defining them. It's just a glorified str.format() invocation. It doesn't have a "scope" outside its own arguments. If you want to resolve something from the Stack scope, you could combine !sub and Jinja syntax together, like this:

!sub
  # Note the double-curly braces is JINJA syntax to access variables within Jinja scope
  - "postgres://{{ var.username }}:{password}@{hostname}:{port}/{{ database }}"
  - password: !ssm /my/ssm/password
     hostname: !stack_output my/database/stack.yaml::HostName
     port: !stack_output my/database/stack.yaml::Port

Notice how, if it's accessible within Jinja, you don't actually need to provide that to !sub as a variable, because Jinja interpolation will happen BEFORE the yaml is rendered. I'm going to look into clarifying this in the docs, since I doubt you'll be the first one confused about this.

docs/_source/docs/stack_config.rst Show resolved Hide resolved
return self.argument


class TestSplit:
Copy link
Contributor

Choose a reason for hiding this comment

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

shouldn't this be TestSelect:?

Also would it make sense to have a test to select on a negative index values?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes!

tests/test_resolvers/test_split.py Show resolved Hide resolved
tests/test_resolvers/test_sub.py Show resolved Hide resolved
tests/test_resolvers/test_join.py Show resolved Hide resolved
setup.py Outdated
"stack_attr = sceptre.resolvers.stack_attr:StackAttr",
"sub = sceptre.resolvers.substitute:Sub",
Copy link
Contributor

Choose a reason for hiding this comment

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

shouldn't this be sub = sceptre.resolvers.sub:Sub?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yikes! Yes! That's probably why my tests are failing.

return self.argument


class TestSelect:
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I need to implement better error handling around missing indexes/keys.

Copy link
Contributor

Choose a reason for hiding this comment

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

Pydantic has changed my life:

https://docs.pydantic.dev/

Copy link
Contributor

@zaro0508 zaro0508 left a comment

Choose a reason for hiding this comment

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

generally LGTM. just a few minor fixes and should be good to go.

[
pytest.param("just a string", id="just a string"),
pytest.param([123, "something"], id="first item is not string"),
pytest.param(["something", 123], id="first item is not string"),
Copy link
Contributor

Choose a reason for hiding this comment

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

first/second

@@ -224,7 +226,7 @@ Assume a Sceptre `copy` hook that calls the `cp command`_:
- !copy
options: "-r"
source: "from_dir"
destination: "to_dir"
destination: !stack_output my/other/stack::CopyDestination
Copy link
Contributor

Choose a reason for hiding this comment

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

i think the before_update and after_update examples are wrong originally. shouldn't they be like this instead?

before_update:
  - !copy [ {"options":"-r", "source": "from_dir", "destination": "to_dir"} ]       	
after_update: !copy
  - options: "-r"
    source: "from_dir"
    destination: !stack_output my/other/stack::CopyDestination

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No. !copy in this imaginary example takes a dict as an argument, not a list. However, after_update and before_update should be lists. So it's correct in the docs, not in your suggestion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants