diff --git a/tackle/exceptions.py b/tackle/exceptions.py index f9d86a227..7c0ee942a 100644 --- a/tackle/exceptions.py +++ b/tackle/exceptions.py @@ -1,4 +1,4 @@ -"""All exceptions used in the tackle box code base are defined here.""" +"""All exceptions are defined here.""" import inspect import os import sys diff --git a/tackle/hooks.py b/tackle/hooks.py new file mode 100644 index 000000000..da26696a6 --- /dev/null +++ b/tackle/hooks.py @@ -0,0 +1,260 @@ +import os +import re +import sys +import importlib +import logging +from pydantic import BaseModel, Field, ConfigError +from pydantic.main import ModelMetaclass +import subprocess +from typing import TYPE_CHECKING + +# TODO: RM after dealing with namespace issue for validators +# https://github.com/robcxyz/tackle-box/issues/43 +# import random +# import string + +from tackle.utils.paths import listdir_absolute +from tackle.utils.files import read_config_file + +if TYPE_CHECKING: + from tackle.models import Context + +logger = logging.getLogger(__name__) + + +class LazyImportHook(BaseModel): + """Object to hold hook metadata so that it can be imported only when called.""" + + hooks_path: str + mod_name: str + provider_name: str + hook_type: str + is_public: bool = False + + def wrapped_exec(self, **kwargs): + kwargs['provider_hooks'].import_with_fallback_install( + mod_name=self.mod_name, + path=self.hooks_path, + ) + return kwargs['provider_hooks'][self.hook_type].wrapped_exec() + + +class LazyBaseFunction(BaseModel): + """ + Base function that declarative hooks are derived from and either imported when a + tackle file is read (by searching in adjacent hooks directory) or on init in local + providers. Used by jinja extensions and filters. + """ + + function_dict: dict = Field( + ..., + description="A dict for the lazy function to be parsed at runtime. Serves as a " + "carrier for the function's schema until it is compiled with " + "`create_function_model`.", + ) + function_fields: list = Field( + None, + description="List of fields used to 1, enrich functions without exec method, " + "and 2, inherit base attributes into methods. Basically a helper.", + ) + + +def import_from_path( + context: 'Context', + provider_path: str, + hooks_dir_name: str = None, +): + """Append a provider with a given path.""" + # Look for `.hooks` or `hooks` dir + if hooks_dir_name is None: + provider_contents = os.listdir(provider_path) + if '.hooks' in provider_contents: + hooks_dir_name = '.hooks' + elif 'hooks' in provider_contents: + hooks_dir_name = 'hooks' + else: + return + + provider_name = os.path.basename(provider_path) + mod_name = 'tackle.providers.' + provider_name + hooks_init_path = os.path.join(provider_path, hooks_dir_name, '__init__.py') + hooks_path = os.path.join(provider_path, hooks_dir_name) + + # If the provider has an __init__.py in the hooks directory, import that + # to check if there are any hook types declared there. If there are, store + # those references so that if the hook type is later called, the hook can + # then be imported. + if os.path.isfile(hooks_init_path): + loader = importlib.machinery.SourceFileLoader(mod_name, hooks_init_path) + mod = loader.load_module() + hook_types = getattr(mod, 'hook_types', []) + for h in hook_types: + hook = LazyImportHook( + hooks_path=hooks_path, + mod_name=mod_name, + provider_name=provider_name, + hook_type=h, + ) + + if hook.is_public: + context.public_hooks[h] = hook + else: + context.private_hooks[h] = hook + + # This pass will import all the modules and extract hooks + import_with_fallback_install(context, mod_name, hooks_path, skip_on_error=True) + + +def get_native_provider_paths(): + """Get a list of paths to the native providers.""" + providers_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), 'providers' + ) + native_providers = [ + os.path.join(providers_path, f) + for f in os.listdir(providers_path) + if os.path.isdir(os.path.join(providers_path, f)) and f != '__pycache__' + ] + return native_providers + + +def import_native_providers(context: 'Context'): + """Iterate through paths and import them.""" + native_provider_paths = get_native_provider_paths() + for i in native_provider_paths: + import_from_path(context, i, hooks_dir_name='hooks') + + +def import_hook_from_path( + context: 'Context', + mod_name: str, + file_path: str, +): + """Import a single hook from a path.""" + file_split = os.path.basename(file_path).split('.') + file_base = file_split[0] + file_extension = file_split[-1] + # Maintaining cookiecutter support here as it might have a `hooks` dir. + if file_base in ('pre_gen_project', 'post_gen_project', '__pycache__'): + return + if file_extension == 'pyc': + return + if file_extension in ('yaml', 'yml'): + # TODO: Turn this into a parser so that declarative hooks can be imported. + # Difference is now that we have access modifiers for hooks where + + # Import declarative hooks + file_contents = read_config_file(file_path) + for k, v in file_contents.items(): + if re.match(r'^[a-zA-Z0-9\_]*(<\-)$', k): + hook_type = k[:-2] + # context.public_context[hook_type] = LazyBaseFunction( + # function_dict=v, + # # function_fields=[], + # ) + context.public_hooks[hook_type] = LazyBaseFunction( + function_dict=v, + # function_fields=[], + ) + elif re.match(r'^[a-zA-Z0-9\_]*(<\_)$', k): + hook_type = k[:-2] + # context.private_context[hook_type] = LazyBaseFunction( + # function_dict=v, + # # function_fields=[], + # ) + context.private_hooks[hook_type] = LazyBaseFunction( + function_dict=v, + # function_fields=[], + ) + return + + if os.path.basename(file_path).split('.')[-1] != "py": + # Only import python files + return + + # TODO: RM after dealing with namespace issue for validators + # Use a unique RUN_ID to prevent duplicate validator errors + # https://github.com/robcxyz/tackle-box/issues/43 + # _run_id = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(4)) + # module_name = mod_name + '.hooks.' + file_base[0] + _run_id + # TODO: Correct version + # module_name = mod_name + '.hooks.' + file_base[0] + # TODO: Hack + try: + module_name = mod_name + '.hooks.' + file_base[0] + context.run_id + except AttributeError: + pass + + loader = importlib.machinery.SourceFileLoader(module_name, file_path) + + if sys.version_info.minor < 10: + mod = loader.load_module() + else: + spec = importlib.util.spec_from_loader(loader.name, loader) + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + loader.exec_module(mod) + + for k, v in mod.__dict__.items(): + if ( + isinstance(v, ModelMetaclass) + and 'hook_type' in v.__fields__ + and k != 'BaseHook' + and v.__fields__['hook_type'].default is not None + ): + # self[v.__fields__['/hook_type'].default] = v + context.private_hooks[v.__fields__['hook_type'].default] = v + + +def import_hooks_from_dir( + context: 'Context', + mod_name: str, + path: str, + skip_on_error: bool = False, +): + """ + Import hooks from a directory. This is meant to be used by generically pointing to + a hooks directory and importing all the relevant hooks into the context. + """ + potential_hooks = listdir_absolute(path) + for f in potential_hooks: + if skip_on_error: + try: + import_hook_from_path(context, mod_name, f) + except (ModuleNotFoundError, ConfigError, ImportError): + logger.debug(f"Skipping importing {f}") + continue + else: + import_hook_from_path(context, mod_name, f) + + +def import_with_fallback_install( + context: 'Context', + mod_name: str, + path: str, + skip_on_error: bool = False, +): + """ + Import a module and on import error, fallback on requirements file and try to + import again. + """ + try: + import_hooks_from_dir(context, mod_name, path, skip_on_error) + except ModuleNotFoundError: + requirements_path = os.path.join(path, '..', 'requirements.txt') + if os.path.isfile(requirements_path): + # It is a convention of providers to have a requirements file at the base. + # Install the contents if there was an import error + subprocess.check_call( + [ + sys.executable, + "-m", + "pip", + "install", + "--quiet", + "--disable-pip-version-check", + "-r", + requirements_path, + ] + ) + import_hooks_from_dir(context, mod_name, path) diff --git a/tackle/models.py b/tackle/models.py index 9e0379400..8f233c126 100644 --- a/tackle/models.py +++ b/tackle/models.py @@ -1,230 +1,23 @@ import os -import sys -import re -import subprocess -import importlib.machinery +import random +import string from abc import ABC - from pydantic import ( BaseModel, SecretStr, Field, Extra, validator, - ConfigError, ) from pydantic.main import ModelMetaclass from jinja2 import Environment, StrictUndefined from jinja2.ext import Extension from typing import Any, Union, Optional, Callable -import logging -import random -import string - -from tackle.utils.paths import listdir_absolute -# TODO: Move to utils -from tackle.render import wrap_jinja_braces -from tackle.utils.files import read_config_file +from tackle.hooks import import_native_providers +from tackle.utils.render import wrap_jinja_braces from tackle.exceptions import TooManyTemplateArgsException -logger = logging.getLogger(__name__) - - -class LazyImportHook(BaseModel): - """Object to hold hook metadata so that it can be imported only when called.""" - - hooks_path: str - mod_name: str - provider_name: str - hook_type: str - - def wrapped_exec(self, **kwargs): - kwargs['provider_hooks'].import_with_fallback_install( - mod_name=self.mod_name, - path=self.hooks_path, - ) - return kwargs['provider_hooks'][self.hook_type].wrapped_exec() - - -class ProviderHooks(dict): - """Dict with hook_types as keys mapped to their corresponding objects.""" - - # List to keep track of new functions which need to be updated into the jinja env's - # filters so that a hook can be called that way. - new_functions: list = [] - - def __init__(self, *args, **kwargs): - super(ProviderHooks, self).__init__(*args, **kwargs) - # https://github.com/robcxyz/tackle-box/issues/43 - # run_id used to make the namespace unique when running multiple runs of tackle - # as in running batches of tests to prevent duplicate validator import errors - self._run_id = ''.join( - random.choice(string.ascii_uppercase + string.digits) for _ in range(4) - ) - self.import_native_providers() - - def import_hook_from_path( - self, - mod_name: str, - file_path: str, - ): - """Import a single hook from a path.""" - file_split = os.path.basename(file_path).split('.') - file_base = file_split[0] - file_extension = file_split[-1] - # Maintaining cookiecutter support here as it might have a `hooks` dir. - if file_base in ('pre_gen_project', 'post_gen_project', '__pycache__'): - return - if file_extension == 'pyc': - return - if file_extension in ('yaml', 'yml'): - # TODO: Turn this into a parser so that declararitive hooks can be imported. - # Difference is now that we have access modifiers for hooks where - - # Import declarative hooks - file_contents = read_config_file(file_path) - for k, v in file_contents.items(): - if re.match(r'^[a-zA-Z0-9\_]*(<\-|<\_)$', k): - hook_type = k[:-2] - self[hook_type] = LazyBaseFunction( - function_dict=v, - # function_fields=[], - ) - # self.new_functions.append(hook_type) - return - - if os.path.basename(file_path).split('.')[-1] != "py": - # Only import python files - return - - # Use a unique RUN_ID to prevent duplicate validator errors - # https://github.com/robcxyz/tackle-box/issues/43 - module_name = mod_name + '.hooks.' + file_base[0] + self._run_id - loader = importlib.machinery.SourceFileLoader(module_name, file_path) - - if sys.version_info.minor < 10: - mod = loader.load_module() - else: - spec = importlib.util.spec_from_loader(loader.name, loader) - mod = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = mod - loader.exec_module(mod) - - for k, v in mod.__dict__.items(): - if ( - isinstance(v, ModelMetaclass) - and 'hook_type' in v.__fields__ - and k != 'BaseHook' - and v.__fields__['hook_type'].default is not None - ): - self[v.__fields__['hook_type'].default] = v - - def import_hooks_from_dir( - self, - mod_name: str, - path: str, - skip_on_error: bool = False, - ): - """ - Import hooks from a directory. This is meant to be used by generically pointing to - a hooks directory and importing all the relevant hooks into the context. - """ - potential_hooks = listdir_absolute(path) - for f in potential_hooks: - if skip_on_error: - try: - self.import_hook_from_path(mod_name, f) - except (ModuleNotFoundError, ConfigError, ImportError): - logger.debug(f"Skipping importing {f}") - continue - else: - self.import_hook_from_path(mod_name, f) - - def import_with_fallback_install( - self, mod_name: str, path: str, skip_on_error: bool = False - ): - """ - Import a module and on import error, fallback on requirements file and try to - import again. - """ - try: - self.import_hooks_from_dir(mod_name, path, skip_on_error) - except ModuleNotFoundError: - requirements_path = os.path.join(path, '..', 'requirements.txt') - if os.path.isfile(requirements_path): - # It is a convention of providers to have a requirements file at the base. - # Install the contents if there was an import error - subprocess.check_call( - [ - sys.executable, - "-m", - "pip", - "install", - "--quiet", - "--disable-pip-version-check", - "-r", - requirements_path, - ] - ) - self.import_hooks_from_dir(mod_name, path) - - def import_from_path(self, provider_path: str, hooks_dir_name: str = None): - """Append a provider with a given path.""" - # Look for `.hooks` or `hooks` dir - if hooks_dir_name is None: - provider_contents = os.listdir(provider_path) - if '.hooks' in provider_contents: - hooks_dir_name = '.hooks' - elif 'hooks' in provider_contents: - hooks_dir_name = 'hooks' - else: - return - - provider_name = os.path.basename(provider_path) - mod_name = 'tackle.providers.' + provider_name - hooks_init_path = os.path.join(provider_path, hooks_dir_name, '__init__.py') - hooks_path = os.path.join(provider_path, hooks_dir_name) - - # If the provider has an __init__.py in the hooks directory, import that - # to check if there are any hook types declared there. If there are, store - # those references so that if the hook type is later called, the hook can - # then be imported. - if os.path.isfile(hooks_init_path): - loader = importlib.machinery.SourceFileLoader(mod_name, hooks_init_path) - mod = loader.load_module() - hook_types = getattr(mod, 'hook_types', []) - for h in hook_types: - hook = LazyImportHook( - hooks_path=hooks_path, - mod_name=mod_name, - provider_name=provider_name, - hook_type=h, - ) - self[h] = hook - - # This pass will import all the modules and extract hooks - self.import_with_fallback_install(mod_name, hooks_path, skip_on_error=True) - - @staticmethod - def get_native_provider_paths(): - """Get a list of paths to the native providers.""" - providers_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'providers' - ) - native_providers = [ - os.path.join(providers_path, f) - for f in os.listdir(providers_path) - if os.path.isdir(os.path.join(providers_path, f)) and f != '__pycache__' - ] - return native_providers - - def import_native_providers(self): - """Iterate through paths and import them.""" - native_provider_paths = self.get_native_provider_paths() - for i in native_provider_paths: - self.import_from_path(i, hooks_dir_name='hooks') - class StrictEnvironment(Environment): """Create strict Jinja2 environment. @@ -233,19 +26,9 @@ class StrictEnvironment(Environment): rendering context. """ - def __init__(self, provider_hooks: dict, **kwargs): + def __init__(self, **kwargs): super(StrictEnvironment, self).__init__(undefined=StrictUndefined, **kwargs) - # Import filters into environment - # for k, v in provider_hooks.items(): - # if isinstance(v, LazyImportHook): - # self.filters[k] = v.wrapped_exec - # elif isinstance(v, LazyBaseFunction): - # self.filters[k] = v.exec - # self.filters[k] = v.wrapped_exec - - # else: - # # Filters don't receive any context (public_context, etc) - # self.filters[k] = v().wrapped_exec + # TODO: Add imports for jinja hook filters class BaseContext(BaseModel): @@ -262,7 +45,10 @@ class BaseContext(BaseModel): key_path: list = [] key_path_block: list = [] - provider_hooks: ProviderHooks = None + + public_hooks: dict = {} + private_hooks: dict = None + default_hook: Any = Field(None, exclude=True) calling_directory: str = None calling_file: str = None @@ -273,6 +59,17 @@ class BaseContext(BaseModel): class Config: smart_union = True + # TODO: RM after dealing with namespace issue for validators + # https://github.com/robcxyz/tackle-box/issues/43 + run_id: str = None + + def __init__(self, **data: Any): + super().__init__(**data) + if self.run_id is None: + self.run_id: str = ''.join( + random.choice(string.ascii_uppercase + string.digits) for _ in range(4) + ) + class Context(BaseContext): """The main object that is being modified by parsing.""" @@ -307,11 +104,12 @@ def __init__(self, **data: Any): self.checkout = 'latest' # Allows for passing the providers between tackle runtimes - if self.provider_hooks is None: - self.provider_hooks = ProviderHooks() + if self.private_hooks is None: + self.private_hooks = {} + import_native_providers(self) if self.env_ is None: - self.env_ = StrictEnvironment(self.provider_hooks) + self.env_ = StrictEnvironment() if self.calling_directory is None: # Can be carried over from another context. Should only be initialized when @@ -339,13 +137,11 @@ class BaseHook(BaseContext, Extension): merge: Union[bool, str] = None confirm: Optional[Any] = None - # Placeholder until help can be fully worked out - help: str = None - # Flag for whether being called directly (True) or by a jinja extension (False) is_hook_call: bool = False skip_import: bool = False hooks_path: str = None + is_public: bool = False env_: Any = None @@ -423,6 +219,7 @@ class FunctionInput(BaseModel): methods: dict = None public: bool = None extends: str = None + help: str = None class Config: extra = 'ignore' @@ -433,23 +230,6 @@ class BaseFunction(BaseHook, FunctionInput, ABC): """Base model when creating new functions.""" -class LazyBaseFunction(BaseModel): - """ - Base function that declarative hooks are derived from and either imported when a - tackle file is read (by searching in adjacent hooks directory) or on init in local - providers. Used by jinja extensions and filters. - """ - - function_dict: dict = Field( - ..., description="A dict for the lazy function to be parsed at runtime." - ) - function_fields: list = Field( - None, - description="List of fields used to 1, enrich functions without exec method, " - "and 2, inherit base attributes into methods. Basically a helper.", - ) - - class JinjaHook(BaseModel): """ Model for jinja hooks. Is instantiated in render.py while rendering from unknown @@ -482,7 +262,8 @@ def wrapped_exec(self, *args, **kwargs): no_input=self.context.no_input, calling_directory=self.context.calling_directory, calling_file=self.context.calling_file, - provider_hooks=self.context.provider_hooks, + private_hooks=self.context.private_hooks, + public_hooks=self.context.public_hooks, key_path=self.context.key_path, verbose=self.context.verbose, ).exec() diff --git a/tackle/parser.py b/tackle/parser.py index 96d34468a..bdedffa5d 100644 --- a/tackle/parser.py +++ b/tackle/parser.py @@ -1,18 +1,40 @@ -import os -import re -import logging -from pathlib import Path +from collections import OrderedDict from functools import partialmethod -from typing import Type, Any, Union, Callable -from pydantic import Field, create_model +import os +from pydantic import Field, create_model, ValidationError from pydantic.main import ModelMetaclass -from collections import OrderedDict -from pydantic import ValidationError +from pydantic.fields import ModelField from pydoc import locate +from pathlib import Path +import re from ruamel.yaml.constructor import CommentedKeyMap, CommentedMap from ruamel.yaml.parser import ParserError +from typing import Type, Any, Union, Callable, Optional -from tackle.render import render_variable, wrap_jinja_braces +from tackle import exceptions +from tackle.hooks import ( + import_from_path, + import_with_fallback_install, + LazyImportHook, + LazyBaseFunction, +) +from tackle.macros import ( + var_hook_macro, + blocks_macro, + compact_hook_call_macro, + list_to_var_macro, +) +from tackle.models import ( + Context, + BaseHook, + BaseFunction, + FunctionInput, + BaseContext, +) +from tackle.render import render_variable +from tackle.settings import settings +from tackle.utils.help import run_help +from tackle.utils.render import wrap_jinja_braces from tackle.utils.dicts import ( nested_get, nested_delete, @@ -38,27 +60,6 @@ find_in_parent, ) from tackle.utils.zipfile import unzip -from tackle.models import ( - Context, - BaseHook, - LazyImportHook, - BaseFunction, - FunctionInput, - LazyBaseFunction, - BaseContext, -) -from tackle.macros import ( - var_hook_macro, - blocks_macro, - compact_hook_call_macro, - list_to_var_macro, -) - -from tackle import exceptions -from tackle.settings import settings - -logger = logging.getLogger(__name__) - BASE_METHODS = [ 'if', @@ -73,6 +74,16 @@ ] +def get_public_or_private_hook( + context: 'Context', + hook_type: str, +) -> Union[Type[BaseHook], LazyBaseFunction]: + h = context.public_hooks.get(hook_type, None) + if h is not None: + return h + return context.private_hooks.get(hook_type, None) + + def get_hook(hook_type, context: 'Context') -> Type[BaseHook]: """ Get the hook from providers. Qualify if the hook is a method and if it is a lazy @@ -88,7 +99,7 @@ def get_hook(hook_type, context: 'Context') -> Type[BaseHook]: # Extract the base hook. hook_type = hook_parts.pop(0) - h = context.provider_hooks.get(hook_type, None) + h = get_public_or_private_hook(context, hook_type) if h is None: exceptions.raise_unknown_hook(context, hook_type) @@ -123,7 +134,7 @@ def get_hook(hook_type, context: 'Context') -> Type[BaseHook]: h = new_hook else: - h = context.provider_hooks.get(hook_type, None) + h = get_public_or_private_hook(context, hook_type) if h is None: # Raise exception for unknown hook exceptions.raise_unknown_hook(context, hook_type) @@ -132,11 +143,12 @@ def get_hook(hook_type, context: 'Context') -> Type[BaseHook]: elif isinstance(h, LazyImportHook): # Install the requirements which will convert all the hooks in that provider # to actual hooks - context.provider_hooks.import_with_fallback_install( + import_with_fallback_install( + context=context, mod_name=h.mod_name, path=h.hooks_path, ) - h = context.provider_hooks[hook_type] + h = get_public_or_private_hook(context, hook_type) # TODO: Refactor this whole function so this is not repeated # Make it so hook is split right away and evaluated in one loop elif isinstance(h, LazyBaseFunction): @@ -381,7 +393,7 @@ def parse_sub_context( return indexed_key_path = context.key_path[ - -(len(context.key_path) - len(context.key_path_block)) : + (len(context.key_path_block) - len(context.key_path)) : # noqa ] if isinstance(indexed_key_path[-1], bytes): @@ -439,7 +451,8 @@ def parse_hook( no_input=context.no_input, calling_directory=context.calling_directory, calling_file=context.calling_file, - provider_hooks=context.provider_hooks, + public_hooks=context.public_hooks, + private_hooks=context.private_hooks, key_path=context.key_path, verbose=context.verbose, env_=context.env_, @@ -566,6 +579,7 @@ def evaluate_args( ) value = args[i] hook_dict[hook_args[i]] = value + args.pop(0) return else: # The hooks arguments are indexed @@ -573,8 +587,9 @@ def evaluate_args( hook_dict[hook_args[i]] = v except IndexError: if len(hook_args) == 0: + # TODO: Give more info on possible methods raise exceptions.UnknownArgumentException( - f"The hook {hook_dict['hook_type']} does not take any " + f"The hook {Hook.identifier.split('.')[-1]} does not take any " f"arguments. Hook argument {v} caused an error.", context=context, ) @@ -766,21 +781,124 @@ def update_input_context_with_kwargs(context: 'Context', kwargs: dict): } -def run_source(context: 'Context', args: list, kwargs: dict, flags: list): +def update_hook_with_kwargs_and_flags(hook: ModelMetaclass, kwargs: dict) -> dict: """ - Take the input dict and impose global args/kwargs/flags with the following logic: - - Use kwargs/flags as overriding keys in the input_context - - Check the input dict if there is a key matching the arg and run that key - - Additional arguments are assessed as - - If the call is to a hook directly, then inject that as an argument - - If the call is to a block of hooks then call the next hook key - - Otherwise run normally (ie full parsing). - - An exception exists for if the last arg is `help` in which case that level's help - is called and exited 0. + For consuming kwargs / flags, once the hook has been identified when calling hooks + via CLI actions, this function matches the kwargs / flags with the hook and returns + any unused kwargs / flags for use in the outer context. Note that flags are kwargs + as they have already been merged by now. + """ + for k, v in kwargs.copy().items(): + if k in hook.__fields__: + if hook.__fields__[k].type_ == bool: + # Flags -> These are evaluated as the inverse of whatever is the default + if hook.__fields__[k].default: # ie -> True + hook.__fields__[k].default = False + else: + hook.__fields__[k].default = True + else: + # Kwargs + hook.__fields__[k].default = v + hook.__fields__[k].required = False # Otherwise will complain + kwargs.pop(k) + return kwargs + + +def find_run_hook_method( + context: 'Context', hook: ModelMetaclass, args: list, kwargs: dict +) -> Any: + """ + Given a hook with args, find if the hook has methods and if it does not, apply the + args to the hook based on the `args` field mapping. Calls the hook. + """ + kwargs = update_hook_with_kwargs_and_flags( + hook=hook, + kwargs=kwargs, + ) + + if kwargs != {}: + hook_name = hook.identifier.split('.')[-1] + if hook_name == '': + hook_name = 'default' + # We were given extra kwargs / flags so should throw error + unknown_args = ' '.join([f"{k}={v}" for k, v in kwargs.items()]) + raise exceptions.UnknownInputArgumentException( + f"The args {unknown_args} not recognized when running the hook/method " + f"{hook_name}. Exiting.", + context=context, + ) + + arg_dict = {} + num_popped = 0 + for i, arg in enumerate(args.copy()): + if arg in hook.__fields__ and hook.__fields__[arg].type_ == Callable: + # Consume the args + args.pop(i - num_popped) + num_popped += 1 + + # Gather the function's dict so it can be compiled into a runnable hook + func_dict = hook.__fields__[arg].default.function_dict.copy() + + # Add inheritance from base function fields + for j in hook.__fields__['function_fields'].default: + # Base method should not override child. + if j not in func_dict: + func_dict[j] = hook.__fields__[j] + + new_hook = create_function_model( + context=context, + func_name=hook.__fields__[arg].name, + func_dict=func_dict, + ) + + hook = new_hook + + elif arg == 'help': + # TODO: Update this + run_help(context=context, args=[]) + return None + elif 'args' in hook.__fields__: + evaluate_args(args, arg_dict, Hook=hook, context=context) + else: + raise exceptions.UnknownInputArgumentException( + "Can't find the ", context=context + ) + + return hook(**kwargs, **arg_dict).exec() + + +def raise_if_args_exist( + context: 'Context', hook: ModelMetaclass, args: list, kwargs: dict, flags: list +): + msgs = [] + if len(args) != 0: + msgs.append(f"args {', '.join(args)}") + if len(kwargs) != 0: + missing_kwargs = ', '.join([f"{k}={v}" for k, v in kwargs.items()]) + msgs.append(f"kwargs {missing_kwargs}") + if len(flags) != 0: + msgs.append(f"flags {', '.join(flags)}") + if len(msgs) != 0: + hook_name = hook.identifier.split('.')[-1] + if hook_name == '': + hook_name = 'default' + raise exceptions.UnknownInputArgumentException( + # TODO: Add the available args/kwargs/flags to this error msg + f"The {' and '.join(msgs)} were not found in the \"{hook_name}\" hook. " + f"Run the same command without the arg/kwarg/flag + \"help\" to see the " + f"available args/kwargs/flags.", + context=context, + ) + + +def run_source(context: 'Context', args: list, kwargs: dict, flags: list) -> Optional: + """ + Process global args/kwargs/flags based on if the args relate to the default hook or + some public hook (usually declarative). Once the hook has been identified, the + args/kwargs/flags are consumed and if there are any args left, an error is raised. """ # Tackle is called both through the CLI and as a package and so to preserve args / - # kwargs we + # kwargs we merge them here. if context.global_args is not None: args = args + context.global_args context.global_args = None @@ -791,54 +909,90 @@ def run_source(context: 'Context', args: list, kwargs: dict, flags: list): context.global_kwargs = None if context.global_flags is not None: + # TODO: Validate -> These are temporarily set to true but will be resolved as + # the inverse of what the default is kwargs.update({i: True for i in context.global_flags}) context.global_flags = None - update_input_context_with_kwargs(context=context, kwargs=kwargs) + # For CLI calls, this logic lines up the args with methods / method args and + # integrates the kwargs / flags into the call + if len(args) == 0 and context.default_hook: # Default hook (no args) + # Add kwargs / flags (already merged into kwargs) to default hook + kwargs = update_hook_with_kwargs_and_flags( + hook=context.default_hook, + kwargs=kwargs, + ) + # Run the default hook as there are no args. The outer context is then parsed + # as otherwise it would be unreachable. + default_hook_output = context.default_hook().exec() + # Return the output of the default hook + # TODO: Determine what the meaning of a primitive type return means with some + # kind of outer context -> Should error (and be caught) if there is a conflict + # in types. + context.public_context = default_hook_output + # TODO: ??? -> If output is primitive, then we need to return it without parsing + # the outer context + raise_if_args_exist( # Raise if there are any args left + context=context, + hook=context.default_hook, + args=args, + kwargs=kwargs, + flags=flags, + ) + elif len(args) != 0: # With args + # Prioritize public_hooks (ie non-default hook) because if the hook exists, + # then we should consume the arg there instead of using the arg as an arg for + # default hook because otherwise the public hook would be unreachable. + if args[0] in context.public_hooks: # + # Search within the public hook for additional args that could be + # interpreted as methods which always get priority over consuming the arg + # as an arg within the hook itself. + public_hook = args.pop(0) # Consume arg + context.public_context = find_run_hook_method( + context=context, + hook=context.public_hooks[public_hook], + args=args, + kwargs=kwargs, + ) + raise_if_args_exist( # Raise if there are any args left + context=context, + hook=context.public_hooks[public_hook], + args=args, + kwargs=kwargs, + flags=flags, + ) + elif context.default_hook: + context.public_context = find_run_hook_method( + context=context, hook=context.default_hook, args=args, kwargs=kwargs + ) + raise_if_args_exist( # Raise if there are any args left + context=context, + hook=context.default_hook, + args=args, + kwargs=kwargs, + flags=flags, + ) + # We are not going to parse the outer context in this case as we are already + # within a hook. Doesn't necessarily make sense in this case. + return + else: + # If there are no declarative hooks defined, use the kwargs to override values + # within the context. + update_input_context_with_kwargs(context=context, kwargs=kwargs) for i in flags: # Process flags by setting key to true context.input_context.update({i: True}) - if len(args) >= 1: - # Loop through all args - for i in args: - # Remove any arrows on the first level keys - first_level_compact_keys = [ - k[:-2] for k, _ in context.input_context.items() if k.endswith('->') - ] - if i in first_level_compact_keys: - arg_key_value = context.input_context[i + '->'] - if isinstance(arg_key_value, str): - # We have a compact hook so nothing else to traverse - break - - elif i in context.input_context: - context.key_path.append(i) - walk_sync(context, context.input_context[i].copy()) - context.key_path.pop() - - elif i in context.provider_hooks: - Hook = context.provider_hooks[i] - args.pop(-1) - evaluate_args(args, {}, Hook=Hook, context=context) - - hook_output_value = Hook().exec() - return hook_output_value - else: - raise exceptions.UnknownInputArgumentException( - f"Argument {i} not found as key in tackle file.", context=context - ) - return - if len(context.input_context) == 0: - raise exceptions.EmptyTackleFileException( - # TODO improve -> Should give help by default? - f"Only functions are declared in {context.input_string} tackle file. Must" - f" provide an argument such as [] or run `tackle {context.input_string}" - f" help` to see more options.", - context=context, - ) + if context.public_context is None: + raise exceptions.EmptyTackleFileException( + # TODO improve -> Should give help by default? + f"Only functions are declared in {context.input_string} tackle file. Must" + f" provide an argument such as [] or run `tackle {context.input_string}" + f" help` to see more options.", + context=context, + ) else: walk_sync(context, context.input_context.copy()) @@ -869,11 +1023,12 @@ def function_walk( existing_context = {} for i in self.function_fields: - # TODO: Why? -> test RM + # Used in hook inheritance existing_context.update({i: getattr(self, i)}) tmp_context = Context( - provider_hooks=self.provider_hooks, + public_hooks=self.public_hooks, + private_hooks=self.private_hooks, existing_context=existing_context, public_context={}, input_context=input_element, @@ -930,7 +1085,7 @@ def create_function_model( # Implement inheritance if 'extends' in func_dict and func_dict['extends'] is not None: - base_hook = context.provider_hooks[func_dict['extends']] + base_hook = get_public_or_private_hook(context, func_dict['extends']) func_dict = {**base_hook().function_dict, **func_dict} func_dict.pop('extends') @@ -945,12 +1100,14 @@ def create_function_model( elif 'exec<-' in func_dict: exec_ = func_dict.pop('exec<-') + # Special vars function_input = FunctionInput( exec_=exec_, return_=func_dict.pop('return') if 'return' in func_dict else None, args=func_dict.pop('args') if 'args' in func_dict else [], render_exclude=func_dict.pop( 'render_exclude') if 'render_exclude' in func_dict else [], + help=func_dict.pop('help') if 'help' in func_dict else None, # TODO: Build validators # validators_=func_dict.pop('validators') if 'validators' in func_dict else None, ) @@ -961,7 +1118,7 @@ def create_function_model( # Create function fields from anything left over in the function dict for k, v in func_dict.items(): if v is None: - # TODO: ? -> why skip? Would be ignored. Empty keys mean something right? + # TODO: Why skip? Would be ignored. Empty keys mean something right? continue if k.endswith(('->', '_>')): @@ -997,6 +1154,9 @@ def create_function_model( new_func[k] = v elif isinstance(v, list): new_func[k] = (list, v) + elif isinstance(v, ModelField): + # Is encountered when inheritance is imposed and calling function methods + new_func[k] = (v.type_, v.default) else: raise Exception("This should never happen") @@ -1045,12 +1205,22 @@ def create_function_model( def extract_functions(context: 'Context'): for k, v in context.input_context.copy().items(): if re.match(r'^[a-zA-Z0-9\_]*(<\-|<\_)$', k): - # TODO: RM arrow and put in associated access modifier namespace - Function = create_function_model(context, k, v) - context.provider_hooks[k[:-2]] = Function - context.input_context.pop(k) + function_name = k[:-2] + arrow = k[-2:] + if function_name == "": + # Function is the default hook + context.default_hook = Function + context.input_context.pop(k) + elif arrow == '<-': # public hook + context.public_hooks[function_name] = Function + context.input_context.pop(k) + elif arrow == '<_': # private hook + context.private_hooks[function_name] = Function + context.input_context.pop(k) + else: + raise # Should never happen def extract_base_file(context: 'Context'): @@ -1072,7 +1242,6 @@ def extract_base_file(context: 'Context'): # Preserve the calling file which should be carried over from tackle calls if context.calling_file is None: context.calling_file = context.input_file - # context.calling_file = path context.current_file = path try: @@ -1099,7 +1268,7 @@ def extract_base_file(context: 'Context'): context.private_context = {} # Import the hooks - context.provider_hooks.import_from_path(context.input_dir) + import_from_path(context, context.input_dir) def import_local_provider_source(context: 'Context', provider_dir: str): diff --git a/tackle/providers/logic/hooks/match.py b/tackle/providers/logic/hooks/match.py index 017d71cdc..e4b0150c6 100644 --- a/tackle/providers/logic/hooks/match.py +++ b/tackle/providers/logic/hooks/match.py @@ -83,7 +83,9 @@ def run_key(self, value): input_context=value, key_path=self.key_path.copy(), key_path_block=self.key_path.copy(), - provider_hooks=self.provider_hooks, + # provider_hooks=self.provider_hooks, + public_hooks=self.public_hooks, + private_hooks=self.private_hooks, public_context=self.public_context, private_context=self.private_context, temporary_context=self.temporary_context, diff --git a/tackle/providers/tackle/hooks/block.py b/tackle/providers/tackle/hooks/block.py index 0a91b8490..e9ddd87e9 100644 --- a/tackle/providers/tackle/hooks/block.py +++ b/tackle/providers/tackle/hooks/block.py @@ -26,7 +26,9 @@ def exec(self) -> Union[dict, list]: self.temporary_context = {} tmp_context = Context( - provider_hooks=self.provider_hooks, + # provider_hooks=self.provider_hooks, + public_hooks=self.public_hooks, + private_hooks=self.private_hooks, public_context=self.public_context, private_context=self.private_context, temporary_context=self.temporary_context, diff --git a/tackle/providers/tackle/hooks/import.py b/tackle/providers/tackle/hooks/import.py index 8ccc2d4f3..d2a3d8f8d 100644 --- a/tackle/providers/tackle/hooks/import.py +++ b/tackle/providers/tackle/hooks/import.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, Field from tackle.models import BaseHook +from tackle.hooks import import_from_path # from tackle.imports import import_from_path from tackle.utils.vcs import get_repo_source @@ -32,16 +33,28 @@ def exec(self) -> None: if isinstance(self.src, str): # Get the provider path either local or remote. provider_path = self.get_dir_or_repo(self.src, self.version) - self.provider_hooks.import_from_path(provider_path) + # self.provider_hooks.import_from_path(provider_path) + + import_from_path(self, provider_path) + + # self.provider_hooks.import_from_path(provider_path) elif isinstance(self.src, list): for i in self.src: if isinstance(i, str): - self.provider_hooks.import_from_path(self.get_dir_or_repo(i, None)) + # self.provider_hooks.import_from_path(self.get_dir_or_repo(i, None)) + import_from_path(self, i) if isinstance(i, dict): # dict types validated above and transposed through same logic repo_source = RepoSource(**i) - self.provider_hooks.import_from_path( + # self.provider_hooks.import_from_path( + # provider_path=self.get_dir_or_repo( + # repo_source.src, repo_source.version + # ), + # ) + + import_from_path( + self, provider_path=self.get_dir_or_repo( repo_source.src, repo_source.version ), diff --git a/tackle/providers/tackle/hooks/provider_docs.py b/tackle/providers/tackle/hooks/provider_docs.py index b5be00ce3..4e584bb37 100644 --- a/tackle/providers/tackle/hooks/provider_docs.py +++ b/tackle/providers/tackle/hooks/provider_docs.py @@ -196,6 +196,7 @@ def exec(self) -> Union[dict, list]: return_type = None # Generate the json schema + # schema = json.loads(h.schema_json(exclude={'default_hook'})) schema = json.loads(h.schema_json()) if self.output_schemas: schema_list.append(schema) diff --git a/tackle/providers/tackle/hooks/tackle.py b/tackle/providers/tackle/hooks/tackle.py index f33ee16db..fc047f10f 100644 --- a/tackle/providers/tackle/hooks/tackle.py +++ b/tackle/providers/tackle/hooks/tackle.py @@ -69,7 +69,9 @@ def exec(self) -> dict: # Evaluated existing_context=existing_context, # Implicit - provider_hooks=self.provider_hooks, + # provider_hooks=self.provider_hooks, + public_hooks=self.public_hooks, + private_hooks=self.private_hooks, no_input=self.no_input, global_kwargs=self.override, find_in_parent=self.find_in_parent, diff --git a/tackle/providers/tackle/tests/import/test_provider_tackle_import.py b/tackle/providers/tackle/tests/import/test_provider_tackle_import.py index 47720a1a1..872f557ec 100644 --- a/tackle/providers/tackle/tests/import/test_provider_tackle_import.py +++ b/tackle/providers/tackle/tests/import/test_provider_tackle_import.py @@ -5,9 +5,6 @@ from tackle.parser import update_source FIXTURES = [ - # # Handlers are experimental features - # 'handler-str.yaml', - # 'handler-list-compact.yaml', 'expanded-string.yaml', 'expanded-list-dict.yaml', 'local.yaml', @@ -19,9 +16,11 @@ def test_provider_system_hook_import(change_dir, target): """Run the source and check that the hooks imported the demo module.""" context = Context(input_string=target) - num_providers = len(context.provider_hooks.keys()) + # num_providers = len(context.provider_hooks.keys()) + num_providers = len(context.private_hooks.keys()) update_source(context) - assert num_providers < len(context.provider_hooks.keys()) + # assert num_providers < len(context.provider_hooks.keys()) + assert num_providers < len(context.private_hooks.keys()) # assert 'tackle.providers.tackle-demos' in context.providers.hook_types diff --git a/tackle/render.py b/tackle/render.py index 1f4cbd52a..a524cb331 100644 --- a/tackle/render.py +++ b/tackle/render.py @@ -5,36 +5,22 @@ from inspect import signature from typing import TYPE_CHECKING, Any, Callable from pydantic import ValidationError +from pydantic.main import ModelMetaclass +from tackle.hooks import LazyBaseFunction from tackle.special_vars import special_variables from tackle.exceptions import ( UnknownTemplateVariableException, MissingTemplateArgsException, MalformedTemplateVariableException, ) +from tackle.utils.imports import get_public_or_private_hook -from pydantic.main import ModelMetaclass if TYPE_CHECKING: from tackle.models import Context, JinjaHook -def wrap_braces_if_not_exist(value): - """Wrap with braces if they don't exist.""" - if '{{' in value and '}}' in value: - # Already templated - return value - return '{{' + value + '}}' - - -def wrap_jinja_braces(value): - """Wrap a string with braces so it can be templated.""" - if isinstance(value, str): - return wrap_braces_if_not_exist(value) - # Nothing else can be wrapped - return value - - def render_variable(context: 'Context', raw: Any): """ Render the raw input. Does recursion with dict and list inputs, otherwise renders @@ -59,6 +45,7 @@ def render_variable(context: 'Context', raw: Any): def create_jinja_hook(context: 'Context', hook: 'ModelMetaclass') -> 'JinjaHook': + """Create a jinja hook which is callable via wrapped_exec.""" from tackle.models import JinjaHook, BaseContext return JinjaHook( @@ -70,7 +57,8 @@ def create_jinja_hook(context: 'Context', hook: 'ModelMetaclass') -> 'JinjaHook' no_input=context.no_input, calling_directory=context.calling_directory, calling_file=context.calling_file, - provider_hooks=context.provider_hooks, + public_hooks=context.public_hooks, + private_hooks=context.private_hooks, key_path=context.key_path, verbose=context.verbose, ), @@ -158,6 +146,9 @@ def render_string(context: 'Context', raw: str): render_context.update({v: special_variables[v]()}) elif 'context' in argments: render_context.update({v: special_variables[v](context)}) + elif 'kwargs' in argments: + # TODO: This should support callable special vars + raise NotImplementedError else: raise ValueError("This should never happen.") else: @@ -168,10 +159,9 @@ def render_string(context: 'Context', raw: str): # Unknown variables can be real unknown variables, preloaded jinja globals or # hooks which need to be inserted into the global env so that they can be called for i in unknown_variables: - if i in context.provider_hooks: - from tackle.models import LazyBaseFunction + if i in context.public_hooks or i in context.private_hooks: + hook = get_public_or_private_hook(context, i) - hook = context.provider_hooks[i] # Keep track of the hook put in globals so that it can be removed later used_hooks.append(i) @@ -181,11 +171,15 @@ def render_string(context: 'Context', raw: str): from tackle.parser import create_function_model hook = create_function_model( - context=context, func_name=i, func_dict=hook.function_dict + context=context, + func_name=i, + # Copying allows calling hook twice - TODO: carry over arrow + func_dict=hook.function_dict.copy(), ) # Replace the provider hooks with instantiated function - context.provider_hooks[i] = hook + # context.provider_hooks[i] = hook + # TODO: carry over arrow to know where to put hook # Create the jinja method with jinja_hook = create_jinja_hook(context, hook) diff --git a/tackle/utils/imports.py b/tackle/utils/imports.py new file mode 100644 index 000000000..c09aefda2 --- /dev/null +++ b/tackle/utils/imports.py @@ -0,0 +1,16 @@ +from typing import Type, Union, TYPE_CHECKING + +if TYPE_CHECKING: + from tackle.models import BaseHook, Context + from tackle.hooks import LazyBaseFunction + + +def get_public_or_private_hook( + context: 'Context', + hook_type: str, +) -> Union[Type['BaseHook'], 'LazyBaseFunction']: + """Get the public or private hook from the context.""" + h = context.public_hooks.get(hook_type, None) + if h is not None: + return h + return context.private_hooks.get(hook_type, None) diff --git a/tackle/utils/render.py b/tackle/utils/render.py new file mode 100644 index 000000000..c250657ed --- /dev/null +++ b/tackle/utils/render.py @@ -0,0 +1,17 @@ +# Moved to utils as used in models.py + + +def wrap_braces_if_not_exist(value): + """Wrap with braces if they don't exist.""" + if '{{' in value and '}}' in value: + # Already templated + return value + return '{{' + value + '}}' + + +def wrap_jinja_braces(value): + """Wrap a string with braces so it can be templated.""" + if isinstance(value, str): + return wrap_braces_if_not_exist(value) + # Nothing else can be wrapped + return value diff --git a/tests/parser/functions/fixtures/cli-default-hook-context.yaml b/tests/parser/functions/fixtures/cli-default-hook-context.yaml new file mode 100644 index 000000000..62065b898 --- /dev/null +++ b/tests/parser/functions/fixtures/cli-default-hook-context.yaml @@ -0,0 +1,21 @@ + +<-: + help: A CLI thing + + stuff: things + do<-: + bar: baz + exec: + d->: var {{bar}} + + exec<-: + p->: var {{stuff}} + +foo: bar + +run<-: + help: + foo: bar + exec: + p->: print {{foo}} + diff --git a/tests/parser/functions/fixtures/cli-default-hook-embedded.yaml b/tests/parser/functions/fixtures/cli-default-hook-embedded.yaml new file mode 100644 index 000000000..961bec39f --- /dev/null +++ b/tests/parser/functions/fixtures/cli-default-hook-embedded.yaml @@ -0,0 +1,46 @@ + +<-: + help: A CLI thing + + foo: bar + foo_full: + type: str + default: bar + description: Foo does... + + do<-: + help: Does... + bar: baz + exec: + d->: var {{bar}} + stuff<-: + things<-: + exec<-: + t->: var {{foo}} + exec<-: + t->: var {{foo_full}} + + exec<-: + p->: var {{foo}} + +run<-: + help: A CLI thing + + foo: bar + foo_full: + type: str + default: bar + + do<-: + bar: baz + exec: + d->: var {{bar}} + stuff<-: + things<-: + exec<-: + t->: var {{foo}} + exec<-: + t->: var {{foo_full}} + + exec<-: + p->: var {{foo}} \ No newline at end of file diff --git a/tests/parser/functions/fixtures/cli-default-hook-no-context.yaml b/tests/parser/functions/fixtures/cli-default-hook-no-context.yaml new file mode 100644 index 000000000..26815d168 --- /dev/null +++ b/tests/parser/functions/fixtures/cli-default-hook-no-context.yaml @@ -0,0 +1,23 @@ + +<-: + help: A CLI thing + + stuff: + default: things + type: str + + things: + type: bool + default: true + + do<-: + bar: baz + args: ['bar'] + exec: + d->: var {{bar}} + + exec: + p->: var {{stuff}} + b->: var {{things}} + + diff --git a/tests/parser/functions/fixtures/cli-hook-no-context.yaml b/tests/parser/functions/fixtures/cli-hook-no-context.yaml new file mode 100644 index 000000000..08efc0b8e --- /dev/null +++ b/tests/parser/functions/fixtures/cli-hook-no-context.yaml @@ -0,0 +1,32 @@ +run<-: + help: A CLI thing + + stuff: + default: things + type: str + + things: + type: bool + default: true + + do<-: + bar: baz + args: ['bar'] + exec: + d->: var {{bar}} + t->: var {{stuff}} + s->: var {{things}} + + exec: + t->: var {{stuff}} + s->: var {{things}} + +<-: + help: A CLI thing + things: + type: bool + default: true + args: ['things'] + exec: + b->: var {{things}} + diff --git a/tests/parser/functions/fixtures/func-provider/hooks/funks.yaml b/tests/parser/functions/fixtures/func-provider/hooks/funks.yaml index fcc554ff3..99ceee6c6 100644 --- a/tests/parser/functions/fixtures/func-provider/hooks/funks.yaml +++ b/tests/parser/functions/fixtures/func-provider/hooks/funks.yaml @@ -15,19 +15,19 @@ a_funky<-: return: do -b_funky<-: - full: - type: str - description: A description - default: a-default - compact: a-default - - exec: - foo: bar - do->: var "{{full}}" - - args: - - full - - return: do +#b_funky<-: +# full: +# type: str +# description: A description +# default: a-default +# compact: a-default +# +# exec: +# foo: bar +# do->: var "{{full}}" +# +# args: +# - full +# +# return: do diff --git a/tests/parser/functions/fixtures/func-provider/tackle.yaml b/tests/parser/functions/fixtures/func-provider/tackle.yaml index 3e77bfb9b..06507edd5 100644 --- a/tests/parser/functions/fixtures/func-provider/tackle.yaml +++ b/tests/parser/functions/fixtures/func-provider/tackle.yaml @@ -4,4 +4,5 @@ stuff: things jinja_extension_default->: var {{a_funky()}} jinja_extension->: var {{a_funky(stuff)}} compact->: a_funky +compact_arg->: a_funky stuff #jinja_filter->: var {{stuff|a_funky}} diff --git a/tests/parser/functions/test_function_calls.py b/tests/parser/functions/test_function_calls.py new file mode 100644 index 000000000..0c8a0b4bc --- /dev/null +++ b/tests/parser/functions/test_function_calls.py @@ -0,0 +1,105 @@ +from tackle import tackle + + +############### +# Default hooks +############### +def test_function_default_hook_no_context(change_curdir_fixtures): + """Validate that we can run a default hook.""" + output = tackle('cli-default-hook-no-context.yaml') + assert output['p'] == 'things' + + +def test_function_default_hook_no_context_kwargs(change_curdir_fixtures): + """Validate that we can run a default hook with a kwarg.""" + output = tackle('cli-default-hook-no-context.yaml', stuff='bar') + assert output['p'] == 'bar' + assert output['b'] + + +def test_function_default_hook_no_context_flags(change_curdir_fixtures): + """ + Validate that flags are interpreted properly. In this case the default is true so + when setting the flag, it should be false. + """ + output = tackle('cli-default-hook-no-context.yaml', global_flags=['things']) + assert isinstance(output['b'], bool) + assert not output['b'] + + +def test_function_default_hook_context(change_curdir_fixtures): + """Test that outer context is additionally parsed with the default hook.""" + output = tackle('cli-default-hook-context.yaml') + assert output['p'] == 'things' + assert output['foo'] == 'bar' + + +def test_function_default_hook_no_context_method_call(change_curdir_fixtures): + """Validate that we can run a default hook.""" + output = tackle('cli-default-hook-no-context.yaml', 'do') + assert output['d'] == 'baz' + + +def test_function_default_hook_no_context_method_call_args(change_curdir_fixtures): + """Validate that we can run a default hook.""" + output = tackle('cli-default-hook-no-context.yaml', 'do', 'bizz') + assert output['d'] == 'bizz' + + +def test_function_default_hook_embedded(change_curdir_fixtures): + """Validate that we can run a default hook embedded methods.""" + output = tackle('cli-default-hook-embedded.yaml', 'do', 'stuff', 'things') + assert output['t'] == 'bar' + + +def test_function_default_hook_embedded_kwargs(change_curdir_fixtures): + """Validate that we can run a default hook embedded methods with kwargs.""" + output = tackle( + 'cli-default-hook-embedded.yaml', 'do', 'stuff', 'things', foo='bing' + ) + assert output['t'] == 'bing' + + +def test_function_default_hook_embedded_kwargs_full(change_curdir_fixtures): + """Validate that we can run a default hook embedded methods with kwargs schema.""" + output = tackle('cli-default-hook-embedded.yaml', 'do', 'stuff', foo_full='bing') + assert output['t'] == 'bing' + + +############# +# Non-default +############# +def test_function_cli_hook_arg(change_curdir_fixtures): + """Validate that we can run a default hook with an arg.""" + output = tackle('cli-hook-no-context.yaml', 'run') + assert output['t'] == 'things' + assert output['s'] + + +def test_function_cli_hook_arg_args(change_curdir_fixtures): + """Validate that we can run a default hook with an arg.""" + output = tackle('cli-hook-no-context.yaml', 'run', 'do', 'bazzz') + assert output['d'] == 'bazzz' + assert output['t'] == 'things' + assert output['s'] + + +def test_function_cli_hook_arg_kwargs(change_curdir_fixtures): + """Validate that we can run a default hook with an arg.""" + output = tackle('cli-hook-no-context.yaml', 'run', stuff='bazzz') + assert output['t'] == 'bazzz' + assert output['s'] + + +def test_function_cli_hook_arg_flags(change_curdir_fixtures): + """Validate that we can run a default hook with an arg.""" + output = tackle('cli-hook-no-context.yaml', 'run', global_flags=['things']) + assert not output['s'] + + +def test_function_hook_embedded_kwargs(change_curdir_fixtures): + """Validate that we can run a default hook embedded methods with kwargs.""" + output = tackle( + 'cli-default-hook-embedded.yaml', 'run', 'do', 'stuff', 'things', foo='bing' + ) + assert output['t'] == 'bing' diff --git a/tests/parser/functions/test_functions.py b/tests/parser/functions/test_functions.py index 41321c01e..861845b12 100644 --- a/tests/parser/functions/test_functions.py +++ b/tests/parser/functions/test_functions.py @@ -37,12 +37,8 @@ def test_function_no_exec(change_curdir_fixtures): def test_function_field_types(change_curdir_fixtures): - """ - Check that when no exec is given that by default the input is returned as is and - validated. - """ + """Check that field types are respected.""" output = tackle('field-types.yaml') - # Based on validation of functions in fixture assert output @@ -115,9 +111,9 @@ def test_function_method_override(change_curdir_fixtures): assert output['nexted_compact']['home'] == 'baz' -def test_function_import_func_from_hooks_dir(change_dir): +def test_function_import_func_from_hooks_dir(change_curdir_fixtures): """Assert that we can call functions from local hooks dir.""" - os.chdir(os.path.join('fixtures', 'func-provider')) + os.chdir('func-provider') o = tackle() assert o['compact'] == 'a-default' assert o['jinja_extension_default'] == 'a-default' diff --git a/tests/parser/functions/test_functions_cli.py b/tests/parser/functions/test_functions_cli.py index 900f3b65d..c884c9bc3 100644 --- a/tests/parser/functions/test_functions_cli.py +++ b/tests/parser/functions/test_functions_cli.py @@ -1,3 +1,4 @@ +import os import pytest import json from ruamel.yaml import YAML @@ -21,3 +22,18 @@ def test_function_model_extraction( expected_output = yaml.load(f) assert json.loads(output) == expected_output + + +@pytest.mark.parametrize("fixture,expected_output,argument", FIXTURES) +def test_function_model_extraction_in_directory( + change_curdir_fixtures, chdir, fixture, expected_output, argument, capsys +): + yaml = YAML() + with open(expected_output) as f: + expected_output = yaml.load(f) + + os.chdir("func-provider") + main([fixture, argument, "-p", "--find-in-parent"]) + output = capsys.readouterr().out + + assert json.loads(output) == expected_output diff --git a/tests/parser/functions/test_functions_exceptions.py b/tests/parser/functions/test_functions_exceptions.py new file mode 100644 index 000000000..c2e062887 --- /dev/null +++ b/tests/parser/functions/test_functions_exceptions.py @@ -0,0 +1,34 @@ +import pytest + +from tackle import tackle +from tackle import exceptions + + +def test_parser_functions_raises_unknown_arg(change_curdir_fixtures): + with pytest.raises(exceptions.UnknownArgumentException): + tackle("cli-default-hook-no-context.yaml", 'NOT_HERE') + + +def test_parser_functions_raises_unknown_kwarg(change_curdir_fixtures): + with pytest.raises(exceptions.UnknownInputArgumentException): + tackle("cli-default-hook-no-context.yaml", NOT_HERE='foo') + + +def test_parser_functions_raises_unknown_flags(change_curdir_fixtures): + with pytest.raises(exceptions.UnknownInputArgumentException): + tackle("cli-default-hook-no-context.yaml", global_flags=['NOT_HERE']) + + +def test_parser_functions_raises_unknown_arg_hook(change_curdir_fixtures): + with pytest.raises(exceptions.UnknownArgumentException): + tackle("cli-hook-no-context.yaml", 'run', 'NOT_HERE') + + +def test_parser_functions_raises_unknown_kwarg_hook(change_curdir_fixtures): + with pytest.raises(exceptions.UnknownInputArgumentException): + tackle("cli-hook-no-context.yaml", 'run', NOT_HERE='foo') + + +def test_parser_functions_raises_unknown_flags_hook(change_curdir_fixtures): + with pytest.raises(exceptions.UnknownInputArgumentException): + tackle("cli-hook-no-context.yaml", 'run', global_flags=['NOT_HERE']) diff --git a/tests/parser/test_parser_exceptions.py b/tests/parser/test_parser_exceptions.py index cdd7c8242..6c39d50df 100644 --- a/tests/parser/test_parser_exceptions.py +++ b/tests/parser/test_parser_exceptions.py @@ -12,8 +12,9 @@ from tackle.cli import main INPUT_SOURCES = [ + # TODO: empty should now be running help screen + # ("empty-with-functions.yaml", EmptyTackleFileException), ("empty.yaml", EmptyTackleFileException), - ("empty-with-functions.yaml", EmptyTackleFileException), ("out-of-range-arg.yaml", UnknownArgumentException), ("non-existent.yaml", UnknownSourceException), ("hook-input-validation-error.yaml", HookParseException),