diff --git a/.github/workflows/load-to-rt.yml b/.github/workflows/load-to-rt.yml index 41079b9c..c46dab4c 100644 --- a/.github/workflows/load-to-rt.yml +++ b/.github/workflows/load-to-rt.yml @@ -51,6 +51,7 @@ jobs: - run: git checkout ${{ env.MAIN_BRANCH }} - run: git checkout -b ${{ env.BOARDING }} + - run: git push origin --delete ${{ env.BOARDING }} || echo "Remote Branch '${{ env.BOARDING }}' does not exist" - run: git config --global user.email "k.lampridis@hotmail.com" - run: git config --global user.name "Konstantinos Lampridis" diff --git a/.github/workflows/merge-rt-in-release.yml b/.github/workflows/merge-rt-in-release.yml index 4642819d..eb8303a7 100644 --- a/.github/workflows/merge-rt-in-release.yml +++ b/.github/workflows/merge-rt-in-release.yml @@ -28,7 +28,10 @@ jobs: merge_rt_in_release: # ALLOWED Head Branches # if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'boarding-auto') - if: github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.merged == true + # if: github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.merged == true + if: github.event_name == 'pull_request' && github.event.action == 'closed' + # && github.event.pull_request.head.ref == 'boarding-auto' && github.event.pull_request.merged == true + # github.event.pull_request.auto_merge_enabled runs-on: ubuntu-latest env: @@ -37,6 +40,10 @@ jobs: RELEASE_BR: 'release' MAIN_BR: 'master' steps: + - run: 'echo "[DEBUG] github.event.pull_request.auto_merge_enabled --> ${{ github.event.pull_request.auto_merge_enabled }}"' + - run: 'echo "github.event.pull_request --> ${{ github.event.pull_request }}"' + - run: "echo \"[DEBUG] github.event.pull_request.merged: '${{ github.event.pull_request.merged == true }}', should be 'true'\"" + - run: "echo \"[DEBUG] github.head_ref: '${{ startsWith(github.head_ref, 'boarding-auto') }}', should be 'true'\"" - run: "echo \"[DEBUG] HEAD: '${{ github.head_ref }}', should be 'boarding-auto'\"" - run: "echo \"[DEBUG] BASE: '${{ github.base_ref }}', should be 'release-train'\"" diff --git a/.gitignore b/.gitignore index ec8af7a8..394574c9 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ notes\.org~ cookie-py\.log soft-rel\.log notes\.md + +# LOGS generate by our python code + +cookie-py.log diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6b93dd9c..5c8f76e8 100755 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,6 +3,32 @@ Changelog ========= +1.12.3 (2024-02-08) +=================== + +**Improved Code Coverage.** + + +Changes +^^^^^^^ + +test +"""" +- verify expected exceptions are thrown, in cases of errors, and add new sanitization test cases + +chore +""""" +- chore(gitignore): update .gitignore + +ci +"" +- trigger Job of merge-rt-in-release only if github.event_name == 'pull_request' && github.event.pull_request.merged == true + +release +""""""" +- bump version to 1.12.3 + + 1.12.2 (2024-02-07) =================== diff --git a/README.rst b/README.rst index cc223128..6e5c8c65 100755 --- a/README.rst +++ b/README.rst @@ -275,9 +275,9 @@ Free/Libre and Open Source Software (FLOSS) .. Github Releases & Tags -.. |commits_since_specific_tag_on_master| image:: https://img.shields.io/github/commits-since/boromir674/cookiecutter-python-package/v1.12.2/master?color=blue&logo=github +.. |commits_since_specific_tag_on_master| image:: https://img.shields.io/github/commits-since/boromir674/cookiecutter-python-package/v1.12.3/master?color=blue&logo=github :alt: GitHub commits since tagged version (branch) - :target: https://github.com/boromir674/cookiecutter-python-package/compare/v1.12.2..master + :target: https://github.com/boromir674/cookiecutter-python-package/compare/v1.12.3..master .. |commits_since_latest_github_release| image:: https://img.shields.io/github/commits-since/boromir674/cookiecutter-python-package/latest?color=blue&logo=semver&sort=semver :alt: GitHub commits since latest release (by SemVer) diff --git a/docs/conf.py b/docs/conf.py index f605d0ce..8b72cad5 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,7 +30,7 @@ author = 'Konstantinos Lampridis' # The full version, including alpha/beta/rc tags -release = '1.12.2' +release = '1.12.3' # -- General configuration --------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 99e65252..47275438 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ build-backend = "poetry.core.masonry.api" ## Also renders on pypi as 'subtitle' [tool.poetry] name = "cookiecutter_python" -version = "1.12.2" +version = "1.12.3" description = "1-click Generator of Python Project, from Template with streamlined \"DevOps\" using a powerful CI/CD Pipeline." authors = ["Konstantinos Lampridis "] maintainers = ["Konstantinos Lampridis "] diff --git a/src/cookiecutter_python/__init__.py b/src/cookiecutter_python/__init__.py index 84fc6fec..607943f5 100755 --- a/src/cookiecutter_python/__init__.py +++ b/src/cookiecutter_python/__init__.py @@ -1,3 +1,3 @@ -__version__ = '1.12.2' +__version__ = '1.12.3' from . import _logging # noqa diff --git a/src/cookiecutter_python/backend/sanitization/interpreters_support.py b/src/cookiecutter_python/backend/sanitization/interpreters_support.py index dcd8978c..c22e15f7 100644 --- a/src/cookiecutter_python/backend/sanitization/interpreters_support.py +++ b/src/cookiecutter_python/backend/sanitization/interpreters_support.py @@ -8,7 +8,6 @@ # TODO Improvement: use an Enum SUPPORTED = { - '3.5', '3.6', '3.7', '3.8', diff --git a/src/cookiecutter_python/backend/sanitization/string_sanitizers/sanitize_reg_input.py b/src/cookiecutter_python/backend/sanitization/string_sanitizers/sanitize_reg_input.py index 242f5374..0c42b69d 100644 --- a/src/cookiecutter_python/backend/sanitization/string_sanitizers/sanitize_reg_input.py +++ b/src/cookiecutter_python/backend/sanitization/string_sanitizers/sanitize_reg_input.py @@ -1,5 +1,6 @@ import json import logging +import typing as t from typing import Pattern, Tuple from ..input_sanitization import Sanitize @@ -12,8 +13,8 @@ class RegExSanitizer: - regex: Pattern - sanitizer: BaseSanitizer + regex: t.ClassVar[Pattern] + sanitizer: t.ClassVar[BaseSanitizer] def __call__(self, data): self.sanitizer(data) diff --git a/src/cookiecutter_python/hooks/post_gen_project.py b/src/cookiecutter_python/hooks/post_gen_project.py index e77b2a57..cd0dda0e 100644 --- a/src/cookiecutter_python/hooks/post_gen_project.py +++ b/src/cookiecutter_python/hooks/post_gen_project.py @@ -5,9 +5,9 @@ """ #!/usr/bin/env python3 +import json import shutil import os -import re import subprocess import sys import typing as t @@ -41,18 +41,11 @@ def get_context() -> OrderedDict: def get_request(): cookie_dict: OrderedDict = get_context() - INITIALIZE_GIT_REPO_FLAG = "{{ cookiecutter.initialize_git_repo|lower }}" - - # DATA: str to value mapping data: t.Dict[str, t.Any] = { - 'cookiecutter': cookie_dict, + 'vars': cookie_dict, 'project_dir': GEN_PROJ_LOC, 'module_name': cookie_dict['pkg_name'], - 'author': "{{ cookiecutter.author }}", - 'author_email': "{{ cookiecutter.author_email }}", - 'initialize_git_repo': {'yes': True}.get(INITIALIZE_GIT_REPO_FLAG, False), - 'project_type': "{{ cookiecutter.project_type }}", - # 'add_cli': {'module+cli': True}.get(project_type, False), + 'initialize_git_repo': {'yes': True}.get(cookie_dict['initialize_git_repo'].lower(), False), 'repo': None, # Docs Website: build/infra config, and Content Templates 'docs_website': { @@ -62,21 +55,7 @@ def get_request(): # internally used to get the template folder of each Doc Builder 'docs_extra_info': DOCS, } - # sanity check on data dict for docs_website and docs_extra_info - # TODO: remove sanity checks - assert 'docs_website' in data.keys(), f"ERROR 1: 'docs_website' not in data.keys()={data.keys()}" - assert 'builder' in data['docs_website'].keys(), f"ERROR 2: 'builder' not in data['docs_website'].keys()={data['docs_website'].keys()}" - assert 'docs_extra_info' in data.keys(), f"ERROR 3a: 'docs_extra_info' not in data.keys()={data.keys()}" - assert data['docs_website']['builder'] in {'mkdocs', 'sphinx'}, f"ERROR 3b: docs_website.builder={data['docs_website']['builder']} not in ['mkdocs', 'sphinx']" - - from pprint import pprint - pprint("\n\n") - pprint(data) - assert data['docs_website']['builder'] in data['docs_extra_info'].keys(), f"ERROR 3: docs_website.builder={data['docs_website']['builder']} not in docs_extra_info.keys()={data['docs_extra_info'].keys()}" - request = type('PostGenProjectRequest', (), data) - assert hasattr(request, 'docs_extra_info') - assert hasattr(request, 'docs_website') - return request + return type('PostGenProjectRequest', (), data) class PostFileRemovalError(Exception): @@ -135,7 +114,7 @@ def post_file_removal(request): from pathlib import Path files_to_remove = [ - os.path.join(request.project_dir, *x) for x in delete_files[request.project_type](request) + os.path.join(request.project_dir, *x) for x in delete_files[request.vars['project_type']](request) ] ## Post Removal, given 'Project Type', of potentially extra files ## for file in files_to_remove: @@ -166,47 +145,44 @@ def post_file_removal(request): if logs_file.exists(): # unintentional behaviour, is still happening if logs_file.stat().st_size == 0: # at least expect empty log file - # safely remove the empty log file - try: + try: # safely remove the empty log file logs_file.unlink() - # windows erro reported on CI - # PermissionError: [WinError 32] The process cannot access the file because it is being used by another process - except PermissionError as e: - print(f"[WARNING]: {e}") - print(f"[WARNING]: Could not remove empty log file: {logs_file}") - print("[WARNING]: Please remove it manually, if you wish to do so.") + except PermissionError as e: # has happened on Windows CI + # PermissionError: [WinError 32] The process cannot access the + # file because it is being used by another process + logger.debug("Permission Error, when removing empty log file: %s", json.dumps({ + 'file': str(logs_file), + 'error': str(e), + 'platform': str(sys.platform), + }, indent=4, sort_keys=True)) else: # captured logs were written in the file: shy from removing it # Tell user about this, and let them decide what to do print(f"[INFO]: Captured Logs were written in {logs_file}") -def _get_run_parameters(python3_minor: int): - def run(args: list, kwargs: dict): - return subprocess.run(*args, **dict(kwargs, check=True)) # pylint: disable=W1510 #nosec +def run_process_python37_n_above(*args, **kwargs): + return [args], dict(capture_output=True, check=True, **kwargs) + +def run_process_python36_n_below(*args, **kwargs): + return [args], dict(stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, **kwargs) + + +def subprocess_run(*args, **kwargs): def _subprocess_run(get_params): def run1(*args, **kwargs): - return run(*get_params(*args, **kwargs)) + args_list, kwargs_dict = get_params(*args, **kwargs) + return subprocess.run(*args_list, **dict(kwargs_dict, check=True)) # pylint: disable=W1510 #nosec return run1 - return { + d = { 'legacy': _subprocess_run(run_process_python36_n_below), 'new': _subprocess_run(run_process_python37_n_above), }[ {True: 'legacy', False: 'new'}[ - python3_minor < 7 # is legacy Python 3.x version (ie 3.5 or 3.6) ? + sys.version_info.minor < 7 # is legacy Python 3.x version (ie 3.5 or 3.6) ? ] ] - - -def run_process_python37_n_above(*args, **kwargs): - return [args], dict(capture_output=True, check=True, **kwargs) - -def run_process_python36_n_below(*args, **kwargs): - return [args], dict(stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, **kwargs) - - -def subprocess_run(*args, **kwargs): - return _get_run_parameters(sys.version_info.minor)(*args, **kwargs) + return d(*args, **kwargs) def initialize_git_repo(project_dir: str): @@ -216,25 +192,6 @@ def initialize_git_repo(project_dir: str): subprocess_run('git', 'init', cwd=project_dir) -def exception(subprocess_exception: subprocess.CalledProcessError): - error_message = str(subprocess_exception.stderr, encoding='utf-8') - if re.match(r'error: could not lock config file .+\.gitconfig File exists', - error_message): - return type('LockFileError', (Exception,), {})(error_message) - return subprocess_exception - -def grant_basic_permissions(project_dir: str): - try: - return subprocess_run( - 'git', 'config', '--global', '--add', 'safe.directory', str(project_dir), - cwd=project_dir, - ) - except subprocess.CalledProcessError as error: - print('Did not add an entry in ~/.gitconfig!') - print(str(error.stderr, encoding='utf-8')) - print(exception(error)) - - def iter_files(request): path_obj = Path(request.project_dir) for file_path in path_obj.rglob('*'): @@ -250,7 +207,7 @@ def iter_files(request): def git_commit(request): """Commit the staged changes in the generated project.""" cookiecutter_config_str = ( - '\n'.join((f" {key}: {val}" for key, val in request.cookiecutter.items())) + '\n' + '\n'.join((f" {key}: {val}" for key, val in request.vars.items())) + '\n' ) commit_message = ( "Template applied from" @@ -263,7 +220,7 @@ def git_commit(request): request.repo.index.add(list( iter((path.relpath(x, start=request.project_dir) for x in iter_files(request))) )) - author = Actor(request.author, request.author_email) + author = Actor(request.vars['author'], request.vars['author_email']) request.repo.index.commit( commit_message, @@ -290,7 +247,7 @@ def is_git_repo_clean(project_directory: str) -> bool: return False -def _post_hook(): +def post_hook(): """Delete irrelevant to Project Type files and optionally do git commit.""" request = get_request() # Delete gen Files related to @@ -312,7 +269,6 @@ def _post_hook(): # Git commit if request.initialize_git_repo: initialize_git_repo(request.project_dir) - # grant_basic_permissions(request.project_dir) request.repo = Repo(request.project_dir) if not is_git_repo_clean(request.project_dir): git_commit(request) @@ -321,14 +277,9 @@ def _post_hook(): return 0 -def post_hook(): - """Delete irrelevant to Project Type files and optionally do git commit.""" - sys.exit(_post_hook()) - - def main(): """Delete irrelevant to Project Type files and optionally do git commit.""" - post_hook() + sys.exit(post_hook()) if __name__ == "__main__": diff --git a/src/cookiecutter_python/hooks/pre_gen_project.py b/src/cookiecutter_python/hooks/pre_gen_project.py index beb56923..37d19f45 100755 --- a/src/cookiecutter_python/hooks/pre_gen_project.py +++ b/src/cookiecutter_python/hooks/pre_gen_project.py @@ -65,11 +65,11 @@ def input_sanitization(request): sanitize['interpreters'](request.interpreters) except sanitize.exceptions['interpreters'] as error: logger.warning("Interpreters Data Error: %s", json.dumps({ - 'error': error, + 'error': str(error), 'interpreters_data': request.interpreters, }, sort_keys=True, indent=4)) raise InputSanitizationError( - "ERROR: {request.interpreters} are not valid 'supported interpreters'!" + f"ERROR: {request.interpreters} are not valid 'supported interpreters'!" ) from error print("Sanitized Input Variables :)") diff --git a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/.gitignore b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/.gitignore index cfb95331..c71cc699 100644 --- a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/.gitignore +++ b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/.gitignore @@ -21,3 +21,7 @@ dependency-graphs/ test-results/ uml-diagrams/ pydoer-graphs/ + +# LOGS + +cookie-py.log diff --git a/tests/conftest.py b/tests/conftest.py index 9bff1602..21a3ea62 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -229,11 +229,11 @@ class HookRequest: # TODO improvement: add key/value types ### We want to skip running the templating engine, so we mock the state ### that the templating engine would have produced. - cookiecutter: t.Optional[t.Dict] = attr.ib( + + # Templated Vars (cookiecutter) use in Context for Jinja Rendering + vars: t.Optional[t.Dict] = attr.ib( default=OrderedDict(td_cookiecutter_json_data, **engine_state['cookiecutter']) ) - author: t.Optional[str] = attr.ib(default='Konstantinos Lampridis') - author_email: t.Optional[str] = attr.ib(default='boromir674@hotmail.com') initialize_git_repo: t.Optional[bool] = attr.ib(default=True) interpreters: t.Optional[t.Dict] = attr.ib( default=[ @@ -265,6 +265,9 @@ class HookRequest: } ) + def __attrs_post_init__(self): + self.vars['project_type'] = self.project_type + class BaseHookRequest(metaclass=SubclassRegistry): pass diff --git a/tests/data/snapshots/biskotaki-gold-standard/.gitignore b/tests/data/snapshots/biskotaki-gold-standard/.gitignore index cfb95331..c71cc699 100644 --- a/tests/data/snapshots/biskotaki-gold-standard/.gitignore +++ b/tests/data/snapshots/biskotaki-gold-standard/.gitignore @@ -21,3 +21,7 @@ dependency-graphs/ test-results/ uml-diagrams/ pydoer-graphs/ + +# LOGS + +cookie-py.log diff --git a/tests/data/snapshots/biskotaki-interactive/.gitignore b/tests/data/snapshots/biskotaki-interactive/.gitignore index cfb95331..c71cc699 100644 --- a/tests/data/snapshots/biskotaki-interactive/.gitignore +++ b/tests/data/snapshots/biskotaki-interactive/.gitignore @@ -21,3 +21,7 @@ dependency-graphs/ test-results/ uml-diagrams/ pydoer-graphs/ + +# LOGS + +cookie-py.log diff --git a/tests/data/snapshots/biskotaki-no-input/.gitignore b/tests/data/snapshots/biskotaki-no-input/.gitignore index cfb95331..c71cc699 100644 --- a/tests/data/snapshots/biskotaki-no-input/.gitignore +++ b/tests/data/snapshots/biskotaki-no-input/.gitignore @@ -21,3 +21,7 @@ dependency-graphs/ test-results/ uml-diagrams/ pydoer-graphs/ + +# LOGS + +cookie-py.log diff --git a/tests/test_post_hook.py b/tests/test_post_hook.py index a53feb49..ab539434 100644 --- a/tests/test_post_hook.py +++ b/tests/test_post_hook.py @@ -1,5 +1,6 @@ import sys import typing as t +from pathlib import Path if sys.version_info >= (3, 8): from typing import Literal, Protocol @@ -130,11 +131,11 @@ def generate_all_extra_files( # Sanity check that no-one inputs the same file twice assert len(create_emulated) == expected_unique_files - # need to make sure to create all files, for Post Remove Hook to work - + # create all derived files, for Post Remove Hook to work for path_tuple in sorted(create_emulated): with open(path.join(project_dir, *path_tuple), 'w') as _file: - _file.write('print("Hello World!"\n') + _file.write('print("Hello World!")\n') + return emulated_post_gen_request return _emulated_generated_project @@ -147,19 +148,41 @@ def get_post_gen_main(get_object, emulated_generated_project): name = 'gg' - def get_pre_gen_hook_project_main(add_cli: bool, project_dir: Path): + def get_pre_gen_hook_project_main( + add_cli: bool, + project_dir: Path, + extra_files: t.Optional[t.List[t.Union[str, t.Tuple[str, ...]]]] = None, + extra_non_empty_files: t.Optional[t.List[t.Union[str, t.Tuple[str, ...]]]] = None, + ): """""" def mock_get_request(): # to avoid bugs we require empty project dir, before emulated generation absolute_proj_dir = Path(project_dir).absolute() assert len(list(absolute_proj_dir.iterdir())) == 0 - # EMULATE a GEN Project, by craeting minimal dummy files and folders + + # EMULATE a GEN Project, by creating minimal dummy files and folders emulated_request = emulated_generated_project( project_dir, name=name, project_type='module+cli' if add_cli else 'module' ) # sanity check that sth got generated assert len(list(absolute_proj_dir.iterdir())) > 0 + + # Emulate Extra Empty files, given specific Test Case Scenario + if extra_files: + for _file_path in extra_files: + if isinstance(_file_path, str): + file_path = (_file_path,) + absolute_proj_dir.joinpath(*file_path).touch() + + # Emulate Extra Non-Empty files, given specific Test Case Scenario + if extra_non_empty_files: + for _file_path in extra_non_empty_files: + if isinstance(_file_path, str): + file_path = (_file_path,) + with open(absolute_proj_dir.joinpath(*file_path), 'w') as _file: + _file.write('print("Hello World!")\n') + return emulated_request # Get a main method, with a mocked get_request @@ -167,13 +190,14 @@ def mock_get_request(): # By monekypatching the `get_request`, with the emulated one # the emulated `get_request`, when called, # first Generates the Emulated Project, and then returns - # the a Request object + # the Request object import sys def emulated_exit(exit_code: int): assert exit_code == 0 return exit_code + # Monkeypatch with MOCKs the 'sys.exit' and 'sys.version_info' objects get_object( "main", "cookiecutter_python.hooks.post_gen_project", @@ -194,6 +218,7 @@ def emulated_exit(exit_code: int): ) }, ) + # Monkeypatch with MOCK the 'get_request' function main_method = get_object( "main", "cookiecutter_python.hooks.post_gen_project", @@ -246,3 +271,63 @@ def test_main(add_cli, get_post_gen_main, assert_initialized_git, tmpdir): assert result is None assert_initialized_git(expexpected_gen_dir) + + +# REQUIRES well maintained emulated generated project (fixtures) +def test_post_file_removal_deletes_empty_logfile_if_found(get_post_gen_main, tmp_path): + + # GIVEN a temporary directory, to store the emulated generated project + project_dir: Path = tmp_path + + # GIVEN a suitably monkeypatched post_gen_project.main method + + # Emulate placement of empty log file inside the project + from cookiecutter_python._logging_config import FILE_TARGET_LOGS + + extra_files: t.List[str] = [FILE_TARGET_LOGS] + + post_hook_main = get_post_gen_main( + True, # True -> with module+cli, else module + # gen_output_dir, + tmp_path, + extra_files=extra_files, + ) + + # WHEN the post_gen_project.main is called + result = post_hook_main() # raises error, if post gen exit code != 0 + + # THEN the post_gen_project.main runs successfully + assert result is None + + # AND the Logs File is deleted in Post Gen Hook, since it is empty + assert not (project_dir / FILE_TARGET_LOGS).exists() + + +# REQUIRES well maintained emulated generated project (fixtures) +def test_post_file_removal_keeps_logfile_if_found_non_empty(get_post_gen_main, tmp_path): + + # GIVEN a temporary directory, to store the emulated generated project + project_dir: Path = tmp_path + + # GIVEN a suitably monkeypatched post_gen_project.main method + + # Emulate placement of empty log file inside the project + from cookiecutter_python._logging_config import FILE_TARGET_LOGS + + post_hook_main = get_post_gen_main( + True, # True -> with module+cli, else module + # gen_output_dir, + tmp_path, + extra_non_empty_files=[FILE_TARGET_LOGS], + ) + + # WHEN the post_gen_project.main is called + result = post_hook_main() # raises error, if post gen exit code != 0 + + # THEN the post_gen_project.main runs successfully + assert result is None + + # AND the Logs File is kept during Post Gen Hook, since it is not empty + assert (project_dir / FILE_TARGET_LOGS).exists() + assert (project_dir / FILE_TARGET_LOGS).is_file() + assert (project_dir / FILE_TARGET_LOGS).stat().st_size > 0 diff --git a/tests/test_prehook.py b/tests/test_prehook.py index 7af3f204..802cb7f7 100644 --- a/tests/test_prehook.py +++ b/tests/test_prehook.py @@ -1,4 +1,5 @@ import os +import typing as t import pytest @@ -51,6 +52,35 @@ def test_incorrect_module_name(is_valid_python_module_name): assert not result +def test_prehook_sanitization_throws_error_on_duplicate_interpreters(): + from cookiecutter_python.hooks.pre_gen_project import sanitize + + with pytest.raises(sanitize.exceptions['interpreters']): + sanitize['interpreters'](["3.10", "3.10"]) + + +def test_prehook_sanitization_throws_error_on_unsupported_interpreters(): + from cookiecutter_python.backend.sanitization.interpreters_support import SUPPORTED + + SUPPORTED_SET: t.Set[str] = SUPPORTED + unsupported_interpreter = '3.5' + + # SANITY to make test case valid + assert unsupported_interpreter not in SUPPORTED_SET + + from cookiecutter_python.hooks.pre_gen_project import sanitize + + with pytest.raises(sanitize.exceptions['interpreters']): + sanitize['interpreters']([unsupported_interpreter]) + + +def test_prehook_sanitization_passes_given_interpreters_supported_by_gen(): + supported_subset = {"3.8", "3.9", "3.10", "3.11"} + from cookiecutter_python.hooks.pre_gen_project import sanitize + + sanitize['interpreters'](supported_subset) + + @pytest.fixture def get_main_with_mocked_template(get_object, request_factory): def get_pre_gen_hook_project_main(overrides={}): @@ -66,6 +96,15 @@ def get_pre_gen_hook_project_main(overrides={}): return get_pre_gen_hook_project_main +def test_main_with_invalid_interpreters(get_main_with_mocked_template, request_factory): + result = get_main_with_mocked_template( + overrides={ + "get_request": lambda: lambda: request_factory.pre(interpreters=['3.5', '3.10']) + } + )() + assert result == 1 # exit code of 1 indicates failed execution + + def test_main_with_invalid_module_name(get_main_with_mocked_template, request_factory): result = get_main_with_mocked_template( overrides={"get_request": lambda: lambda: request_factory.pre(module_name="121212")} diff --git a/tests/test_sanitization_component.py b/tests/test_sanitization_component.py new file mode 100644 index 00000000..9fdde836 --- /dev/null +++ b/tests/test_sanitization_component.py @@ -0,0 +1,117 @@ +import pytest + + +def test_registering_multiple_exceptions_under_the_same_type_allows_catching_multiple_errors(): + + import json + import logging + + logger = logging.getLogger(__name__) + + # GIVEN a Sanitize Task - Type + SANITIZE_TASK_TYPE = 'unit-test-sanitizer' + + # GIVEN the official way of a Registering a Sanitizer Task / Type + from cookiecutter_python.backend.sanitization.input_sanitization import Sanitize + + # Backend Code that declares and registers a new Sanitizer + @Sanitize.register_sanitizer(SANITIZE_TASK_TYPE) + def verify_input_string_not_empty_and_only_lowercase_latin_chars(string: str) -> None: + + if len(string) < 1: + raise StringWithNoLengthError("String With No Length Error") + + if set(string).difference(set('abcdefghijklmnopqrstuvwxyz')): + raise StringWithImpropperCharsError( + "String With Impropper Chars Error. Only [a-z] are allowed" + ) + + # GIVEN a way to register exceptions under this Sanitization Task / Type + + @Sanitize.register_exception(SANITIZE_TASK_TYPE) + class StringWithNoLengthError(Exception): + pass + + # WHEN we register 2 Exceptions under the same Type + @Sanitize.register_exception(SANITIZE_TASK_TYPE) + class StringWithImpropperCharsError(Exception): + pass + + # SANITY Santizer has been registered + from cookiecutter_python.backend import sanitize + + assert SANITIZE_TASK_TYPE in sanitize.sanitizers_map + assert sanitize.sanitizers_map[SANITIZE_TASK_TYPE] + + # SANITY Production Sanitizers automatically loaded! + PRODUCTION_SANITIZERS = { + 'module-name', + 'semantic-version', + 'interpreters', + } + assert set(sanitize.sanitizers_map.keys()) == {SANITIZE_TASK_TYPE}.union( + PRODUCTION_SANITIZERS + ) + + # SANITY Exceptions have been registered + assert sanitize.exceptions_map[SANITIZE_TASK_TYPE] == [ + StringWithNoLengthError, + StringWithImpropperCharsError, + ] + + # SANITY StringWithNoLengthError is thrown expectedly / "correctly" + # we Sanitize a string that has no length, we catch 1st exception + with pytest.raises(StringWithNoLengthError): + verify_input_string_not_empty_and_only_lowercase_latin_chars('') + + # SANITY StringWithImpropperCharsError is thrown expectedly / "correctly" + # we Sanitize a string with improper characters, we catch 2nd exception + with pytest.raises(StringWithImpropperCharsError): + verify_input_string_not_empty_and_only_lowercase_latin_chars('123') + + # THEN we should be able to catch both exceptions, in case of error + class InputSanitizationError(Exception): + pass + + # WHEN we use the Sanitizer, as it is designed to be used, with empty string + input_string = '' + with pytest.raises(InputSanitizationError): + try: + sanitize[SANITIZE_TASK_TYPE](input_string) + except sanitize.exceptions[SANITIZE_TASK_TYPE] as error: + logger.warning( + "Input String Value (format) Error: %s", + json.dumps( + { + 'error': str(error), + 'input_string': input_string, + }, + sort_keys=True, + indent=4, + ), + ) + raise InputSanitizationError( + f"ERROR: '{input_string}' could not pass Sanitization, due to invalid format." + ) from error + + # WHEN we use the Sanitizer, on string with non [a-z] characters + + input_string = '123' + with pytest.raises(InputSanitizationError): + try: + sanitize[SANITIZE_TASK_TYPE](input_string) + except sanitize.exceptions[SANITIZE_TASK_TYPE] as error: + logger.warning( + "Input String Value (format) Error: %s", + json.dumps( + { + 'error': str(error), + 'input_string': input_string, + }, + sort_keys=True, + indent=4, + ), + ) + raise InputSanitizationError( + f"ERROR: '{input_string}' could not pass Sanitization, due to invalid format." + ) from error