From cb05d54e43588f0a8ff9098131acd77ece5a6555 Mon Sep 17 00:00:00 2001 From: konstantinos Date: Fri, 27 May 2022 15:40:19 +0300 Subject: [PATCH 01/18] dev(tox): add checks for 'scripts' dir, add DRY, make 'black', 'isort' cmds only do 'lint-check' by default and add switch to allow doing 'lint-apply' --- tox.ini | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tox.ini b/tox.ini index aa5d47d4..591e9855 100644 --- a/tox.ini +++ b/tox.ini @@ -47,6 +47,7 @@ setenv = COVERAGE_FILE = {toxworkdir}{/}.coverage.{envname} TEST_STATUS_DIR = {envtmpdir} PYPY3323BUG = 1 + black,lint: BLACK_ARGS = "src tests scripts" extras = test commands = @@ -130,7 +131,7 @@ description = Type checking with mypy extras = typing usedevelop = true changedir = {toxinidir} -commands = mypy --show-error-codes {posargs:src{/}{env:PY_PACKAGE}{/}hooks src{/}{env:PY_PACKAGE}{/}backend tests} +commands = mypy --show-error-codes {posargs:src{/}{env:PY_PACKAGE}{/}hooks src{/}{env:PY_PACKAGE}{/}backend tests scripts} ## DOCUMENTATION @@ -268,8 +269,9 @@ commands_pre = python -c 'import os; f = ".pylintrc"; exec("if os.path.exists(f) commands = prospector tests prospector src + prospector scripts isort {env:APPLY_LINT:--check} . - black {env:APPLY_LINT:--check} -S --config pyproject.toml tests src + black {env:APPLY_LINT:--check} -S --config pyproject.toml "{env:BLACK_ARGS}" commands_post = python -c 'import os; f = ".pylintrc-bak"; exec("if os.path.exists(f):\n os.rename(f, \".pylintrc\")")' @@ -278,13 +280,14 @@ description = black ops deps = black skip_install = true changedir = {toxinidir} -commands = black {posargs} -S --config pyproject.toml tests src +commands = black {env:APPLY_BLACK:--check} -S --config pyproject.toml "{env:BLACK_ARGS}" + [testenv:isort] -descript = isort +description = isort deps = isort >= 5.0.0 skip_install = true -commands = isort {posargs} {toxinidir} +commands = isort {env:APPLY_ISORT:--check} {toxinidir} ## Code Static Analysis @@ -310,8 +313,9 @@ commands_pre = # So we temporarily "hide" .pylintrc from prospector python -c 'import os; f = ".pylintrc"; exec("if os.path.exists(f):\n os.rename(f, \".pylintrc-bak\")")' commands = - prospector tests prospector src + prospector tests + prospector scripts commands_post = # We "restore" .pylintrc (to be available to the pylint env command) python -c 'import os; f = ".pylintrc-bak"; exec("if os.path.exists(f):\n os.rename(f, \".pylintrc\")")' From 97511498ed5cfc6800c97d6a32d14913cdbf03c9 Mon Sep 17 00:00:00 2001 From: konstantinos Date: Fri, 27 May 2022 15:42:42 +0300 Subject: [PATCH 02/18] refactor(lint): apply isort and black lint --- scripts/parse_version.py | 69 ++++++++++--------- .../scripts/parse_version.py | 69 ++++++++++--------- 2 files changed, 76 insertions(+), 62 deletions(-) diff --git a/scripts/parse_version.py b/scripts/parse_version.py index 2c737ce4..617e56ca 100644 --- a/scripts/parse_version.py +++ b/scripts/parse_version.py @@ -22,13 +22,11 @@ TOML_FILE = os.path.abspath(os.path.join(my_dir, '..', TOML)) DEMO_SECTION: str = ( - "[tool.software-release]\nversion_variable = " - "src/package_name/__init__.py:__version__" + "[tool.software-release]\nversion_variable = " "src/package_name/__init__.py:__version__" ) def build_client_callback(data: MatchData, factory: ExceptionFactory) -> ClientCallback: - def client_callback(file_path: str, regex: str) -> t.Tuple: with open(file_path, 'r') as _file: contents = _file.read() @@ -38,38 +36,44 @@ def client_callback(file_path: str, regex: str) -> t.Tuple: return extracted_tuple else: raise factory(file_path, regex, contents) + return client_callback # PARSERS -software_release_parser = build_client_callback(( - 'search', - [re.MULTILINE,], - lambda match: (match.group(1), match.group(2)) -), +software_release_parser = build_client_callback( + ( + 'search', + [ + re.MULTILINE, + ], + lambda match: (match.group(1), match.group(2)), + ), lambda file_path, reg, string: RuntimeError( - "Expected to find the '[tool.software-release]' section, in " - f"the '{file_path}' file, with key " - "'version_variable'.\nFor example:\n" - f"{DEMO_SECTION}\n " - "indicates that the version string should be looked up in " - f"the src/package_name/__init__.py file and specifically " - "a '__version__ = 1.2.3' kind of line is expected to be found." - ) + "Expected to find the '[tool.software-release]' section, in " + f"the '{file_path}' file, with key " + "'version_variable'.\nFor example:\n" + f"{DEMO_SECTION}\n " + "indicates that the version string should be looked up in " + f"the src/package_name/__init__.py file and specifically " + "a '__version__ = 1.2.3' kind of line is expected to be found." + ), ) -version_file_parser = build_client_callback(( - 'search', - [re.MULTILINE,], - lambda match: (match.group(1),) -), +version_file_parser = build_client_callback( + ( + 'search', + [ + re.MULTILINE, + ], + lambda match: (match.group(1),), + ), lambda file_path, reg, string: AttributeError( - "Could not find a match for regex {regex} when applied to:".format( - regex=reg - ) + "\n{content}".format(content=string) - ) + "Could not find a match for regex {regex} when applied to:".format(regex=reg) + + "\n{content}".format(content=string) + ), ) @@ -84,15 +88,18 @@ def parse_version(software_release_cfg: str) -> str: """ header = r'\[tool\.software-release\]' sep = r'[\w\s=/\.:\d]+' # in some cases accounts for miss-typed characters! - version_specification = \ + version_specification = ( r"version_variable[\ \t]*=[\ \t]*['\"]?([\w\.]+(?:/[\w\.]+)*):(\w+)['\"]?" + ) regex = f"{header}{sep}{version_specification}" - file_name_with_version, version_variable_name = \ - software_release_parser(software_release_cfg, regex) + file_name_with_version, version_variable_name = software_release_parser( + software_release_cfg, regex + ) - file_with_version_string = \ - os.path.abspath(os.path.join(my_dir, '../', file_name_with_version)) + file_with_version_string = os.path.abspath( + os.path.join(my_dir, '../', file_name_with_version) + ) if not os.path.isfile(file_with_version_string): raise FileNotFoundError( @@ -104,7 +111,7 @@ def parse_version(software_release_cfg: str) -> str: ) reg = f'^{version_variable_name}' + r'\s*=\s*[\'\"]([^\'\"]*)[\'\"]' - version, = version_file_parser(file_with_version_string, reg) + (version,) = version_file_parser(file_with_version_string, reg) return version diff --git a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/scripts/parse_version.py b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/scripts/parse_version.py index 2c737ce4..617e56ca 100644 --- a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/scripts/parse_version.py +++ b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/scripts/parse_version.py @@ -22,13 +22,11 @@ TOML_FILE = os.path.abspath(os.path.join(my_dir, '..', TOML)) DEMO_SECTION: str = ( - "[tool.software-release]\nversion_variable = " - "src/package_name/__init__.py:__version__" + "[tool.software-release]\nversion_variable = " "src/package_name/__init__.py:__version__" ) def build_client_callback(data: MatchData, factory: ExceptionFactory) -> ClientCallback: - def client_callback(file_path: str, regex: str) -> t.Tuple: with open(file_path, 'r') as _file: contents = _file.read() @@ -38,38 +36,44 @@ def client_callback(file_path: str, regex: str) -> t.Tuple: return extracted_tuple else: raise factory(file_path, regex, contents) + return client_callback # PARSERS -software_release_parser = build_client_callback(( - 'search', - [re.MULTILINE,], - lambda match: (match.group(1), match.group(2)) -), +software_release_parser = build_client_callback( + ( + 'search', + [ + re.MULTILINE, + ], + lambda match: (match.group(1), match.group(2)), + ), lambda file_path, reg, string: RuntimeError( - "Expected to find the '[tool.software-release]' section, in " - f"the '{file_path}' file, with key " - "'version_variable'.\nFor example:\n" - f"{DEMO_SECTION}\n " - "indicates that the version string should be looked up in " - f"the src/package_name/__init__.py file and specifically " - "a '__version__ = 1.2.3' kind of line is expected to be found." - ) + "Expected to find the '[tool.software-release]' section, in " + f"the '{file_path}' file, with key " + "'version_variable'.\nFor example:\n" + f"{DEMO_SECTION}\n " + "indicates that the version string should be looked up in " + f"the src/package_name/__init__.py file and specifically " + "a '__version__ = 1.2.3' kind of line is expected to be found." + ), ) -version_file_parser = build_client_callback(( - 'search', - [re.MULTILINE,], - lambda match: (match.group(1),) -), +version_file_parser = build_client_callback( + ( + 'search', + [ + re.MULTILINE, + ], + lambda match: (match.group(1),), + ), lambda file_path, reg, string: AttributeError( - "Could not find a match for regex {regex} when applied to:".format( - regex=reg - ) + "\n{content}".format(content=string) - ) + "Could not find a match for regex {regex} when applied to:".format(regex=reg) + + "\n{content}".format(content=string) + ), ) @@ -84,15 +88,18 @@ def parse_version(software_release_cfg: str) -> str: """ header = r'\[tool\.software-release\]' sep = r'[\w\s=/\.:\d]+' # in some cases accounts for miss-typed characters! - version_specification = \ + version_specification = ( r"version_variable[\ \t]*=[\ \t]*['\"]?([\w\.]+(?:/[\w\.]+)*):(\w+)['\"]?" + ) regex = f"{header}{sep}{version_specification}" - file_name_with_version, version_variable_name = \ - software_release_parser(software_release_cfg, regex) + file_name_with_version, version_variable_name = software_release_parser( + software_release_cfg, regex + ) - file_with_version_string = \ - os.path.abspath(os.path.join(my_dir, '../', file_name_with_version)) + file_with_version_string = os.path.abspath( + os.path.join(my_dir, '../', file_name_with_version) + ) if not os.path.isfile(file_with_version_string): raise FileNotFoundError( @@ -104,7 +111,7 @@ def parse_version(software_release_cfg: str) -> str: ) reg = f'^{version_variable_name}' + r'\s*=\s*[\'\"]([^\'\"]*)[\'\"]' - version, = version_file_parser(file_with_version_string, reg) + (version,) = version_file_parser(file_with_version_string, reg) return version From c0e2bb6503606b4b12e5a225eba797e159267486 Mon Sep 17 00:00:00 2001 From: konstantinos Date: Sat, 28 May 2022 06:45:21 +0300 Subject: [PATCH 03/18] build(pyproject.toml): add PyInquirer '>= 1.0.3 and < 1.1.0' dependency: required by checkbox dialog --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 9ec426f2..d8803410 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,7 @@ click = "^8" cookiecutter = "^1.7.3" software-patterns = "^1.2.1" requests-futures = "^1.0.0" +PyInquirer = "^1.0.3" # A list of all of the optional dependencies, some of which are included in the From 081e0e9e3bb98de5b2b1b76c9d0b7fcb4c75285a Mon Sep 17 00:00:00 2001 From: konstantinos Date: Sat, 28 May 2022 06:51:19 +0300 Subject: [PATCH 04/18] feat(interpreter-selection): generate Project's CI config, from user's input python interpreters --- .../backend/interpreters_support.py | 51 ++++++++++++++ src/cookiecutter_python/backend/main.py | 19 +++++- src/cookiecutter_python/cookiecutter.json | 5 +- src/cookiecutter_python/handle/__init__.py | 0 .../handle/interpreters_support.py | 66 +++++++++++++++++++ .../hooks/pre_gen_project.py | 25 ++++++- .../.github/workflows/test.yaml | 3 +- 7 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 src/cookiecutter_python/backend/interpreters_support.py create mode 100644 src/cookiecutter_python/handle/__init__.py create mode 100644 src/cookiecutter_python/handle/interpreters_support.py diff --git a/src/cookiecutter_python/backend/interpreters_support.py b/src/cookiecutter_python/backend/interpreters_support.py new file mode 100644 index 00000000..2b3104c0 --- /dev/null +++ b/src/cookiecutter_python/backend/interpreters_support.py @@ -0,0 +1,51 @@ +import typing as t + + +InterpretersSequence = t.Sequence[str] + + +# TODO Improvement: use an Enum +# SUPPORTED = { +# 'py35', +# 'py36', +# 'py37', +# 'py38', +# 'py39', +# 'py310', +# 'py311', +# } + +SUPPORTED = { + '3.5', + '3.6', + '3.7', + '3.8', + '3.9', + '3.10', + '3.11', +} + + +def verify_input_interpreters(interpreters: InterpretersSequence) -> None: + user_interpreters_set = set(interpreters) + if len(user_interpreters_set) != len(interpreters): + raise InvalidInterpretersError("Found duplicate interpreters!") + + if not user_interpreters_set.issubset(SUPPORTED): + # not all user requested interpreters are included in the supported ones + raise InvalidInterpretersError( + "Unsupported interpreter given Error!\n" + \ + "Given interpreters: [{given}]\n".format(given=', '.join(interpreters)) + \ + "Supported interpreters: [{supported}]\n".format(supported=', '.join(SUPPORTED)) + \ + "Unsupported interpreters: [{unsupported}]".format(unsupported=', '.join(iter(unsupported_interpreters(interpreters)))) + ) + + +def unsupported_interpreters(interpreters: InterpretersSequence) -> t.Iterator[str]: + for interpreter in interpreters: + if interpreter not in SUPPORTED: + yield interpreter + + +class InvalidInterpretersError(Exception): + pass diff --git a/src/cookiecutter_python/backend/main.py b/src/cookiecutter_python/backend/main.py index bd8da14c..f24414e9 100644 --- a/src/cookiecutter_python/backend/main.py +++ b/src/cookiecutter_python/backend/main.py @@ -5,9 +5,13 @@ from cookiecutter_python.backend.check_pypi import check_pypi from cookiecutter_python.backend.check_pypi_handler import handler +from cookiecutter_python.handle.interpreters_support import ( + handle as get_interpreters +) from .cookiecutter_proxy import cookiecutter + logger = logging.getLogger(__name__) my_dir = os.path.dirname(os.path.realpath(os.path.abspath(__file__))) @@ -31,11 +35,24 @@ def generate( template: str = os.path.join(my_dir, '..') + # interpreters the user desires to have their package support + # interpreters = get_interpreters()['supported-interpreters'] + interpreters = get_interpreters() + + if extra_context: + new_context = dict(extra_context, **{ + 'interpreters': interpreters, + }) + else: + new_context = { + 'interpreters': interpreters, + } + project_dir = cookiecutter( template, checkout, no_input, - extra_context=extra_context, + extra_context=new_context, replay=replay, overwrite_if_exists=overwrite, output_dir=output_dir, diff --git a/src/cookiecutter_python/cookiecutter.json b/src/cookiecutter_python/cookiecutter.json index 9a20cd40..a3d2091b 100644 --- a/src/cookiecutter_python/cookiecutter.json +++ b/src/cookiecutter_python/cookiecutter.json @@ -12,5 +12,6 @@ "release_date": "{% now 'utc', '%Y-%m-%d' %}", "year": "{% now 'utc', '%Y' %}", "version": "0.0.1", - "initialize_git_repo": ["yes", "no"] -} \ No newline at end of file + "initialize_git_repo": ["yes", "no"], + "interpreters": {} +} diff --git a/src/cookiecutter_python/handle/__init__.py b/src/cookiecutter_python/handle/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/cookiecutter_python/handle/interpreters_support.py b/src/cookiecutter_python/handle/interpreters_support.py new file mode 100644 index 00000000..4e93ada0 --- /dev/null +++ b/src/cookiecutter_python/handle/interpreters_support.py @@ -0,0 +1,66 @@ +import typing as t + +from PyInquirer import prompt + + +INTERPRETERS_ATTR = 'interpreters' + + +class WithUserInterpreters(t.Protocol): + interpreters: t.Optional[t.Sequence[str]] + + +def handle(request: t.Optional[WithUserInterpreters] = None) -> t.Sequence[str]: + print('--- EEEEEE ------') + if request and hasattr(request, INTERPRETERS_ATTR): + return getattr(request, INTERPRETERS_ATTR) + interpreters = dialog() + + interpreter_aliases = [] + for name in interpreters['supported-interpreters']: + b = name.replace('py', '') + interpreter_aliases.append(b[0] + '.' + b[1:]) + return {'supported-interpreters': interpreter_aliases} + + +def dialog() -> t.Sequence[str]: + + return prompt([ + # Question 1 + { + 'type': 'checkbox', + 'name': 'supported-interpreters', + 'message': 'Select the python Interpreters you wish to support', + 'choices': [ + { + 'name': 'py35', + 'checked': False + }, + { + 'name': 'py36', + 'checked': True + }, + { + 'name': 'py37', + 'checked': True + }, + { + 'name': 'py38', + 'checked': True + }, + { + 'name': 'py39', + 'checked': True + }, + { + 'name': 'py310', + 'checked': True + }, + { + 'name': 'py311', + 'checked': False + }, + ] + }, + + ]) diff --git a/src/cookiecutter_python/hooks/pre_gen_project.py b/src/cookiecutter_python/hooks/pre_gen_project.py index 515a2bbd..1350cc15 100644 --- a/src/cookiecutter_python/hooks/pre_gen_project.py +++ b/src/cookiecutter_python/hooks/pre_gen_project.py @@ -5,6 +5,11 @@ build_input_verification, ) +from cookiecutter_python.backend.interpreters_support import ( + verify_input_interpreters, + InvalidInterpretersError, +) + def get_request(): # Templated Variables should be centralized here for easier inspection @@ -12,6 +17,16 @@ def get_request(): # due to the templated (dynamically injected) code in this file # the name the client code should use to import the generated package/module + from collections import OrderedDict + COOKIECUTTER = ( + OrderedDict() + ) # We init the variable to the same type that will be set in the next line. + COOKIECUTTER = {{ cookiecutter }} + interpreters = {{ cookiecutter.interpreters }} + + print('\n--- TEMPLATE VARS ---\n') + print('\ndata\n' + '\n'.join([f"{k}: {v}" for k, v in COOKIECUTTER.items()])) + module_name = '{{ cookiecutter.pkg_name }}' return type( @@ -21,6 +36,7 @@ def get_request(): 'module_name': module_name, 'pypi_package': module_name.replace('_', '-'), 'package_version_string': '{{ cookiecutter.version }}', + 'interpreters': interpreters['supported-interpreters'], }, ) @@ -50,13 +66,20 @@ def input_sanitization(request): raise InputValueError( f'ERROR: {request.package_version_string} is not a valid Semantic Version!' ) from error + + try: + verify_input_interpreters(request.interpreters) + except InvalidInterpretersError as error: + # TODO log maybe + raise error + print("Sanitized Input Variables :)") def hook_main(request): try: input_sanitization(request) - except InputValueError as error: + except (InputValueError, InvalidInterpretersError) as error: print(error) return 1 return 0 diff --git a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/.github/workflows/test.yaml b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/.github/workflows/test.yaml index e380f2e5..21505b4a 100644 --- a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/.github/workflows/test.yaml +++ b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/.github/workflows/test.yaml @@ -21,8 +21,7 @@ jobs: strategy: matrix: platform: [ubuntu-latest, macos-latest] - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] - + python-version: ["{{ cookiecutter.interpreters['supported-interpreters'] | join('", "')}}"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ "{{" }} matrix.python-version {{ "}}" }} From 411622b4f95a1e43e1b705ff40cf83ea79730f6c Mon Sep 17 00:00:00 2001 From: konstantinos Date: Sat, 28 May 2022 06:57:27 +0300 Subject: [PATCH 05/18] add support for --no-input to 'interpreter selection' --- src/cookiecutter_python/backend/main.py | 2 +- .../handle/interpreters_support.py | 74 ++++++++++--------- 2 files changed, 42 insertions(+), 34 deletions(-) diff --git a/src/cookiecutter_python/backend/main.py b/src/cookiecutter_python/backend/main.py index f24414e9..da976b43 100644 --- a/src/cookiecutter_python/backend/main.py +++ b/src/cookiecutter_python/backend/main.py @@ -37,7 +37,7 @@ def generate( # interpreters the user desires to have their package support # interpreters = get_interpreters()['supported-interpreters'] - interpreters = get_interpreters() + interpreters = get_interpreters(no_input=no_input) if extra_context: new_context = dict(extra_context, **{ diff --git a/src/cookiecutter_python/handle/interpreters_support.py b/src/cookiecutter_python/handle/interpreters_support.py index 4e93ada0..19a933f0 100644 --- a/src/cookiecutter_python/handle/interpreters_support.py +++ b/src/cookiecutter_python/handle/interpreters_support.py @@ -6,15 +6,53 @@ INTERPRETERS_ATTR = 'interpreters' +choices = [ + { + 'name': 'py35', + 'checked': False + }, + { + 'name': 'py36', + 'checked': True + }, + { + 'name': 'py37', + 'checked': True + }, + { + 'name': 'py38', + 'checked': True + }, + { + 'name': 'py39', + 'checked': True + }, + { + 'name': 'py310', + 'checked': True + }, + { + 'name': 'py311', + 'checked': False + }, +] + class WithUserInterpreters(t.Protocol): interpreters: t.Optional[t.Sequence[str]] -def handle(request: t.Optional[WithUserInterpreters] = None) -> t.Sequence[str]: - print('--- EEEEEE ------') +def handle( + request: t.Optional[WithUserInterpreters] = None, + no_input: bool =False, +) -> t.Sequence[str]: if request and hasattr(request, INTERPRETERS_ATTR): return getattr(request, INTERPRETERS_ATTR) - interpreters = dialog() + if no_input: + interpreters = {'supported-interpreters': [ + x['name'] for x in choices if x['checked'] + ]} + else: + interpreters = dialog() interpreter_aliases = [] for name in interpreters['supported-interpreters']: @@ -31,36 +69,6 @@ def dialog() -> t.Sequence[str]: 'type': 'checkbox', 'name': 'supported-interpreters', 'message': 'Select the python Interpreters you wish to support', - 'choices': [ - { - 'name': 'py35', - 'checked': False - }, - { - 'name': 'py36', - 'checked': True - }, - { - 'name': 'py37', - 'checked': True - }, - { - 'name': 'py38', - 'checked': True - }, - { - 'name': 'py39', - 'checked': True - }, - { - 'name': 'py310', - 'checked': True - }, - { - 'name': 'py311', - 'checked': False - }, - ] }, ]) From f3881b6910171153ae005f57ee0f94dbd6ce1c6c Mon Sep 17 00:00:00 2001 From: konstantinos Date: Sat, 28 May 2022 21:55:53 +0300 Subject: [PATCH 06/18] wip: tests failing only due to the pyinquirer --- pyproject.toml | 2 +- .../backend/interpreters_support.py | 11 +- src/cookiecutter_python/backend/main.py | 17 +- .../handle/interpreters_support.py | 78 ++-- .../hooks/pre_gen_project.py | 16 +- tests/conftest.py | 372 ++++++++++++------ tests/data/test_cookiecutter.json | 6 + tests/test_cli.py | 95 ++++- tests/test_prehook.py | 26 +- 9 files changed, 387 insertions(+), 236 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d8803410..bdd2d912 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,7 +96,7 @@ cookiecutter = "^1.7.3" software-patterns = "^1.2.1" requests-futures = "^1.0.0" PyInquirer = "^1.0.3" - +prompt-toolkit = "==1.0.14" # A list of all of the optional dependencies, some of which are included in the # below `extras`. They can be opted into by apps. diff --git a/src/cookiecutter_python/backend/interpreters_support.py b/src/cookiecutter_python/backend/interpreters_support.py index 2b3104c0..9fdddf3d 100644 --- a/src/cookiecutter_python/backend/interpreters_support.py +++ b/src/cookiecutter_python/backend/interpreters_support.py @@ -1,6 +1,5 @@ import typing as t - InterpretersSequence = t.Sequence[str] @@ -34,10 +33,12 @@ def verify_input_interpreters(interpreters: InterpretersSequence) -> None: if not user_interpreters_set.issubset(SUPPORTED): # not all user requested interpreters are included in the supported ones raise InvalidInterpretersError( - "Unsupported interpreter given Error!\n" + \ - "Given interpreters: [{given}]\n".format(given=', '.join(interpreters)) + \ - "Supported interpreters: [{supported}]\n".format(supported=', '.join(SUPPORTED)) + \ - "Unsupported interpreters: [{unsupported}]".format(unsupported=', '.join(iter(unsupported_interpreters(interpreters)))) + "Unsupported interpreter given Error!\n" + + "Given interpreters: [{given}]\n".format(given=', '.join(interpreters)) + + "Supported interpreters: [{supported}]\n".format(supported=', '.join(SUPPORTED)) + + "Unsupported interpreters: [{unsupported}]".format( + unsupported=', '.join(iter(unsupported_interpreters(interpreters))) + ) ) diff --git a/src/cookiecutter_python/backend/main.py b/src/cookiecutter_python/backend/main.py index da976b43..170a8757 100644 --- a/src/cookiecutter_python/backend/main.py +++ b/src/cookiecutter_python/backend/main.py @@ -5,13 +5,10 @@ from cookiecutter_python.backend.check_pypi import check_pypi from cookiecutter_python.backend.check_pypi_handler import handler -from cookiecutter_python.handle.interpreters_support import ( - handle as get_interpreters -) +from cookiecutter_python.handle.interpreters_support import handle as get_interpreters from .cookiecutter_proxy import cookiecutter - logger = logging.getLogger(__name__) my_dir = os.path.dirname(os.path.realpath(os.path.abspath(__file__))) @@ -30,6 +27,7 @@ def generate( directory=None, skip_if_file_exists=False, ) -> str: + print('______ DEBUG 1') # first request is started in background check_future, pkg_name = check_pypi(config_file, default_config) @@ -38,11 +36,14 @@ def generate( # interpreters the user desires to have their package support # interpreters = get_interpreters()['supported-interpreters'] interpreters = get_interpreters(no_input=no_input) - + print('______ DEBUG') if extra_context: - new_context = dict(extra_context, **{ - 'interpreters': interpreters, - }) + new_context = dict( + extra_context, + **{ + 'interpreters': interpreters, + } + ) else: new_context = { 'interpreters': interpreters, diff --git a/src/cookiecutter_python/handle/interpreters_support.py b/src/cookiecutter_python/handle/interpreters_support.py index 19a933f0..252a6dd1 100644 --- a/src/cookiecutter_python/handle/interpreters_support.py +++ b/src/cookiecutter_python/handle/interpreters_support.py @@ -2,73 +2,59 @@ from PyInquirer import prompt - INTERPRETERS_ATTR = 'interpreters' choices = [ - { - 'name': 'py35', - 'checked': False - }, - { - 'name': 'py36', - 'checked': True - }, - { - 'name': 'py37', - 'checked': True - }, - { - 'name': 'py38', - 'checked': True - }, - { - 'name': 'py39', - 'checked': True - }, - { - 'name': 'py310', - 'checked': True - }, - { - 'name': 'py311', - 'checked': False - }, + {'name': 'py35', 'checked': False}, + {'name': 'py36', 'checked': True}, + {'name': 'py37', 'checked': True}, + {'name': 'py38', 'checked': True}, + {'name': 'py39', 'checked': True}, + {'name': 'py310', 'checked': True}, + {'name': 'py311', 'checked': False}, ] + class WithUserInterpreters(t.Protocol): interpreters: t.Optional[t.Sequence[str]] def handle( request: t.Optional[WithUserInterpreters] = None, - no_input: bool =False, + no_input: bool = False, ) -> t.Sequence[str]: if request and hasattr(request, INTERPRETERS_ATTR): return getattr(request, INTERPRETERS_ATTR) if no_input: - interpreters = {'supported-interpreters': [ - x['name'] for x in choices if x['checked'] - ]} + interpreters = {'supported-interpreters': [x['name'] for x in choices if x['checked']]} else: - interpreters = dialog() + interpreters = dialog() + print('\nHANDLE:\n') + print(interpreters) + return { + 'supported-interpreters': transform_interpreters(interpreters) + } + +def transform_interpreters(interpreters: t.Sequence[str]) -> t.Sequence[str]: interpreter_aliases = [] - for name in interpreters['supported-interpreters']: + for name in interpreters: b = name.replace('py', '') - interpreter_aliases.append(b[0] + '.' + b[1:]) - return {'supported-interpreters': interpreter_aliases} + interpreter_aliases.append(b[0] + '.' + b[1:]) + return interpreter_aliases def dialog() -> t.Sequence[str]: - return prompt([ - # Question 1 - { - 'type': 'checkbox', - 'name': 'supported-interpreters', - 'message': 'Select the python Interpreters you wish to support', - }, - - ]) + return prompt( + [ + # Question 1 + { + 'type': 'checkbox', + 'name': 'supported-interpreters', + 'message': 'Select the python Interpreters you wish to support', + 'choices': choices, + }, + ] + ) diff --git a/src/cookiecutter_python/hooks/pre_gen_project.py b/src/cookiecutter_python/hooks/pre_gen_project.py index 1350cc15..bba31c38 100644 --- a/src/cookiecutter_python/hooks/pre_gen_project.py +++ b/src/cookiecutter_python/hooks/pre_gen_project.py @@ -4,10 +4,9 @@ InputValueError, build_input_verification, ) - from cookiecutter_python.backend.interpreters_support import ( - verify_input_interpreters, InvalidInterpretersError, + verify_input_interpreters, ) @@ -17,15 +16,9 @@ def get_request(): # due to the templated (dynamically injected) code in this file # the name the client code should use to import the generated package/module - from collections import OrderedDict - COOKIECUTTER = ( - OrderedDict() - ) # We init the variable to the same type that will be set in the next line. - COOKIECUTTER = {{ cookiecutter }} - interpreters = {{ cookiecutter.interpreters }} + print('\n--- Pre Hook Get Request') - print('\n--- TEMPLATE VARS ---\n') - print('\ndata\n' + '\n'.join([f"{k}: {v}" for k, v in COOKIECUTTER.items()])) + interpreters = {{ cookiecutter.interpreters }} module_name = '{{ cookiecutter.pkg_name }}' @@ -66,7 +59,6 @@ def input_sanitization(request): raise InputValueError( f'ERROR: {request.package_version_string} is not a valid Semantic Version!' ) from error - try: verify_input_interpreters(request.interpreters) except InvalidInterpretersError as error: @@ -87,6 +79,8 @@ def hook_main(request): def _main(): request = get_request() + # print(request) + # print('Computed Variables:\n{req}'.format(req=str(request))) return hook_main(request) diff --git a/tests/conftest.py b/tests/conftest.py index 85c29b70..1a869273 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,10 @@ import os from abc import ABC, abstractmethod -from typing import Callable +import typing as t import pytest -from software_patterns import SubclassRegistry -my_dir = os.path.dirname(os.path.realpath(os.path.abspath(__file__))) +my_dir = os.path.dirname(os.path.realpath(__file__)) class AbstractCLIResult(ABC): @@ -26,18 +25,16 @@ def stderr(self) -> str: @pytest.fixture -def production_template(): - import cookiecutter_python as cpp - - path = os.path.dirname(cpp.__file__) - return path +def production_template() -> str: + import cookiecutter_python + return os.path.dirname(cookiecutter_python.__file__) @pytest.fixture -def load_context_json(): +def load_context_json() -> t.Callable[[str], t.Dict]: import json - def _load_context_json(file_path: str) -> dict: + def _load_context_json(file_path: str) -> t.Dict: with open(file_path, 'r') as fp: data = json.load(fp) return data @@ -46,19 +43,35 @@ def _load_context_json(file_path: str) -> dict: @pytest.fixture -def test_context_file(): - MY_DIR = os.path.dirname(os.path.realpath(__file__)) - TEST_DATA_DIR = os.path.join(MY_DIR, 'data') - return os.path.join(TEST_DATA_DIR, 'test_cookiecutter.json') +def test_context_file() -> str: + return os.path.abspath(os.path.join( + my_dir, + 'data', + 'test_cookiecutter.json' + )) @pytest.fixture -def test_context(load_context_json, test_context_file) -> dict: +def test_context(load_context_json, test_context_file) -> t.Dict: return load_context_json(test_context_file) +class ProjectGenerationRequestData(t.Protocol): + template: str + destination: str + default_dict: t.Dict[str, t.Any] + extra_context: t.Optional[t.Dict[str, t.Any]] + + +@pytest.fixture +def production_templated_project(production_template) -> str: + return os.path.join(production_template, r'{{ cookiecutter.project_slug }}') + + @pytest.fixture -def test_project_generation_request(production_template, test_context, tmpdir): +def test_project_generation_request( + production_template, test_context, tmpdir) -> ProjectGenerationRequestData: + """Test data, holding information on how to invoke the cli for testing.""" return type( 'GenerationRequest', (), @@ -66,18 +79,31 @@ def test_project_generation_request(production_template, test_context, tmpdir): 'template': production_template, 'destination': tmpdir, 'default_dict': test_context, + 'extra_context': { + 'interpreters': { + 'supported-interpreters': [ + '3.6', + '3.7', + '3.8', + '3.9', + '3.10', + ] + } + }, }, ) @pytest.fixture -def generate_project(): +def generate_project() -> t.Callable[[ProjectGenerationRequestData], str]: from cookiecutter_python.backend import cookiecutter - def _generate_project(generate_request): + def _generate_project(generate_request: ProjectGenerationRequestData) -> str: + print('\nGENERATE REQ:\n', generate_request) return cookiecutter( generate_request.template, no_input=True, + extra_context=generate_request.extra_context, output_dir=generate_request.destination, overwrite_if_exists=True, default_config=generate_request.default_dict, @@ -86,11 +112,6 @@ def _generate_project(generate_request): return _generate_project -@pytest.fixture -def production_templated_project(production_template): - return os.path.join(production_template, r'{{ cookiecutter.project_slug }}') - - @pytest.fixture def project_dir( generate_project, test_project_generation_request, production_templated_project @@ -109,6 +130,204 @@ def project_dir( return proj_dir +@pytest.fixture +def emulated_production_cookiecutter_dict(production_template, test_context): + import json + + with open(os.path.join(production_template, "cookiecutter.json"), "r") as fp: + return dict(json.load(fp), **test_context) + + +class HookRequest(t.Protocol): + project_dir: t.Optional[str] + # TODO improvement: add key/value types + cookiecutter: t.Optional[t.Dict] + author: t.Optional[str] + author_email: t.Optional[str] + initialize_git_repo: t.Optional[bool] + interpreters: t.Optional[t.Dict] + + module_name: t.Optional[str] + pypi_package: t.Optional[str] + package_version_string: t.Optional[str] + + +class CreateRequestInterface(t.Protocol): + create: t.Callable[[str, t.Any], HookRequest] + +class SubclassRegistryType(t.Protocol): + registry: CreateRequestInterface + + +@pytest.fixture +def hook_request_new(emulated_production_cookiecutter_dict: t.Dict) -> SubclassRegistryType: + """Emulate the templated data used in the 'pre' and 'post' hooks scripts. + + Before and after the actual generation process (ie read the termplate files, + generate the output files, etc), there 2 scripts that run. The 'pre' script + (implemented as src/cookiecutter/hooks/pre_gen_project.py) and the 'post' + script (implemented as src/cookiecutter/hooks/post_gen_project.py) run + before and after the generation process respectively. + + These scripts are also templated! Consequently, similarly to the how the + templated package depends on the 'templated variables', the 'pre' and 'post' + scripts need a 'templating engine'. + + In our unit tests we do not run a 'templating engine' and thus it is + required to mock the templated variables, when testing the 'pre' or 'post' + script. + + This fixture provides an easily modified/extended infrastructure to mock all + the necessary 'template variables' mentioned above. + + Thus, when writing (unit) test cases for testing code in the 'pre' or 'post' + scripts (python modules) it is recommended to use this fixture to mock any + 'templated variables', according to your needs. + + Tip: + Templated variables typically appear in double curly braces: + ie {{ ... }}). + If the 'code under test' depends on any 'template variable', (ie if you + see code inside double curly braces), such as for example the common + '{{ cookiecutter }}', then it is recommended to use this fixture to mock + any required 'templated variable'. + + Returns: + [type]: [description] + """ + class SimpleHookRequest(object): + pass + + from software_patterns import SubclassRegistry + class BaseHookRequest(metaclass=SubclassRegistry): + pass + + @BaseHookRequest.register_as_subclass('pre') + class PreGenProjectRequest(SimpleHookRequest): + def __init__(self, **kwargs): + print('PreGenProjectRequest\n', kwargs) + self.module_name = kwargs.get('module_name', 'awesome_novelty_python_library') + self.pypi_package = kwargs.get('pypi_package', self.module_name.replace('_', '-')) + self.package_version_string = kwargs.get('package_version_string', '0.0.1') + self.interpreters = kwargs.get('interpreters', [ + '3.5', + '3.6', + '3.7', + '3.8', + '3.9', + '3.10', + '3.11', + ] + ) + + @BaseHookRequest.register_as_subclass('post') + class PostGenProjectRequest(SimpleHookRequest): + def __init__(self, **kwargs): + print('PostGenProjectRequest\n', kwargs) + self.project_dir = kwargs['project_dir'] + self.cookiecutter = kwargs.get( + 'cookiecutter', emulated_production_cookiecutter_dict + ) + self.author = kwargs.get('author', 'Konstantinos Lampridis') + self.author_email = kwargs.get('author_email', 'boromir674@hotmail.com') + self.initialize_git_repo = kwargs.get('initialize_git_repo', True) + + return type('RequestInfra', (), { + 'class_ref': SimpleHookRequest, + 'registry': BaseHookRequest, + }) + + +# creates a request when called +CreateRequestFunction = t.Callable[[t.Any], HookRequest] +# creates a callable, that when called creates a request +# CreateRequestFunctionCallback = t.Callable[[str], CreateRequestFunction] +class RequestFactoryType(t.Protocol): + pre: CreateRequestFunction + post: CreateRequestFunction + + +@pytest.fixture +def request_factory(hook_request_new) -> RequestFactoryType: + def create_request_function(type_id: str) -> CreateRequestFunction: + def _create_request(**kwargs): + print('\nDEBUG ---- ') + print('\n'.join([ + f"{k}: {v}" for k, v in kwargs.items() + ])) + _ = hook_request_new.registry.create(type_id, **kwargs) + print(_) + return _ + return _create_request + + return type( + 'RequestFactory', + (), + { + 'pre': create_request_function('pre'), + 'post': create_request_function('post'), + }, + ) + + +PythonType = t.Union[bool, str, None] + +@pytest.fixture +def generate_python_args() -> t.Callable[[t.Any], t.Sequence[t.Union[str, PythonType]]]: + """Get a list of objects that can be passed in the `generate` function. + + Returns a callable that upon invocation creates a list of objects suitable + for passing into the `generate` method. The callable accepts **kwargs that + allow to provide values to override the defaults. + + Returns: + callable: the callable that creates `generate` arguments lists + """ + class Args: + args = [ + ('--no-input', False), + ('--checkout', False), + ('--verbose', False), + ('--replay', False), + ('--overwrite', False), + ('--output-dir', '.'), + ( + '--config-file', + os.path.abspath(os.path.join(my_dir, '..', '.github', 'biskotaki.yaml')) + ), + ('--default-config', False), + ('--directory', None), + ('--skip-if-file-exists', False), + ] + + def __init__(self, **kwargs) -> None: + for k, v in Args.args: + setattr(self, k, kwargs.get(k, v)) + + def __iter__(self) -> t.Iterator[t.Tuple[str, PythonType]]: + return iter([(k, getattr(self, k)) for k, _ in Args.args]) + + def keys(self): + return iter([k for k, _ in iter(self)]) + + def parameters(*args, **kwargs) -> t.Sequence[t.Union[str, PythonType]]: + args_obj = Args(**kwargs) + from functools import reduce + + return ( + reduce( + lambda i, j: i + j, [[key, value] for key, value in iter(args_obj) if value] + ), + {}, + ) + + return parameters + + + + + + # HELPERS @pytest.fixture def get_cli_invocation(): @@ -136,7 +355,7 @@ def stdout(self) -> str: def stderr(self) -> str: return self._stderr - def get_callable(executable: str, *args, **kwargs) -> Callable[[], AbstractCLIResult]: + def get_callable(executable: str, *args, **kwargs) -> t.Callable[[], AbstractCLIResult]: def _callable() -> AbstractCLIResult: completed_process = subprocess.run( [executable] + list(args), env=kwargs.get('env', {}) @@ -343,108 +562,3 @@ def __call__(self, symbol_ref: str, module: str, **kwargs): ) return ObjectGetterAdapter() - - -@pytest.fixture -def emulated_production_cookiecutter_dict(production_template, test_context): - import json - - with open(os.path.join(production_template, "cookiecutter.json"), "r") as fp: - return dict(json.load(fp), **test_context) - - -@pytest.fixture -def hook_request_new(emulated_production_cookiecutter_dict): - class HookRequest(object): - pass - - class BaseHookRequest(metaclass=SubclassRegistry): - pass - - @BaseHookRequest.register_as_subclass('pre') - class PreGenProjectRequest(HookRequest): - def __init__(self, **kwargs): - self.module_name = kwargs.get('module_name', 'awesome_novelty_python_library') - self.pypi_package = kwargs.get('pypi_package', self.module_name.replace('_', '-')) - self.package_version_string = kwargs.get('package_version_string', '0.0.1') - - @BaseHookRequest.register_as_subclass('post') - class PostGenProjectRequest(HookRequest): - def __init__(self, **kwargs): - self.project_dir = kwargs['project_dir'] - self.cookiecutter = kwargs.get( - 'cookiecutter', emulated_production_cookiecutter_dict - ) - self.author = kwargs.get('author', 'Konstantinos Lampridis') - self.author_email = kwargs.get('author_email', 'boromir674@hotmail.com') - self.initialize_git_repo = kwargs.get('initialize_git_repo', True) - - return type('RequestInfra', (), {'class_ref': HookRequest, 'registry': BaseHookRequest}) - - -@pytest.fixture -def request_factory(hook_request_new): - def create_request_callback(type_id: str): - def _create_request(**kwargs): - return hook_request_new.registry.create(type_id, **kwargs) - - return _create_request - - return type( - 'RequestFactory', - (), - { - 'pre': create_request_callback('pre'), - 'post': create_request_callback('post'), - }, - ) - - -@pytest.fixture -def generate_python_args(): - """Get a list of objects that can be passed in the `generate` function. - - Returns a callable that upon invocation creates a list of objects suitable - for passing into the `generate` method. The callable accepts **kwargs that - allow to provide values to override the defaults. - - Returns: - callable: the callable that creates `generate` arguments lists - """ - - class Args: - args = [ - ('--no-input', False), - ('--checkout', False), - ('--verbose', False), - ('--replay', False), - ('--overwrite', False), - ('--output-dir', '.'), - ('--config-file', os.path.join(my_dir, '..', '.github', 'biskotaki.yaml')), - ('--default-config', False), - ('--directory', None), - ('--skip-if-file-exists', False), - ] - - def __init__(self, **kwargs) -> None: - for k, v in Args.args: - setattr(self, k, kwargs.get(k, v)) - - def __iter__(self): - return iter([(k, getattr(self, k)) for k, _ in Args.args]) - - def keys(self): - return iter([k for k, _ in iter(self)]) - - def parameters(*args, **kwargs): - args_obj = Args(**kwargs) - from functools import reduce - - return ( - reduce( - lambda i, j: i + j, [[key, value] for key, value in iter(args_obj) if value] - ), - {}, - ) - - return parameters diff --git a/tests/data/test_cookiecutter.json b/tests/data/test_cookiecutter.json index 520ccc66..e9bc43cb 100644 --- a/tests/data/test_cookiecutter.json +++ b/tests/data/test_cookiecutter.json @@ -14,6 +14,12 @@ "year": "2022", "version": "0.0.1", "initialize_git_repo": "yes", + "interpreters": { + "supported-interpreters": [ + "py38", + "py39" + ] + }, "_template": "." } } diff --git a/tests/test_cli.py b/tests/test_cli.py index 399c6e2b..4f1b6cc4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8,6 +8,7 @@ def main_command(): return main +@pytest.mark.network_bound @pytest.mark.runner_setup(mix_stderr=False) def test_cli(main_command, generate_python_args, isolated_cli_runner): args, kwargs = generate_python_args(output_dir='gen') @@ -25,11 +26,41 @@ def test_cli(main_command, generate_python_args, isolated_cli_runner): assert result.exit_code == 0 +# @pytest.fixture +# def mock_check_pypi(): +# def get_mock_check_pypi(answer: bool): +# return ( +# type( +# 'Future', +# (), +# { +# 'result': lambda: type( +# 'HttpResponse', +# (), +# { +# 'status_code': 200 if answer else 404, +# }, +# ) +# }, +# ), +# 'biskotaki', +# ) + +# return get_mock_check_pypi +import typing as t +class HttpResponseLike(t.Protocol): + status_code: int +class FutureLike(t.Protocol): + result: t.Callable[[], HttpResponseLike] + +CheckPypi = t.Callable[[str, str], t.Tuple[FutureLike, str]] + @pytest.fixture -def mock_check_pypi(): - def get_mock_check_pypi(answer: bool): - return ( - type( +def get_check_pypi_mock(): + # def check_pypi_mock(config_file: str, default_config: str) -> t.Tuple[FutureLike, str]: + + def a(emulated_success=True): + return type( 'Future', (), { @@ -37,26 +68,58 @@ def get_mock_check_pypi(answer: bool): 'HttpResponse', (), { - 'status_code': 200 if answer else 404, + 'status_code': 200 if emulated_success else 404, }, ) }, - ), - 'biskotaki', - ) + ) + def _get_check_pypi_mock(emulated_success: bool = True) -> FutureLike: + def check_pypi_mock(*args, **kwargs) -> t.Tuple[FutureLike, str]: + return ( + a(emulated_success=emulated_success), + 'biskotaki', + ) + return check_pypi_mock - return get_mock_check_pypi + # return ( + # , + # 'biskotaki', + # ) + + return _get_check_pypi_mock + + +@pytest.fixture +def mock_check_pypi( + get_object, + get_check_pypi_mock, +): + def get_generate_with_mocked_check_pypi(**overrides) -> t.Callable[..., t.Any]: # todo specify + """Mocks namespace and returns the 'generate' object.""" + return get_object('generate', 'cookiecutter_python.backend.main', + overrides=dict( + {"check_pypi": lambda: get_check_pypi_mock(emulated_success=True)}, **overrides)) + + return get_generate_with_mocked_check_pypi @pytest.mark.runner_setup(mix_stderr=False) -def test_cli_offline(get_object, mock_check_pypi, generate_python_args, isolated_cli_runner): - args, kwargs = generate_python_args(output_dir='gen') +def test_cli_offline( + get_object, + mock_check_pypi, generate_python_args, isolated_cli_runner +): + cli_main = get_object('main', 'cookiecutter_python.cli') - get_object( - 'generate', - 'cookiecutter_python.backend.main', - overrides={"check_pypi": lambda: lambda x, y: mock_check_pypi(True)}, - ) + _generate = mock_check_pypi() + + # cli_main = get_object('main', 'cookiecutter_python.cli', overrides=dict( + # {"get_request": lambda: mock_check_pypi}, **overrides + # ),) + # cli_main = get_object('main', 'cookiecutter_python.cli', overrides=dict( + # {"get_request": lambda: lambda: request_factory.pre()}, **overrides + # ),) + # args, kwargs = generate_python_args(output_dir='gen') + args, kwargs = generate_python_args() result = isolated_cli_runner.invoke( cli_main, args=args, diff --git a/tests/test_prehook.py b/tests/test_prehook.py index ebb2a234..dfe24389 100644 --- a/tests/test_prehook.py +++ b/tests/test_prehook.py @@ -51,6 +51,9 @@ def test_correct_module_name(correct_module_name, is_valid_python_module_name): @pytest.fixture def get_main_with_mocked_template(get_object, request_factory): + # def get_request() + + def get_pre_gen_hook_project_main(overrides={}): main_method = get_object( "_main", @@ -64,29 +67,12 @@ def get_pre_gen_hook_project_main(overrides={}): return get_pre_gen_hook_project_main -# def test_main(get_main_with_mocked_template): -# result = get_main_with_mocked_template( -# overrides={ -# # we mock the IS_PYTHON_PACKAGE callable, to avoid dependency on network -# # we also indicate the package name is NOT found already on pypi -# 'available_on_pypi': lambda: lambda x: None -# } -# )() -# assert result == 0 # 0 indicates successfull executions (as in a shell) - - -def test_main(get_main_with_mocked_template): - result = get_main_with_mocked_template()() +def test_main(get_main_with_mocked_template, request_factory): + main = get_main_with_mocked_template() + result = main() assert result == 0 # 0 indicates successfull executions (as in a shell) -# def test_main_without_ask_pypi_installed(get_main_with_mocked_template): -# def _is_registered_on_pypi(package_name: str): -# raise ImportError -# result = get_main_with_mocked_template(overrides={"is_registered_on_pypi": lambda: _is_registered_on_pypi})() -# assert result == 0 # 0 indicates successfull executions (as in a shell) - - 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")} From 516b2b18199a744c79623425e7adbb9fa8e30ff1 Mon Sep 17 00:00:00 2001 From: konstantinos Date: Mon, 30 May 2022 03:59:51 +0300 Subject: [PATCH 07/18] wip: properly integrate interpreters selection with --no-input --- .github/biskotaki.yaml | 1 + .../backend/cookiecutter_proxy.py | 48 +++++-- src/cookiecutter_python/backend/main.py | 127 +++++++++++++----- .../backend/user_config_proxy.py | 19 ++- src/cookiecutter_python/cli.py | 2 +- src/cookiecutter_python/cookiecutter.json | 11 +- .../handle/dialogs/__init__.py | 0 .../handle/dialogs/interpreters.py | 17 +++ .../handle/interpreters_support.py | 44 ++---- .../hooks/post_gen_project.py | 37 +++-- .../hooks/pre_gen_project.py | 15 ++- tests/conftest.py | 88 ++++++++---- tests/test_cli.py | 94 +++++-------- tests/test_post_hook.py | 30 +++-- 14 files changed, 334 insertions(+), 199 deletions(-) create mode 100644 src/cookiecutter_python/handle/dialogs/__init__.py create mode 100644 src/cookiecutter_python/handle/dialogs/interpreters.py diff --git a/.github/biskotaki.yaml b/.github/biskotaki.yaml index 175e6d24..27935492 100644 --- a/.github/biskotaki.yaml +++ b/.github/biskotaki.yaml @@ -10,3 +10,4 @@ default_context: github_username: boromir674 project_short_description: Project generated from the https://github.com/boromir674/cookiecutter-python-package/tree/master/src/cookiecutter_python cookiecutter initialize_git_repo: no + interpreters: {"supported-interpreters": ["3.6", "3.7", "3.8", "3.9", "3.10"]} diff --git a/src/cookiecutter_python/backend/cookiecutter_proxy.py b/src/cookiecutter_python/backend/cookiecutter_proxy.py index dfefc73c..d2c1deb6 100644 --- a/src/cookiecutter_python/backend/cookiecutter_proxy.py +++ b/src/cookiecutter_python/backend/cookiecutter_proxy.py @@ -9,6 +9,10 @@ __all__ = ['cookiecutter'] +# This sets the root logger to write to stdout (your console). +# Your script/app needs to call this somewhere at least once. +logging.basicConfig() + logger = logging.getLogger(__name__) my_dir = os.path.dirname(os.path.realpath(__file__)) @@ -19,26 +23,42 @@ class CookiecutterSubject(ProxySubject[str]): class CookiecutterProxy(Proxy[str]): + """Proxy to cookiecutter: 'from cookiecutter.main import cookiecutter'.""" + def request(self, *args, **kwargs) -> str: """[summary] Returns: str: [description] """ - logger.info( - 'Cookiecutter invocation: %s', - json.dumps( - { - 'args': '[{arg_values}]'.format( - arg_values=', '.join([f"'{str(x)}'" for x in args]) - ), - 'kwargs': '{{{key_value_pairs}}}'.format( - key_value_pairs=json.dumps({k: str(v) for k, v in kwargs.items()}) - ), - } - ), - ) - output_dir: str = super().request(*args, **kwargs) + print('Cookiecutter Proxy Request: %s', json.dumps({ + 'keyword_args': {k: str(v) for k, v in kwargs.items()}, + 'positional_args': [str(arg_value) for arg_value in args], + }, indent=2, sort_keys=True)) + # logger.debug('Cookiecutter Proxy Request: %s', json.dumps({ + # 'keyword_args': {k: str(v) for k, v in kwargs.items()}, + # 'positional_args': [str(arg_value) for arg_value in args], + # }, indent=2, sort_keys=True)) + # logger.info( + # 'Cookiecutter invocation: %s', + # json.dumps( + # { + # 'positional_args': '[{arg_values}]'.format( + # arg_values=', '.join([f"'{str(x)}'" for x in args]) + # ), + # 'kwargs': '{{{key_value_pairs}}}'.format( + # key_value_pairs=json.dumps({k: str(v) for k, v in kwargs.items()}) + # ), + # } + # ), + # ) + try: + output_dir: str = super().request(*args, **kwargs) + except KeyError as error: + print(error) + import inspect + print(inspect.signature(cookiecutter_main_handler)) + raise error return output_dir diff --git a/src/cookiecutter_python/backend/main.py b/src/cookiecutter_python/backend/main.py index 170a8757..3dc46aa4 100644 --- a/src/cookiecutter_python/backend/main.py +++ b/src/cookiecutter_python/backend/main.py @@ -1,17 +1,76 @@ import logging import os - -from requests.exceptions import ConnectionError +import sys +import json +import typing as t +from requests.exceptions import ConnectionError, JSONDecodeError from cookiecutter_python.backend.check_pypi import check_pypi from cookiecutter_python.backend.check_pypi_handler import handler -from cookiecutter_python.handle.interpreters_support import handle as get_interpreters from .cookiecutter_proxy import cookiecutter logger = logging.getLogger(__name__) -my_dir = os.path.dirname(os.path.realpath(os.path.abspath(__file__))) +my_dir = os.path.dirname(os.path.realpath(__file__)) + + +def load_yaml(config_file): + import io + import poyo + from cookiecutter.exceptions import InvalidConfiguration + with io.open(config_file, encoding='utf-8') as file_handle: + try: + yaml_dict = poyo.parse_string(file_handle.read()) + except poyo.exceptions.PoyoException as e: + raise InvalidConfiguration( + 'Unable to parse YAML file {}. Error: {}' ''.format(config_file, e) + ) + return yaml_dict + +def supported_interpreters(config_file, no_input) -> t.Sequence[str]: + if not no_input: + if not config_file: + print(sys.version_info) + print(sys.version_info < (3, 10)) + if sys.version_info < (3, 10): + # with cookie allowed/selected interpreters + return check_box_dialog() + else: + # TODO: with user's allowed/selected interpreters + # try running dialog with defaults loaded from users config + # except: check_box_dialog with cookiecutter defaults + + data = load_yaml(config_file) + context = data['default_context'] + try: + interpreters_data = json.loads(context['interpreters']) + return {'supported-interpreters': interpreters_data['supported-interpreters']} + except (KeyError, JSONDecodeError, Exception) as error: + print(error) + print("Could not read the expected 'interpreters' data format") + return check_box_dialog() + else: + if not config_file: + # TODO load cookiecutter and return interpreters + return None + else: + data = load_yaml(config_file) + context = data['default_context'] + try: + interpreters_data = json.loads(context['interpreters']) + return {'supported-interpreters': interpreters_data['supported-interpreters']} + except (KeyError, JSONDecodeError, Exception) as error: + print(error) + print("Could not read the expected 'interpreters' data format") + # TODO load cookiecutter and return interpreters + return None + + +def check_box_dialog(): + from cookiecutter_python.handle.interpreters_support import handle as get_interpreters + interpreters = get_interpreters() + return interpreters def generate( @@ -27,32 +86,38 @@ def generate( directory=None, skip_if_file_exists=False, ) -> str: - print('______ DEBUG 1') + print('Start Python Generator !') # first request is started in background - check_future, pkg_name = check_pypi(config_file, default_config) - - template: str = os.path.join(my_dir, '..') - - # interpreters the user desires to have their package support - # interpreters = get_interpreters()['supported-interpreters'] - interpreters = get_interpreters(no_input=no_input) - print('______ DEBUG') - if extra_context: - new_context = dict( - extra_context, - **{ + # check_future, pkg_name = check_pypi(config_file, default_config) + + template: str = os.path.abspath(os.path.join(my_dir, '..')) + + # we handle the interactive input from user here, since cookiecutter does + # not provide a user-friendly interface for (our use case) the + # 'interpreters' template variable + print(config_file, no_input) + interpreters = supported_interpreters(config_file, no_input) + print('Computed Interpreters to pass to Generator:', interpreters) + + if interpreters: # update extra_context + # supported interpreters supplied either from yaml or from user's input + if extra_context: + new_context = dict( + extra_context, + **{ + 'interpreters': interpreters, + } + ) + else: + new_context = { 'interpreters': interpreters, } - ) else: - new_context = { - 'interpreters': interpreters, - } - + new_context = extra_context project_dir = cookiecutter( template, - checkout, - no_input, + checkout=checkout, + no_input=no_input, extra_context=new_context, replay=replay, overwrite_if_exists=overwrite, @@ -64,13 +129,13 @@ def generate( skip_if_file_exists=skip_if_file_exists, ) - if pkg_name: - # eval future by waiting only if needed! - try: - handler(lambda x: check_future.result().status_code == 200)(pkg_name) - except ConnectionError as error: - raise CheckPypiError("Connection error while checking PyPi") from error - + # if pkg_name: + # # eval future by waiting only if needed! + # try: + # handler(lambda x: check_future.result().status_code == 200)(pkg_name) + # except ConnectionError as error: + # raise CheckPypiError("Connection error while checking PyPi") from error + print('Finished :)') return project_dir diff --git a/src/cookiecutter_python/backend/user_config_proxy.py b/src/cookiecutter_python/backend/user_config_proxy.py index a198b9bb..52f3377d 100644 --- a/src/cookiecutter_python/backend/user_config_proxy.py +++ b/src/cookiecutter_python/backend/user_config_proxy.py @@ -1,6 +1,7 @@ import os from typing import Any, MutableMapping - +import logging +import json from cookiecutter.config import get_user_config as cookie_get_config from software_patterns import Proxy, ProxySubject @@ -9,9 +10,17 @@ __all__ = ['get_user_config'] +logger = logging.getLogger(__name__) + + my_dir = os.path.dirname(os.path.realpath(__file__)) +# DEFAULT_CONFIG PROXY + +from cookiecutter.config import DEFAULT_CONFIG + + ReturnValueType = MutableMapping[str, Any] @@ -20,7 +29,13 @@ class GetUserConfigSubject(ProxySubject[ReturnValueType]): class GetUserConfigProxy(Proxy[ReturnValueType]): - pass + def request(self, *args, **kwargs): + print('\n---- GetUserConfigProxy ----') + logger.error('Get User Config Proxy Request: %s', json.dumps({ + 'keyword_args': {k: str(v) for k, v in kwargs.items()}, + 'positional_args': [str(arg_value) for arg_value in args], + }, indent=2, sort_keys=True)) + return super().request(*args, **kwargs) # Singleton and Adapter of Cookiecutter Proxy diff --git a/src/cookiecutter_python/cli.py b/src/cookiecutter_python/cli.py index 0fe1b313..ac0bbf21 100644 --- a/src/cookiecutter_python/cli.py +++ b/src/cookiecutter_python/cli.py @@ -106,7 +106,7 @@ def main( # TODO Improvement: add logging configuration # from cookiecutter.log import configure_logger # configure_logger(stream_level='DEBUG' if verbose else 'INFO', debug_file=debug_file) - + print(f'\nSanity Check\nno_input: {type(no_input)}, {no_input}') try: project: str = generate( checkout, diff --git a/src/cookiecutter_python/cookiecutter.json b/src/cookiecutter_python/cookiecutter.json index a3d2091b..32e1afbe 100644 --- a/src/cookiecutter_python/cookiecutter.json +++ b/src/cookiecutter_python/cookiecutter.json @@ -13,5 +13,14 @@ "year": "{% now 'utc', '%Y' %}", "version": "0.0.1", "initialize_git_repo": ["yes", "no"], - "interpreters": {} + "interpreters": { + "supported-interpreters": [ + "3.6", + "3.7", + "3.8", + "3.9", + "3.10", + "3.11" + ] + } } diff --git a/src/cookiecutter_python/handle/dialogs/__init__.py b/src/cookiecutter_python/handle/dialogs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/cookiecutter_python/handle/dialogs/interpreters.py b/src/cookiecutter_python/handle/dialogs/interpreters.py new file mode 100644 index 00000000..17d6185e --- /dev/null +++ b/src/cookiecutter_python/handle/dialogs/interpreters.py @@ -0,0 +1,17 @@ +import typing as t +from PyInquirer import prompt + + +def dialog(choices: t.Dict[str, t.Union[str, bool]]) -> t.Sequence[str]: + + return prompt( + [ + # Question 1 + { + 'type': 'checkbox', + 'name': 'supported-interpreters', + 'message': 'Select the python Interpreters you wish to support', + 'choices': choices, + }, + ] + ) diff --git a/src/cookiecutter_python/handle/interpreters_support.py b/src/cookiecutter_python/handle/interpreters_support.py index 252a6dd1..8e8772b4 100644 --- a/src/cookiecutter_python/handle/interpreters_support.py +++ b/src/cookiecutter_python/handle/interpreters_support.py @@ -1,9 +1,8 @@ import typing as t -from PyInquirer import prompt - INTERPRETERS_ATTR = 'interpreters' +from .dialogs.interpreters import dialog choices = [ {'name': 'py35', 'checked': False}, @@ -16,24 +15,20 @@ ] -class WithUserInterpreters(t.Protocol): - interpreters: t.Optional[t.Sequence[str]] +def handle() -> t.Sequence[str]: + """Hande request to create the 'supported interpreters' used in the Project generationfor the generate a project with supporting python interpreters. + Args: + request (t.Optional[WithUserInterpreters], optional): [description]. Defaults to None. + no_input (bool, optional): [description]. Defaults to False. -def handle( - request: t.Optional[WithUserInterpreters] = None, - no_input: bool = False, -) -> t.Sequence[str]: - if request and hasattr(request, INTERPRETERS_ATTR): - return getattr(request, INTERPRETERS_ATTR) - if no_input: - interpreters = {'supported-interpreters': [x['name'] for x in choices if x['checked']]} - else: - interpreters = dialog() - print('\nHANDLE:\n') - print(interpreters) + Returns: + t.Sequence[str]: [description] + """ return { - 'supported-interpreters': transform_interpreters(interpreters) + 'supported-interpreters': transform_interpreters( + dialog(choices)['supported-interpreters'] + ) } @@ -42,19 +37,6 @@ def transform_interpreters(interpreters: t.Sequence[str]) -> t.Sequence[str]: for name in interpreters: b = name.replace('py', '') interpreter_aliases.append(b[0] + '.' + b[1:]) + print('ALIASES:', interpreter_aliases) return interpreter_aliases - -def dialog() -> t.Sequence[str]: - - return prompt( - [ - # Question 1 - { - 'type': 'checkbox', - 'name': 'supported-interpreters', - 'message': 'Select the python Interpreters you wish to support', - 'choices': choices, - }, - ] - ) diff --git a/src/cookiecutter_python/hooks/post_gen_project.py b/src/cookiecutter_python/hooks/post_gen_project.py index 626d2945..fd5a73ee 100644 --- a/src/cookiecutter_python/hooks/post_gen_project.py +++ b/src/cookiecutter_python/hooks/post_gen_project.py @@ -11,7 +11,7 @@ PROJECT_DIRECTORY = os.path.realpath(os.path.curdir) -def get_templated_vars(): +def get_request(): # Templated Variables should be centralized here for easier inspection COOKIECUTTER = ( OrderedDict() @@ -115,8 +115,6 @@ def run(args: list, kwargs: dict): return { 'legacy': lambda project_dir: run(*python36_n_below_run_params(project_dir)), - # 'legacy': lambda project_dir: python36_n_below_run_params(project_dir), - # 'new': lambda project_dir: python37_n_above_run_params(project_dir), 'new': lambda project_dir: run(*python37_n_above_run_params(project_dir)), }[ {True: 'legacy', False: 'new'}[ @@ -144,18 +142,31 @@ def is_git_repo_clean(project_directory: str): return False -def main(request): +def _post_hook(): + print('\n --- POST GEN SCRIPT') + request = get_request() + print('Computed Templated Vars for Post Script') if request.initialize_git_repo: - initialize_git_repo(request.project_dir) - grant_basic_permissions(request.project_dir) - if not is_git_repo_clean(request.project_dir): - git_add(request.project_dir) - git_commit(request) + try: + initialize_git_repo(request.project_dir) + grant_basic_permissions(request.project_dir) + if not is_git_repo_clean(request.project_dir): + git_add(request.project_dir) + git_commit(request) + except Exception as error: + print(error) + print('ERROR in Post Script.\nExiting with 1') + return 1 + return 0 - print("Finished Python Generation !!") - sys.exit(0) + +def post_hook(): + sys.exit(_post_hook()) + + +def main(): + post_hook() if __name__ == "__main__": - REQUEST = get_templated_vars() - main(REQUEST) + main() diff --git a/src/cookiecutter_python/hooks/pre_gen_project.py b/src/cookiecutter_python/hooks/pre_gen_project.py index bba31c38..e8ca4214 100644 --- a/src/cookiecutter_python/hooks/pre_gen_project.py +++ b/src/cookiecutter_python/hooks/pre_gen_project.py @@ -1,4 +1,5 @@ import sys +import json from cookiecutter_python.backend.input_sanitization import ( InputValueError, @@ -17,9 +18,15 @@ def get_request(): # the name the client code should use to import the generated package/module print('\n--- Pre Hook Get Request') - - interpreters = {{ cookiecutter.interpreters }} - + from collections import OrderedDict + cookiecutter = OrderedDict() + cookiecutter = {{ cookiecutter }} + + print('\n', type(cookiecutter['interpreters'])) + interpreters = cookiecutter['interpreters'] + if type(interpreters) == str: # we assume it is json + interpreters = json.loads(interpreters) + cookiecutter['interpreters'] = interpreters module_name = '{{ cookiecutter.pkg_name }}' return type( @@ -80,7 +87,7 @@ def hook_main(request): def _main(): request = get_request() # print(request) - # print('Computed Variables:\n{req}'.format(req=str(request))) + print('Computed Variables:\n{req}'.format(req=str(request))) return hook_main(request) diff --git a/tests/conftest.py b/tests/conftest.py index 1a869273..65f986bc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -273,8 +273,14 @@ def _create_request(**kwargs): PythonType = t.Union[bool, str, None] @pytest.fixture -def generate_python_args() -> t.Callable[[t.Any], t.Sequence[t.Union[str, PythonType]]]: - """Get a list of objects that can be passed in the `generate` function. +def cli_invoker_params() -> t.Callable[[t.Any], t.Sequence[t.Union[str, PythonType]]]: + """Create parameters for running a test that invokes a cli program. + + Use to generate the cli (string) arguments (positional and optional), as + well other optional information to be passed into a 'cli test invocation' + function. + + Get a list of objects that can be passed in the `generate` function. Returns a callable that upon invocation creates a list of objects suitable for passing into the `generate` method. The callable accepts **kwargs that @@ -283,8 +289,16 @@ def generate_python_args() -> t.Callable[[t.Any], t.Sequence[t.Union[str, Python Returns: callable: the callable that creates `generate` arguments lists """ + from copy import deepcopy + from functools import reduce + from collections import OrderedDict + + CLIOverrideData = t.Optional[t.Dict[str, PythonType]] + class Args: - args = [ + args = [ # these flags and default values emulate the 'generate-python' + # cli (exception is the '--config-file' flag where we pass the + # biskotaki yaml by default, instead of None) ('--no-input', False), ('--checkout', False), ('--verbose', False), @@ -292,7 +306,7 @@ class Args: ('--overwrite', False), ('--output-dir', '.'), ( - '--config-file', + '--config-file', # biskotaki yaml as default instead of None os.path.abspath(os.path.join(my_dir, '..', '.github', 'biskotaki.yaml')) ), ('--default-config', False), @@ -300,34 +314,56 @@ class Args: ('--skip-if-file-exists', False), ] - def __init__(self, **kwargs) -> None: - for k, v in Args.args: - setattr(self, k, kwargs.get(k, v)) + def __init__(self, args_with_default: CLIOverrideData = None, **kwargs) -> None: + self.cli_defaults = OrderedDict(Args.args) + # self.map = OrderedDict(Args.args, **dict(args_with_default if args_with_default else {})) - def __iter__(self) -> t.Iterator[t.Tuple[str, PythonType]]: - return iter([(k, getattr(self, k)) for k, _ in Args.args]) - - def keys(self): - return iter([k for k, _ in iter(self)]) - - def parameters(*args, **kwargs) -> t.Sequence[t.Union[str, PythonType]]: - args_obj = Args(**kwargs) - from functools import reduce + if args_with_default is None: + self.map = deepcopy(self.cli_defaults) + else: + assert all([k in self.cli_defaults for k in args_with_default]) + self.map = OrderedDict(self.cli_defaults, **dict(args_with_default)) + assert [k for k in self.map] == [k for k, _ in Args.args] == [k for k in self.cli_defaults] + + def __iter__(self) -> t.Iterator[str]: + for cli_arg, default_value in self.map.items(): + if bool(default_value): + yield cli_arg + if type(self.cli_defaults[cli_arg]) != bool: + yield default_value + + def parameters( + optional_cli_args: CLIOverrideData = None + ) -> t.Tuple[t.Sequence[str], t.Dict]: + """Generate parameters for running a test that invokes a cli program. + + Parameters of a test that invokes a cli program are distinguished in two + types: + + - the actual cli parameters, as a list of strings + these would function the same as if the program was invoked in a + shell script or in an interactive console/terminal. + - optional information to be passed to the cli invoker as required + per test case, as **kwargs + + Generate, positional and/or optional (ie flags) cli arguments. + + Input kwargs can be used to overide the default values for the flags + specified in class Args (see above). + + Args: + optional_cli_args (CLIOverrideData, optional): cli optional + arguments to override. Defaults to None. - return ( - reduce( - lambda i, j: i + j, [[key, value] for key, value in iter(args_obj) if value] - ), - {}, - ) + Returns: + t.Tuple[t.Sequence[str], t.Dict]: the requested cli invoker test + parameters + """ + return list(Args(args_with_default=optional_cli_args)), {} return parameters - - - - # HELPERS @pytest.fixture def get_cli_invocation(): diff --git a/tests/test_cli.py b/tests/test_cli.py index 4f1b6cc4..d55655ba 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,21 +1,20 @@ import pytest -@pytest.fixture -def main_command(): - from cookiecutter_python.cli import main - - return main - - @pytest.mark.network_bound @pytest.mark.runner_setup(mix_stderr=False) -def test_cli(main_command, generate_python_args, isolated_cli_runner): - args, kwargs = generate_python_args(output_dir='gen') +def test_cli(cli_invoker_params, isolated_cli_runner): + from cookiecutter_python.cli import main + + args, kwargs = cli_invoker_params( + optional_cli_args={ + '--no-input': True, + } + ) assert type(kwargs) == dict assert type(args) == list result = isolated_cli_runner.invoke( - main_command, + main, args=args, input=None, env=None, @@ -26,40 +25,19 @@ def test_cli(main_command, generate_python_args, isolated_cli_runner): assert result.exit_code == 0 -# @pytest.fixture -# def mock_check_pypi(): -# def get_mock_check_pypi(answer: bool): -# return ( -# type( -# 'Future', -# (), -# { -# 'result': lambda: type( -# 'HttpResponse', -# (), -# { -# 'status_code': 200 if answer else 404, -# }, -# ) -# }, -# ), -# 'biskotaki', -# ) - -# return get_mock_check_pypi import typing as t class HttpResponseLike(t.Protocol): status_code: int class FutureLike(t.Protocol): result: t.Callable[[], HttpResponseLike] -CheckPypi = t.Callable[[str, str], t.Tuple[FutureLike, str]] +CheckPypiOutput = t.Tuple[FutureLike, str] -@pytest.fixture -def get_check_pypi_mock(): - # def check_pypi_mock(config_file: str, default_config: str) -> t.Tuple[FutureLike, str]: +CheckPypi = t.Callable[[str, str], CheckPypiOutput] - def a(emulated_success=True): +@pytest.fixture +def get_check_pypi_mock() -> t.Callable[[t.Optional[bool]], CheckPypi]: + def build_check_pypi_mock_output(emulated_success=True) -> FutureLike: return type( 'Future', (), @@ -73,27 +51,19 @@ def a(emulated_success=True): ) }, ) - def _get_check_pypi_mock(emulated_success: bool = True) -> FutureLike: - def check_pypi_mock(*args, **kwargs) -> t.Tuple[FutureLike, str]: + def _get_check_pypi_mock(emulated_success: bool = True) -> t.Callable[..., CheckPypiOutput]: + def check_pypi_mock(*args, **kwargs) -> CheckPypiOutput: return ( - a(emulated_success=emulated_success), + build_check_pypi_mock_output(emulated_success=emulated_success), 'biskotaki', ) return check_pypi_mock - # return ( - # , - # 'biskotaki', - # ) - return _get_check_pypi_mock @pytest.fixture -def mock_check_pypi( - get_object, - get_check_pypi_mock, -): +def mock_check_pypi(get_check_pypi_mock, get_object): def get_generate_with_mocked_check_pypi(**overrides) -> t.Callable[..., t.Any]: # todo specify """Mocks namespace and returns the 'generate' object.""" return get_object('generate', 'cookiecutter_python.backend.main', @@ -102,24 +72,23 @@ def get_generate_with_mocked_check_pypi(**overrides) -> t.Callable[..., t.Any]: return get_generate_with_mocked_check_pypi +# @pytest.fixture +# def cli_main(): +# try: -@pytest.mark.runner_setup(mix_stderr=False) -def test_cli_offline( - get_object, - mock_check_pypi, generate_python_args, isolated_cli_runner -): - cli_main = get_object('main', 'cookiecutter_python.cli') +# import sys +# @pytest.mark.skipif(sys.version_info < (3, 10), reason="requires python3.10 or higher") +# @pytest.mark.skipif(sys.version_info >= (3, 10), reason="requires python >= 3.6 or higher") +@pytest.mark.runner_setup(mix_stderr=False) +def test_cli_offline(mock_check_pypi, cli_invoker_params, isolated_cli_runner): + from cookiecutter_python.cli import main as cli_main _generate = mock_check_pypi() - # cli_main = get_object('main', 'cookiecutter_python.cli', overrides=dict( - # {"get_request": lambda: mock_check_pypi}, **overrides - # ),) - # cli_main = get_object('main', 'cookiecutter_python.cli', overrides=dict( - # {"get_request": lambda: lambda: request_factory.pre()}, **overrides - # ),) - # args, kwargs = generate_python_args(output_dir='gen') - args, kwargs = generate_python_args() + args, kwargs = cli_invoker_params(optional_cli_args={ + '--no-input': True, + }) + result = isolated_cli_runner.invoke( cli_main, args=args, @@ -129,4 +98,5 @@ def test_cli_offline( color=False, **kwargs, ) + print('OUT:\n', result.stdout) assert result.exit_code == 0 diff --git a/tests/test_post_hook.py b/tests/test_post_hook.py index d7ea1b44..3c8401b1 100644 --- a/tests/test_post_hook.py +++ b/tests/test_post_hook.py @@ -1,26 +1,28 @@ import pytest - + @pytest.fixture -def get_main_with_mocked_template(get_object, request_factory): +def get_post_gen_main(get_object, request_factory): + def mock_get_request(): + return request_factory.post( + project_dir="mock_project_folder", # TODO find out if we can use a temp dir + initialize_git_repo=False, + ) + def get_pre_gen_hook_project_main(overrides={}): main_method = get_object( - "main", + "_post_hook", "cookiecutter_python.hooks.post_gen_project", + overrides=overrides if overrides else { + 'get_request': lambda: mock_get_request + } ) - return lambda: main_method( - request_factory.post( - project_dir="dummy_folder", # TODO find out if we can use a temp dir - initialize_git_repo=False, - ) - ) + return main_method return get_pre_gen_hook_project_main -def test_main(get_main_with_mocked_template): - try: - result = get_main_with_mocked_template()() - except SystemExit as error: - result = error.code +def test_main(get_post_gen_main): + post_hook_main = get_post_gen_main() + result = post_hook_main() assert result == 0 From fc7e6c6422e8a609522c30febcebae9c17a46d32 Mon Sep 17 00:00:00 2001 From: konstantinos Date: Mon, 30 May 2022 21:23:47 +0300 Subject: [PATCH 08/18] test(generate): write scenarios with/without 'config file' and with/without given 'interpreters' --- tests/conftest.py | 83 ++++++++++++++++--- .../data/biskotaki-without-interpreters.yaml | 12 +++ tests/test_cli.py | 38 --------- tests/test_generate.py | 61 ++++++++++++++ tests/test_prehook.py | 4 +- 5 files changed, 145 insertions(+), 53 deletions(-) create mode 100644 tests/data/biskotaki-without-interpreters.yaml create mode 100644 tests/test_generate.py diff --git a/tests/conftest.py b/tests/conftest.py index 65f986bc..a513f3c3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,6 +56,11 @@ def test_context(load_context_json, test_context_file) -> t.Dict: return load_context_json(test_context_file) +@pytest.fixture +def production_templated_project(production_template) -> str: + return os.path.join(production_template, r'{{ cookiecutter.project_slug }}') + + class ProjectGenerationRequestData(t.Protocol): template: str destination: str @@ -63,14 +68,9 @@ class ProjectGenerationRequestData(t.Protocol): extra_context: t.Optional[t.Dict[str, t.Any]] -@pytest.fixture -def production_templated_project(production_template) -> str: - return os.path.join(production_template, r'{{ cookiecutter.project_slug }}') - - @pytest.fixture def test_project_generation_request( - production_template, test_context, tmpdir) -> ProjectGenerationRequestData: + production_template, tmpdir) -> ProjectGenerationRequestData: """Test data, holding information on how to invoke the cli for testing.""" return type( 'GenerationRequest', @@ -78,15 +78,13 @@ def test_project_generation_request( { 'template': production_template, 'destination': tmpdir, - 'default_dict': test_context, + 'default_dict': False, 'extra_context': { 'interpreters': { 'supported-interpreters': [ - '3.6', '3.7', '3.8', '3.9', - '3.10', ] } }, @@ -99,13 +97,13 @@ def generate_project() -> t.Callable[[ProjectGenerationRequestData], str]: from cookiecutter_python.backend import cookiecutter def _generate_project(generate_request: ProjectGenerationRequestData) -> str: - print('\nGENERATE REQ:\n', generate_request) return cookiecutter( generate_request.template, no_input=True, extra_context=generate_request.extra_context, output_dir=generate_request.destination, overwrite_if_exists=True, + # TODO: below takes a boolean variable! default_config=generate_request.default_dict, ) @@ -127,15 +125,39 @@ def project_dir( assert set(expected_files) == set(runtime_files) assert len(expected_files) == len(runtime_files) assert all(['tox.ini' in x for x in (expected_files, runtime_files)]) + + p = os.path.abspath( os.path.join(proj_dir, '.github', 'workflows', 'test.yaml') ) + print(p) + with open(p, 'r') as f: + contents = f.read() + import re + ver = r'"3\.(?:[6789]|10|11)"' + # assert build matrix definition includes one or more python interpreters + assert re.search( # python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + fr'python-version:\s*\[\s*{ver}(?:(?:\s*,\s*{ver})*)\s*\]', contents) + + # assert that python interpreters are the expected ones given that we + # invoke the 'generate_project' function: + # no user yaml config & enabled the default_dict Flag! + b = ', '.join((f'"{int_ver}"' for int_ver in test_project_generation_request.extra_context['interpreters']['supported-interpreters'])) + assert f"python-version: [{b}]" in contents + assert 'python-version: ["3.7", "3.8", "3.9"]' in contents return proj_dir @pytest.fixture -def emulated_production_cookiecutter_dict(production_template, test_context): +def emulated_production_cookiecutter_dict(production_template, test_context) -> t.Mapping: + """Equivalent to the {{ cookiecutter }} templated variable runtime value. + + Returns: + t.Mapping: cookiecutter runtime configuration, as key/value hash map + """ import json + from collections import OrderedDict with open(os.path.join(production_template, "cookiecutter.json"), "r") as fp: - return dict(json.load(fp), **test_context) + data: OrderedDict = json.load(fp, object_pairs_hook=OrderedDict) + return OrderedDict(data, **test_context) class HookRequest(t.Protocol): @@ -158,6 +180,7 @@ class CreateRequestInterface(t.Protocol): class SubclassRegistryType(t.Protocol): registry: CreateRequestInterface +# Mock Infra @pytest.fixture def hook_request_new(emulated_production_cookiecutter_dict: t.Dict) -> SubclassRegistryType: @@ -269,6 +292,42 @@ def _create_request(**kwargs): }, ) +class HttpResponseLike(t.Protocol): + status_code: int +class FutureLike(t.Protocol): + result: t.Callable[[], HttpResponseLike] + +CheckPypiOutput = t.Tuple[FutureLike, str] + +CheckPypi = t.Callable[[str, str], CheckPypiOutput] + + +@pytest.fixture +def get_check_pypi_mock() -> t.Callable[[t.Optional[bool]], CheckPypi]: + def build_check_pypi_mock_output(emulated_success=True) -> FutureLike: + return type( + 'Future', + (), + { + 'result': lambda: type( + 'HttpResponse', + (), + { + 'status_code': 200 if emulated_success else 404, + }, + ) + }, + ) + def _get_check_pypi_mock(emulated_success: bool = True) -> t.Callable[..., CheckPypiOutput]: + def check_pypi_mock(*args, **kwargs) -> CheckPypiOutput: + return ( + build_check_pypi_mock_output(emulated_success=emulated_success), + 'biskotaki', + ) + return check_pypi_mock + + return _get_check_pypi_mock + PythonType = t.Union[bool, str, None] diff --git a/tests/data/biskotaki-without-interpreters.yaml b/tests/data/biskotaki-without-interpreters.yaml new file mode 100644 index 00000000..e9517469 --- /dev/null +++ b/tests/data/biskotaki-without-interpreters.yaml @@ -0,0 +1,12 @@ +default_context: + project_name: Biskotaki + project_slug: biskotaki + repo_name: biskotaki + pkg_name: biskotaki + full_name: Konstantinos Lampridis + author: Konstantinos Lampridis + email: k.lampridis@hotmail.com + author_email: k.lampridis@hotmail.com + github_username: boromir674 + project_short_description: Project entirely generated using https://github.com/boromir674/cookiecutter-python-package/ + initialize_git_repo: no diff --git a/tests/test_cli.py b/tests/test_cli.py index d55655ba..dc139cc1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -26,40 +26,6 @@ def test_cli(cli_invoker_params, isolated_cli_runner): import typing as t -class HttpResponseLike(t.Protocol): - status_code: int -class FutureLike(t.Protocol): - result: t.Callable[[], HttpResponseLike] - -CheckPypiOutput = t.Tuple[FutureLike, str] - -CheckPypi = t.Callable[[str, str], CheckPypiOutput] - -@pytest.fixture -def get_check_pypi_mock() -> t.Callable[[t.Optional[bool]], CheckPypi]: - def build_check_pypi_mock_output(emulated_success=True) -> FutureLike: - return type( - 'Future', - (), - { - 'result': lambda: type( - 'HttpResponse', - (), - { - 'status_code': 200 if emulated_success else 404, - }, - ) - }, - ) - def _get_check_pypi_mock(emulated_success: bool = True) -> t.Callable[..., CheckPypiOutput]: - def check_pypi_mock(*args, **kwargs) -> CheckPypiOutput: - return ( - build_check_pypi_mock_output(emulated_success=emulated_success), - 'biskotaki', - ) - return check_pypi_mock - - return _get_check_pypi_mock @pytest.fixture @@ -72,10 +38,6 @@ def get_generate_with_mocked_check_pypi(**overrides) -> t.Callable[..., t.Any]: return get_generate_with_mocked_check_pypi -# @pytest.fixture -# def cli_main(): -# try: - # import sys # @pytest.mark.skipif(sys.version_info < (3, 10), reason="requires python3.10 or higher") diff --git a/tests/test_generate.py b/tests/test_generate.py new file mode 100644 index 00000000..2b0aa9bd --- /dev/null +++ b/tests/test_generate.py @@ -0,0 +1,61 @@ +import typing as t +import pytest + + +@pytest.mark.parametrize('config_file, expected_interpreters', [ + ('.github/biskotaki.yaml', ['3.6', '3.7', '3.8', '3.9', '3.10']), + (None, ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11']), + ( + 'tests/data/biskotaki-without-interpreters.yaml', + ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11'] + ), +]) +def test_generate_with_mocked_network( + config_file: str, + expected_interpreters: t.Sequence[str], + get_object, + get_check_pypi_mock, + assert_interpreters_array_in_build_matrix, + tmpdir, +): + generate = get_object('generate', 'cookiecutter_python.backend.main', + overrides={"check_pypi": lambda: get_check_pypi_mock(emulated_success=True)}, + ) + project_dir: str = generate( + checkout=None, + no_input=True, + extra_context=None, + replay=False, + overwrite=False, + output_dir=tmpdir, + config_file=config_file, + default_config=False, + password=None, + directory=None, + skip_if_file_exists=False, + ) + + assert_interpreters_array_in_build_matrix( + project_dir, expected_interpreters + ) + + + +# ASSERT Fictures + + +@pytest.fixture +def assert_interpreters_array_in_build_matrix() -> t.Callable[[str, t.Sequence[str]], None]: + import os + def _assert_interpreters_array_in_build_matrix( + project_dir: str, + interpreters: t.Sequence[str], + ) -> None: + p = os.path.abspath(os.path.join(project_dir, '.github', 'workflows', + 'test.yaml') ) + with open(p, 'r') as f: + contents = f.read() + + b = ', '.join((f'"{int_ver}"' for int_ver in interpreters)) + assert f"python-version: [{b}]" in contents + return _assert_interpreters_array_in_build_matrix \ No newline at end of file diff --git a/tests/test_prehook.py b/tests/test_prehook.py index dfe24389..ecc7b02f 100644 --- a/tests/test_prehook.py +++ b/tests/test_prehook.py @@ -51,8 +51,6 @@ def test_correct_module_name(correct_module_name, is_valid_python_module_name): @pytest.fixture def get_main_with_mocked_template(get_object, request_factory): - # def get_request() - def get_pre_gen_hook_project_main(overrides={}): main_method = get_object( @@ -67,7 +65,7 @@ def get_pre_gen_hook_project_main(overrides={}): return get_pre_gen_hook_project_main -def test_main(get_main_with_mocked_template, request_factory): +def test_main(get_main_with_mocked_template): main = get_main_with_mocked_template() result = main() assert result == 0 # 0 indicates successfull executions (as in a shell) From 8d640c58735cff06aeb26dfd32b6d3e39683ae23 Mon Sep 17 00:00:00 2001 From: konstantinos Date: Mon, 30 May 2022 22:36:06 +0300 Subject: [PATCH 09/18] feat: populate cli defaults with user's yaml if found, for interactive run --- src/cookiecutter_python/backend/main.py | 56 +++++++++---------- .../handle/interpreters_support.py | 36 ++++-------- 2 files changed, 40 insertions(+), 52 deletions(-) diff --git a/src/cookiecutter_python/backend/main.py b/src/cookiecutter_python/backend/main.py index 3dc46aa4..f87d1c89 100644 --- a/src/cookiecutter_python/backend/main.py +++ b/src/cookiecutter_python/backend/main.py @@ -29,48 +29,48 @@ def load_yaml(config_file): return yaml_dict def supported_interpreters(config_file, no_input) -> t.Sequence[str]: - if not no_input: + if not no_input: # interactive if not config_file: print(sys.version_info) print(sys.version_info < (3, 10)) if sys.version_info < (3, 10): # with cookie allowed/selected interpreters return check_box_dialog() + # else let cookiecutter cli handle! else: # TODO: with user's allowed/selected interpreters # try running dialog with defaults loaded from users config # except: check_box_dialog with cookiecutter defaults + return check_box_dialog(config_file=config_file) - data = load_yaml(config_file) - context = data['default_context'] - try: - interpreters_data = json.loads(context['interpreters']) - return {'supported-interpreters': interpreters_data['supported-interpreters']} - except (KeyError, JSONDecodeError, Exception) as error: - print(error) - print("Could not read the expected 'interpreters' data format") - return check_box_dialog() - else: - if not config_file: - # TODO load cookiecutter and return interpreters + else: # non-interactive + if not config_file: # use cookiecutter.json for values return None else: data = load_yaml(config_file) context = data['default_context'] - try: + try: # use user's config yaml for values interpreters_data = json.loads(context['interpreters']) return {'supported-interpreters': interpreters_data['supported-interpreters']} except (KeyError, JSONDecodeError, Exception) as error: print(error) print("Could not read the expected 'interpreters' data format") - # TODO load cookiecutter and return interpreters - return None + return None # use cookiecutter.json for values -def check_box_dialog(): +def check_box_dialog(config_file=None) -> t.Mapping[str, t.Sequence[str]]: from cookiecutter_python.handle.interpreters_support import handle as get_interpreters - interpreters = get_interpreters() - return interpreters + defaults = None + if config_file: + data = load_yaml(config_file) + context = data['default_context'] + try: # use user's config yaml for default values in checkbox dialog + interpreters_data = json.loads(context['interpreters']) + defaults = interpreters_data['supported-interpreters'] + except (KeyError, JSONDecodeError, Exception) as error: + print(error) + print("Could not find 'interpreters' in user's config yaml") + return get_interpreters(choices=defaults) def generate( @@ -88,16 +88,15 @@ def generate( ) -> str: print('Start Python Generator !') # first request is started in background - # check_future, pkg_name = check_pypi(config_file, default_config) + check_future, pkg_name = check_pypi(config_file, default_config) template: str = os.path.abspath(os.path.join(my_dir, '..')) # we handle the interactive input from user here, since cookiecutter does # not provide a user-friendly interface for (our use case) the # 'interpreters' template variable - print(config_file, no_input) interpreters = supported_interpreters(config_file, no_input) - print('Computed Interpreters to pass to Generator:', interpreters) + print('Interpreters Data:', interpreters) if interpreters: # update extra_context # supported interpreters supplied either from yaml or from user's input @@ -114,6 +113,7 @@ def generate( } else: new_context = extra_context + project_dir = cookiecutter( template, checkout=checkout, @@ -129,12 +129,12 @@ def generate( skip_if_file_exists=skip_if_file_exists, ) - # if pkg_name: - # # eval future by waiting only if needed! - # try: - # handler(lambda x: check_future.result().status_code == 200)(pkg_name) - # except ConnectionError as error: - # raise CheckPypiError("Connection error while checking PyPi") from error + if pkg_name: + # eval future by waiting only if needed! + try: + handler(lambda x: check_future.result().status_code == 200)(pkg_name) + except ConnectionError as error: + raise CheckPypiError("Connection error while checking PyPi") from error print('Finished :)') return project_dir diff --git a/src/cookiecutter_python/handle/interpreters_support.py b/src/cookiecutter_python/handle/interpreters_support.py index 8e8772b4..bd999040 100644 --- a/src/cookiecutter_python/handle/interpreters_support.py +++ b/src/cookiecutter_python/handle/interpreters_support.py @@ -4,18 +4,19 @@ from .dialogs.interpreters import dialog -choices = [ - {'name': 'py35', 'checked': False}, - {'name': 'py36', 'checked': True}, - {'name': 'py37', 'checked': True}, - {'name': 'py38', 'checked': True}, - {'name': 'py39', 'checked': True}, - {'name': 'py310', 'checked': True}, - {'name': 'py311', 'checked': False}, +CHOICES = [ # this should match the cookiecutter.json +# TODO Improvement: dynamically read from cookiecutter.json + {'name': '3.6', 'checked': True}, + {'name': '3.7', 'checked': True}, + {'name': '3.8', 'checked': True}, + {'name': '3.9', 'checked': True}, + {'name': '3.10', 'checked': True}, + {'name': '3.11', 'checked': False}, + {'name': '3.12', 'checked': False}, ] -def handle() -> t.Sequence[str]: +def handle(choices: t.Optional[t.Sequence[str]] = None) -> t.Sequence[str]: """Hande request to create the 'supported interpreters' used in the Project generationfor the generate a project with supporting python interpreters. Args: @@ -25,18 +26,5 @@ def handle() -> t.Sequence[str]: Returns: t.Sequence[str]: [description] """ - return { - 'supported-interpreters': transform_interpreters( - dialog(choices)['supported-interpreters'] - ) - } - - -def transform_interpreters(interpreters: t.Sequence[str]) -> t.Sequence[str]: - interpreter_aliases = [] - for name in interpreters: - b = name.replace('py', '') - interpreter_aliases.append(b[0] + '.' + b[1:]) - print('ALIASES:', interpreter_aliases) - return interpreter_aliases - + return {'supported-interpreters': dialog( + [{'name': version, 'checked': True} for version in choices] if choices else CHOICES)['supported-interpreters']} From d4b9708e6f6fa6da900a081d7805afc68fb794f1 Mon Sep 17 00:00:00 2001 From: konstantinos Date: Tue, 31 May 2022 00:16:16 +0300 Subject: [PATCH 10/18] test(invalid-module-name): verify 'pre gen' script sxits with 1 --- src/cookiecutter_python/backend/main.py | 34 ++++++++++--------------- tests/test_prehook.py | 5 ++++ 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/cookiecutter_python/backend/main.py b/src/cookiecutter_python/backend/main.py index f87d1c89..282c67ec 100644 --- a/src/cookiecutter_python/backend/main.py +++ b/src/cookiecutter_python/backend/main.py @@ -34,45 +34,37 @@ def supported_interpreters(config_file, no_input) -> t.Sequence[str]: print(sys.version_info) print(sys.version_info < (3, 10)) if sys.version_info < (3, 10): - # with cookie allowed/selected interpreters return check_box_dialog() # else let cookiecutter cli handle! else: - # TODO: with user's allowed/selected interpreters - # try running dialog with defaults loaded from users config - # except: check_box_dialog with cookiecutter defaults return check_box_dialog(config_file=config_file) else: # non-interactive if not config_file: # use cookiecutter.json for values return None else: - data = load_yaml(config_file) - context = data['default_context'] - try: # use user's config yaml for values - interpreters_data = json.loads(context['interpreters']) - return {'supported-interpreters': interpreters_data['supported-interpreters']} - except (KeyError, JSONDecodeError, Exception) as error: - print(error) - print("Could not read the expected 'interpreters' data format") - return None # use cookiecutter.json for values + return get_interpreters_from_yaml(config_file) def check_box_dialog(config_file=None) -> t.Mapping[str, t.Sequence[str]]: from cookiecutter_python.handle.interpreters_support import handle as get_interpreters defaults = None if config_file: - data = load_yaml(config_file) - context = data['default_context'] - try: # use user's config yaml for default values in checkbox dialog - interpreters_data = json.loads(context['interpreters']) - defaults = interpreters_data['supported-interpreters'] - except (KeyError, JSONDecodeError, Exception) as error: - print(error) - print("Could not find 'interpreters' in user's config yaml") + defaults = get_interpreters_from_yaml(config_file)['supported-interpreters'] return get_interpreters(choices=defaults) +def get_interpreters_from_yaml(config_file: str) -> t.Optional[t.Mapping[str, t.Sequence[str]]]: + data = load_yaml(config_file) + context = data['default_context'] + try: # use user's config yaml for default values in checkbox dialog + interpreters_data = json.loads(context['interpreters']) + return {'supported-interpreters': interpreters_data['supported-interpreters']} + except (KeyError, JSONDecodeError, Exception) as error: + print(error) + print("Could not find 'interpreters' in user's config yaml") + + def generate( checkout=None, no_input=False, diff --git a/tests/test_prehook.py b/tests/test_prehook.py index ecc7b02f..8cfb165a 100644 --- a/tests/test_prehook.py +++ b/tests/test_prehook.py @@ -49,6 +49,11 @@ def test_correct_module_name(correct_module_name, is_valid_python_module_name): assert result == True +def test_incorrect_module_name(is_valid_python_module_name): + result = is_valid_python_module_name('23numpy') + assert result == False + + @pytest.fixture def get_main_with_mocked_template(get_object, request_factory): From 56f3c7f48ebf22d8495d9a6ac68534655298c1b2 Mon Sep 17 00:00:00 2001 From: konstantinos Date: Tue, 31 May 2022 00:16:41 +0300 Subject: [PATCH 11/18] dev(tox): add env for integration testing --- tox.ini | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tox.ini b/tox.ini index 591e9855..d61bad35 100644 --- a/tox.ini +++ b/tox.ini @@ -92,6 +92,19 @@ description = "Install in 'edit' mode & Test" usedevelop = true +# INTEGRATION TEST +[testenv:{py311, py310, py39, py38, py37, py36, pypy3}-integration{, -linux, -macos, -windows}] +description = "Integration Testing in 'edit' mode" +deps = tox +usedevelop = true +commands = + pytest -ra --cov --cov-report=term-missing \ + --cov-report=html:{envdir}/htmlcov --cov-context=test \ + --cov-report=xml:{toxworkdir}/coverage.{envname}.xml \ + {posargs:-n auto} tests --run-integration --run-network_bound + + + [testenv:coverage] description = combine coverage from test environments passenv = From 3f1b139fe33b9c68cbd5caad3ec84c33717a5b23 Mon Sep 17 00:00:00 2001 From: konstantinos Date: Tue, 31 May 2022 20:33:01 +0300 Subject: [PATCH 12/18] release(semantic_version): bump version to 1.3.0 --- README.rst | 4 ++-- pyproject.toml | 2 +- src/cookiecutter_python/__init__.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 51434a41..e2bfef52 100755 --- a/README.rst +++ b/README.rst @@ -196,9 +196,9 @@ For more complex use cases, you can modify the Template and also leverage all of .. Github Releases & Tags -.. |commits_since_specific_tag_on_master| image:: https://img.shields.io/github/commits-since/boromir674/cookiecutter-python-package/v1.2.1/master?color=blue&logo=github +.. |commits_since_specific_tag_on_master| image:: https://img.shields.io/github/commits-since/boromir674/cookiecutter-python-package/v1.3.0/master?color=blue&logo=github :alt: GitHub commits since tagged version (branch) - :target: https://github.com/boromir674/cookiecutter-python-package/compare/v1.2.1..master + :target: https://github.com/boromir674/cookiecutter-python-package/compare/v1.3.0..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/pyproject.toml b/pyproject.toml index bdd2d912..90e81ce5 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.2.1" +version = "1.3.0" description = "Yet another modern Python Package (pypi) with emphasis in CI/CD and automation." authors = ["Konstantinos Lampridis "] maintainers = ["Konstantinos Lampridis "] diff --git a/src/cookiecutter_python/__init__.py b/src/cookiecutter_python/__init__.py index 3f262a63..19b4f1d6 100755 --- a/src/cookiecutter_python/__init__.py +++ b/src/cookiecutter_python/__init__.py @@ -1 +1 @@ -__version__ = '1.2.1' +__version__ = '1.3.0' From ea1ccb84d7df39aff10921663b7038cf80114094 Mon Sep 17 00:00:00 2001 From: konstantinos Date: Tue, 31 May 2022 20:41:50 +0300 Subject: [PATCH 13/18] docs: update changelog with the release's changes --- CHANGELOG.rst | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3d9cbc81..de3b93a7 100755 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,69 @@ Changelog ========= +1.3.0 (2022-05-31) +================== + +Python Interpreters Support and Test +------------------------------------ + +This release allows the user to select the Python Interpreters they wish their Project +to support and be tested on. +The generator then creates the Test Matrix in the CI config file, which factors in the +Python Interpreter versions supplied by the user. + +Consistent with the currect behaviour of the cli, passing the '--no-input' flag, +instructs the Generator to try find the selected interpreters in a config yaml file, +if given, or else to use the information in the cookiecutter.json. + +If the '--no-input' flag is missing, then the user is asked for input, through +their console. +The input is read by supplying an interactive console dialog, which allows the user to +easily select the interpreters they wish to support, by enabling or disabling +'check boxes' through their console. + +Development +----------- + +All tox environments related to 'Linting' now all do by default a 'check'. +Doing a 'check' means returning a 0 as exit code in case the check is successfull +and it is suitable for local and remote running on a CI server. + +The aforementioned environments are 'lint', 'black', 'isort': +- tox -e lint +- tox -e black +- tox -e isort + +Optionally, running as below will modify the source code to comply with +each respective 'lint check'. + +Running environment 'lint', 'black', 'isort' with 'lint apply' enabled: +- *APPLY_LINT= tox -e lint* +- *APPLY_BLACK= tox -e black* +- *APPLY_ISORT= tox -e isort* + +Changes +^^^^^^^ + +feature +""""""" +- generate the Project's CI Test Workflow with a build matrix based on the user's input python interpreters + +test +"""" +- verify 'pre gen' script exits with 1 in case module name given is incorrect +- write scenarios with/without 'config file' and with/without given 'interpreters' + +development +""""""""""" +- add env for integration testing +- add checks for 'scripts' dir, make 'black', 'isort' cmds only do 'lint-check' by default and add switch to allow doing 'lint-apply' + +build +""""" +- add PyInquirer '>= 1.0.3 and < 1.1.0' dependency: required by checkbox dialog + + 1.2.1 (2022-05-27) ================== From 53a2cd9fe2b03cba31868f60913f40dfc8285cbd Mon Sep 17 00:00:00 2001 From: konstantinos Date: Wed, 1 Jun 2022 13:12:00 +0300 Subject: [PATCH 14/18] refactor: fix types and apply linting --- .../backend/cookiecutter_proxy.py | 16 +- src/cookiecutter_python/backend/main.py | 97 +++++++++++-- .../backend/user_config_proxy.py | 25 ++-- .../handle/dialogs/interpreters.py | 3 +- .../handle/interpreters_support.py | 11 +- .../hooks/pre_gen_project.py | 5 +- src/stubs/cookiecutter/exceptions.pyi | 1 + src/stubs/poyo/__init__.pyi | 10 ++ tests/conftest.py | 137 +++++++++++------- tests/test_cli.py | 21 ++- tests/test_generate.py | 38 ++--- tests/test_post_hook.py | 6 +- tests/test_prehook.py | 1 - 13 files changed, 250 insertions(+), 121 deletions(-) create mode 100644 src/stubs/cookiecutter/exceptions.pyi create mode 100644 src/stubs/poyo/__init__.pyi diff --git a/src/cookiecutter_python/backend/cookiecutter_proxy.py b/src/cookiecutter_python/backend/cookiecutter_proxy.py index d2c1deb6..43e5afaf 100644 --- a/src/cookiecutter_python/backend/cookiecutter_proxy.py +++ b/src/cookiecutter_python/backend/cookiecutter_proxy.py @@ -31,10 +31,17 @@ def request(self, *args, **kwargs) -> str: Returns: str: [description] """ - print('Cookiecutter Proxy Request: %s', json.dumps({ - 'keyword_args': {k: str(v) for k, v in kwargs.items()}, - 'positional_args': [str(arg_value) for arg_value in args], - }, indent=2, sort_keys=True)) + print( + 'Cookiecutter Proxy Request: %s', + json.dumps( + { + 'keyword_args': {k: str(v) for k, v in kwargs.items()}, + 'positional_args': [str(arg_value) for arg_value in args], + }, + indent=2, + sort_keys=True, + ), + ) # logger.debug('Cookiecutter Proxy Request: %s', json.dumps({ # 'keyword_args': {k: str(v) for k, v in kwargs.items()}, # 'positional_args': [str(arg_value) for arg_value in args], @@ -57,6 +64,7 @@ def request(self, *args, **kwargs) -> str: except KeyError as error: print(error) import inspect + print(inspect.signature(cookiecutter_main_handler)) raise error return output_dir diff --git a/src/cookiecutter_python/backend/main.py b/src/cookiecutter_python/backend/main.py index 282c67ec..9392112b 100644 --- a/src/cookiecutter_python/backend/main.py +++ b/src/cookiecutter_python/backend/main.py @@ -1,8 +1,10 @@ +import json import logging import os import sys -import json import typing as t + +from cookiecutter.exceptions import InvalidConfiguration from requests.exceptions import ConnectionError, JSONDecodeError from cookiecutter_python.backend.check_pypi import check_pypi @@ -15,10 +17,13 @@ my_dir = os.path.dirname(os.path.realpath(__file__)) -def load_yaml(config_file): +def load_yaml(config_file) -> t.Mapping: + # TODO use a proxy to load yaml import io + import poyo from cookiecutter.exceptions import InvalidConfiguration + with io.open(config_file, encoding='utf-8') as file_handle: try: yaml_dict = poyo.parse_string(file_handle.read()) @@ -28,7 +33,11 @@ def load_yaml(config_file): ) return yaml_dict -def supported_interpreters(config_file, no_input) -> t.Sequence[str]: + +GivenInterpreters = t.Mapping[str, t.Sequence[str]] + + +def supported_interpreters(config_file, no_input) -> t.Optional[GivenInterpreters]: if not no_input: # interactive if not config_file: print(sys.version_info) @@ -43,26 +52,76 @@ def supported_interpreters(config_file, no_input) -> t.Sequence[str]: if not config_file: # use cookiecutter.json for values return None else: - return get_interpreters_from_yaml(config_file) - + try: + return get_interpreters_from_yaml(config_file) + except ( + InvalidConfiguration, + UserConfigFormatError, + NoInterpretersInUserConfigException, + JSONDecodeError, + ): + return None + + +def check_box_dialog(config_file=None) -> GivenInterpreters: + from cookiecutter_python.handle.interpreters_support import ( + handle as get_interpreters, + ) -def check_box_dialog(config_file=None) -> t.Mapping[str, t.Sequence[str]]: - from cookiecutter_python.handle.interpreters_support import handle as get_interpreters defaults = None if config_file: - defaults = get_interpreters_from_yaml(config_file)['supported-interpreters'] + try: + defaults = get_interpreters_from_yaml(config_file)['supported-interpreters'] + except ( + InvalidConfiguration, + UserConfigFormatError, + NoInterpretersInUserConfigException, + JSONDecodeError, + ): + pass return get_interpreters(choices=defaults) -def get_interpreters_from_yaml(config_file: str) -> t.Optional[t.Mapping[str, t.Sequence[str]]]: +def get_interpreters_from_yaml(config_file: str) -> GivenInterpreters: + """Parse the 'interpreters' variable out of the user's config yaml file. + + Args: + config_file (str): path to the user's config yaml file + + Raises: + InvalidConfiguration: if yaml parser fails to load the user's config + UserConfigFormatError: if yaml doesn't contain the 'default_context' key + NoInterpretersInUserConfigException: if yaml doesn't contain the + 'interpreters' key, under the 'default_context' key + JSONDecodeError: if json parser fails to load the 'interpreters' value + + Returns: + GivenInterpreters: dictionary with intepreters as a sequence of strings, + mapped to the 'supported-interpreters' key + """ data = load_yaml(config_file) + if 'default_context' not in data: + raise UserConfigFormatError( + "User config (is valid yaml but) does not contain a 'default_context' outer key!" + ) context = data['default_context'] - try: # use user's config yaml for default values in checkbox dialog - interpreters_data = json.loads(context['interpreters']) - return {'supported-interpreters': interpreters_data['supported-interpreters']} - except (KeyError, JSONDecodeError, Exception) as error: - print(error) - print("Could not find 'interpreters' in user's config yaml") + if 'interpreters' not in context: + raise NoInterpretersInUserConfigException( + "No 'iterpreters' key found in user's config (under the 'default_context' key)." + ) + interpreters_data = json.loads(context['interpreters']) + return {'supported-interpreters': interpreters_data['supported-interpreters']} + # return interpreters_data['supported-interpreters'] + + # try: # use user's config yaml for default values in checkbox dialog + # interpreters_data = json.loads(context['interpreters']) + # except JSONDecodeError as error: + + # except (KeyError, TypeError, JSONDecodeError) as error: + # print(error) + # return {'supported-interpreters': interpreters_data['supported-interpreters']} + # print("Could not find 'interpreters' in user's config yaml") + # return None def generate( @@ -133,3 +192,11 @@ def generate( class CheckPypiError(Exception): pass + + +class UserConfigFormatError(Exception): + pass + + +class NoInterpretersInUserConfigException(Exception): + pass diff --git a/src/cookiecutter_python/backend/user_config_proxy.py b/src/cookiecutter_python/backend/user_config_proxy.py index 52f3377d..3333f196 100644 --- a/src/cookiecutter_python/backend/user_config_proxy.py +++ b/src/cookiecutter_python/backend/user_config_proxy.py @@ -1,7 +1,8 @@ +import json +import logging import os from typing import Any, MutableMapping -import logging -import json + from cookiecutter.config import get_user_config as cookie_get_config from software_patterns import Proxy, ProxySubject @@ -16,11 +17,6 @@ my_dir = os.path.dirname(os.path.realpath(__file__)) -# DEFAULT_CONFIG PROXY - -from cookiecutter.config import DEFAULT_CONFIG - - ReturnValueType = MutableMapping[str, Any] @@ -31,10 +27,17 @@ class GetUserConfigSubject(ProxySubject[ReturnValueType]): class GetUserConfigProxy(Proxy[ReturnValueType]): def request(self, *args, **kwargs): print('\n---- GetUserConfigProxy ----') - logger.error('Get User Config Proxy Request: %s', json.dumps({ - 'keyword_args': {k: str(v) for k, v in kwargs.items()}, - 'positional_args': [str(arg_value) for arg_value in args], - }, indent=2, sort_keys=True)) + logger.error( + 'Get User Config Proxy Request: %s', + json.dumps( + { + 'keyword_args': {k: str(v) for k, v in kwargs.items()}, + 'positional_args': [str(arg_value) for arg_value in args], + }, + indent=2, + sort_keys=True, + ), + ) return super().request(*args, **kwargs) diff --git a/src/cookiecutter_python/handle/dialogs/interpreters.py b/src/cookiecutter_python/handle/dialogs/interpreters.py index 17d6185e..15cae27c 100644 --- a/src/cookiecutter_python/handle/dialogs/interpreters.py +++ b/src/cookiecutter_python/handle/dialogs/interpreters.py @@ -1,8 +1,9 @@ import typing as t + from PyInquirer import prompt -def dialog(choices: t.Dict[str, t.Union[str, bool]]) -> t.Sequence[str]: +def dialog(choices: t.Dict[str, t.Union[str, bool]]) -> t.Dict[str, t.Sequence[str]]: return prompt( [ diff --git a/src/cookiecutter_python/handle/interpreters_support.py b/src/cookiecutter_python/handle/interpreters_support.py index bd999040..a71bf119 100644 --- a/src/cookiecutter_python/handle/interpreters_support.py +++ b/src/cookiecutter_python/handle/interpreters_support.py @@ -5,7 +5,7 @@ from .dialogs.interpreters import dialog CHOICES = [ # this should match the cookiecutter.json -# TODO Improvement: dynamically read from cookiecutter.json + # TODO Improvement: dynamically read from cookiecutter.json {'name': '3.6', 'checked': True}, {'name': '3.7', 'checked': True}, {'name': '3.8', 'checked': True}, @@ -16,7 +16,7 @@ ] -def handle(choices: t.Optional[t.Sequence[str]] = None) -> t.Sequence[str]: +def handle(choices: t.Optional[t.Sequence[str]] = None) -> t.Dict[str, t.Sequence[str]]: """Hande request to create the 'supported interpreters' used in the Project generationfor the generate a project with supporting python interpreters. Args: @@ -26,5 +26,8 @@ def handle(choices: t.Optional[t.Sequence[str]] = None) -> t.Sequence[str]: Returns: t.Sequence[str]: [description] """ - return {'supported-interpreters': dialog( - [{'name': version, 'checked': True} for version in choices] if choices else CHOICES)['supported-interpreters']} + return dialog( + [{'name': version, 'checked': True} for version in choices] if choices else CHOICES + ) + # return {'supported-interpreters': dialog( + # [{'name': version, 'checked': True} for version in choices] if choices else CHOICES)['supported-interpreters']} diff --git a/src/cookiecutter_python/hooks/pre_gen_project.py b/src/cookiecutter_python/hooks/pre_gen_project.py index e8ca4214..e1950c2f 100644 --- a/src/cookiecutter_python/hooks/pre_gen_project.py +++ b/src/cookiecutter_python/hooks/pre_gen_project.py @@ -1,5 +1,5 @@ -import sys import json +import sys from cookiecutter_python.backend.input_sanitization import ( InputValueError, @@ -19,8 +19,9 @@ def get_request(): # the name the client code should use to import the generated package/module print('\n--- Pre Hook Get Request') from collections import OrderedDict + cookiecutter = OrderedDict() - cookiecutter = {{ cookiecutter }} + cookiecutter = {{cookiecutter}} print('\n', type(cookiecutter['interpreters'])) interpreters = cookiecutter['interpreters'] diff --git a/src/stubs/cookiecutter/exceptions.pyi b/src/stubs/cookiecutter/exceptions.pyi new file mode 100644 index 00000000..70009c19 --- /dev/null +++ b/src/stubs/cookiecutter/exceptions.pyi @@ -0,0 +1 @@ +class InvalidConfiguration(Exception): ... diff --git a/src/stubs/poyo/__init__.pyi b/src/stubs/poyo/__init__.pyi new file mode 100644 index 00000000..32fcc897 --- /dev/null +++ b/src/stubs/poyo/__init__.pyi @@ -0,0 +1,10 @@ +import typing as t + +def parse_string(string: str) -> t.Dict: ... + +class PoyoExceptionStub(Exception): ... + +class ExceptionsAttribute(t.Protocol): + PoyoException: t.Type[PoyoExceptionStub] + +exceptions: ExceptionsAttribute diff --git a/tests/conftest.py b/tests/conftest.py index a513f3c3..081d2dab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,6 @@ import os -from abc import ABC, abstractmethod import typing as t +from abc import ABC, abstractmethod import pytest @@ -27,6 +27,7 @@ def stderr(self) -> str: @pytest.fixture def production_template() -> str: import cookiecutter_python + return os.path.dirname(cookiecutter_python.__file__) @@ -44,11 +45,7 @@ def _load_context_json(file_path: str) -> t.Dict: @pytest.fixture def test_context_file() -> str: - return os.path.abspath(os.path.join( - my_dir, - 'data', - 'test_cookiecutter.json' - )) + return os.path.abspath(os.path.join(my_dir, 'data', 'test_cookiecutter.json')) @pytest.fixture @@ -70,7 +67,8 @@ class ProjectGenerationRequestData(t.Protocol): @pytest.fixture def test_project_generation_request( - production_template, tmpdir) -> ProjectGenerationRequestData: + production_template, tmpdir +) -> ProjectGenerationRequestData: """Test data, holding information on how to invoke the cli for testing.""" return type( 'GenerationRequest', @@ -89,7 +87,7 @@ def test_project_generation_request( } }, }, - ) + )() @pytest.fixture @@ -126,20 +124,29 @@ def project_dir( assert len(expected_files) == len(runtime_files) assert all(['tox.ini' in x for x in (expected_files, runtime_files)]) - p = os.path.abspath( os.path.join(proj_dir, '.github', 'workflows', 'test.yaml') ) + p = os.path.abspath(os.path.join(proj_dir, '.github', 'workflows', 'test.yaml')) print(p) with open(p, 'r') as f: contents = f.read() import re + ver = r'"3\.(?:[6789]|10|11)"' # assert build matrix definition includes one or more python interpreters assert re.search( # python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] - fr'python-version:\s*\[\s*{ver}(?:(?:\s*,\s*{ver})*)\s*\]', contents) + fr'python-version:\s*\[\s*{ver}(?:(?:\s*,\s*{ver})*)\s*\]', contents + ) # assert that python interpreters are the expected ones given that we # invoke the 'generate_project' function: # no user yaml config & enabled the default_dict Flag! - b = ', '.join((f'"{int_ver}"' for int_ver in test_project_generation_request.extra_context['interpreters']['supported-interpreters'])) + b = ', '.join( + ( + f'"{int_ver}"' + for int_ver in test_project_generation_request.extra_context['interpreters'][ + 'supported-interpreters' + ] + ) + ) assert f"python-version: [{b}]" in contents assert 'python-version: ["3.7", "3.8", "3.9"]' in contents return proj_dir @@ -148,7 +155,7 @@ def project_dir( @pytest.fixture def emulated_production_cookiecutter_dict(production_template, test_context) -> t.Mapping: """Equivalent to the {{ cookiecutter }} templated variable runtime value. - + Returns: t.Mapping: cookiecutter runtime configuration, as key/value hash map """ @@ -177,11 +184,14 @@ class HookRequest(t.Protocol): class CreateRequestInterface(t.Protocol): create: t.Callable[[str, t.Any], HookRequest] + class SubclassRegistryType(t.Protocol): registry: CreateRequestInterface + # Mock Infra + @pytest.fixture def hook_request_new(emulated_production_cookiecutter_dict: t.Dict) -> SubclassRegistryType: """Emulate the templated data used in the 'pre' and 'post' hooks scripts. @@ -218,10 +228,12 @@ def hook_request_new(emulated_production_cookiecutter_dict: t.Dict) -> SubclassR Returns: [type]: [description] """ + class SimpleHookRequest(object): pass from software_patterns import SubclassRegistry + class BaseHookRequest(metaclass=SubclassRegistry): pass @@ -232,7 +244,9 @@ def __init__(self, **kwargs): self.module_name = kwargs.get('module_name', 'awesome_novelty_python_library') self.pypi_package = kwargs.get('pypi_package', self.module_name.replace('_', '-')) self.package_version_string = kwargs.get('package_version_string', '0.0.1') - self.interpreters = kwargs.get('interpreters', [ + self.interpreters = kwargs.get( + 'interpreters', + [ '3.5', '3.6', '3.7', @@ -240,7 +254,7 @@ def __init__(self, **kwargs): '3.9', '3.10', '3.11', - ] + ], ) @BaseHookRequest.register_as_subclass('post') @@ -255,14 +269,18 @@ def __init__(self, **kwargs): self.author_email = kwargs.get('author_email', 'boromir674@hotmail.com') self.initialize_git_repo = kwargs.get('initialize_git_repo', True) - return type('RequestInfra', (), { - 'class_ref': SimpleHookRequest, - 'registry': BaseHookRequest, - }) + return type( + 'RequestInfra', + (), + { + 'class_ref': SimpleHookRequest, + 'registry': BaseHookRequest, + }, + )() # creates a request when called -CreateRequestFunction = t.Callable[[t.Any], HookRequest] +CreateRequestFunction = t.Callable[..., HookRequest] # creates a callable, that when called creates a request # CreateRequestFunctionCallback = t.Callable[[str], CreateRequestFunction] class RequestFactoryType(t.Protocol): @@ -273,14 +291,9 @@ class RequestFactoryType(t.Protocol): @pytest.fixture def request_factory(hook_request_new) -> RequestFactoryType: def create_request_function(type_id: str) -> CreateRequestFunction: - def _create_request(**kwargs): - print('\nDEBUG ---- ') - print('\n'.join([ - f"{k}: {v}" for k, v in kwargs.items() - ])) - _ = hook_request_new.registry.create(type_id, **kwargs) - print(_) - return _ + def _create_request(self, **kwargs): + return hook_request_new.registry.create(type_id, **kwargs) + return _create_request return type( @@ -290,13 +303,17 @@ def _create_request(**kwargs): 'pre': create_request_function('pre'), 'post': create_request_function('post'), }, - ) + )() + class HttpResponseLike(t.Protocol): status_code: int + + class FutureLike(t.Protocol): result: t.Callable[[], HttpResponseLike] + CheckPypiOutput = t.Tuple[FutureLike, str] CheckPypi = t.Callable[[str, str], CheckPypiOutput] @@ -306,33 +323,40 @@ class FutureLike(t.Protocol): def get_check_pypi_mock() -> t.Callable[[t.Optional[bool]], CheckPypi]: def build_check_pypi_mock_output(emulated_success=True) -> FutureLike: return type( - 'Future', - (), - { - 'result': lambda: type( - 'HttpResponse', - (), - { - 'status_code': 200 if emulated_success else 404, - }, - ) - }, - ) - def _get_check_pypi_mock(emulated_success: bool = True) -> t.Callable[..., CheckPypiOutput]: + 'Future', + (), + { + 'result': lambda: type( + 'HttpResponse', + (), + { + 'status_code': 200 if emulated_success else 404, + }, + ) + }, + )() + + def _get_check_pypi_mock( + emulated_success: t.Optional[bool] = True, + ) -> t.Callable[..., CheckPypiOutput]: def check_pypi_mock(*args, **kwargs) -> CheckPypiOutput: return ( build_check_pypi_mock_output(emulated_success=emulated_success), 'biskotaki', ) + return check_pypi_mock return _get_check_pypi_mock PythonType = t.Union[bool, str, None] +CLIOverrideData = t.Optional[t.Dict[str, PythonType]] +CLIRunnerParameters = t.Tuple[t.Sequence[str], t.Dict[str, t.Any]] + @pytest.fixture -def cli_invoker_params() -> t.Callable[[t.Any], t.Sequence[t.Union[str, PythonType]]]: +def cli_invoker_params() -> t.Callable[[t.Any], CLIRunnerParameters]: """Create parameters for running a test that invokes a cli program. Use to generate the cli (string) arguments (positional and optional), as @@ -348,16 +372,13 @@ def cli_invoker_params() -> t.Callable[[t.Any], t.Sequence[t.Union[str, PythonTy Returns: callable: the callable that creates `generate` arguments lists """ - from copy import deepcopy - from functools import reduce from collections import OrderedDict - - CLIOverrideData = t.Optional[t.Dict[str, PythonType]] + from copy import deepcopy class Args: - args = [ # these flags and default values emulate the 'generate-python' - # cli (exception is the '--config-file' flag where we pass the - # biskotaki yaml by default, instead of None) + args = [ # these flags and default values emulate the 'generate-python' + # cli (exception is the '--config-file' flag where we pass the + # biskotaki yaml by default, instead of None) ('--no-input', False), ('--checkout', False), ('--verbose', False), @@ -366,7 +387,7 @@ class Args: ('--output-dir', '.'), ( '--config-file', # biskotaki yaml as default instead of None - os.path.abspath(os.path.join(my_dir, '..', '.github', 'biskotaki.yaml')) + os.path.abspath(os.path.join(my_dir, '..', '.github', 'biskotaki.yaml')), ), ('--default-config', False), ('--directory', None), @@ -382,23 +403,27 @@ def __init__(self, args_with_default: CLIOverrideData = None, **kwargs) -> None: else: assert all([k in self.cli_defaults for k in args_with_default]) self.map = OrderedDict(self.cli_defaults, **dict(args_with_default)) - assert [k for k in self.map] == [k for k, _ in Args.args] == [k for k in self.cli_defaults] + assert ( + [k for k in self.map] + == [k for k, _ in Args.args] + == [k for k in self.cli_defaults] + ) def __iter__(self) -> t.Iterator[str]: for cli_arg, default_value in self.map.items(): if bool(default_value): yield cli_arg if type(self.cli_defaults[cli_arg]) != bool: - yield default_value + yield str(default_value) def parameters( - optional_cli_args: CLIOverrideData = None - ) -> t.Tuple[t.Sequence[str], t.Dict]: + optional_cli_args: CLIOverrideData = None, + ) -> CLIRunnerParameters: """Generate parameters for running a test that invokes a cli program. - + Parameters of a test that invokes a cli program are distinguished in two types: - + - the actual cli parameters, as a list of strings these would function the same as if the program was invoked in a shell script or in an interactive console/terminal. diff --git a/tests/test_cli.py b/tests/test_cli.py index dc139cc1..0c96fdf5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -30,11 +30,17 @@ def test_cli(cli_invoker_params, isolated_cli_runner): @pytest.fixture def mock_check_pypi(get_check_pypi_mock, get_object): - def get_generate_with_mocked_check_pypi(**overrides) -> t.Callable[..., t.Any]: # todo specify + def get_generate_with_mocked_check_pypi( + **overrides, + ) -> t.Callable[..., t.Any]: # todo specify """Mocks namespace and returns the 'generate' object.""" - return get_object('generate', 'cookiecutter_python.backend.main', + return get_object( + 'generate', + 'cookiecutter_python.backend.main', overrides=dict( - {"check_pypi": lambda: get_check_pypi_mock(emulated_success=True)}, **overrides)) + {"check_pypi": lambda: get_check_pypi_mock(emulated_success=True)}, **overrides + ), + ) return get_generate_with_mocked_check_pypi @@ -45,11 +51,14 @@ def get_generate_with_mocked_check_pypi(**overrides) -> t.Callable[..., t.Any]: @pytest.mark.runner_setup(mix_stderr=False) def test_cli_offline(mock_check_pypi, cli_invoker_params, isolated_cli_runner): from cookiecutter_python.cli import main as cli_main - _generate = mock_check_pypi() - args, kwargs = cli_invoker_params(optional_cli_args={ + mock_check_pypi() + + args, kwargs = cli_invoker_params( + optional_cli_args={ '--no-input': True, - }) + } + ) result = isolated_cli_runner.invoke( cli_main, diff --git a/tests/test_generate.py b/tests/test_generate.py index 2b0aa9bd..82e35c1f 100644 --- a/tests/test_generate.py +++ b/tests/test_generate.py @@ -1,15 +1,19 @@ import typing as t + import pytest -@pytest.mark.parametrize('config_file, expected_interpreters', [ - ('.github/biskotaki.yaml', ['3.6', '3.7', '3.8', '3.9', '3.10']), - (None, ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11']), - ( - 'tests/data/biskotaki-without-interpreters.yaml', - ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11'] - ), -]) +@pytest.mark.parametrize( + 'config_file, expected_interpreters', + [ + ('.github/biskotaki.yaml', ['3.6', '3.7', '3.8', '3.9', '3.10']), + (None, ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11']), + ( + 'tests/data/biskotaki-without-interpreters.yaml', + ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11'], + ), + ], +) def test_generate_with_mocked_network( config_file: str, expected_interpreters: t.Sequence[str], @@ -18,7 +22,9 @@ def test_generate_with_mocked_network( assert_interpreters_array_in_build_matrix, tmpdir, ): - generate = get_object('generate', 'cookiecutter_python.backend.main', + generate = get_object( + 'generate', + 'cookiecutter_python.backend.main', overrides={"check_pypi": lambda: get_check_pypi_mock(emulated_success=True)}, ) project_dir: str = generate( @@ -35,10 +41,7 @@ def test_generate_with_mocked_network( skip_if_file_exists=False, ) - assert_interpreters_array_in_build_matrix( - project_dir, expected_interpreters - ) - + assert_interpreters_array_in_build_matrix(project_dir, expected_interpreters) # ASSERT Fictures @@ -47,15 +50,16 @@ def test_generate_with_mocked_network( @pytest.fixture def assert_interpreters_array_in_build_matrix() -> t.Callable[[str, t.Sequence[str]], None]: import os + def _assert_interpreters_array_in_build_matrix( project_dir: str, interpreters: t.Sequence[str], ) -> None: - p = os.path.abspath(os.path.join(project_dir, '.github', 'workflows', - 'test.yaml') ) + p = os.path.abspath(os.path.join(project_dir, '.github', 'workflows', 'test.yaml')) with open(p, 'r') as f: contents = f.read() - + b = ', '.join((f'"{int_ver}"' for int_ver in interpreters)) assert f"python-version: [{b}]" in contents - return _assert_interpreters_array_in_build_matrix \ No newline at end of file + + return _assert_interpreters_array_in_build_matrix diff --git a/tests/test_post_hook.py b/tests/test_post_hook.py index 3c8401b1..94e09d51 100644 --- a/tests/test_post_hook.py +++ b/tests/test_post_hook.py @@ -1,6 +1,6 @@ import pytest - + @pytest.fixture def get_post_gen_main(get_object, request_factory): def mock_get_request(): @@ -13,9 +13,7 @@ def get_pre_gen_hook_project_main(overrides={}): main_method = get_object( "_post_hook", "cookiecutter_python.hooks.post_gen_project", - overrides=overrides if overrides else { - 'get_request': lambda: mock_get_request - } + overrides=overrides if overrides else {'get_request': lambda: mock_get_request}, ) return main_method diff --git a/tests/test_prehook.py b/tests/test_prehook.py index 8cfb165a..4754d133 100644 --- a/tests/test_prehook.py +++ b/tests/test_prehook.py @@ -56,7 +56,6 @@ def test_incorrect_module_name(is_valid_python_module_name): @pytest.fixture def get_main_with_mocked_template(get_object, request_factory): - def get_pre_gen_hook_project_main(overrides={}): main_method = get_object( "_main", From 6081830143abcb82a142f14a13a311d793bb20eb Mon Sep 17 00:00:00 2001 From: konstantinos Date: Wed, 1 Jun 2022 14:40:18 +0300 Subject: [PATCH 15/18] refactor --- src/cookiecutter_python/backend/main.py | 63 ++++++++++--------------- tox.ini | 4 +- 2 files changed, 27 insertions(+), 40 deletions(-) diff --git a/src/cookiecutter_python/backend/main.py b/src/cookiecutter_python/backend/main.py index 9392112b..c68cf909 100644 --- a/src/cookiecutter_python/backend/main.py +++ b/src/cookiecutter_python/backend/main.py @@ -1,9 +1,11 @@ +import io import json import logging import os import sys import typing as t +import poyo from cookiecutter.exceptions import InvalidConfiguration from requests.exceptions import ConnectionError, JSONDecodeError @@ -19,11 +21,6 @@ def load_yaml(config_file) -> t.Mapping: # TODO use a proxy to load yaml - import io - - import poyo - from cookiecutter.exceptions import InvalidConfiguration - with io.open(config_file, encoding='utf-8') as file_handle: try: yaml_dict = poyo.parse_string(file_handle.read()) @@ -39,28 +36,22 @@ def load_yaml(config_file) -> t.Mapping: def supported_interpreters(config_file, no_input) -> t.Optional[GivenInterpreters]: if not no_input: # interactive - if not config_file: - print(sys.version_info) - print(sys.version_info < (3, 10)) - if sys.version_info < (3, 10): - return check_box_dialog() - # else let cookiecutter cli handle! - else: - return check_box_dialog(config_file=config_file) - - else: # non-interactive - if not config_file: # use cookiecutter.json for values - return None - else: - try: - return get_interpreters_from_yaml(config_file) - except ( - InvalidConfiguration, - UserConfigFormatError, - NoInterpretersInUserConfigException, - JSONDecodeError, - ): - return None + if sys.version_info < (3, 10): + check_box_dialog(config_file=config_file) + # else return None: let generator backend (ie cookiecutter) handle + # receiving the 'supported-interpreters' information from user input + # non-interactive + if config_file: + try: + return get_interpreters_from_yaml(config_file) + except ( + InvalidConfiguration, + UserConfigFormatError, + NoInterpretersInUserConfigException, + JSONDecodeError, + ): + pass + return None def check_box_dialog(config_file=None) -> GivenInterpreters: @@ -107,21 +98,15 @@ def get_interpreters_from_yaml(config_file: str) -> GivenInterpreters: context = data['default_context'] if 'interpreters' not in context: raise NoInterpretersInUserConfigException( - "No 'iterpreters' key found in user's config (under the 'default_context' key)." + "No 'interpreters' key found in user's config (under the 'default_context' key)." ) interpreters_data = json.loads(context['interpreters']) + if 'supported-interpreters' not in interpreters_data: + raise UserConfigFormatError( + "User config (is valid yaml but) does not contain a " + "'supported-interpreters' key in the 'interpreters' key" + ) return {'supported-interpreters': interpreters_data['supported-interpreters']} - # return interpreters_data['supported-interpreters'] - - # try: # use user's config yaml for default values in checkbox dialog - # interpreters_data = json.loads(context['interpreters']) - # except JSONDecodeError as error: - - # except (KeyError, TypeError, JSONDecodeError) as error: - # print(error) - # return {'supported-interpreters': interpreters_data['supported-interpreters']} - # print("Could not find 'interpreters' in user's config yaml") - # return None def generate( diff --git a/tox.ini b/tox.ini index d61bad35..15e4ae6b 100644 --- a/tox.ini +++ b/tox.ini @@ -141,10 +141,12 @@ commands = [testenv:type] description = Type checking with mypy +basepython = {env:TOXPYTHON:python3} extras = typing usedevelop = true changedir = {toxinidir} -commands = mypy --show-error-codes {posargs:src{/}{env:PY_PACKAGE}{/}hooks src{/}{env:PY_PACKAGE}{/}backend tests scripts} +commands = + mypy --show-error-codes {posargs:src{/}{env:PY_PACKAGE}{/}hooks src{/}{env:PY_PACKAGE}{/}backend tests scripts} ## DOCUMENTATION From d6849d05c5fbbaab43587da82b2af9b550d900ab Mon Sep 17 00:00:00 2001 From: konstantinos Date: Wed, 1 Jun 2022 15:17:57 +0300 Subject: [PATCH 16/18] refactor: apply lint --- .../backend/cookiecutter_proxy.py | 29 ++----------------- src/cookiecutter_python/backend/main.py | 9 ++---- .../handle/interpreters_support.py | 7 ++--- .../hooks/pre_gen_project.py | 7 ++--- tox.ini | 7 ++--- 5 files changed, 14 insertions(+), 45 deletions(-) diff --git a/src/cookiecutter_python/backend/cookiecutter_proxy.py b/src/cookiecutter_python/backend/cookiecutter_proxy.py index 43e5afaf..55ee0d16 100644 --- a/src/cookiecutter_python/backend/cookiecutter_proxy.py +++ b/src/cookiecutter_python/backend/cookiecutter_proxy.py @@ -31,7 +31,7 @@ def request(self, *args, **kwargs) -> str: Returns: str: [description] """ - print( + logger.debug( 'Cookiecutter Proxy Request: %s', json.dumps( { @@ -42,32 +42,7 @@ def request(self, *args, **kwargs) -> str: sort_keys=True, ), ) - # logger.debug('Cookiecutter Proxy Request: %s', json.dumps({ - # 'keyword_args': {k: str(v) for k, v in kwargs.items()}, - # 'positional_args': [str(arg_value) for arg_value in args], - # }, indent=2, sort_keys=True)) - # logger.info( - # 'Cookiecutter invocation: %s', - # json.dumps( - # { - # 'positional_args': '[{arg_values}]'.format( - # arg_values=', '.join([f"'{str(x)}'" for x in args]) - # ), - # 'kwargs': '{{{key_value_pairs}}}'.format( - # key_value_pairs=json.dumps({k: str(v) for k, v in kwargs.items()}) - # ), - # } - # ), - # ) - try: - output_dir: str = super().request(*args, **kwargs) - except KeyError as error: - print(error) - import inspect - - print(inspect.signature(cookiecutter_main_handler)) - raise error - return output_dir + return super().request(*args, **kwargs) # Singleton and Adapter of Cookiecutter Proxy diff --git a/src/cookiecutter_python/backend/main.py b/src/cookiecutter_python/backend/main.py index c68cf909..4053e3f8 100644 --- a/src/cookiecutter_python/backend/main.py +++ b/src/cookiecutter_python/backend/main.py @@ -24,10 +24,10 @@ def load_yaml(config_file) -> t.Mapping: with io.open(config_file, encoding='utf-8') as file_handle: try: yaml_dict = poyo.parse_string(file_handle.read()) - except poyo.exceptions.PoyoException as e: + except poyo.exceptions.PoyoException as error: raise InvalidConfiguration( - 'Unable to parse YAML file {}. Error: {}' ''.format(config_file, e) - ) + 'Unable to parse YAML file {}. Error: {}' ''.format(config_file, error) + ) from error return yaml_dict @@ -128,9 +128,6 @@ def generate( template: str = os.path.abspath(os.path.join(my_dir, '..')) - # we handle the interactive input from user here, since cookiecutter does - # not provide a user-friendly interface for (our use case) the - # 'interpreters' template variable interpreters = supported_interpreters(config_file, no_input) print('Interpreters Data:', interpreters) diff --git a/src/cookiecutter_python/handle/interpreters_support.py b/src/cookiecutter_python/handle/interpreters_support.py index a71bf119..2e024ca5 100644 --- a/src/cookiecutter_python/handle/interpreters_support.py +++ b/src/cookiecutter_python/handle/interpreters_support.py @@ -1,8 +1,9 @@ import typing as t +from .dialogs.interpreters import dialog + INTERPRETERS_ATTR = 'interpreters' -from .dialogs.interpreters import dialog CHOICES = [ # this should match the cookiecutter.json # TODO Improvement: dynamically read from cookiecutter.json @@ -17,7 +18,7 @@ def handle(choices: t.Optional[t.Sequence[str]] = None) -> t.Dict[str, t.Sequence[str]]: - """Hande request to create the 'supported interpreters' used in the Project generationfor the generate a project with supporting python interpreters. + """Get the 'supported interpreters' data, from user's input in interactive dialog. Args: request (t.Optional[WithUserInterpreters], optional): [description]. Defaults to None. @@ -29,5 +30,3 @@ def handle(choices: t.Optional[t.Sequence[str]] = None) -> t.Dict[str, t.Sequenc return dialog( [{'name': version, 'checked': True} for version in choices] if choices else CHOICES ) - # return {'supported-interpreters': dialog( - # [{'name': version, 'checked': True} for version in choices] if choices else CHOICES)['supported-interpreters']} diff --git a/src/cookiecutter_python/hooks/pre_gen_project.py b/src/cookiecutter_python/hooks/pre_gen_project.py index e1950c2f..c12dbf6b 100644 --- a/src/cookiecutter_python/hooks/pre_gen_project.py +++ b/src/cookiecutter_python/hooks/pre_gen_project.py @@ -1,5 +1,6 @@ import json import sys +from collections import OrderedDict from cookiecutter_python.backend.input_sanitization import ( InputValueError, @@ -18,16 +19,14 @@ def get_request(): # the name the client code should use to import the generated package/module print('\n--- Pre Hook Get Request') - from collections import OrderedDict cookiecutter = OrderedDict() - cookiecutter = {{cookiecutter}} + cookiecutter: OrderedDict = {{cookiecutter}} print('\n', type(cookiecutter['interpreters'])) interpreters = cookiecutter['interpreters'] - if type(interpreters) == str: # we assume it is json + if isinstance(interpreters, str): # we assume it is json interpreters = json.loads(interpreters) - cookiecutter['interpreters'] = interpreters module_name = '{{ cookiecutter.pkg_name }}' return type( diff --git a/tox.ini b/tox.ini index 15e4ae6b..816c5d2a 100644 --- a/tox.ini +++ b/tox.ini @@ -145,8 +145,7 @@ basepython = {env:TOXPYTHON:python3} extras = typing usedevelop = true changedir = {toxinidir} -commands = - mypy --show-error-codes {posargs:src{/}{env:PY_PACKAGE}{/}hooks src{/}{env:PY_PACKAGE}{/}backend tests scripts} +commands = mypy --show-error-codes {posargs:src{/}{env:PY_PACKAGE}{/}hooks src{/}{env:PY_PACKAGE}{/}backend tests scripts} ## DOCUMENTATION @@ -295,7 +294,8 @@ description = black ops deps = black skip_install = true changedir = {toxinidir} -commands = black {env:APPLY_BLACK:--check} -S --config pyproject.toml "{env:BLACK_ARGS}" +commands = black {posargs:{env:APPLY_BLACK:--check}} --skip-string-normalization \ + --config pyproject.toml "{env:BLACK_ARGS}" [testenv:isort] @@ -336,7 +336,6 @@ commands_post = python -c 'import os; f = ".pylintrc-bak"; exec("if os.path.exists(f):\n os.rename(f, \".pylintrc\")")' - ## GENERATE ARCHITECTURE GRAPHS [testenv:graphs] From 4348d5b161d19e8d27fe9e01949c309eb6bce673 Mon Sep 17 00:00:00 2001 From: konstantinos Date: Wed, 1 Jun 2022 15:26:12 +0300 Subject: [PATCH 17/18] ci(py36): fix type checking --- tests/conftest.py | 15 ++++++++------- tox.ini | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 081d2dab..dc89cb3c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ import os import typing as t from abc import ABC, abstractmethod +from typing import Protocol import pytest @@ -58,7 +59,7 @@ def production_templated_project(production_template) -> str: return os.path.join(production_template, r'{{ cookiecutter.project_slug }}') -class ProjectGenerationRequestData(t.Protocol): +class ProjectGenerationRequestData(Protocol): template: str destination: str default_dict: t.Dict[str, t.Any] @@ -167,7 +168,7 @@ def emulated_production_cookiecutter_dict(production_template, test_context) -> return OrderedDict(data, **test_context) -class HookRequest(t.Protocol): +class HookRequest(Protocol): project_dir: t.Optional[str] # TODO improvement: add key/value types cookiecutter: t.Optional[t.Dict] @@ -181,11 +182,11 @@ class HookRequest(t.Protocol): package_version_string: t.Optional[str] -class CreateRequestInterface(t.Protocol): +class CreateRequestInterface(Protocol): create: t.Callable[[str, t.Any], HookRequest] -class SubclassRegistryType(t.Protocol): +class SubclassRegistryType(Protocol): registry: CreateRequestInterface @@ -283,7 +284,7 @@ def __init__(self, **kwargs): CreateRequestFunction = t.Callable[..., HookRequest] # creates a callable, that when called creates a request # CreateRequestFunctionCallback = t.Callable[[str], CreateRequestFunction] -class RequestFactoryType(t.Protocol): +class RequestFactoryType(Protocol): pre: CreateRequestFunction post: CreateRequestFunction @@ -306,11 +307,11 @@ def _create_request(self, **kwargs): )() -class HttpResponseLike(t.Protocol): +class HttpResponseLike(Protocol): status_code: int -class FutureLike(t.Protocol): +class FutureLike(Protocol): result: t.Callable[[], HttpResponseLike] diff --git a/tox.ini b/tox.ini index 816c5d2a..320211c8 100644 --- a/tox.ini +++ b/tox.ini @@ -302,7 +302,7 @@ commands = black {posargs:{env:APPLY_BLACK:--check}} --skip-string-normalization description = isort deps = isort >= 5.0.0 skip_install = true -commands = isort {env:APPLY_ISORT:--check} {toxinidir} +commands = isort {posargs:{env:APPLY_ISORT:--check}} {toxinidir} ## Code Static Analysis From b16837614fe160ffa48f859f7a2079dbfe1136ca Mon Sep 17 00:00:00 2001 From: konstantinos Date: Wed, 1 Jun 2022 18:41:29 +0300 Subject: [PATCH 18/18] ci(type-check): fix type check for python 3.6 --- pyproject.toml | 1 + src/stubs/requests_futures/sessions.pyi | 8 +- tests/conftest.py | 175 +++++++++--------------- 3 files changed, 67 insertions(+), 117 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 90e81ce5..de3648bf 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,6 +107,7 @@ pytest-click = { version = "~= 1.1.0", optional = true } pytest-cov = { version = ">= 2.12", optional = true } pytest-explicit = { version = "~= 1.0.1", optional = true } pytest-xdist = { version = ">= 1.34", optional = true } +attrs = { version = "^21.4.0", optional = true } # Docs: development and build dependencies sphinx = { version = "~= 4.0", optional = true } diff --git a/src/stubs/requests_futures/sessions.pyi b/src/stubs/requests_futures/sessions.pyi index f2bdd098..db207b73 100644 --- a/src/stubs/requests_futures/sessions.pyi +++ b/src/stubs/requests_futures/sessions.pyi @@ -1,10 +1,10 @@ -from typing import Any, Protocol +from typing import Any -class Response(Protocol): +class Response: status_code: int -class Future(Protocol): +class Future: def result(self) -> Response: ... -class FuturesSession(Protocol): +class FuturesSession: def get(self, url: str, **kwargs: Any) -> Future: ... diff --git a/tests/conftest.py b/tests/conftest.py index dc89cb3c..55dd7882 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,8 @@ import os import typing as t from abc import ABC, abstractmethod -from typing import Protocol +import attr import pytest my_dir = os.path.dirname(os.path.realpath(__file__)) @@ -59,10 +59,11 @@ def production_templated_project(production_template) -> str: return os.path.join(production_template, r'{{ cookiecutter.project_slug }}') -class ProjectGenerationRequestData(Protocol): +@attr.s(auto_attribs=True, kw_only=True) +class ProjectGenerationRequestData: template: str destination: str - default_dict: t.Dict[str, t.Any] + default_dict: bool extra_context: t.Optional[t.Dict[str, t.Any]] @@ -71,24 +72,20 @@ def test_project_generation_request( production_template, tmpdir ) -> ProjectGenerationRequestData: """Test data, holding information on how to invoke the cli for testing.""" - return type( - 'GenerationRequest', - (), - { - 'template': production_template, - 'destination': tmpdir, - 'default_dict': False, - 'extra_context': { - 'interpreters': { - 'supported-interpreters': [ - '3.7', - '3.8', - '3.9', - ] - } - }, + return ProjectGenerationRequestData( + template=production_template, + destination=tmpdir, + default_dict=False, + extra_context={ + 'interpreters': { + 'supported-interpreters': [ + '3.7', + '3.8', + '3.9', + ] + } }, - )() + ) @pytest.fixture @@ -168,33 +165,41 @@ def emulated_production_cookiecutter_dict(production_template, test_context) -> return OrderedDict(data, **test_context) -class HookRequest(Protocol): - project_dir: t.Optional[str] - # TODO improvement: add key/value types - cookiecutter: t.Optional[t.Dict] - author: t.Optional[str] - author_email: t.Optional[str] - initialize_git_repo: t.Optional[bool] - interpreters: t.Optional[t.Dict] - - module_name: t.Optional[str] - pypi_package: t.Optional[str] - package_version_string: t.Optional[str] - - -class CreateRequestInterface(Protocol): - create: t.Callable[[str, t.Any], HookRequest] - - -class SubclassRegistryType(Protocol): - registry: CreateRequestInterface - +@pytest.fixture +def hook_request_class(emulated_production_cookiecutter_dict): + @attr.s(auto_attribs=True, kw_only=True) + class HookRequest: + project_dir: t.Optional[str] + # TODO improvement: add key/value types + cookiecutter: t.Optional[t.Dict] = attr.ib( + default=emulated_production_cookiecutter_dict + ) + 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=[ + '3.6', + '3.7', + '3.8', + '3.9', + '3.10', + '3.11', + ] + ) + module_name: t.Optional[str] = attr.ib(default='awesome_novelty_python_library') + pypi_package: t.Optional[str] = attr.ib( + default=attr.Factory( + lambda self: self.module_name.replace('_', '-'), takes_self=True + ) + ) + package_version_string: t.Optional[str] = attr.ib(default='0.0.1') -# Mock Infra + return HookRequest @pytest.fixture -def hook_request_new(emulated_production_cookiecutter_dict: t.Dict) -> SubclassRegistryType: +def hook_request_new(hook_request_class): """Emulate the templated data used in the 'pre' and 'post' hooks scripts. Before and after the actual generation process (ie read the termplate files, @@ -229,71 +234,28 @@ def hook_request_new(emulated_production_cookiecutter_dict: t.Dict) -> SubclassR Returns: [type]: [description] """ - - class SimpleHookRequest(object): - pass - from software_patterns import SubclassRegistry class BaseHookRequest(metaclass=SubclassRegistry): pass + @attr.s(auto_attribs=True, kw_only=True) @BaseHookRequest.register_as_subclass('pre') - class PreGenProjectRequest(SimpleHookRequest): - def __init__(self, **kwargs): - print('PreGenProjectRequest\n', kwargs) - self.module_name = kwargs.get('module_name', 'awesome_novelty_python_library') - self.pypi_package = kwargs.get('pypi_package', self.module_name.replace('_', '-')) - self.package_version_string = kwargs.get('package_version_string', '0.0.1') - self.interpreters = kwargs.get( - 'interpreters', - [ - '3.5', - '3.6', - '3.7', - '3.8', - '3.9', - '3.10', - '3.11', - ], - ) + class PreGenProjectRequest(hook_request_class): + project_dir: str = attr.ib(default=None) @BaseHookRequest.register_as_subclass('post') - class PostGenProjectRequest(SimpleHookRequest): - def __init__(self, **kwargs): - print('PostGenProjectRequest\n', kwargs) - self.project_dir = kwargs['project_dir'] - self.cookiecutter = kwargs.get( - 'cookiecutter', emulated_production_cookiecutter_dict - ) - self.author = kwargs.get('author', 'Konstantinos Lampridis') - self.author_email = kwargs.get('author_email', 'boromir674@hotmail.com') - self.initialize_git_repo = kwargs.get('initialize_git_repo', True) - - return type( - 'RequestInfra', - (), - { - 'class_ref': SimpleHookRequest, - 'registry': BaseHookRequest, - }, - )() - + class PostGenProjectRequest(hook_request_class): + pass -# creates a request when called -CreateRequestFunction = t.Callable[..., HookRequest] -# creates a callable, that when called creates a request -# CreateRequestFunctionCallback = t.Callable[[str], CreateRequestFunction] -class RequestFactoryType(Protocol): - pre: CreateRequestFunction - post: CreateRequestFunction + return BaseHookRequest @pytest.fixture -def request_factory(hook_request_new) -> RequestFactoryType: - def create_request_function(type_id: str) -> CreateRequestFunction: +def request_factory(hook_request_new): + def create_request_function(type_id: str): def _create_request(self, **kwargs): - return hook_request_new.registry.create(type_id, **kwargs) + return hook_request_new.create(type_id, **kwargs) return _create_request @@ -307,22 +269,10 @@ def _create_request(self, **kwargs): )() -class HttpResponseLike(Protocol): - status_code: int - - -class FutureLike(Protocol): - result: t.Callable[[], HttpResponseLike] - - -CheckPypiOutput = t.Tuple[FutureLike, str] - -CheckPypi = t.Callable[[str, str], CheckPypiOutput] - - @pytest.fixture -def get_check_pypi_mock() -> t.Callable[[t.Optional[bool]], CheckPypi]: - def build_check_pypi_mock_output(emulated_success=True) -> FutureLike: +def get_check_pypi_mock(): + def build_check_pypi_mock_output(emulated_success=True): + return type( 'Future', (), @@ -339,8 +289,8 @@ def build_check_pypi_mock_output(emulated_success=True) -> FutureLike: def _get_check_pypi_mock( emulated_success: t.Optional[bool] = True, - ) -> t.Callable[..., CheckPypiOutput]: - def check_pypi_mock(*args, **kwargs) -> CheckPypiOutput: + ): + def check_pypi_mock(*args, **kwargs): return ( build_check_pypi_mock_output(emulated_success=emulated_success), 'biskotaki', @@ -397,7 +347,6 @@ class Args: def __init__(self, args_with_default: CLIOverrideData = None, **kwargs) -> None: self.cli_defaults = OrderedDict(Args.args) - # self.map = OrderedDict(Args.args, **dict(args_with_default if args_with_default else {})) if args_with_default is None: self.map = deepcopy(self.cli_defaults)