Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add pytest fixtures to be used in template development #30

Merged
merged 3 commits into from
May 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
164 changes: 164 additions & 0 deletions cookieplone/templates/fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
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


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_data(template_repository_root) -> dict:
"""Return configuration from cookiecutter.json."""
return _read_configuration(template_repository_root)


@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


@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]:
"""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))
13 changes: 13 additions & 0 deletions cookieplone/templates/types.py
Original file line number Diff line number Diff line change
@@ -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: ...
1 change: 1 addition & 0 deletions news/29.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add pytest fixtures to be used in template development [@ericof]
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
26 changes: 26 additions & 0 deletions tests/_resources/templates/project/cookiecutter.json
Original file line number Diff line number Diff line change
@@ -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": ""
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# {{ cookiecutter.title }}
## {{ cookiecutter.description }}

{%- if cookiecutter.check == '1' %}
{{ cookiecutter.__profile_language}}
{%- endif %}
20 changes: 20 additions & 0 deletions tests/_resources/templates/sub/bar/cookiecutter.json
Original file line number Diff line number Diff line change
@@ -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": ""
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# {{ cookiecutter.title }}
## {{ cookiecutter.description }}

BAR!

{%- if cookiecutter.check == '1' and cookiecutter.double_check == '1' %}
{{ cookiecutter.__profile_language}}
{%- endif %}
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import pytest
from git import Repo

pytest_plugins = "pytester"


@pytest.fixture()
def tmp_repo(tmp_path):
Expand Down Expand Up @@ -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
31 changes: 31 additions & 0 deletions tests/templates/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Loading