Skip to content

Commit

Permalink
fix: match / block hook not handling existing contexts properly #57 #45
Browse files Browse the repository at this point in the history
  • Loading branch information
robcxyz committed Jun 14, 2023
1 parent 7cb33b9 commit f47e211
Show file tree
Hide file tree
Showing 4 changed files with 169 additions and 80 deletions.
51 changes: 29 additions & 22 deletions tackle/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
from tackle import exceptions
from tackle.hooks import (
import_from_path,
import_with_fallback_install,
LazyBaseFunction,
LazyImportHook,
)
from tackle.macros import (
var_hook_macro,
Expand All @@ -48,6 +50,9 @@
from tackle.render import render_variable
from tackle.settings import settings
from tackle.utils.dicts import (
get_readable_key_path,
get_set_temporary_context,
get_target_and_key,
nested_get,
nested_delete,
nested_set,
Expand Down Expand Up @@ -421,15 +426,20 @@ def parse_sub_context(
def new_hook(
context: 'Context',
hook_dict: dict,
hook: ModelMetaclass,
Hook: ModelMetaclass,
):
"""Create a new instantiated hook."""
# TODO: WIP - https://github.com/sudoblockio/tackle/issues/104
tmp_no_input = None if 'no_input' not in hook_dict else hook_dict.pop('no_input')

skip_output = Hook.__fields__['skip_output'] # Use later
try:
hook = hook(
hook = Hook(
**hook_dict,
no_input=context.no_input if tmp_no_input is None else tmp_no_input,
temporary_context={} if skip_output.default else context.temporary_context,
key_path=context.key_path,
key_path_block=context.key_path_block,
input_context=context.input_context,
public_context=context.public_context,
private_context=context.private_context,
Expand Down Expand Up @@ -463,8 +473,8 @@ def new_hook(
return

msg = str(e)
if hook.identifier.startswith('tackle.providers'):
id_list = hook.identifier.split('.')
if Hook.identifier.startswith('tackle.providers'):
id_list = Hook.identifier.split('.')
provider_doc_url_str = id_list[2].title()
# Replace the validated object name (ex PrintHook) with the
# hook_type field that users would more easily know.
Expand All @@ -490,7 +500,7 @@ def parse_hook_execute(
render_hook_vars(context=context, hook_dict=hook_dict, Hook=Hook)

# Instantiate the hook
hook = new_hook(context=context, hook_dict=hook_dict, hook=Hook)
hook = new_hook(context=context, hook_dict=hook_dict, Hook=Hook)
if hook is None:
return

Expand All @@ -509,12 +519,22 @@ def parse_hook_execute(
hook_output_value = run_hook_in_dir(hook)

if hook.skip_output:
# hook property that is only true for `block`/`match` hooks which write to the
# contexts themselves, thus their output is normally skipped except for merges.
if hook.merge:
# In this case we take the public context and overwrite the current context
# with the output indexed back one key.
merge_block_output(
hook_output_value=hook_output_value,
context=context,
append_hook_value=append_hook_value,
)
elif context.temporary_context is not None:
# Write the indexed output to the `temporary_context` as it was only written
# to the `public_context` and not maintained between items in a list
if not isinstance(context.key_path[-1], bytes):
get_set_temporary_context(context)

elif hook.merge:
merge_output(
hook_output_value=hook_output_value,
Expand All @@ -537,25 +557,12 @@ def evaluate_for(context: 'Context', hook_dict: dict, Hook: ModelMetaclass):

hook_dict.pop('for')

# Need add an empty list in the value so we have something to append to
# Need add an empty list in the value so we have something to append to except when
# we are merging.
if 'merge' not in hook_dict:
target_context, key_path = get_target_and_key(context)
nested_set(target_context, key_path, [])
set_key(context=context, value=[])
elif not hook_dict['merge']:
target_context, key_path = get_target_and_key(context)
# If we are merging into a list / dict, we don't want init a list
nested_set(target_context, key_path, [])

# Account for nested contexts and justify the new keys based on the key path within
# blocks by trimming the key_path_block from the key_path.
if len(context.key_path_block) != 0:
tmp_key_path = context.key_path[
len(context.key_path_block) - len(context.key_path) :
] # noqa
if context.temporary_context is None:
context.temporary_context = {} if isinstance(tmp_key_path[0], str) else []
tmp_key_path = [i for i in tmp_key_path if i not in ('->', '_>')]
nested_set(context.temporary_context, tmp_key_path, [])
set_key(context=context, value=[])

for i, l in (
enumerate(loop_targets)
Expand Down
158 changes: 101 additions & 57 deletions tackle/providers/logic/hooks/match.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""Match hook."""
from typing import Union
from typing import Union, Optional, Any
import re

from tackle.models import BaseHook, Context, Field
from tackle.parser import walk_sync
from tackle.parser import walk_element
from tackle.render import render_string
from tackle.exceptions import HookCallException

Expand All @@ -22,72 +22,23 @@ class MatchHook(BaseHook):
case: dict = Field(
...,
description="A dictionary where the keys are cases to be matched. Runs hooks "
"if present.",
"if present.",
)

args: list = ['value']

skip_output: bool = True
_render_exclude = {'case'}
_docs_order = 3

def block_macro(self, key, val) -> dict:
"""Take input and create a block hook to parse."""
output = {key[-2:]: 'block', 'merge': True}
aliases = [v.alias for _, v in BaseHook.__fields__.items()] + ['->', '_>']
for k, v in val.items():
if k not in aliases:
# Set the keys under the `items` key per the block hook's input
output.update({'items': {k: v}})
else:
output.update({k: v})
if self.verbose:
print(
"You are likely going to hit a bug."
"https://github.com/sudoblockio/tackle/issues/67"
)
return {key[:-2]: output}
# return output

def exec(self) -> Union[dict, list]:
# Condition catches everything except expanded hook calls and blocks (ie key->)
for k, v in self.case.items():
if re.match(k, self.value):
# Dicts that are not expanded hooks themselves
if isinstance(v, (dict, list)) and not ('->' in v or '_>' in v):
return self.run_key(v)
elif isinstance(v, dict):
# return self.run_key({k: {**v, **{'merge': True}}})
return self.run_key({k: {**v, **{'merge': True}}})
elif isinstance(v, str):
return render_string(self, v)
return v

elif re.match(k[:-2], self.value) and k[-2:] in ('->', '_>'):
# Return the value indexed without arrow
if isinstance(v, str):
return self.run_key({k[:-2]: {k[-2:]: v + ' --merge'}})
elif isinstance(v, dict):
return self.run_key(self.block_macro(k, v))
else:
raise HookCallException(
f"Matched value must be of type string or dict, not {v}.",
context=self,
) from None

raise HookCallException(
f"Value `{self.value}` not found in "
f"{' ,'.join([i for i in list(self.case)])}",
context=self,
) from None

def run_key(self, value):
self.skip_output: bool = True
if self.temporary_context is None:
self.temporary_context = {}

tmp_context = Context(
input_context=value,
key_path=self.key_path.copy(),
key_path_block=self.key_path.copy(),
# provider_hooks=self.provider_hooks,
public_hooks=self.public_hooks,
private_hooks=self.private_hooks,
public_context=self.public_context,
Expand All @@ -100,6 +51,99 @@ def run_key(self, value):
verbose=self.verbose,
override_context=self.override_context,
)
walk_sync(context=tmp_context, element=value.copy())
walk_element(context=tmp_context, element=value.copy())

return tmp_context.public_context

return self.public_context
def block_macro(self, key: str, val: dict) -> dict:
"""Take matched input dict and create a `block` hook to parse."""
# Remove the merge which will be inserted into the parsed block hook.
merge = self.merge if 'merge' not in val else val['merge']
if merge:
# Do this because arrows can stack up and mess up merge
self.key_path = self.key_path[:-1]
# We now don't want the hook to be merged
self.merge = False

output = {
key[-2:]: 'block',
'merge': merge,
'items': {},
}
# Have a collection of fields that are part of the base.
aliases = [v.alias for _, v in BaseHook.__fields__.items()] + ['->', '_>']
for k, v in val.items():
if k not in aliases:
# Set the keys under the `items` key per the block hook's input
output['items'].update({k: v})
else:
output.update({k: v})
return output

def match_case(self, v: Any):
# Normal dicts
if isinstance(v, dict) and not ('->' in v or '_>' in v):
# TODO: Determine if `match` hook should parse dictionaries by default
# https://github.com/sudoblockio/tackle/issues/160
# This will change to something like this but k will have a `->` suffixed:
# return self.run_key(self.block_macro(k, v))
self.skip_output = False
return v
# Dicts that are expanded hooks
elif isinstance(v, dict):
return self.run_key(v)
elif isinstance(v, (str, int)):
self.skip_output = False
return render_string(self, v)
self.skip_output = False
return v

def match_case_block(self, k: str, v: Any):
# Return the value indexed without arrow
if isinstance(v, str):
return self.run_key({k[:-2]: {k[-2:]: v + ' --merge'}})
elif isinstance(v, dict):
# We are in a block
return self.run_key(self.block_macro(k, v))
else:
raise HookCallException(
f"Matched value must be of type string or dict, not {v}.",
context=self,
) from None

def exec(self) -> Optional[Union[dict, list]]:
default_value = None
default_key = None
# Condition catches everything except expanded hook calls and blocks (ie key->)
for k, v in self.case.items():
if k in ['_', '_->']:
default_value = v
default_key = k
# Save this value for later in case nothing is matched
continue
try:
_match = re.fullmatch(k, self.value)
except re.error as e:
raise HookCallException(
f"Error in match hook case '{k}'\n{e}\nMalformed regex. Must "
f"with python's `re` module syntax.",
context=self,
) from None
if _match:
return self.match_case(v=v)

# TODO: This regex needs to be modified to not match empty hooks
# ie - `->`: x - should not match everything
# Case where we have an arrow in a key - ie `key->: ...`
elif re.fullmatch(k[:-2], self.value) and k[-2:] in ('->', '_>'):
return self.match_case_block(k, v)
if default_key is not None:
if '->' in default_key or '_>' in default_key:
return self.match_case_block(k=default_key, v=default_value)
return self.match_case(v=default_value)

raise HookCallException(
f"Value `{self.value}` not found in "
f"{' ,'.join([i for i in list(self.case)])}",
context=self,
) from None
2 changes: 1 addition & 1 deletion tackle/providers/tackle/hooks/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,6 @@ def exec(self) -> Union[dict, list]:
verbose=self.verbose,
override_context=self.override_context,
)
walk_sync(context=tmp_context, element=self.items.copy())
walk_element(context=tmp_context, element=self.items.copy())

return self.public_context
38 changes: 38 additions & 0 deletions tackle/utils/dicts.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,44 @@ def remove_arrows_from_key_path(key_path: list) -> list:
return output


def set_temporary_context(
context: 'Context',
value: Any,
key_path: list,
) -> None:
tmp_key_path = key_path[(len(context.key_path_block) - len(key_path)):]

if context.temporary_context is None:
context.temporary_context = {} if isinstance(tmp_key_path[0], str) else []
tmp_key_path = [i for i in tmp_key_path if i not in ('->', '_>')]

if len(tmp_key_path) == 0:
# Nothing to set in tmp context
return

nested_set(context.temporary_context, tmp_key_path, value)


def get_set_temporary_context(
context: 'Context',
) -> None:
"""
Used in hooks with indented contexts (ie block/match), it gets the output of the
target context (public / private) sets that value within the temporary context
so that it can be used for rendering.
"""
target_context, set_key_path = get_target_and_key(
context=context,
key_path=context.key_path
)
value = nested_get(element=target_context, keys=set_key_path)
set_temporary_context(
context=context,
value=value,
key_path=context.key_path,
)


def set_key(
context: 'Context',
value: Any,
Expand Down

0 comments on commit f47e211

Please sign in to comment.