diff --git a/as3ninja/api.py b/as3ninja/api.py index 2e5a918..397a404 100644 --- a/as3ninja/api.py +++ b/as3ninja/api.py @@ -16,6 +16,7 @@ ) from .gitget import Gitget, GitgetException from .schema import AS3Schema, AS3SchemaVersionError, AS3ValidationError +from .templateconfiguration import AS3TemplateConfiguration, AS3TemplateConfigurationError from .utils import deserialize CORS_SETTINGS = { @@ -58,13 +59,13 @@ class AS3DeclareGit(BaseModel): branch: Optional[str] commit: Optional[str] depth: int = 1 - template_configuration: Optional[Union[str, List[str]]] + template_configuration: Optional[Union[List[Union[dict, str]], dict, str]] class AS3Declare(BaseModel): """Model for an inline AS3 Declaration""" - template_configuration: Union[dict, List[dict]] + template_configuration: Union[List[dict], dict] declaration_template: str @@ -164,17 +165,20 @@ async def post_declaration_transform(as3d: AS3Declare): """Transforms an AS3 declaration template, see ``AS3Declare`` for details on the expected input. Returns the AS3 Declaration.""" error = None try: - d = AS3Declaration( - template_configuration=as3d.template_configuration, + as3tc = AS3TemplateConfiguration(as3d.template_configuration) + + as3declaration = AS3Declaration( + template_configuration=as3tc.dict(), declaration_template=as3d.declaration_template, ) - return d.declaration + return as3declaration.dict() except ( AS3SchemaVersionError, AS3JSONDecodeError, AS3TemplateSyntaxError, AS3UndefinedError, + AS3TemplateConfigurationError, ) as exc: error = Error(code=400, message=str(exc)) raise HTTPException(status_code=error.code, detail=error.message) @@ -185,53 +189,30 @@ async def post_declaration_git_transform(as3d: AS3DeclareGit): """Transforms an AS3 declaration template, see ``AS3DeclareGit`` for details on the expected input. Returns the AS3 Declaration.""" error = None try: - template_configuration: list = [] with Gitget( repository=as3d.repository, branch=as3d.branch, commit=as3d.commit, depth=as3d.depth, ) as gitrepo: - if as3d.template_configuration and isinstance( - as3d.template_configuration, list - ): - for config_file in as3d.template_configuration: - template_configuration.append( - deserialize(datasource=f"{gitrepo.repodir}/{config_file}") - ) - elif as3d.template_configuration: - template_configuration.append( - deserialize( - datasource=f"{gitrepo.repodir}/{as3d.template_configuration}" - ) - ) - else: - try: - template_configuration.append( - deserialize(datasource=f"{gitrepo.repodir}/ninja.json") - ) - except: - # json failed, try yaml, then yml - try: - template_configuration.append( - deserialize(datasource=f"{gitrepo.repodir}/ninja.yaml") - ) - except: - template_configuration.append( - deserialize(datasource=f"{gitrepo.repodir}/ninja.yml") - ) - template_configuration.append({"as3ninja": {"git": gitrepo.info}}) - d = AS3Declaration( - template_configuration=template_configuration, + as3tc = AS3TemplateConfiguration( + template_configuration=as3d.template_configuration, + base_path=f"{gitrepo.repodir}/", + overlay={"as3ninja": {"git": gitrepo.info}}, + ) + + as3declaration = AS3Declaration( + template_configuration=as3tc.dict(), jinja2_searchpath=gitrepo.repodir, ) - return d.declaration + return as3declaration.dict() except ( GitgetException, AS3SchemaVersionError, AS3JSONDecodeError, AS3TemplateSyntaxError, AS3UndefinedError, + AS3TemplateConfigurationError, ) as exc: error = Error(code=400, message=str(exc)) raise HTTPException(status_code=error.code, detail=error.message) diff --git a/as3ninja/cli.py b/as3ninja/cli.py index 9d33961..4d69f83 100755 --- a/as3ninja/cli.py +++ b/as3ninja/cli.py @@ -12,6 +12,7 @@ from .declaration import AS3Declaration from .gitget import Gitget from .schema import AS3Schema +from .templateconfiguration import AS3TemplateConfiguration, AS3TemplateConfigurationError from .utils import deserialize # TODO: figure out how click can raise an non-zero exit code on AS3ValidationError @@ -66,40 +67,27 @@ def transform( pretty: bool, ): """Transforms a declaration template using the configuration file to an AS3 delcaration which is validated against the JSON schema.""" - template_configuration: list = [] - if configuration_file: - for config_file in configuration_file: - template_configuration.append(deserialize(datasource=config_file)) - else: - try: - template_configuration.append(deserialize(datasource="./ninja.json")) - except: - # json failed, try yaml, then yml - try: - template_configuration.append(deserialize(datasource="./ninja.yaml")) - except: - template_configuration.append(deserialize(datasource="./ninja.yml")) - if declaration_template: template = declaration_template.read() else: template = None - as3d = AS3Declaration( - declaration_template=template, template_configuration=template_configuration + as3tc = AS3TemplateConfiguration(template_configuration=configuration_file) + as3declaration = AS3Declaration( + declaration_template=template, template_configuration=as3tc.dict() ) if validate: as3s = AS3Schema() - as3s.validate(declaration=as3d.declaration) + as3s.validate(declaration=as3declaration.dict()) if output_file: - output_file.write(as3d.declaration_asjson) + output_file.write(as3declaration.json()) else: if pretty: - print(json.dumps(as3d.declaration, indent=4, sort_keys=True)) + print(json.dumps(as3declaration.dict(), indent=4, sort_keys=True)) else: - print(as3d.declaration_asjson) + print(as3declaration.json()) @cli.command() @@ -120,12 +108,22 @@ def transform( @click.option( "--pretty", required=False, default=False, is_flag=True, help="Pretty print JSON" ) +@click.option( + "-c", + "--configuration-file", + required=False, + nargs=0, + type=str, + help="JSON/YAML configuration file used to parameterize declaration template", +) +@click.argument("configuration-file", nargs=-1) @click.option("--repository", required=True, default=False, help="Git repository") @click.option("--branch", required=False, default=False, help="Git branch to use") @click.option("--commit", required=False, default=False, help="Git commit id (long)") @click.option("--depth", required=False, default=False, help="Git clone depth") @logger.catch def git_transform( + configuration_file: Optional[tuple], repository: str, branch: Union[str, None], commit: Union[str, None], @@ -137,37 +135,27 @@ def git_transform( """Transforms a declaration from a git repository using either the default configuration files (ninja.json/yaml/yml) or the configuration file specified through the command line. The AS3 delcaration which is validated against the JSON schema. """ - template_configuration: list = [] with Gitget( repository=repository, branch=branch, commit=commit, depth=depth ) as gitrepo: - try: - template_configuration.append( - deserialize(datasource=f"{gitrepo.repodir}/ninja.json") - ) - except: - # json failed, try yaml, then yml - try: - template_configuration.append( - deserialize(datasource=f"{gitrepo.repodir}/ninja.yaml") - ) - except: - template_configuration.append( - deserialize(datasource=f"{gitrepo.repodir}/ninja.yml") - ) - template_configuration.append({"as3ninja": {"git": gitrepo.info}}) - as3d = AS3Declaration( - template_configuration=template_configuration, + as3tc = AS3TemplateConfiguration( + template_configuration=configuration_file, + base_path=f"{gitrepo.repodir}/", + overlay={"as3ninja": {"git": gitrepo.info}}, + ) + + as3declaration = AS3Declaration( + template_configuration=as3tc.dict(), jinja2_searchpath=gitrepo.repodir, ) if validate: as3s = AS3Schema() - as3s.validate(declaration=as3d.declaration) + as3s.validate(declaration=as3declaration.dict()) if output_file: - output_file.write(as3d.declaration_asjson) + output_file.write(as3declaration.json()) else: if pretty: - print(json.dumps(as3d.declaration, indent=4, sort_keys=True)) + print(json.dumps(as3declaration.dict(), indent=4, sort_keys=True)) else: - print(as3d.declaration_asjson) + print(as3declaration.json()) diff --git a/as3ninja/declaration.py b/as3ninja/declaration.py index 4afd89a..38fa2a9 100644 --- a/as3ninja/declaration.py +++ b/as3ninja/declaration.py @@ -16,6 +16,7 @@ from .filters import ninjafilters from .functions import ninjafunctions +from .templateconfiguration import AS3TemplateConfiguration from .utils import deserialize __all__ = [ @@ -144,150 +145,52 @@ class AS3Declaration: The template file reference is expected to be at `as3ninja.declaration_template` within the configuration. An explicitly specified declaration_template takes precedence over any included template. - :param template_configuration: Template configuration as ``dict`` or ``list`` + :param template_configuration: AS3 Template Configuration as ``dict`` or ``list`` :param declaration_template: Optional Declaration Template as ``str`` (Default value = ``None``) :param jinja2_searchpath: The jinja2 search path for the FileSystemLoader. Important for jinja2 includes. (Default value = ``"."``) """ def __init__( self, - template_configuration: Union[dict, List[dict]], + template_configuration: dict, declaration_template: str = None, jinja2_searchpath: str = ".", ): - self.__configuration: dict = {} - self._template_configuration = template_configuration self._declaration_template = declaration_template - self._configuration = template_configuration self._jinja2_searchpath = jinja2_searchpath if not self._declaration_template: try: - declaration_template_file = self.configuration["as3ninja"][ + declaration_template_file = self._template_configuration["as3ninja"][ "declaration_template" ] self._declaration_template = deserialize( datasource=f"{self._jinja2_searchpath}/{declaration_template_file}", return_as=str, ) - except (KeyError, TypeError) as err: + except (KeyError, TypeError) as exc: raise KeyError( - f"as3ninja.declaration_template not valid or missing in template_configuration: {err}" + f"as3ninja.declaration_template not valid or missing in template_configuration: {exc}" ) self._transform() - @property - def declaration(self) -> dict: - """Read-Only Property returns the tranformed AS3 declaration as ``dict``""" + def dict(self) -> dict: + """Returns the AS3 Declaration.""" return self._declaration - @property - def declaration_asjson(self) -> Union[str, None]: - """Read-Only Property returns the tranformed AS3 declaration as ``str`` (contains JSON)""" - if not self._declaration_asjson: - self._declaration_asjson = json.dumps(self._declaration) - return self._declaration_asjson - - @property - def _declaration(self) -> dict: - """Private Property: Returns the declaration as dict""" - return self.__declaration - - @_declaration.setter - def _declaration(self, declaration: str) -> None: - """Private Property: sets __declaration and _declaration_asjson variables - - :param declaration: AS3 declaration - """ - try: - self.__declaration = json.loads(declaration) - # this properly formats the json - self._declaration_asjson = json.dumps(json.loads(declaration)) - except json.decoder.JSONDecodeError as exc: - raise AS3JSONDecodeError("JSONDecodeError", exc) - - @property - def configuration(self) -> dict: - """Read-Only Property returns the template configuration as dict. - This is the merged configuration in case template_configuration was a list of configurations. - """ - return self.__configuration - - @property - def _configuration(self) -> dict: - """ - Private Property: Returns the template configuration as dict. - This is the merged configuration in case template_configuration was a list of configurations. - """ - return self.__configuration - - @_configuration.setter - def _configuration(self, template_configuration: Union[dict, list]) -> None: - """ - Private Property: Merges a list of template_configuration elements in case a list is specified. - - :param template_configuration: Union[dict, list]: - - """ - if isinstance(template_configuration, list): - for entry in template_configuration: - self.__configuration = self._dict_deep_update( - self.__configuration, entry - ) - elif isinstance(template_configuration, dict): - self.__configuration = template_configuration - else: - raise TypeError( - f"template_configuration has wrong type:{type(template_configuration)}" - ) - - def _dict_deep_update(self, dict_to_update: dict, update: dict) -> dict: - """ - Private Method: similar to dict.update() but with full depth. - - :param dict_to_update: dict: - :param update: dict: - - Example: - - .. code:: python - - dict.update: - { 'a': {'b':1, 'c':2} }.update({'a': {'d':3} }) - -> { 'a': {'d':3} } - - _dict_deep_update: - { 'a': {'b':1, 'c':2} } with _dict_deep_update({'a': {'d':3} }) - -> { 'a': {'b':1, 'c':2, 'd':3} } - - """ - for k, v in iteritems(update): - dv = dict_to_update.get(k, {}) - if not isinstance(dv, abc.Mapping): - dict_to_update[k] = v - elif isinstance(v, abc.Mapping): - dict_to_update[k] = self._dict_deep_update(dv, v) - else: - dict_to_update[k] = v - return dict_to_update + def json(self) -> str: + """Returns the AS3 Declaration as JSON.""" + return self._declaration_json @property def declaration_template(self) -> Union[str, None]: - """Read-Only Property returns the declaration template as dict or None (if non-existend).""" + """Property contains the declaration template loaded or provided during instantiation""" return self._declaration_template - @property - def template_configuration(self) -> Union[dict, list, None]: - """ - Read-Only Property returns the template configuration(s) as specified during class initialization. - It returns either a dict or list of dicts. - """ - return self._template_configuration - - def _transform(self) -> None: - """ - Private Method: Transforms the declaration_template using the template_configuration to an AS3 declaration. + def _jinja2_render(self) -> str: + """Renders the declaration using jinja2. + Raises relevant exceptions which need to be handled by the caller. """ env = Environment( # nosec (bandit: autoescaping is not helpful for as3ninja's use-case) loader=ChoiceLoader( @@ -303,21 +206,38 @@ def _transform(self) -> None: autoescape=False, ) env.globals["jinja2_searchpath"] = self._jinja2_searchpath + "/" - env.globals["ninja"] = self.configuration + env.globals["ninja"] = self._template_configuration env.globals.update(ninjafunctions) env.filters.update(ninjafilters) + return env.get_template("template").render() + + def _transform(self) -> None: + """Transforms the declaration_template using the template_configuration to an AS3 declaration. + On error raises: + + - AS3TemplateSyntaxError on jinja2 template syntax errors + - AS3UndefinedError for undefined variables in the declaration template + - AS3JSONDecodeError in case the rendered declaration is not valid JSON + """ try: - self._declaration = env.get_template("template").render() - except (TemplateSyntaxError, UndefinedError) as exc: - if isinstance(exc, TemplateSyntaxError): - raise AS3TemplateSyntaxError( - "AS3 declaration template caused jinja2 syntax error", - self.declaration_template, - exc, - ) - elif isinstance(exc, UndefinedError): - raise AS3UndefinedError( - "AS3 declaration template tried to operate on an Undefined variable, attribute or type", - exc, - ) + declaration = self._jinja2_render() + + self._declaration = json.loads(declaration) + self._declaration_json = json.dumps( + self._declaration + ) # properly formats JSON + + except TemplateSyntaxError as exc: + raise AS3TemplateSyntaxError( + "AS3 declaration template caused jinja2 syntax error", + self.declaration_template, + exc, + ) + except UndefinedError as exc: + raise AS3UndefinedError( + "AS3 declaration template tried to operate on an Undefined variable, attribute or type", + exc, + ) + except json.decoder.JSONDecodeError as exc: + raise AS3JSONDecodeError("JSONDecodeError", exc) diff --git a/as3ninja/templateconfiguration.py b/as3ninja/templateconfiguration.py new file mode 100644 index 0000000..24eae7c --- /dev/null +++ b/as3ninja/templateconfiguration.py @@ -0,0 +1,274 @@ +# -*- coding: utf-8 -*- +"""The AS3TemplateConfiguration module. Allows to build an AS3 Template Configuration from YAML, JSON or dict.""" +import json +from collections import abc +from pathlib import Path +from typing import List, Optional, Union, Generator + +from pydantic import BaseModel, ValidationError, validator +from six import iteritems + +from as3ninja.utils import DictLike, deserialize + +__all__ = [ + "AS3TemplateConfiguration", + "AS3TemplateConfigurationError", +] + + +class AS3TemplateConfigurationError(ValueError): + """Raised when a problem occurs during building the Template Configuration.""" + + pass + + +class AS3TemplateConfiguration(DictLike): + """The AS3TemplateConfiguration module. Allows to build an AS3 Template Configuration from YAML, JSON or dict. + Creates a AS3TemplateConfiguration instance for use with AS3Declaration. + + The Template Configuration can be created from one or more files or `dicts`. + Globbing based on pathlib Path glob is supported to load multiple files. + De-serialization for files is automatically performed, YAML and JSON is supported. + If a file is included multiple times, it is only loaded once on first occurrence. + AS3TemplateConfigurationError exception is raised when a file is not found or not readable. + + Files can be included using the as3ninja.include ``Union[str, List[str]]`` namespace in every specified configuration file. + Files included through this namespace will not be checked for as3ninja.include and therefore cannot include further files. + + The as3ninja.include namespace is updated will entries of all as3ninja.include entries, globbing will be expanded. This helps during troubleshooting. + + If a list of inputs is provided, the input will be merged using :py:meth:`_dict_deep_update`. + + If template_configuration is ``None``, AS3TemplateConfiguration will look for the first default configuration + file it finds in the current working directory (files are in order: `ninja.json`, `ninja.yaml`, `ninja.yml`). + + :param template_configuration: Template Configuration (Optional) + :param base_path: Base path for any configuration file includes. (Optional) + + + Example usage: + + .. code:: python + + from as3ninja.templateconfiguration import AS3TemplateConfiguration + + as3tc = AS3TemplateConfiguration([ + {"inlineConfig": True}, + "./config.yaml", + "./config.json", + "./includes/*.yaml" + ]) + + as3tc.dict() + as3tc.json() + as3tc_dict = dict(as3tc) + + """ + + class TemplateConfigurationValidator(BaseModel): + """Data Model validation and de-serialization for as3ninja.include namespace.""" + + template_configuration: Union[List[Union[dict, str]], dict, str] + + def __init__( + self, + template_configuration: Optional[Union[List[Union[dict, str]], dict, str]] = None, + base_path: Optional[str] = "", + overlay: Optional[dict] = None + ): + #print(f"AS3TemplateConfiguration __init__: template_configuration:{template_configuration}") + self._includes: list = [] + self._configuration: dict = {} + self._configuration_json: str = "" + self._template_configurations: list = [] + + self._base_path: str = base_path + + if template_configuration is None: + template_configuration = self._ninja_default_configfile() + + try: + self.TemplateConfigurationValidator( + template_configuration=template_configuration + ) + except ValidationError as exc: + raise AS3TemplateConfigurationError("Input Validation Error") from exc + + if isinstance(template_configuration, tuple): + self._template_configurations = list(template_configuration) + elif not isinstance(template_configuration, list): + self._template_configurations = [template_configuration] + else: + self._template_configurations = template_configuration + + #print( f"AS3TemplateConfiguration __init__: self._template_configurations:{self._template_configurations}") + if overlay: + self._template_configurations.append(overlay) + + self._deserialize_files() + #print( f"AS3TemplateConfiguration __init__: self._template_configurations:{self._template_configurations}") + self._import_includes() # import as3ninja.include includes + + self._merge_configuration() + + self._update_configuration_includes() + self._tidy_as3ninja_namespace() + + self._dict = self._configuration # enable DictLike + #print( f"AS3TemplateConfiguration __init__: self._configuration:{self._configuration}") + + def _deserialize_files(self): + """De-serialize configuration files in self._template_configurations""" + _template_configurations = [] + for config in self._template_configurations: + if isinstance(config, str): # every str is a configuration file + # defer de-serialization to _import_includes + _template_configurations.append( + {"as3ninja": {"__deserialize_file": [config]}} + ) + else: + _template_configurations.append(config) + + self._template_configurations = _template_configurations + + self._import_includes(defferred=True) # import defferred file includes + + def _tidy_as3ninja_namespace(self): + """Tidy as3ninja. namespace in the configuration. + Removes: + + - __deserialize_file + - removes entire as3ninja namespace if empty + """ + if self._configuration.get("as3ninja", {}).get("__deserialize_file"): + del self._configuration["as3ninja"]["__deserialize_file"] + + # as3ninja might be empty if was only used with __deserialize_file + if "as3ninja" in self._configuration and not self._configuration["as3ninja"]: + del self._configuration["as3ninja"] + + def _update_configuration_includes(self): + """Updates as3ninja.include with the full list of included files and removes __deserialize_file""" + if self._configuration.get("as3ninja", {}).get("include"): + self._configuration["as3ninja"]["include"] = self._includes + + def dict(self) -> dict: + """Returns the merged Template Configuration""" + return self._configuration + + def json(self) -> str: + """Returns the merged Template Configuration as JSON""" + if not self._configuration_json: + self._configuration_json = json.dumps(self._configuration) + + return self._configuration_json + + def _ninja_default_configfile(self) -> str: + """Identify first config file which exists:ninja.json, ninja.yaml or ninja.yml. + Raise AS3TemplateConfigurationError on error.""" + for ninja_configfile in ("ninja.json", "ninja.yaml", "ninja.yml"): + if Path(self._base_path + ninja_configfile).is_file(): + return ninja_configfile + + raise AS3TemplateConfigurationError( + f"No AS3 Ninja configuration file found (ninja.json, ninja.yaml, ninja.yml) (base_path:{self._base_path})" + ) + + def _import_includes(self, defferred: bool = False): + """Iterates the list of Template Configurations and imports all includes in order. + + :param defferred: Include defferred includes instead of user specified as3ninja.include + """ + _expanded_template_configurations = [] + + for current_config in self._template_configurations: + _expanded_template_configurations.append(current_config) + #print(f"_import_includes: current_config:{current_config}, type:{type(current_config)}") + if defferred: + register = False + includes = current_config.get("as3ninja", {}).get( + "__deserialize_file", [] + ) + else: + register = True + includes = current_config.get("as3ninja", {}).get("include", []) + # includes can be specified as str but a list is expected by _deserialize_includes + if isinstance(includes, str): + includes = [includes] + + for include_config in self._deserialize_includes( + includes, register=register + ): + _expanded_template_configurations.append(include_config) + + self._template_configurations = _expanded_template_configurations + + def _path_glob(self, pattern: str) -> Generator[Path, None, None]: + """Path(self._base_path).glob(pattern) with support for an absolute pattern.""" + _base_path = self._base_path + if pattern.startswith("/"): + pattern = pattern.lstrip("/") + _base_path = _base_path + "/" + + return Path(_base_path).glob(pattern) + + def _deserialize_includes(self, includes: List[str], register: bool = True) -> dict: + """Iterates and expands over the list of includes and yields the deseriealized data. + + :param includes: List of include files + :param register: Register include file to avoid double inclusion (Default: ``True``) + """ + for include in includes: + if not list(self._path_glob(include)): + # Path().glob() didn't find any file + raise AS3TemplateConfigurationError( + f"Include: {str(include)} doesn't exist or not a file (base_path:{self._base_path})." + ) + + # globbing potentially results in multiple files to include + for include_file in sorted(self._path_glob(include)): + if not include_file.is_file(): + raise AS3TemplateConfigurationError( + f"Include: {str(include_file)} doesn't exist or not a file (base_path:{self._base_path})." + ) + # avoid including the same configuration template multiple times + if register: + if str(include_file) in self._includes: + continue + self._includes.append(str(include_file)) + + yield deserialize(str(include_file)) + + def _merge_configuration(self): + """Merges _template_configurations list of dicts to a single dict""" + for config in self._template_configurations: + self._configuration = self._dict_deep_update(self._configuration, config) + + def _dict_deep_update(self, dict_to_update: dict, update: dict) -> dict: + """Similar to dict.update() but with full depth. + + :param dict_to_update: dict to update (will be mutated) + :param update: dict: dict to use for updating dict_to_update + + Example: + + .. code:: python + + dict.update: + { 'a': {'b':1, 'c':2} }.update({'a': {'d':3} }) + -> { 'a': {'d':3} } + + _dict_deep_update: + { 'a': {'b':1, 'c':2} } with _dict_deep_update({'a': {'d':3} }) + -> { 'a': {'b':1, 'c':2, 'd':3} } + + """ + for k, v in iteritems(update): + dv = dict_to_update.get(k, {}) + if not isinstance(dv, abc.Mapping): + dict_to_update[k] = v + elif isinstance(v, abc.Mapping): + dict_to_update[k] = self._dict_deep_update(dv, v) + else: + dict_to_update[k] = v + return dict_to_update diff --git a/as3ninja/utils.py b/as3ninja/utils.py index 831f2f1..1d1b2d1 100644 --- a/as3ninja/utils.py +++ b/as3ninja/utils.py @@ -1,9 +1,7 @@ # -*- coding: utf-8 -*- -""" -utils holds various helper functions used in as3ninja. -""" +"""Various utils and helpes used by AS3 Ninja""" import json -from typing import Union +from typing import Any, ItemsView, Iterator, KeysView, List, Tuple, Union, ValuesView import yaml @@ -45,3 +43,46 @@ def deserialize( ) return _data + + +class DictLike: + """Makes objects `feel` like a dict. + + Implements required dunder methods and common methods used to access dict data. + """ + + _dict: dict = {} + + def __iter__(self) -> Iterator[str]: + for key in self._dict: + yield key + + def __len__(self) -> int: + return len(self._dict) + + def __contains__(self, item: Any) -> bool: + return item in self._dict + + def __eq__(self, other: Any) -> bool: + return self._dict.items() == other.items() + + def __getitem__(self, key: str) -> Any: + return self._dict.__getitem__(key) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self._dict})" + + def __str__(self) -> str: + return str(self._dict) + + def get(self, key: Any, default: Any = None) -> Any: + return self._dict.get(key, default) + + def keys(self) -> KeysView[Any]: + return self._dict.keys() + + def values(self) -> ValuesView[Any]: + return self._dict.values() + + def items(self) -> ItemsView[Any, Any]: + return self._dict.items() diff --git a/tests/test_cli.py b/tests/test_cli.py index fa60174..7cd841b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -31,6 +31,7 @@ def test_yaml_datatypes(fixture_clicker): ], ) assert result.exit_code == 0 + print(f"\n\nresult.output:{result.output}\n\n") assert format_json(result.output) == format_json( load_file("examples/yaml_datatypes/output.json") ) diff --git a/tests/test_declaration.py b/tests/test_declaration.py index 4574f97..6a0e524 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -10,9 +10,10 @@ AS3TemplateSyntaxError, AS3UndefinedError, ) +from as3ninja.templateconfiguration import AS3TemplateConfiguration from tests.utils import fixture_tmpdir, format_json, load_file -mock_template_configuration: dict = {"a": "aaa", "b": "bbb"} +mock_template_configuration = AS3TemplateConfiguration({"a": "aaa", "b": "bbb"}) mock_declaration_template: str = """{ "json": true, "a": "{{ninja.a}}", @@ -24,11 +25,12 @@ "b": "bbb" }""" -mock_template_configuration2: list = [ - {"a": "aaa", "b": "bbb"}, - {"a": "AAA", "c": "CCC"}, -] -mock_template_configuration2_merged: dict = {"a": "AAA", "b": "bbb", "c": "CCC"} +mock_template_configuration2 = AS3TemplateConfiguration( + [{"a": "aaa", "b": "bbb"}, {"a": "AAA", "c": "CCC"},] +) +mock_template_configuration2_merged = AS3TemplateConfiguration( + {"a": "AAA", "b": "bbb", "c": "CCC"} +) mock_declaration_template2: str = """{ "json": true, "a": "{{ninja.a}}", @@ -42,27 +44,31 @@ "c": "CCC" }""" -mock_template_configuration_with_template: list = [ - {"a": "aaa", "b": "bbb", "as3ninja": {"declaration_template": "/dev/null"}}, - { - "a": "AAA", - "c": "CCC", - "as3ninja": { - "declaration_template": "tests/testdata/declaration/transform/template.j2" +mock_template_configuration_with_template = AS3TemplateConfiguration( + [ + {"a": "aaa", "b": "bbb", "as3ninja": {"declaration_template": "/dev/null"}}, + { + "a": "AAA", + "c": "CCC", + "as3ninja": { + "declaration_template": "tests/testdata/declaration/transform/template.j2" + }, }, - }, -] - -mock_template_configuration_with_template_inline: list = [ - {"a": "aaa", "b": "bbb"}, - { - "a": "AAA", - "c": "CCC", - "as3ninja": { - "declaration_template": '{"json": True,"a": "{{ninja.a}}","b": "{{ninja.b}}","c": "{{ninja.c}}"}' + ] +) + +mock_template_configuration_with_template_inline = AS3TemplateConfiguration( + [ + {"a": "aaa", "b": "bbb"}, + { + "a": "AAA", + "c": "CCC", + "as3ninja": { + "declaration_template": '{"json": True,"a": "{{ninja.a}}","b": "{{ninja.b}}","c": "{{ninja.c}}"}' + }, }, - }, -] + ] +) @pytest.fixture(scope="class") @@ -87,7 +93,7 @@ def as3d_interface2(): def as3d_empty(): return AS3Declaration( declaration_template='{"json": true}', - template_configuration={"non empty": "dict"}, + template_configuration=AS3TemplateConfiguration({"non empty": "dict"}), ) @@ -96,49 +102,29 @@ def as3d_empty(): class Test_Interface: @staticmethod def test_declaration(as3d_interface1): - assert isinstance(as3d_interface1.declaration, dict) - assert as3d_interface1.declaration == json.loads(mock_declaration) + assert isinstance(as3d_interface1.dict(), dict) + assert as3d_interface1.dict() == json.loads(mock_declaration) @staticmethod - def test_declaration_asjson(as3d_interface1, as3d_interface2): - assert isinstance(as3d_interface1.declaration_asjson, str) - assert format_json(as3d_interface1.declaration_asjson) == format_json( - mock_declaration - ) + def test_json(as3d_interface1, as3d_interface2): + assert isinstance(as3d_interface1.json(), str) + assert format_json(as3d_interface1.json()) == format_json(mock_declaration) - assert isinstance(as3d_interface2.declaration_asjson, str) - assert format_json(as3d_interface2.declaration_asjson) == format_json( - mock_declaration2 - ) + assert isinstance(as3d_interface2.json(), str) + assert format_json(as3d_interface2.json()) == format_json(mock_declaration2) @staticmethod def test_declaration_template(as3d_interface1): assert isinstance(as3d_interface1.declaration_template, str) assert as3d_interface1.declaration_template == mock_declaration_template - @staticmethod - def test_template_configuration(as3d_interface1, as3d_interface2): - assert isinstance(as3d_interface1.template_configuration, dict) - assert as3d_interface1.template_configuration == mock_template_configuration - - assert isinstance(as3d_interface2.template_configuration, list) - assert as3d_interface2.template_configuration == mock_template_configuration2 - - @staticmethod - def test_configuration(as3d_interface1, as3d_interface2): - assert isinstance(as3d_interface1.configuration, dict) - assert as3d_interface1.configuration == mock_template_configuration - - assert isinstance(as3d_interface2.configuration, dict) - assert as3d_interface2.configuration == mock_template_configuration2_merged - @staticmethod def test_declaration_template_file_in_configuration(): as3d = AS3Declaration( template_configuration=mock_template_configuration_with_template ) - assert isinstance(as3d.declaration, dict) - assert format_json(as3d.declaration_asjson) == format_json(mock_declaration2) + assert isinstance(as3d.dict(), dict) + assert format_json(as3d.json()) == format_json(mock_declaration2) @staticmethod def test_declaration_template_in_configuration_inline(): @@ -164,57 +150,14 @@ def test_fail_empty_init(): AS3Declaration() -@pytest.mark.usefixtures("as3d_empty") -class Test__dict_deep_update: - @staticmethod - def test_simple(as3d_empty): - dict_to_update = {"a": {"a": 1}} - update = {"b": {"b": 1}, "a": {"b": 1}} - expected_result = {"a": {"a": 1, "b": 1}, "b": {"b": 1}} - as3d = as3d_empty._dict_deep_update( - dict_to_update=dict_to_update, update=update - ) - assert as3d == expected_result - - @staticmethod - def test_nested(as3d_empty): - dict_to_update = {"a": {"a": 1}} - update = {"b": {"b": 1}, "a": {"b": 1, "a": {"updated_by_b": 1}}} - expected_result = {"a": {"a": {"updated_by_b": 1}, "b": 1}, "b": {"b": 1}} - as3d = as3d_empty._dict_deep_update( - dict_to_update=dict_to_update, update=update - ) - assert as3d == expected_result - - @staticmethod - def test_list(as3d_empty): - dict_to_update = {"a": {"a": [1, 2, 3]}, (1, 2, 3): {"tuple": True}} - update = { - "b": {"b": 1}, - "a": {"b": 1, "a": {"updated_by_b": 1}}, - (1, 2, 3): {"tuple": True, "updated_by_b": 1}, - } - expected_result = { - "a": {"a": {"updated_by_b": 1}, "b": 1}, - "b": {"b": 1}, - (1, 2, 3): {"tuple": True, "updated_by_b": 1}, - } - as3d = as3d_empty._dict_deep_update( - dict_to_update=dict_to_update, update=update - ) - assert as3d == expected_result - - class Test_implicit_transform: - # TODO: test with empty configuration - # TODO: test with configuration only, where template file location is read from configuration @staticmethod def test_simple(): as3d = AS3Declaration( declaration_template=mock_declaration_template, template_configuration=mock_template_configuration, ) - assert as3d.declaration["a"] == "aaa" + assert as3d.dict()["a"] == "aaa" @staticmethod def test_simple_list(): @@ -222,8 +165,8 @@ def test_simple_list(): declaration_template=mock_declaration_template2, template_configuration=mock_template_configuration2, ) - assert as3d.declaration["a"] == "AAA" - assert as3d.declaration["c"] == "CCC" + assert as3d.dict()["a"] == "AAA" + assert as3d.dict()["c"] == "CCC" @staticmethod def test_file_include_searchpath(): @@ -239,7 +182,7 @@ def test_file_include_searchpath(): template_configuration=configuration, jinja2_searchpath="tests/testdata/declaration/transform/", ) - assert as3d.declaration == expected_result + assert as3d.dict() == expected_result @staticmethod def test_file_include_searchpath_2(): @@ -255,7 +198,7 @@ def test_file_include_searchpath_2(): template_configuration=configuration, jinja2_searchpath="tests/testdata/", ) - assert as3d.declaration == expected_result + assert as3d.dict() == expected_result @staticmethod def test_file_include_no_jinja2_searchpath(): @@ -269,7 +212,7 @@ def test_file_include_no_jinja2_searchpath(): as3d = AS3Declaration( declaration_template=template, template_configuration=configuration ) - assert as3d.declaration == expected_result + assert as3d.dict() == expected_result @staticmethod def test_file_include_searchpath_configlist(): @@ -282,14 +225,13 @@ def test_file_include_searchpath_configlist(): as3d = AS3Declaration( declaration_template=template, - template_configuration=configuration, + template_configuration=AS3TemplateConfiguration(configuration), jinja2_searchpath="tests/testdata/declaration/transform/", ) - assert as3d.declaration == expected_result + assert as3d.dict() == expected_result -class Test_transform_method: - # TODO: test the transform method +class Test_transform_syntaxerror: @staticmethod def test_multi_template_syntax_error(): """https://github.com/simonkowallik/as3ninja/issues/4""" @@ -303,8 +245,9 @@ def test_multi_template_syntax_error(): template_configuration={}, jinja2_searchpath="tests/testdata/declaration/syntax_error/", ) - assert "{% This line raises a Syntax Error %}<---- Error line:2" in str(exc.value) - + assert "{% This line raises a Syntax Error %}<---- Error line:2" in str( + exc.value + ) class Test_invalid_declarations: diff --git a/tests/test_templateconfiguration.py b/tests/test_templateconfiguration.py new file mode 100644 index 0000000..a6cb0f7 --- /dev/null +++ b/tests/test_templateconfiguration.py @@ -0,0 +1,569 @@ +import json + +from pathlib import Path +import pytest +from pydantic import ValidationError + +from as3ninja.templateconfiguration import ( + AS3TemplateConfiguration, + AS3TemplateConfigurationError, +) + +from .utils import format_json + + +class Test_TemplateConfigurationValidator: + @pytest.mark.parametrize( + "test_data", + [ + [{"d1_k1": 1, "d1_k2": 2}, {"d2_k1": 3, "d2_k2": 4}], + ["str1", "str2"], + [{"DictInList": 1}, "StrInList"], + {"SingeDict": 1, "a": "a"}, + "SingleStr", + ], + ) + def test_allowed_input(self, test_data): + """test allowed input formats and types""" + result = AS3TemplateConfiguration.TemplateConfigurationValidator( + template_configuration=test_data + ) + assert result.template_configuration == test_data + + +class Test_AS3TemplateConfiguration_interface: + @staticmethod + def test_dict(): + """expect .dict() to return dict. + expect it to be equal to data""" + data = {"config": True} + as3tc = AS3TemplateConfiguration(data) + + assert isinstance(as3tc.dict(), dict) + assert as3tc.dict() == data + assert dict(as3tc) == data + + @staticmethod + def test_json(): + """exepct .json() to return str. + expect return value to be equal to data. + expect _configuration_json to be equal to data. + """ + data = {"config": True} + as3tc = AS3TemplateConfiguration(data) + + assert isinstance(as3tc.json(), str) + assert format_json(as3tc.json()) == format_json(data) + + @staticmethod + def test_api_input1(): + """Test input similar to what the API could see.""" + JSON = """ + { + "template_configuration": [ + {"inline_json": true}, + "tests/testdata/AS3TemplateConfiguration/file.json", + {"as3ninja": { + "include": "tests/testdata/AS3TemplateConfiguration/included2a.yaml" + } + } + ] + } + """ + data = json.loads(JSON) + + as3tc = AS3TemplateConfiguration(**data) + + assert as3tc.dict()["inline_json"] is True + assert as3tc.dict()["file.json"] is True + assert as3tc.dict()["included2a.yaml"] is True + # 'tests/testdata/AS3TemplateConfiguration/file.json' will be deserialized instead of added to as3ninja.include + assert as3tc.dict()["as3ninja"]["include"] == [ + "tests/testdata/AS3TemplateConfiguration/included2a.yaml" + ] + + @staticmethod + def test_api_input_globbing(): + """Test input similar to what the API could see.""" + JSON = """ + { + "template_configuration": [ + {"inline_json": true}, + "tests/testdata/AS3TemplateConfiguration/file.*", + {"as3ninja": { + "include": "tests/testdata/AS3TemplateConfiguration/included2a.yaml" + } + } + ] + } + """ + data = json.loads(JSON) + + as3tc = AS3TemplateConfiguration(**data) + + assert as3tc.dict()["inline_json"] is True + assert as3tc.dict()["file.yaml"] is True + assert as3tc.dict()["file.json"] is True + assert as3tc.dict()["included2a.yaml"] is True + assert as3tc.dict()["as3ninja"]["include"] == [ + "tests/testdata/AS3TemplateConfiguration/included2a.yaml" + ] + + @staticmethod + def test_api_input_globbing_includes(): + """Test complex input similar to what the API could see.""" + JSON = """ + { + "template_configuration": [ + {"inline_json": true}, + "tests/testdata/AS3TemplateConfiguration/file.*", + "tests/testdata/AS3TemplateConfiguration/include3.yaml", + {"as3ninja": { + "include": "tests/testdata/AS3TemplateConfiguration/include2.yaml" + } + }, + "tests/testdata/AS3TemplateConfiguration/include1.yaml", + "tests/testdata/AS3TemplateConfiguration/include3.yaml" + ] + } + """ + data = json.loads(JSON) + + as3tc = AS3TemplateConfiguration(**data) + + assert ( + as3tc.dict()["inline_json"] is True + ) # inline json is part of the configuration + assert as3tc.dict()["file.yaml"] is True # file.* globbing includes file.yaml + assert as3tc.dict()["file.json"] is True # file.* globbing includes file.json + assert ( + as3tc.dict()["include3.yaml"] is True + ) # include3.yaml is part of the configuration + assert ( + as3tc.dict()["included3.yaml"] is True + ) # included3.yaml is included by include3.yaml + + assert ( + as3tc.dict()["include2.yaml"] is True + ) # include2.yaml is part of the configuration but CANNOT include further files! + # {"as3ninja": {"include": "../include2.yaml"}} is an include already and cannot + # include further files: + assert ( + as3tc.dict().get("included2a.yaml", False) is False + ) # nested includes are not supported + assert ( + as3tc.dict().get("included2b.yaml", False) is False + ) # nested includes are not supported + assert ( + as3tc.dict().get("included2c.yaml", False) is False + ) # nested includes are not supported + + assert ( + as3tc.dict()["include1.yaml"] is True + ) # include1.yaml is part of the configuration + assert ( + as3tc.dict()["included1.yaml"] is True + ) # included1.yaml is included by include1.yaml and part of the configuration + + assert ( + as3tc.dict()["data"] == "include3.yaml" + ) # include3.yaml is the last included configuration, it does include further files + # but these files have been included before, hence they are not included again + + # includes in perserved order + assert as3tc.dict()["as3ninja"]["include"] == [ + "tests/testdata/AS3TemplateConfiguration/included3.yaml", + "tests/testdata/AS3TemplateConfiguration/include2.yaml", + "tests/testdata/AS3TemplateConfiguration/included1.yaml", + ] + + @staticmethod + def test_api_input_path_prefix(): + """Test complex input similar to what the API could see with path_prefix. + Test with ugly but technically correct paths. + """ + JSON = """ + { + "template_configuration": [ + {"inline_json": true}, + "////./AS3TemplateConfiguration/file.*", + {"as3ninja": { + "include": "././//./AS3TemplateConfiguration/include2.yaml" + } + }, + "AS3TemplateConfiguration/include1_relativePath.yaml" + ], + "base_path": "tests/testdata/" + } + """ + data = json.loads(JSON) + + as3tc = AS3TemplateConfiguration(**data) + + assert ( + as3tc.dict() == {'inline_json': True, 'as3ninja': {'include': ['tests/testdata/AS3TemplateConfiguration/include2.yaml', 'tests/testdata/AS3TemplateConfiguration/included1.yaml']}, 'file.json': True, 'content': {'jsonList': ['A', 'B', 'C'], 'yamlList': ['a', 'b', 'c']}, 'file.yaml': True, 'include2.yaml': True, 'data': 'included1.yaml', 'include1_relativePath.yaml': True, 'included1.yaml': True} + ) + + + @staticmethod + def test_repr_not_supported(): + """Test complex input with repr(). Due to the include handling repr() will not reproduce the same results!""" + data = { + "template_configuration": [ + {"inline_json": True}, + "tests/testdata/AS3TemplateConfiguration/file.*", + "tests/testdata/AS3TemplateConfiguration/include3.yaml", + { + "as3ninja": { + "include": "tests/testdata/AS3TemplateConfiguration/include2.yaml" + } + }, + "tests/testdata/AS3TemplateConfiguration/include1.yaml", + "tests/testdata/AS3TemplateConfiguration/include3.yaml", + ] + } + + as3tc = AS3TemplateConfiguration(**data) + + assert as3tc != eval(repr(as3tc)) + + + @staticmethod + def test_absolute_file_glob(): + """Test that absolute file paths work""" + data = { + "template_configuration": [ + f"{Path.cwd()}/tests/testdata/AS3TemplateConfiguration/include1.*", + ] + } + + as3tc = AS3TemplateConfiguration(**data) + + assert "included1.yaml" in as3tc.dict() + + + @staticmethod + def test_overlay(): + """Test """ + data = [ + "tests/testdata/AS3TemplateConfiguration/include1.yaml", + {"inline": True, "overlay": False} + ] + + as3tc = AS3TemplateConfiguration(template_configuration=data, overlay={"overlay": True}) + + assert "included1.yaml" in as3tc.dict() + assert as3tc.dict()["inline"] is True + assert as3tc.dict()["overlay"] is True + + + +class Test_AS3TemplateConfiguration_include: + @staticmethod + def test_include2(): + """expected include order: included2b.yaml, included2a.yaml, included2c.yaml""" + data = [ + {"first_config": True, "as3ninja": {"first_config": True}}, + "tests/testdata/AS3TemplateConfiguration/include2.yaml", + {"last_config": True, "as3ninja": {"last_config": True}}, + ] + expected_include_order = [ + "tests/testdata/AS3TemplateConfiguration/included2b.yaml", + "tests/testdata/AS3TemplateConfiguration/included2a.yaml", + "tests/testdata/AS3TemplateConfiguration/included2c.yaml", + ] + + tc = AS3TemplateConfiguration(data) + + assert tc.dict()["as3ninja"]["include"] == expected_include_order + assert tc.dict()["data"] == "included2c.yaml" + assert "first_config" in tc.dict()["as3ninja"] + assert "last_config" in tc.dict()["as3ninja"] + + @staticmethod + def test_include1(): + data = [ + {"first_config": True, "as3ninja": {"first_config": True}}, + "tests/testdata/AS3TemplateConfiguration/include1.yaml", + {"last_config": True, "as3ninja": {"last_config": True}}, + ] + expected_includes = ["tests/testdata/AS3TemplateConfiguration/included1.yaml"] + + tc = AS3TemplateConfiguration(data) + + assert tc.dict()["as3ninja"]["include"] == expected_includes + assert tc.dict()["data"] == "included1.yaml" + assert "first_config" in tc.dict()["as3ninja"] + assert "last_config" in tc.dict()["as3ninja"] + + @staticmethod + def test_include3(): + """assure non-recursive includes. + include3.yaml includes included3.yaml which again has includes. + These includes in included3.yaml MUST be ignored.""" + data = [ + {"first_config": True, "as3ninja": {"first_config": True}}, + "tests/testdata/AS3TemplateConfiguration/include3.yaml", + {"last_config": True, "as3ninja": {"last_config": True}}, + ] + expected_include_order = [ + "tests/testdata/AS3TemplateConfiguration/included3.yaml" + ] + + tc = AS3TemplateConfiguration(data) + + assert tc.dict()["as3ninja"]["include"] == expected_include_order + assert tc.dict()["data"] == "included3.yaml" + assert "first_config" in tc.dict()["as3ninja"] + assert "last_config" in tc.dict()["as3ninja"] + + @staticmethod + def test_multi(): + data = [ + {"first_config": True, "as3ninja": {"first_config": True}}, + "tests/testdata/AS3TemplateConfiguration/include1.yaml", + "tests/testdata/AS3TemplateConfiguration/include2.yaml", + "tests/testdata/AS3TemplateConfiguration/include3.yaml", + {"last_config": True, "as3ninja": {"last_config": True}}, + ] + expected_include_order = [ + "tests/testdata/AS3TemplateConfiguration/included1.yaml", + "tests/testdata/AS3TemplateConfiguration/included2b.yaml", + "tests/testdata/AS3TemplateConfiguration/included2a.yaml", + "tests/testdata/AS3TemplateConfiguration/included2c.yaml", + "tests/testdata/AS3TemplateConfiguration/included3.yaml", + ] + + tc = AS3TemplateConfiguration(data) + + assert tc.dict()["as3ninja"]["include"] == expected_include_order + assert tc.dict()["data"] == "included3.yaml" + assert "first_config" in tc.dict()["as3ninja"] + assert "last_config" in tc.dict()["as3ninja"] + + @staticmethod + def test_explicit_nonexisting_include(): + nonexisting_file_name = ( + "tests/testdata/AS3TemplateConfiguration/DOESNOTEXIST.yaml" + ) + data = [ + {"as3ninja": {"include": nonexisting_file_name}}, + ] + + with pytest.raises(AS3TemplateConfigurationError) as excInfo: + AS3TemplateConfiguration(data) + + assert nonexisting_file_name in str(excInfo.value) + + @staticmethod + def test_glob_nonexisting_include(): + nonexisting_file_name = ( + "tests/testdata/AS3TemplateConfiguration/DOESNOTEXIST*.yaml" + ) + data = [ + {"as3ninja": {"include": nonexisting_file_name}}, + ] + + with pytest.raises(AS3TemplateConfigurationError) as excInfo: + AS3TemplateConfiguration(data) + + assert nonexisting_file_name in str(excInfo.value) + + @staticmethod + def test_nonfile_include(): + # directory != file + nonexisting_file_name = "tests/testdata/AS3TemplateConfiguration" + data = [ + {"as3ninja": {"include": nonexisting_file_name}}, + ] + + with pytest.raises(AS3TemplateConfigurationError) as excInfo: + AS3TemplateConfiguration(data) + + assert nonexisting_file_name in str(excInfo.value) + + +class Test_AS3TemplateConfiguration_ninja_configfile: + @staticmethod + def test_nofile(): + """No configuration given, must load ninja.(json|yaml|yml) which doesnt exist""" + with pytest.raises(AS3TemplateConfigurationError) as excInfo: + AS3TemplateConfiguration(None) + + assert "No AS3 Ninja configuration file found" in str(excInfo.value) + + @staticmethod + def test_ninja_yaml(mocker): + """./ninja.yaml exists""" + # ninja.json, ninja.yaml, ninja.yml + from pathlib import Path + + mocked_Path = mocker.MagicMock(**{"is_file.side_effect": [False, True, False]}) + + mocked_Path.return_value = mocked_Path + mocker.patch("as3ninja.templateconfiguration.Path", new=mocked_Path) + + # mock _deserialize_includes to prevent actual de-serialization of non-exiting / mocked files + mocker.patch( + "as3ninja.templateconfiguration.AS3TemplateConfiguration._deserialize_includes" + ) + + AS3TemplateConfiguration(None) + + # stops at 2nd call, as ninja.yaml is found + assert mocked_Path.mock_calls == [ + mocker.call("ninja.json"), + mocker.call.is_file(), + mocker.call("ninja.yaml"), + mocker.call.is_file(), + ] + assert mocked_Path.call_count == 2 + + @staticmethod + def test_ninja_all(mocker): + """./ninja.json, ./ninja.yaml, ./ninja.yml exists but ./ninja.json is used""" + # ninja.json, ninja.yaml, ninja.yml + mocked_Path = mocker.MagicMock(**{"is_file.side_effect": [True, True, True]}) + mocked_Path.return_value = mocked_Path + mocker.patch("as3ninja.templateconfiguration.Path", new=mocked_Path) + + # mock _deserialize_includes to prevent actual de-serialization of non-exiting / mocked files + mocker.patch( + "as3ninja.templateconfiguration.AS3TemplateConfiguration._deserialize_includes" + ) + + AS3TemplateConfiguration(None) + + # stops at 2nd call, as ninja.yaml is found + assert mocked_Path.mock_calls == [ + mocker.call("ninja.json"), + mocker.call.is_file(), + ] + assert mocked_Path.call_count == 1 + + +class Test__dict_deep_update: + @staticmethod + def test_simple(): + dict_to_update = {"a": {"a": 1}} + update = {"b": {"b": 1}, "a": {"b": 1}} + expected_result = {"a": {"a": 1, "b": 1}, "b": {"b": 1}} + result = AS3TemplateConfiguration({})._dict_deep_update( + dict_to_update=dict_to_update, update=update + ) + assert result == expected_result + + @staticmethod + def test_nested(): + dict_to_update = {"a": {"a": 1}} + update = {"b": {"b": 1}, "a": {"b": 1, "a": {"updated_by_b": 1}}} + expected_result = {"a": {"a": {"updated_by_b": 1}, "b": 1}, "b": {"b": 1}} + result = AS3TemplateConfiguration({})._dict_deep_update( + dict_to_update=dict_to_update, update=update + ) + assert result == expected_result + + @staticmethod + def test_list(): + dict_to_update = {"a": {"a": [1, 2, 3]}, (1, 2, 3): {"tuple": True}} + update = { + "b": {"b": 1}, + "a": {"b": 1, "a": {"updated_by_b": 1}}, + (1, 2, 3): {"tuple": True, "updated_by_b": 1}, + } + expected_result = { + "a": {"a": {"updated_by_b": 1}, "b": 1}, + "b": {"b": 1}, + (1, 2, 3): {"tuple": True, "updated_by_b": 1}, + } + result = AS3TemplateConfiguration({})._dict_deep_update( + dict_to_update=dict_to_update, update=update + ) + assert result == expected_result + + +class Test_AS3TemplateConfiguration: + @staticmethod + def test_fail_file_missing(): + data = "tests/testdata/AS3TemplateConfiguration/DOESNOTEXIST.yaml" + with pytest.raises(AS3TemplateConfigurationError): + AS3TemplateConfiguration(data) + + @staticmethod + def test_fail_globbing_notfile(): + data = "tests/testdata/AS3TemplateConfiguration*" + with pytest.raises(AS3TemplateConfigurationError): + AS3TemplateConfiguration(data) + + @staticmethod + def test_fail_wrong_datatype(): + class WrongDatatype: + pass + + data = WrongDatatype + with pytest.raises(AS3TemplateConfigurationError): + AS3TemplateConfiguration(data) + + @staticmethod + def test_str(): + data = "tests/testdata/AS3TemplateConfiguration/file.yaml" + AS3TemplateConfiguration(data) + + @staticmethod + def test_str_globbing(): + data = "tests/testdata/AS3TemplateConfiguration/file.*" + AS3TemplateConfiguration(data) + + @staticmethod + def test_dict(): + data = {"deserialized": True} + AS3TemplateConfiguration(data) + + @staticmethod + def test_tuple_dict(): + data = ({"deserialized json": True}, {"deserialized yaml": True}) + AS3TemplateConfiguration(data) + + @staticmethod + def test_list_dict(): + data = [{"deserialized json": True}, {"deserialized yaml": True}] + AS3TemplateConfiguration(data) + + @staticmethod + def test_list_str(): + data = [ + "tests/testdata/AS3TemplateConfiguration/file.json", + "tests/testdata/AS3TemplateConfiguration/file.yaml", + ] + AS3TemplateConfiguration(data) + + @staticmethod + def test_tuple_str(): + data = ( + "tests/testdata/AS3TemplateConfiguration/file.json", + "tests/testdata/AS3TemplateConfiguration/file.yaml", + ) + AS3TemplateConfiguration(data) + + @staticmethod + def test_List_mixed(): + expected_result = { + "deserialized json": True, + "file.yaml": True, + "content": {"yamlList": ["a", "b", "c"], "jsonList": ["A", "B", "C"]}, + "deserialized yaml": True, + "file.json": True, + } + data = ( + {"deserialized json": True}, + "tests/testdata/AS3TemplateConfiguration/file.yaml", + {"deserialized yaml": True}, + "tests/testdata/AS3TemplateConfiguration/file.json", + ) + assert AS3TemplateConfiguration(data).dict() == expected_result + + @staticmethod + def test_empty_as3ninja_namespace(): + expected_result = {"deserialized json": True} + data = ({"deserialized json": True}, {"as3ninja": {}}) + assert AS3TemplateConfiguration(data).dict() == expected_result diff --git a/tests/test_utils.py b/tests/test_utils.py index c6a2ac5..6ef22b7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- +from copy import deepcopy + import pytest -from as3ninja.utils import deserialize +from as3ninja.utils import DictLike, deserialize json_str = """ { @@ -138,3 +140,60 @@ def test_yaml_scanner_error(): """ with pytest.raises(ValueError): deserialize(datasource=not_yaml) + + +class Test_DictLike: + class DLTest(DictLike): + def __init__(self, configuration: dict): + self._dict = deepcopy(configuration) + + dltest_data = {"key1": {"dict": True, "numbers": [1, 2, 3]}, "key2": "string"} + dltest_instance = DLTest(dltest_data) + + def test_dict_items(self): + assert self.dltest_instance.items() == self.dltest_data.items() + + def test_dict_values(self): + assert str(self.dltest_instance.values()) == str(self.dltest_data.values()) + + def test_dict_keys(self): + assert self.dltest_instance.keys() == self.dltest_data.keys() + + def test_dict_get(self): + assert self.dltest_instance.get("key1") == self.dltest_data.get("key1") + + def test_dict_get_missing(self): + assert self.dltest_instance.get("MissingKey") == self.dltest_data.get( + "MissingKey" + ) + + def test_dunder_str(self): + assert str(self.dltest_instance) == str(self.dltest_data) + + def test_dunder_repr(self): + DLTest = self.DLTest + assert eval(repr(self.dltest_instance)) == DLTest(self.dltest_data) + + def test_dunder_getitem(self): + assert self.dltest_instance.__getitem__("key1") == self.dltest_data.__getitem__( + "key1" + ) + + def test_dunder_eq(self): + assert dict(self.dltest_instance) == self.dltest_data + + def test_dunder_contains(self): + assert "key1" in self.dltest_instance + assert "key2" in self.dltest_instance + assert not "MissingKey" in self.dltest_instance + + def test_dunder_len(self): + assert len(self.dltest_instance) == 2 + + def test_dunder_iter(self): + + keylist = list(self.dltest_instance.keys()) + + for key in self.dltest_instance: + assert key == keylist.pop(keylist.index(key)) + assert len(keylist) == 0 diff --git a/tests/testdata/AS3TemplateConfiguration/file.json b/tests/testdata/AS3TemplateConfiguration/file.json new file mode 100644 index 0000000..1a09f9b --- /dev/null +++ b/tests/testdata/AS3TemplateConfiguration/file.json @@ -0,0 +1,6 @@ +{ + "file.json": true, + "content": { + "jsonList": ["A", "B", "C"] + } +} diff --git a/tests/testdata/AS3TemplateConfiguration/file.yaml b/tests/testdata/AS3TemplateConfiguration/file.yaml new file mode 100644 index 0000000..c1a5902 --- /dev/null +++ b/tests/testdata/AS3TemplateConfiguration/file.yaml @@ -0,0 +1,6 @@ +file.yaml: true +content: + yamlList: + - a + - b + - c diff --git a/tests/testdata/AS3TemplateConfiguration/include1.yaml b/tests/testdata/AS3TemplateConfiguration/include1.yaml new file mode 100644 index 0000000..9a9b7ab --- /dev/null +++ b/tests/testdata/AS3TemplateConfiguration/include1.yaml @@ -0,0 +1,5 @@ +include1.yaml: true +data: include1.yaml + +as3ninja: + include: tests/testdata/AS3TemplateConfiguration/included1.yaml diff --git a/tests/testdata/AS3TemplateConfiguration/include1_relativePath.yaml b/tests/testdata/AS3TemplateConfiguration/include1_relativePath.yaml new file mode 100644 index 0000000..070c9de --- /dev/null +++ b/tests/testdata/AS3TemplateConfiguration/include1_relativePath.yaml @@ -0,0 +1,5 @@ +include1_relativePath.yaml: true +data: include1_relativePath.yaml + +as3ninja: + include: AS3TemplateConfiguration/included1.yaml diff --git a/tests/testdata/AS3TemplateConfiguration/include2.yaml b/tests/testdata/AS3TemplateConfiguration/include2.yaml new file mode 100644 index 0000000..811d091 --- /dev/null +++ b/tests/testdata/AS3TemplateConfiguration/include2.yaml @@ -0,0 +1,7 @@ +include2.yaml: true +data: include2.yaml + +as3ninja: + include: + - tests/testdata/AS3TemplateConfiguration/included2b.yaml + - tests/testdata/AS3TemplateConfiguration/included2*.yaml diff --git a/tests/testdata/AS3TemplateConfiguration/include3.yaml b/tests/testdata/AS3TemplateConfiguration/include3.yaml new file mode 100644 index 0000000..57f8a55 --- /dev/null +++ b/tests/testdata/AS3TemplateConfiguration/include3.yaml @@ -0,0 +1,6 @@ +include3.yaml: true +data: include3.yaml + +as3ninja: + include: + - tests/testdata/AS3TemplateConfiguration/included3.yaml diff --git a/tests/testdata/AS3TemplateConfiguration/included1.yaml b/tests/testdata/AS3TemplateConfiguration/included1.yaml new file mode 100644 index 0000000..48beacf --- /dev/null +++ b/tests/testdata/AS3TemplateConfiguration/included1.yaml @@ -0,0 +1,2 @@ +included1.yaml: true +data: included1.yaml diff --git a/tests/testdata/AS3TemplateConfiguration/included2a.yaml b/tests/testdata/AS3TemplateConfiguration/included2a.yaml new file mode 100644 index 0000000..66cdf04 --- /dev/null +++ b/tests/testdata/AS3TemplateConfiguration/included2a.yaml @@ -0,0 +1,2 @@ +included2a.yaml: true +data: included2a.yaml diff --git a/tests/testdata/AS3TemplateConfiguration/included2b.yaml b/tests/testdata/AS3TemplateConfiguration/included2b.yaml new file mode 100644 index 0000000..f8d1960 --- /dev/null +++ b/tests/testdata/AS3TemplateConfiguration/included2b.yaml @@ -0,0 +1,2 @@ +included2b.yaml: true +data: included2b.yaml diff --git a/tests/testdata/AS3TemplateConfiguration/included2c.yaml b/tests/testdata/AS3TemplateConfiguration/included2c.yaml new file mode 100644 index 0000000..16a9da0 --- /dev/null +++ b/tests/testdata/AS3TemplateConfiguration/included2c.yaml @@ -0,0 +1,2 @@ +included2c.yaml: true +data: included2c.yaml diff --git a/tests/testdata/AS3TemplateConfiguration/included3.yaml b/tests/testdata/AS3TemplateConfiguration/included3.yaml new file mode 100644 index 0000000..2229582 --- /dev/null +++ b/tests/testdata/AS3TemplateConfiguration/included3.yaml @@ -0,0 +1,9 @@ +included3.yaml: true +data: included3.yaml + +# recursive or circular includes are unsupported +as3ninja: + include: + - tests/testdata/AS3TemplateConfiguration/include1.yaml + - tests/testdata/AS3TemplateConfiguration/include2.yaml + - tests/testdata/AS3TemplateConfiguration/included3.yaml diff --git a/tests/utils.py b/tests/utils.py index 44fe638..fb699d8 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -5,7 +5,7 @@ import shutil from pathlib import Path from tempfile import mkdtemp - +from typing import Union import pytest # from tests.utils import * @@ -14,9 +14,11 @@ __all__ = ["format_json", "load_file", "fixture_tmpdir"] -def format_json(jsonstr: str) -> str: +def format_json(jsondata: Union[str, dict]) -> str: """formats json based on the formatting defaults of json.dumps""" - return json.dumps(json.loads(jsonstr), sort_keys=True) + if isinstance(jsondata, str): + return json.dumps(json.loads(jsondata), sort_keys=True) + return json.dumps(jsondata, sort_keys=True) def load_file(filename: str) -> str: