From 08e15a27f09615a6629503479750235246a42934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Mon, 27 May 2024 18:26:42 -0300 Subject: [PATCH 1/3] Add pytest fixtures to be used in template development (Fix #29) --- cookieplone/templates/__init__.py | 0 cookieplone/templates/fixtures.py | 152 ++++++++++++++++ cookieplone/templates/types.py | 13 ++ news/29.feature | 1 + pyproject.toml | 5 +- .../templates/project/cookiecutter.json | 26 +++ .../README.md | 6 + .../templates/sub/bar/cookiecutter.json | 20 +++ .../README.md | 8 + tests/conftest.py | 8 + tests/templates/conftest.py | 31 ++++ tests/templates/test_fixtures.py | 166 ++++++++++++++++++ 12 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 cookieplone/templates/__init__.py create mode 100644 cookieplone/templates/fixtures.py create mode 100644 cookieplone/templates/types.py create mode 100644 news/29.feature create mode 100644 tests/_resources/templates/project/cookiecutter.json create mode 100644 tests/_resources/templates/project/{{ cookiecutter.__folder_name }}/README.md create mode 100644 tests/_resources/templates/sub/bar/cookiecutter.json create mode 100644 tests/_resources/templates/sub/bar/{{ cookiecutter.__folder_name }}/README.md create mode 100644 tests/templates/conftest.py create mode 100644 tests/templates/test_fixtures.py diff --git a/cookieplone/templates/__init__.py b/cookieplone/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cookieplone/templates/fixtures.py b/cookieplone/templates/fixtures.py new file mode 100644 index 0000000..671fed6 --- /dev/null +++ b/cookieplone/templates/fixtures.py @@ -0,0 +1,152 @@ +import json +import re +from pathlib import Path + +import pytest +from binaryornot.check import is_binary + +from . import types + + +@pytest.fixture(scope="session") +def variable_pattern() -> re.Pattern: + return re.compile( + "({{ ?(cookiecutter)[.]([a-zA-Z0-9-_]*)|{%.+(cookiecutter)[.]([a-zA-Z0-9-_]*).+%})" # noQA: E501 + ) + + +@pytest.fixture(scope="session") +def find_variables(variable_pattern) -> types.VariableFinder: + """Find variables in a string.""" + + def func(data: str) -> set: + keys = set() + matches = variable_pattern.findall(data) or [] + for match in matches: + # Remove empty matches + match = [item for item in match if item.strip()] + keys.add(match[-1]) + return keys + + return func + + +def _as_sorted_list(value: set) -> list: + """Convert a set to a list and sort it.""" + value = list(value) + return sorted(value) + + +@pytest.fixture(scope="session") +def template_repository_root() -> Path: + """Template root.""" + return Path().cwd().resolve() + + +@pytest.fixture(scope="session") +def template_folder_name() -> str: + """Name used for the template folder.""" + return "{{ cookiecutter.__folder_name }}" + + +@pytest.fixture(scope="session") +def valid_key() -> types.VariableValidator: + """Check if we will check for this key.""" + + def func(key: str, ignore: list[str] | None = None) -> bool: + ignore = ignore if ignore else ["__prompts__"] + return all([ + key not in ignore, + key.startswith("__") or not key.startswith("_"), + ]) + + return func + + +@pytest.fixture +def configuration_data(template_repository_root) -> dict: + """Return configuration from cookiecutter.json.""" + file_ = template_repository_root / "cookiecutter.json" + return json.loads(file_.read_text()) + + +@pytest.fixture +def configuration_variables(configuration_data, valid_key) -> set[str]: + """Return a set of variables available in cookiecutter.json.""" + return {key for key in configuration_data if valid_key(key)} + + +@pytest.fixture +def sub_templates(configuration_data, template_repository_root) -> list[Path]: + """Return a list of subtemplates used by this template.""" + templates = [] + parent = template_repository_root.parent + sub_templates = configuration_data.get("__cookieplone_subtemplates", []) + for sub_template in sub_templates: + sub_template_id = sub_template[0] + sub_template_path = (parent / sub_template_id).resolve() + if not sub_template_path.exists(): + sub_template_path = (parent.parent / sub_template_id).resolve() + templates.append(sub_template_path) + return templates + + +def _all_files_in_template( + base_path: Path, template_folder_name_: str, include_configuration: bool = True +) -> list[Path]: + """Get all files in a template repository.""" + hooks_folder = base_path / "hooks" + hooks_files = list(hooks_folder.glob("**/*")) + template_folder = base_path / template_folder_name_ + project_files = list(template_folder.glob("**/*")) + all_files = hooks_files + project_files + if include_configuration: + all_files.append(base_path / "cookiecutter.json") + return all_files + + +@pytest.fixture +def project_variables( + template_repository_root, + find_variables, + configuration_data, + template_folder_name, + sub_templates, +) -> set[str]: + """Return a set with all variables used in the project.""" + base_data = f"{json.dumps(configuration_data)} {template_folder_name}" + variables = find_variables(base_data) + all_files = _all_files_in_template(template_repository_root, template_folder_name) + for sub_template_path in sub_templates: + all_files.extend( + _all_files_in_template(sub_template_path, template_folder_name, True) + ) + for filepath in all_files: + data = filepath.name + is_file = filepath.is_file() + if is_file and not is_binary(f"{filepath}"): + data = f"{data} {filepath.read_text()}" + variables.update(find_variables(data)) + return variables + + +@pytest.fixture +def variables_required() -> set[str]: + """Variables required to be present, even if not used.""" + return {"__cookieplone_repository_path", "__cookieplone_template"} + + +@pytest.fixture +def variables_missing(configuration_variables, project_variables) -> list[str]: + """Return a list of variables used in the project but not in the configuration.""" + return _as_sorted_list(project_variables.difference(configuration_variables)) + + +@pytest.fixture +def variables_not_used( + configuration_variables, project_variables, variables_required +) -> list[str]: + """Return a list of variables in the configuration but not used in the project.""" + # Add variables_required + project_variables.update(variables_required) + return _as_sorted_list(configuration_variables.difference(project_variables)) diff --git a/cookieplone/templates/types.py b/cookieplone/templates/types.py new file mode 100644 index 0000000..3261497 --- /dev/null +++ b/cookieplone/templates/types.py @@ -0,0 +1,13 @@ +from pathlib import Path +from typing import Protocol + +StrPath = str | Path +DataStructure = dict | list + + +class VariableFinder(Protocol): + def __call__(self, data: str) -> set: ... + + +class VariableValidator(Protocol): + def __call__(self, key: str, ignore: list[str] | None) -> bool: ... diff --git a/news/29.feature b/news/29.feature new file mode 100644 index 0000000..0a79e39 --- /dev/null +++ b/news/29.feature @@ -0,0 +1 @@ +Add pytest fixtures to be used in template development [@ericof] diff --git a/pyproject.toml b/pyproject.toml index 474116e..f614829 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,11 +32,14 @@ dependencies = [ "packaging==24.0", "gitpython==3.1.43", "xmltodict==0.13.0", - "black", + "black==24.4.2", "isort", "zpretty" ] +[project.entry-points.pytest11] +cookieplone = "cookieplone.templates.fixtures" + [project.scripts] cookieplone = 'cookieplone.__main__:main' diff --git a/tests/_resources/templates/project/cookiecutter.json b/tests/_resources/templates/project/cookiecutter.json new file mode 100644 index 0000000..27b9258 --- /dev/null +++ b/tests/_resources/templates/project/cookiecutter.json @@ -0,0 +1,26 @@ +{ + "title": "Addon", + "description": "A new addon for Plone", + "check": "1", + "__folder_name": "test", + "__profile_language": "en", + "__prompts__": { + "title": "Addon Title", + "description": "A short description of your addon" + }, + "_copy_without_render": [], + "_extensions": [ + "cookieplone.filters.pascal_case", + "cookieplone.filters.package_name", + "cookieplone.filters.package_namespace" + ], + "__cookieplone_subtemplates": [ + [ + "sub/bar", + "Bar", + "1" + ] + ], + "__cookieplone_repository_path": "", + "__cookieplone_template": "" +} diff --git a/tests/_resources/templates/project/{{ cookiecutter.__folder_name }}/README.md b/tests/_resources/templates/project/{{ cookiecutter.__folder_name }}/README.md new file mode 100644 index 0000000..faa767c --- /dev/null +++ b/tests/_resources/templates/project/{{ cookiecutter.__folder_name }}/README.md @@ -0,0 +1,6 @@ +# {{ cookiecutter.title }} +## {{ cookiecutter.description }} + +{%- if cookiecutter.check == '1' %} +{{ cookiecutter.__profile_language}} +{%- endif %} diff --git a/tests/_resources/templates/sub/bar/cookiecutter.json b/tests/_resources/templates/sub/bar/cookiecutter.json new file mode 100644 index 0000000..cd01e52 --- /dev/null +++ b/tests/_resources/templates/sub/bar/cookiecutter.json @@ -0,0 +1,20 @@ +{ + "title": "Addon", + "description": "A new addon for Plone", + "check": "1", + "double_check": "1", + "__folder_name": "test", + "__profile_language": "en", + "__prompts__": { + "title": "Addon Title", + "description": "A short description of your addon" + }, + "_copy_without_render": [], + "_extensions": [ + "cookieplone.filters.pascal_case", + "cookieplone.filters.package_name", + "cookieplone.filters.package_namespace" + ], + "__cookieplone_repository_path": "", + "__cookieplone_template": "" +} diff --git a/tests/_resources/templates/sub/bar/{{ cookiecutter.__folder_name }}/README.md b/tests/_resources/templates/sub/bar/{{ cookiecutter.__folder_name }}/README.md new file mode 100644 index 0000000..a53153e --- /dev/null +++ b/tests/_resources/templates/sub/bar/{{ cookiecutter.__folder_name }}/README.md @@ -0,0 +1,8 @@ +# {{ cookiecutter.title }} +## {{ cookiecutter.description }} + +BAR! + +{%- if cookiecutter.check == '1' and cookiecutter.double_check == '1' %} +{{ cookiecutter.__profile_language}} +{%- endif %} diff --git a/tests/conftest.py b/tests/conftest.py index ca54a9d..2ebe704 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,8 @@ import pytest from git import Repo +pytest_plugins = "pytester" + @pytest.fixture() def tmp_repo(tmp_path): @@ -34,3 +36,9 @@ def func(filepath: str) -> str: return data return func + + +@pytest.fixture(scope="session") +def project_source() -> Path: + path = (Path(__file__).parent / "_resources" / "templates").resolve() + return path diff --git a/tests/templates/conftest.py b/tests/templates/conftest.py new file mode 100644 index 0000000..62454c4 --- /dev/null +++ b/tests/templates/conftest.py @@ -0,0 +1,31 @@ +import shutil + +import pytest + + +@pytest.fixture +def project_folder(pytester, project_source): + """Create fake cookiecutter project.""" + dest_path = pytester._path / "templates" + if dest_path.exists(): + shutil.rmtree(dest_path, True) + shutil.copytree(project_source, dest_path) + return dest_path + + +@pytest.fixture +def testdir(pytester, project_folder): + # create a temporary conftest.py file + pytester.makeconftest( + f""" + from pathlib import Path + import pytest + + pytest_plugins = ["cookieplone.templates.fixtures"] + + @pytest.fixture(scope="session") + def template_repository_root() -> Path: + return Path("{project_folder}") / "project" + """ + ) + return pytester diff --git a/tests/templates/test_fixtures.py b/tests/templates/test_fixtures.py new file mode 100644 index 0000000..ed82094 --- /dev/null +++ b/tests/templates/test_fixtures.py @@ -0,0 +1,166 @@ +import pytest + + +def test_fixture_variable_pattern(testdir): + """Test variable_pattern fixture.""" + testdir.makepyfile( + """ + import re + + def test_fixture(variable_pattern): + assert isinstance(variable_pattern, re.Pattern) + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +@pytest.mark.parametrize( + "value,expected", + [ + ["{{cookiecutter.title}}", "title"], + ["{{ cookiecutter.title}}", "title"], + ["{{ cookiecutter.title }}", "title"], + [ + "{%- if cookiecutter.__devops_traefik_local_include_ui == 'yes' %}", + "__devops_traefik_local_include_ui", + ], + ], +) +def test_fixture_find_variables(testdir, value: str, expected: str): + """Test find_variables fixture.""" + testdir.makepyfile( + f""" + def test_fixture(find_variables): + result = find_variables("{value}") + assert isinstance(result, set) + assert "{expected}" in result + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +@pytest.mark.parametrize( + "value,expected", + [ + ["title", True], + ["__title", True], + ["_internal", False], + ["__prompts__", False], + ], +) +def test_fixture_valid_key(testdir, value: str, expected: bool): + """Test valid_key fixture.""" + testdir.makepyfile( + f""" + def test_fixture(valid_key): + result = valid_key("{value}") + assert result is {expected} + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_fixture_template_folder_name(testdir): + """Test template_folder_name fixture.""" + expected = "{{ cookiecutter.__folder_name }}" + testdir.makepyfile( + f""" + def test_fixture(template_folder_name): + assert template_folder_name == "{expected}" + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +@pytest.mark.parametrize("key", ["title", "description", "check"]) +def test_fixture_configuration_data(testdir, key: str): + """Test configuration_data fixture.""" + testdir.makepyfile( + f""" + def test_fixture(configuration_data): + assert isinstance(configuration_data, dict) + assert "{key}" in configuration_data + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_fixture_sub_templates(testdir): + """Test sub_templates fixture.""" + testdir.makepyfile( + """ + from pathlib import Path + + def test_fixture(sub_templates): + assert isinstance(sub_templates, list) + assert len(sub_templates) == 1 + assert isinstance(sub_templates[0], Path) + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +@pytest.mark.parametrize( + "key", ["title", "description", "check", "__profile_language", "double_check"] +) +def test_fixture_project_variables(testdir, key: str): + """Test project_variables fixture.""" + testdir.makepyfile( + f""" + def test_fixture(project_variables): + assert isinstance(project_variables, set) + assert "{key}" in project_variables + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +@pytest.mark.parametrize( + "key", ["__cookieplone_repository_path", "__cookieplone_template"] +) +def test_fixture_variables_required(testdir, key: str): + """Test variables_required fixture.""" + testdir.makepyfile( + f""" + def test_fixture(variables_required): + assert isinstance(variables_required, set) + assert "{key}" in variables_required + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +@pytest.mark.parametrize("key", ["double_check"]) +def test_fixture_variables_missing(testdir, key: str): + """Test variables_missing fixture.""" + testdir.makepyfile( + f""" + def test_fixture(variables_missing): + assert isinstance(variables_missing, list) + assert "{key}" in variables_missing + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +@pytest.mark.parametrize("key", ["__cookieplone_subtemplates"]) +def test_fixture_variables_not_used(testdir, key: str): + """Test variables_not_used fixture.""" + testdir.makepyfile( + f""" + def test_fixture(variables_not_used): + assert isinstance(variables_not_used, list) + assert "{key}" in variables_not_used + """ + ) + result = testdir.runpytest() + result.assert_outcomes(passed=1) From d5b4211789c20274956d08ec819ec2f2db5127ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Mon, 27 May 2024 18:53:07 -0300 Subject: [PATCH 2/3] Also get configuration variables from sub templates --- cookieplone/templates/fixtures.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/cookieplone/templates/fixtures.py b/cookieplone/templates/fixtures.py index 671fed6..76d8a61 100644 --- a/cookieplone/templates/fixtures.py +++ b/cookieplone/templates/fixtures.py @@ -63,17 +63,16 @@ def func(key: str, ignore: list[str] | None = None) -> bool: return func -@pytest.fixture -def configuration_data(template_repository_root) -> dict: - """Return configuration from cookiecutter.json.""" - file_ = template_repository_root / "cookiecutter.json" +def _read_configuration(base_folder: Path) -> dict: + """Read cookiecutter.json.""" + file_ = base_folder / "cookiecutter.json" return json.loads(file_.read_text()) @pytest.fixture -def configuration_variables(configuration_data, valid_key) -> set[str]: - """Return a set of variables available in cookiecutter.json.""" - return {key for key in configuration_data if valid_key(key)} +def configuration_data(template_repository_root) -> dict: + """Return configuration from cookiecutter.json.""" + return _read_configuration(template_repository_root) @pytest.fixture @@ -91,6 +90,19 @@ def sub_templates(configuration_data, template_repository_root) -> list[Path]: return templates +@pytest.fixture +def configuration_variables(configuration_data, sub_templates, valid_key) -> set[str]: + """Return a set of variables available in cookiecutter.json.""" + # Variables + variables = {key for key in configuration_data if valid_key(key)} + for sub_template in sub_templates: + sub_config = _read_configuration(sub_template) + variables.update({ + key for key in sub_config if valid_key(key) and key.startswith("__") + }) + return variables + + def _all_files_in_template( base_path: Path, template_folder_name_: str, include_configuration: bool = True ) -> list[Path]: From 42da3cd82ac6b65cbb85516c681471e2f56a67de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Mon, 27 May 2024 18:56:07 -0300 Subject: [PATCH 3/3] Do not load cookieplone pytest plugin --- tests/templates/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/templates/conftest.py b/tests/templates/conftest.py index 62454c4..2aa6126 100644 --- a/tests/templates/conftest.py +++ b/tests/templates/conftest.py @@ -21,7 +21,7 @@ def testdir(pytester, project_folder): from pathlib import Path import pytest - pytest_plugins = ["cookieplone.templates.fixtures"] + # pytest_plugins = ["cookieplone.templates.fixtures"] @pytest.fixture(scope="session") def template_repository_root() -> Path: