From 33f2ecff6d33ee80e8aca259d3682ee39ad97732 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Sun, 26 Oct 2025 15:16:03 +0200 Subject: [PATCH 01/35] fix: use template vars to replace hard-coded relics --- .../scripts/gen_api_refs_pages.py | 6 +++--- .../src/{{ cookiecutter.pkg_name }}/_logging.py | 4 ++-- .../topics/development/index.md" | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/scripts/gen_api_refs_pages.py b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/scripts/gen_api_refs_pages.py index d9e3634d..ddf005b3 100644 --- a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/scripts/gen_api_refs_pages.py +++ b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/scripts/gen_api_refs_pages.py @@ -18,10 +18,10 @@ # Loop, recursively, over all Python *.py Files, inside 'src' dir and subdirs for path in sorted(src.rglob("*.py")): ## 1. extract Relative path from Python File and remove suffix (.py) - # EG src/biskotaki/cli.py --> biskotaki/cli + # EG src/{{ cookiecutter.pkg_name }}/cli.py --> {{ cookiecutter.pkg_name }}/cli _module_path = path.relative_to(src).with_suffix("") ## 2. derive corresponding Docs File to write Directives into - # EG biskotaki/cli --> biskotaki/cli.md + # EG {{ cookiecutter.pkg_name }}/cli --> {{ cookiecutter.pkg_name }}/cli.md doc_path = path.relative_to(src).with_suffix(".md") ## 3. derive Full Path to Docs File full_doc_path = Path("reference", doc_path) @@ -71,7 +71,7 @@ This page provides documentation for our command line tools. ::: mkdocs-click - :module: biskotakigold.cli + :module: {{ cookiecutter.pkg_name }}.cli :command: main """ # TODO: write better content for the CLI.md file diff --git a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.pkg_name }}/_logging.py b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.pkg_name }}/_logging.py index 99e7c008..4771c77f 100644 --- a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.pkg_name }}/_logging.py +++ b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.pkg_name }}/_logging.py @@ -29,8 +29,8 @@ import logging -# for 'biskotaki' app/code -FILE_TARGET_LOGS = 'biskotaki.log' +# for '{{ cookiecutter.pkg_name }}' python module +FILE_TARGET_LOGS = '{{ cookiecutter.pkg_name }}.log' #### FILE LOGGING # set up logging to file for DEBUG Level and above diff --git "a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.docs_builder == \"mkdocs\" %}docs{% else %}PyGen_TO_DELETE{% endif %}/topics/development/index.md" "b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.docs_builder == \"mkdocs\" %}docs{% else %}PyGen_TO_DELETE{% endif %}/topics/development/index.md" index 5a026902..7de28ac2 100644 --- "a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.docs_builder == \"mkdocs\" %}docs{% else %}PyGen_TO_DELETE{% endif %}/topics/development/index.md" +++ "b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.docs_builder == \"mkdocs\" %}docs{% else %}PyGen_TO_DELETE{% endif %}/topics/development/index.md" @@ -1,6 +1,6 @@ # Development Here you will find topics related to `Development`, the `build`, and the -`CI/CD` Pipeline design of the **Biskotaki** open-source project. +`CI/CD` Pipeline design of the **{{ cookiecutter.project_name }}** open-source project.
From 248604b21e7d81b3c4d29a214a7f496f0fd5fe95 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Sun, 26 Oct 2025 15:18:26 +0200 Subject: [PATCH 02/35] tests(data): update biskotaki-no-input Snapshot, used in Regression Testing --- .../biskotaki-gold-standard/docs/topics/development/index.md | 2 +- .../biskotaki-gold-standard/scripts/gen_api_refs_pages.py | 4 ++-- .../snapshots/biskotaki-no-input/src/biskotaki/_logging.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/data/snapshots/biskotaki-gold-standard/docs/topics/development/index.md b/tests/data/snapshots/biskotaki-gold-standard/docs/topics/development/index.md index 5a026902..455821b7 100644 --- a/tests/data/snapshots/biskotaki-gold-standard/docs/topics/development/index.md +++ b/tests/data/snapshots/biskotaki-gold-standard/docs/topics/development/index.md @@ -1,6 +1,6 @@ # Development Here you will find topics related to `Development`, the `build`, and the -`CI/CD` Pipeline design of the **Biskotaki** open-source project. +`CI/CD` Pipeline design of the **Biskotaki Gold Standard** open-source project.
diff --git a/tests/data/snapshots/biskotaki-gold-standard/scripts/gen_api_refs_pages.py b/tests/data/snapshots/biskotaki-gold-standard/scripts/gen_api_refs_pages.py index d9e3634d..0ee4e048 100644 --- a/tests/data/snapshots/biskotaki-gold-standard/scripts/gen_api_refs_pages.py +++ b/tests/data/snapshots/biskotaki-gold-standard/scripts/gen_api_refs_pages.py @@ -18,10 +18,10 @@ # Loop, recursively, over all Python *.py Files, inside 'src' dir and subdirs for path in sorted(src.rglob("*.py")): ## 1. extract Relative path from Python File and remove suffix (.py) - # EG src/biskotaki/cli.py --> biskotaki/cli + # EG src/biskotakigold/cli.py --> biskotakigold/cli _module_path = path.relative_to(src).with_suffix("") ## 2. derive corresponding Docs File to write Directives into - # EG biskotaki/cli --> biskotaki/cli.md + # EG biskotakigold/cli --> biskotakigold/cli.md doc_path = path.relative_to(src).with_suffix(".md") ## 3. derive Full Path to Docs File full_doc_path = Path("reference", doc_path) diff --git a/tests/data/snapshots/biskotaki-no-input/src/biskotaki/_logging.py b/tests/data/snapshots/biskotaki-no-input/src/biskotaki/_logging.py index 99e7c008..c31367d1 100644 --- a/tests/data/snapshots/biskotaki-no-input/src/biskotaki/_logging.py +++ b/tests/data/snapshots/biskotaki-no-input/src/biskotaki/_logging.py @@ -29,7 +29,7 @@ import logging -# for 'biskotaki' app/code +# for 'biskotaki' python module FILE_TARGET_LOGS = 'biskotaki.log' #### FILE LOGGING From 81fedc50c3b5b9c26d70df01416c5b7f7cbc267a Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Mon, 27 Oct 2025 00:49:23 +0200 Subject: [PATCH 03/35] fix: preserve user config values for derived fields in interactive mode - Fix hardcoded field subsets in helpers.py that ignored derived fields from config files - Modify dialog defaults in project_name.py to respect config values before falling back to lambda computation - Prevent Jinja2 re-rendering from overwriting explicit user config values - Add comprehensive test suite for interactive config bug scenarios - Ensure derived fields like project_slug, pkg_name show config file values in interactive CLI - Maintain backward compatibility with dynamic field computation when values not provided Resolves issue where --config-file values were ignored for fields that come after project_name in cookiecutter.json, causing interactive mode to show computed values instead of user's explicit configuration. --- src/cookiecutter_python/backend/helpers.py | 110 ++++++---- .../handle/dialogs/lib/project_name.py | 35 +++- .../handle/interactive_cli_pipeline.py | 1 + tests/conftest.py | 6 +- tests/test_interactive_config_bug.py | 189 ++++++++++++++++++ 5 files changed, 284 insertions(+), 57 deletions(-) create mode 100644 tests/test_interactive_config_bug.py diff --git a/src/cookiecutter_python/backend/helpers.py b/src/cookiecutter_python/backend/helpers.py index 523b9771..4c1317a9 100644 --- a/src/cookiecutter_python/backend/helpers.py +++ b/src/cookiecutter_python/backend/helpers.py @@ -1,3 +1,5 @@ +"""Interactive Dialog wizards to gather User Input for Context variables.""" + import json import logging import typing as t @@ -69,51 +71,71 @@ def parse_context(config_file: str): **{k: v[0] for k, v in choices.items() if k not in user_default_context}, ) + # Render cookiecutter.json again with context to resolve derived fields + # This ensures derived fields like pkg_name get computed properly + template = env.get_template('cookiecutter.json') + rendered_with_context = template.render({'cookiecutter': context_defaults}) + resolved_cook_json: t.Mapping[str, t.Any] = json.loads(rendered_with_context) + + # Update context_defaults with resolved derived fields, but preserve user config values + for key, value in resolved_cook_json.items(): + # Only update if the key wasn't explicitly provided by user + if key not in user_default_context: + context_defaults[key] = value + pipe = InteractiveDialogsPipeline() - # assert choices['rtd_python_version'] == [], f"DEBUG: {choices['rtd_python_version']}" - res = pipe.process( - [ - { - "project_name": context_defaults['project_name'], - "project_type": { - 'default': context_defaults['project_type'], - 'choices': choices['project_type'], - }, - "full_name": context_defaults['full_name'], - "author_email": context_defaults['author_email'], - "github_username": context_defaults['github_username'], - "project_short_description": context_defaults['project_short_description'], - # "release_date": context_defaults['release_date'], - # "year": context_defaults['year'], - "version": context_defaults['version'], - "initialize_git_repo": { - 'default': context_defaults['initialize_git_repo'], - 'choices': choices['initialize_git_repo'], - }, - "supported-interpreters": { - # 'default': context_defaults['initialize_git_repo'], - 'choices': [ - (choice, True) - for choice in user_interpreters.get( - 'supported-interpreters', - ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"], - ) - ], - }, - "docs_builder": { - 'default': context_defaults['docs_builder'], - 'choices': choices['docs_builder'], - }, - "rtd_python_version": { - 'default': context_defaults['rtd_python_version'], - 'choices': choices['rtd_python_version'], - }, - "cicd": { - 'default': context_defaults['cicd'], - 'choices': choices['cicd'], - }, + # Build the context dynamically to include all fields + interactive_context = {} + + # Simple fields (no choices) + simple_fields = [ + 'project_name', + 'project_slug', + 'pkg_name', + 'repo_name', + 'readthedocs_project_slug', + 'docker_image', + 'full_name', + 'author', + 'author_email', + 'github_username', + 'project_short_description', + 'pypi_subtitle', + 'version', + ] + + for field in simple_fields: + if field in context_defaults: + interactive_context[field] = context_defaults[field] + + # Choice fields (with options) + choice_fields = [ + 'project_type', + 'initialize_git_repo', + 'docs_builder', + 'rtd_python_version', + 'cicd', + ] + + for field in choice_fields: + if field in choices: + interactive_context[field] = { + 'default': context_defaults[field], + 'choices': choices[field], } - ] - ) + + # Special handling for interpreters + interactive_context["supported-interpreters"] = { + 'choices': [ + (choice, True) + for choice in user_interpreters.get( + 'supported-interpreters', + ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"], + ) + ], + } + + # assert choices['rtd_python_version'] == [], f"DEBUG: {choices['rtd_python_version']}" + res = pipe.process([interactive_context]) return res diff --git a/src/cookiecutter_python/handle/dialogs/lib/project_name.py b/src/cookiecutter_python/handle/dialogs/lib/project_name.py index 265f1a2d..017f1c2a 100644 --- a/src/cookiecutter_python/handle/dialogs/lib/project_name.py +++ b/src/cookiecutter_python/handle/dialogs/lib/project_name.py @@ -29,33 +29,48 @@ def dialog(self, cookie_vars) -> Mapping[str, str]: 'type': 'input', 'name': 'project_slug', 'message': 'Enter the Project Slug (ie lowercase text, no spaces):', - 'default': lambda answers: answers['project_name'] - .lower() - .replace(' ', '-'), + 'default': cookie_vars.get('project_slug') + or (lambda answers: answers['project_name'].lower().replace(' ', '-')), }, { 'type': 'input', 'name': 'pkg_name', 'message': 'Enter the Package Name (ie lowercase text, no spaces):', - 'default': lambda answers: answers['project_slug'].replace('-', '_'), + 'default': ( + cookie_vars['pkg_name'] + if cookie_vars.get('pkg_name') + else (lambda answers: answers['project_slug'].replace('-', '_')) + ), }, { 'type': 'input', 'name': 'repo_name', 'message': 'Enter the Repository Name (ie lowercase text, no spaces):', - 'default': lambda answers: answers['project_slug'], + 'default': ( + cookie_vars['repo_name'] + if cookie_vars.get('repo_name') + else (lambda answers: answers['project_slug']) + ), }, { 'type': 'input', 'name': 'readthedocs_project_slug', 'message': 'Enter the ReadTheDocs Project Slug (ie lowercase text, no spaces):', - 'default': lambda answers: answers['project_slug'], + 'default': ( + cookie_vars['readthedocs_project_slug'] + if cookie_vars.get('readthedocs_project_slug') + else (lambda answers: answers['project_slug']) + ), }, { 'type': 'input', 'name': 'docker_image', 'message': 'Enter the Docker Image Name (ie lowercase text, no spaces):', - 'default': lambda answers: answers['project_slug'], + 'default': ( + cookie_vars['docker_image'] + if cookie_vars.get('docker_image') + else (lambda answers: answers['project_slug']) + ), }, # full_name { @@ -69,7 +84,8 @@ def dialog(self, cookie_vars) -> Mapping[str, str]: 'type': 'input', 'name': 'author', 'message': 'Enter author:', - 'default': lambda answers: answers['full_name'], + 'default': cookie_vars.get('author') + or (lambda answers: answers['full_name']), }, # author_email { @@ -97,7 +113,8 @@ def dialog(self, cookie_vars) -> Mapping[str, str]: 'type': 'input', 'name': 'pypi_subtitle', 'message': 'Enter pypi_subtitle:', - 'default': lambda answers: answers['project_short_description'], + 'default': cookie_vars.get('pypi_subtitle') + or (lambda answers: answers['project_short_description']), }, # release_date { diff --git a/src/cookiecutter_python/handle/interactive_cli_pipeline.py b/src/cookiecutter_python/handle/interactive_cli_pipeline.py index c3d5f824..ba4cd3bc 100644 --- a/src/cookiecutter_python/handle/interactive_cli_pipeline.py +++ b/src/cookiecutter_python/handle/interactive_cli_pipeline.py @@ -13,5 +13,6 @@ class InteractiveDialogsPipeline: def process(self, request): """Process sequence of Interactive User Dialogs, for Context Information.""" for dialog in self.dialogs: + # Call the dialog method (ie ProjectNameDialog.dialog) of each dialog node request = NodeFactory.create(dialog).process(request) return request diff --git a/tests/conftest.py b/tests/conftest.py index 429d0eac..f74324c1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -343,8 +343,7 @@ class ConfigProtocol(Protocol): class ConfigInterfaceProtocol(t.Protocol): - def __getitem__(self, file_path_str: t.Union[str, None]) -> ConfigProtocol: - ... + def __getitem__(self, file_path_str: t.Union[str, None]) -> ConfigProtocol: ... @pytest.fixture @@ -499,8 +498,7 @@ def config_file(self) -> t.Union[Path, None]: class RelativePathsGenerator(Protocol): """Generate relative paths from a given root folder.""" - def relative_file_paths(self) -> t.Iterator[Path]: - ... + def relative_file_paths(self) -> t.Iterator[Path]: ... @pytest.fixture diff --git a/tests/test_interactive_config_bug.py b/tests/test_interactive_config_bug.py new file mode 100644 index 00000000..ee67f627 --- /dev/null +++ b/tests/test_interactive_config_bug.py @@ -0,0 +1,189 @@ +"""Test for interactive CLI bug with derived fields from config file.""" + +from unittest.mock import patch + +import yaml + + +def test_derived_fields_show_config_values_in_interactive_mode(tmp_path): + """Test that derived fields show config file values in interactive mode. + + GIVEN User used --config-file CLI flag with a valid YAML config + WHEN the config YAML file includes derived fields like "pkg_name" that come after "project_name" + THEN in interactive CLI the value from config YAML is displayed for "pkg_name" + AND NOT the computed value derived from project_name transformation + """ + # Create a config file with explicit project_slug (minimal config on purpose) + config_data = { + 'default_context': { + 'project_slug': 'custom-package-name', # This should override derived value + 'initialize_git_repo': 'no', + } + } + + config_file = tmp_path / "test_config.yaml" + config_file.write_text(yaml.dump(config_data)) + + # Parse context with config file (simulating what happens in interactive mode) + from cookiecutter_python.backend.helpers import parse_context + + # Mock the interactive pipeline to capture what values are passed to it + captured_context = None + + def mock_process(self, context_list): + nonlocal captured_context + # Capture the dictionary that would be passed to interactive dialogs + captured_context = context_list[0] # First item is the context dict for dialogs + return context_list + + # Replace the real InteractiveDialogsPipeline.process with our mock + with patch( + 'cookiecutter_python.backend.helpers.InteractiveDialogsPipeline.process', mock_process + ): + parse_context(str(config_file)) + + # The captured context should contain the config file value for pkg_name + assert captured_context is not None, "Context should have been captured" + print("Captured context:", captured_context) + # Check that project_slug is present in the captured context + # This is the bug - currently project_slug is not included in the context passed to dialogs + assert 'project_slug' in captured_context, ( + "project_slug should be included in context passed to interactive dialogs. " + "Current implementation in helpers.py only includes subset of fields." + ) + + # The value should be from config file, not derived from project_name + expected_project_slug = 'custom-package-name' # From config file + + assert captured_context['project_slug'] == expected_project_slug, ( + f"Expected project_slug from config file '{expected_project_slug}', " + f"but got derived value '{captured_context.get('project_slug', 'NOT_FOUND')}'" + ) + + +def test_derived_fields_fallback_to_computed_values_when_not_in_config(tmp_path): + """Test that derived fields fallback to computed values when not in config. + + GIVEN User used --config-file CLI flag with a valid YAML config + WHEN the config YAML file does NOT include derived fields like "pkg_name" + THEN in interactive CLI the computed/derived value is displayed for "pkg_name" + """ + # Create a config file WITHOUT pkg_name + config_data = { + 'default_context': { + 'project_name': 'My Test Project', + 'project_type': 'module', + # Note: pkg_name is NOT specified + 'full_name': 'Test User', + 'author_email': 'test@example.com', + 'github_username': 'testuser', + 'project_short_description': 'A test project', + 'version': '1.0.0', + 'initialize_git_repo': 'no', + 'docs_builder': 'sphinx', + 'rtd_python_version': '3.10', + 'cicd': 'stable', + } + } + + config_file = tmp_path / "test_config.yaml" + config_file.write_text(yaml.dump(config_data)) + + # Parse context with config file + from cookiecutter_python.backend.helpers import parse_context + + captured_context = None + + def mock_process(self, context_list): + nonlocal captured_context + # Capture the dictionary that would be passed to interactive dialogs + captured_context = context_list[0] # First item is the context dict for dialogs + return context_list + + # Replace the real InteractiveDialogsPipeline.process with our mock + with patch( + 'cookiecutter_python.backend.helpers.InteractiveDialogsPipeline.process', mock_process + ): + parse_context(str(config_file)) + + # pkg_name should still be available and have derived value + assert captured_context is not None + + # Even when not in config, pkg_name should be in context for the dialog + assert ( + 'pkg_name' in captured_context + ), "pkg_name should be available in context even when not in config file" + + # Should be the derived value since not in config - but the dialog lambda will compute it + # The lambda function in the dialog will compute: 'my-test-project'.replace('-', '_') = 'my_test_project' + # But since we're mocking the dialog, we expect the context to contain the lambda function, + # not the pre-computed value. The actual computation happens in the real dialog. + + # For this test, we verify that pkg_name has the empty fallback (indicating no user config) + # and the dialog system will use the lambda to compute the value when actually running + assert captured_context['pkg_name'] == '', ( + f"Expected empty pkg_name to trigger lambda computation, " + f"but got '{captured_context.get('pkg_name', 'NOT_FOUND')}'" + ) + + +def test_all_cookiecutter_json_fields_available_in_interactive_context(tmp_path): + """Test that all cookiecutter.json fields are available in interactive context. + + GIVEN the cookiecutter.json defines various fields including derived ones + WHEN parsing context for interactive mode + THEN all fields should be available in the context passed to dialogs + """ + # Basic config with just required fields + config_data = { + 'default_context': { + 'project_name': 'Test Project', + 'project_type': 'module', + 'full_name': 'Test User', + 'author_email': 'test@example.com', + 'github_username': 'testuser', + 'project_short_description': 'A test project', + 'version': '1.0.0', + 'initialize_git_repo': 'no', + 'docs_builder': 'sphinx', + 'rtd_python_version': '3.10', + 'cicd': 'stable', + } + } + + config_file = tmp_path / "test_config.yaml" + config_file.write_text(yaml.dump(config_data)) + + # Parse context + from cookiecutter_python.backend.helpers import parse_context + + captured_context = None + + def mock_process(self, context_list): + nonlocal captured_context + # Capture the dictionary that would be passed to interactive dialogs + captured_context = context_list[0] # First item is the context dict for dialogs + return context_list + + # Replace the real InteractiveDialogsPipeline.process with our mock + with patch( + 'cookiecutter_python.backend.helpers.InteractiveDialogsPipeline.process', mock_process + ): + parse_context(str(config_file)) + + # All derived fields should be in context + derived_fields = [ + 'project_slug', + 'pkg_name', + 'repo_name', + 'readthedocs_project_slug', + 'docker_image', + 'author', # derived from full_name + 'pypi_subtitle', # derived from project_short_description + ] + + for field in derived_fields: + assert field in captured_context, ( + f"Derived field '{field}' should be available in interactive context. " + f"Available fields: {list(captured_context.keys())}" + ) From 87cc82614dfff9b54dc50dbbd9827b6f875b868c Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Mon, 27 Oct 2025 00:51:42 +0200 Subject: [PATCH 04/35] fix tox --- tox.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tox.ini b/tox.ini index 8ae65050..418c5fb3 100755 --- a/tox.ini +++ b/tox.ini @@ -418,6 +418,7 @@ deps = black isort >= 5.0.0 passenv = APPLY_LINT +setenv = {[sca]setenv} skip_install = true changedir = {toxinidir} commands = @@ -459,6 +460,7 @@ commands = isort \ [testenv:ruff] description = Run the ruff static analysis tool basepython = {env:TOXPYTHON:python3} +setenv = {[sca]setenv} deps = ruff skip_install = true # make sure to avoid checking the Template pyproject.toml, due to parse error From b6e4d2c40660df708ea709bc5124c6429983bb00 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Mon, 27 Oct 2025 01:13:31 +0200 Subject: [PATCH 05/35] build: update build to match sources --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9eed9bcd..5b0b7623 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ include = [ "LICENSE", "README.md", "CONTRIBUTING.md", - "CHANGELOG.rst", + "CHANGELOG.md", # "Dockerfile", ] exclude = [ From 5f30142714c461a1927d66a3fe6c775088ac0721 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Mon, 27 Oct 2025 01:26:17 +0200 Subject: [PATCH 06/35] style: apply same version black as in CI --- tests/conftest.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f74324c1..429d0eac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -343,7 +343,8 @@ class ConfigProtocol(Protocol): class ConfigInterfaceProtocol(t.Protocol): - def __getitem__(self, file_path_str: t.Union[str, None]) -> ConfigProtocol: ... + def __getitem__(self, file_path_str: t.Union[str, None]) -> ConfigProtocol: + ... @pytest.fixture @@ -498,7 +499,8 @@ def config_file(self) -> t.Union[Path, None]: class RelativePathsGenerator(Protocol): """Generate relative paths from a given root folder.""" - def relative_file_paths(self) -> t.Iterator[Path]: ... + def relative_file_paths(self) -> t.Iterator[Path]: + ... @pytest.fixture From a91c2a9afeb6e7283157a5f8d0e1b50ac8c2dce3 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Mon, 27 Oct 2025 01:26:33 +0200 Subject: [PATCH 07/35] test: update expectations --- tests/test_build_backend_sdist.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_build_backend_sdist.py b/tests/test_build_backend_sdist.py index 2cdfdf82..70a4d508 100644 --- a/tests/test_build_backend_sdist.py +++ b/tests/test_build_backend_sdist.py @@ -308,7 +308,7 @@ def sdist_expected_correct_file_structure(): 'pyproject.toml', 'README.md', 'LICENSE', - 'CHANGELOG.rst', + 'CHANGELOG.md', 'CONTRIBUTING.md', ) # Injected by Build Backend (Process) From b9815aa10a0c50774a96ce6b7b4d2bfd0913f031 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Mon, 27 Oct 2025 02:26:53 +0200 Subject: [PATCH 08/35] test: update test expectations --- .../test_build_creates_artifacts.py | 15 +++++++++++---- .../biskotaki_ci_no_input/test_lint_passes.py | 4 ++-- tests/test_build_backend_sdist.py | 1 + 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_build_creates_artifacts.py b/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_build_creates_artifacts.py index 5ca47d26..dc2f9a48 100644 --- a/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_build_creates_artifacts.py +++ b/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_build_creates_artifacts.py @@ -1,3 +1,4 @@ +import os import sys import pytest @@ -18,7 +19,7 @@ def test_running_build_creates_source_and_wheel_distros( ## Programmatically run Build, with the entrypoint we suggest, for a Dev to run res = my_run_subprocess( # tox -e build - *[sys.executable, '-m', 'tox', '-r', '-vv', '-e', 'build'], + *['tox', '-r', '-vv', '-e', 'build'], cwd=snapshot_dir, check=False, # prevent raising exception, so we can do clean up shell=False, # prevent execution of untrusted input @@ -81,7 +82,7 @@ def test_running_build_creates_source_and_wheel_distros( # Check that Code passes Metadata Checks out of the box # run `tox -e check` and make sure we first do clean up before throwing an error res = my_run_subprocess( # tox -e check - *[sys.executable, '-m', 'tox', '-r', '-vv', '-e', 'check'], + *['tox', '-r', '-vv', '-e', 'check'], cwd=snapshot_dir, check=False, # prevent raising exception, so we can do clean up shell=False, # prevent execution of untrusted input @@ -89,14 +90,20 @@ def test_running_build_creates_source_and_wheel_distros( # required by Check environment 'PKG_VERSION': '0.0.1', # required due to programmatic execution, in this case - 'PATH': str(Path(sys.executable).parent), + 'PATH': os.environ.get('PATH', str(Path(sys.executable).parent)), }, ) # CLEAN UP: Remove .tox/check folder, created by tox import shutil - shutil.rmtree(snapshot_dir / '.tox' / 'check') + try: + shutil.rmtree(snapshot_dir / '.tox' / 'check') + except FileNotFoundError: + import logging + + logger = logging.getLogger(__name__) + logger.warning("No .tox/check folder found to clean up") # Check that Code passes Metadata Checks out of the box assert res.exit_code == 0 diff --git a/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py b/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py index 164d5058..258471fa 100644 --- a/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py +++ b/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py @@ -25,7 +25,7 @@ def test_running_lint_passes(snapshot_name, my_run_subprocess, test_root): # Programmatically run Lint, with the entrypoint we suggest, for a Dev to run res = my_run_subprocess( # tox -e lint - *[sys.executable, '-m', 'tox', '-vv', '-e', 'lint'], + *['tox', '-vv', '-e', 'lint'], cwd=snapshot_dir, check=False, # prevent raising exception, so we can do clean up shell=False, # prevent execution of untrusted input @@ -66,7 +66,7 @@ def test_running_ruff_passes(snapshot_name, my_run_subprocess, test_root): # Programmatically run Lint, with the entrypoint we suggest, for a Dev to run res = my_run_subprocess( # tox -e ruff - *[sys.executable, '-m', 'tox', '-vv', '-e', 'ruff'], + *['tox', '-vv', '-e', 'ruff'], cwd=snapshot_dir, check=False, # prevent raising exception, so we can do clean up shell=False, # prevent execution of untrusted input diff --git a/tests/test_build_backend_sdist.py b/tests/test_build_backend_sdist.py index 70a4d508..f83ab5db 100644 --- a/tests/test_build_backend_sdist.py +++ b/tests/test_build_backend_sdist.py @@ -295,6 +295,7 @@ def sdist_expected_correct_file_structure(): 'tests/test_error_classifier.py', 'tests/test_generate.py', 'tests/test_gold_standard.py', + 'tests/test_interactive_config_bug.py', 'tests/test_module.py', 'tests/test_post_hook.py', 'tests/test_prehook.py', From 7abf2565cfcb8687a10b9d978f09185086f55df2 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Tue, 28 Oct 2025 00:44:49 +0200 Subject: [PATCH 09/35] refactor: fix ruff issues --- .../snapshot/biskotaki_ci_no_input/test_lint_passes.py | 2 -- tests/test_build_backend_sdist.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py b/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py index 258471fa..5d870245 100644 --- a/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py +++ b/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py @@ -1,5 +1,3 @@ -import sys - import pytest diff --git a/tests/test_build_backend_sdist.py b/tests/test_build_backend_sdist.py index f83ab5db..f642e0cd 100644 --- a/tests/test_build_backend_sdist.py +++ b/tests/test_build_backend_sdist.py @@ -618,7 +618,7 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: import re assert re.search(r"Building sdist\.\.\.", result.stdout) - pattern = "Successfully built .*cookiecutter_python-.+\.tar\.gz" + pattern = r"Successfully built .*cookiecutter_python-.+\.tar\.gz" assert re.search(pattern, result.stdout) # After build, retrieve the tar.gz file From 62e788426dff60de3358824d0f838bb005ed8037 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Tue, 28 Oct 2025 01:14:40 +0200 Subject: [PATCH 10/35] ci: fix build sanity checks --- tests/test_build_backend_sdist.py | 71 +++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 8 deletions(-) diff --git a/tests/test_build_backend_sdist.py b/tests/test_build_backend_sdist.py index f642e0cd..98335510 100644 --- a/tests/test_build_backend_sdist.py +++ b/tests/test_build_backend_sdist.py @@ -583,8 +583,11 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: # Create a temporary directory import tempfile import typing as t + import os - temp_dir: str = tempfile.mkdtemp() + # Make directory unique for pytest-xdist parallel execution + worker_id = os.environ.get("PYTEST_XDIST_WORKER", "master") + temp_dir: str = tempfile.mkdtemp(suffix=f"-{worker_id}") OUT_DIR = Path(temp_dir) / "unit-test-sdist_built_at_runtime_with_build" # Get distro_path: ie '/site-packages/cookiecutter_python' @@ -605,15 +608,67 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: str(OUT_DIR), str(project_path), ] - result = my_run_subprocess(*COMMAND_LINE_ARGS, check=False) + + # Add isolation to prevent cross-worker conflicts + env = os.environ.copy() + env["BUILD_BACKEND_ISOLATION"] = "true" + env["SETUPTOOLS_SCM_DEBUG"] = "1" if worker_id != "master" else "0" + + print(f"\n[Worker {worker_id}] Building with command: {' '.join(COMMAND_LINE_ARGS)}") + print(f"[Worker {worker_id}] Output directory: {OUT_DIR}") + print(f"[Worker {worker_id}] Project path: {project_path}") + print(f"[Worker {worker_id}] Python executable: {PYTHON}") + print(f"[Worker {worker_id}] Current working directory: {os.getcwd()}") + + # Check for potential lock files that could cause conflicts + lock_files = [ + project_path / "poetry.lock", + project_path / ".build-lock", + project_path / "pyproject.toml.lock" + ] + for lock_file in lock_files: + if lock_file.exists(): + print(f"[Worker {worker_id}] Lock file exists: {lock_file}") + + result = my_run_subprocess(*COMMAND_LINE_ARGS, check=False, env=env) print() - print("==========") - print(result.stdout) - print("==========") - print(result.stderr) - print("==========") - assert result.exit_code == 0, f"Expected exit code 0, got {result.exit_code}" + print("=" * 60) + print(f"[Worker {worker_id}] BUILD RESULT - Exit code: {result.exit_code}") + print("=" * 60) + print("STDOUT:") + stdout_content = result.stdout if result.stdout else "(empty)" + print(repr(stdout_content)) # Use repr to see hidden characters + print(stdout_content) + print("=" * 60) + print("STDERR:") + stderr_content = result.stderr if result.stderr else "(empty)" + print(repr(stderr_content)) # Use repr to see hidden characters + print(stderr_content) + print("=" * 60) + + if result.exit_code != 0: + print(f"[Worker {worker_id}] BUILD FAILED!") + print(f"Command: {' '.join(COMMAND_LINE_ARGS)}") + print(f"Working directory: {project_path}") + print(f"Output directory exists: {OUT_DIR.exists()}") + if OUT_DIR.exists(): + print(f"Output directory contents: {list(OUT_DIR.iterdir()) if OUT_DIR.is_dir() else 'Not a directory'}") + + # Additional debugging for CI environment + print(f"Environment variables of interest:") + debug_env_vars = ["PYTHONPATH", "PATH", "HOME", "BUILD_BACKEND_ISOLATION", "SETUPTOOLS_SCM_DEBUG"] + for var in debug_env_vars: + print(f" {var}: {env.get(var, 'NOT SET')}") + + assert result.exit_code == 0, ( + f"[Worker {worker_id}] Build failed with exit code {result.exit_code}\n" + f"Command: {' '.join(COMMAND_LINE_ARGS)}\n" + f"Working directory: {project_path}\n" + f"Environment: PYTEST_XDIST_WORKER={worker_id}\n" + f"STDOUT: {repr(result.stdout)}\n" + f"STDERR: {repr(result.stderr)}" + ) import re From ff271623bb846b75c6bc1a5d16568bcf0d0cd195 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Tue, 28 Oct 2025 01:15:28 +0200 Subject: [PATCH 11/35] refactor: apply lint --- tests/test_build_backend_sdist.py | 32 +++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/tests/test_build_backend_sdist.py b/tests/test_build_backend_sdist.py index 98335510..9ebeb7e3 100644 --- a/tests/test_build_backend_sdist.py +++ b/tests/test_build_backend_sdist.py @@ -581,9 +581,9 @@ def test_sdist_includes_dirs_and_files_exactly_as_expected_when_produced_via_uv_ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: """Build project (at runtime) with 'build module', and return SDist tar.gz file.""" # Create a temporary directory + import os import tempfile import typing as t - import os # Make directory unique for pytest-xdist parallel execution worker_id = os.environ.get("PYTEST_XDIST_WORKER", "master") @@ -608,28 +608,28 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: str(OUT_DIR), str(project_path), ] - + # Add isolation to prevent cross-worker conflicts env = os.environ.copy() env["BUILD_BACKEND_ISOLATION"] = "true" env["SETUPTOOLS_SCM_DEBUG"] = "1" if worker_id != "master" else "0" - + print(f"\n[Worker {worker_id}] Building with command: {' '.join(COMMAND_LINE_ARGS)}") print(f"[Worker {worker_id}] Output directory: {OUT_DIR}") print(f"[Worker {worker_id}] Project path: {project_path}") print(f"[Worker {worker_id}] Python executable: {PYTHON}") print(f"[Worker {worker_id}] Current working directory: {os.getcwd()}") - + # Check for potential lock files that could cause conflicts lock_files = [ project_path / "poetry.lock", project_path / ".build-lock", - project_path / "pyproject.toml.lock" + project_path / "pyproject.toml.lock", ] for lock_file in lock_files: if lock_file.exists(): print(f"[Worker {worker_id}] Lock file exists: {lock_file}") - + result = my_run_subprocess(*COMMAND_LINE_ARGS, check=False, env=env) print() @@ -643,24 +643,32 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: print("=" * 60) print("STDERR:") stderr_content = result.stderr if result.stderr else "(empty)" - print(repr(stderr_content)) # Use repr to see hidden characters + print(repr(stderr_content)) # Use repr to see hidden characters print(stderr_content) print("=" * 60) - + if result.exit_code != 0: print(f"[Worker {worker_id}] BUILD FAILED!") print(f"Command: {' '.join(COMMAND_LINE_ARGS)}") print(f"Working directory: {project_path}") print(f"Output directory exists: {OUT_DIR.exists()}") if OUT_DIR.exists(): - print(f"Output directory contents: {list(OUT_DIR.iterdir()) if OUT_DIR.is_dir() else 'Not a directory'}") - + print( + f"Output directory contents: {list(OUT_DIR.iterdir()) if OUT_DIR.is_dir() else 'Not a directory'}" + ) + # Additional debugging for CI environment print(f"Environment variables of interest:") - debug_env_vars = ["PYTHONPATH", "PATH", "HOME", "BUILD_BACKEND_ISOLATION", "SETUPTOOLS_SCM_DEBUG"] + debug_env_vars = [ + "PYTHONPATH", + "PATH", + "HOME", + "BUILD_BACKEND_ISOLATION", + "SETUPTOOLS_SCM_DEBUG", + ] for var in debug_env_vars: print(f" {var}: {env.get(var, 'NOT SET')}") - + assert result.exit_code == 0, ( f"[Worker {worker_id}] Build failed with exit code {result.exit_code}\n" f"Command: {' '.join(COMMAND_LINE_ARGS)}\n" From ace303a25abe963615ac1c29448145a5e576bcd0 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Tue, 28 Oct 2025 01:34:29 +0200 Subject: [PATCH 12/35] ci: make sdist test more robust --- tests/test_build_backend_sdist.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/test_build_backend_sdist.py b/tests/test_build_backend_sdist.py index 9ebeb7e3..cb511879 100644 --- a/tests/test_build_backend_sdist.py +++ b/tests/test_build_backend_sdist.py @@ -598,7 +598,29 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: # invoke build module as frontend to whatever [build-system] is in pyproject.toml import sys - PYTHON = sys.executable # python from virtualenv + # Use the most appropriate Python executable to avoid CI path validation errors + # Priority: 1) Current virtual env if within project, 2) Project .venv, 3) Fallback + current_python = sys.executable + + # Check if current python is within the project directory (safe for Poetry) + try: + Path(current_python).relative_to(project_path) + PYTHON = current_python # Safe to use current python + print(f"[Worker {worker_id}] Using current Python (within project): {PYTHON}") + except ValueError: + # Current python is outside project (e.g., CI hostedtoolcache) + # Try project .venv python instead + venv_python = project_path / ".venv" / "bin" / "python" + if venv_python.exists(): + PYTHON = str(venv_python) + print( + f"[Worker {worker_id}] Using .venv Python (current outside project): {PYTHON}" + ) + else: + # Last resort: use current python and hope for the best + PYTHON = current_python + print(f"[Worker {worker_id}] Using current Python (no .venv found): {PYTHON}") + COMMAND_LINE_ARGS: t.List[str] = [ PYTHON, "-m", @@ -658,7 +680,7 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: ) # Additional debugging for CI environment - print(f"Environment variables of interest:") + print("Environment variables of interest:") debug_env_vars = [ "PYTHONPATH", "PATH", From 698d2c461ffc92d4baeeb902dfb301de875a81d8 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Tue, 28 Oct 2025 01:41:53 +0200 Subject: [PATCH 13/35] revert to 'python -m' tox invocation in Tests --- .../biskotaki_ci_no_input/test_build_creates_artifacts.py | 4 ++-- .../snapshot/biskotaki_ci_no_input/test_lint_passes.py | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_build_creates_artifacts.py b/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_build_creates_artifacts.py index dc2f9a48..ede8ed8c 100644 --- a/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_build_creates_artifacts.py +++ b/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_build_creates_artifacts.py @@ -19,7 +19,7 @@ def test_running_build_creates_source_and_wheel_distros( ## Programmatically run Build, with the entrypoint we suggest, for a Dev to run res = my_run_subprocess( # tox -e build - *['tox', '-r', '-vv', '-e', 'build'], + *[sys.executable, '-m', 'tox', '-r', '-vv', '-e', 'build'], cwd=snapshot_dir, check=False, # prevent raising exception, so we can do clean up shell=False, # prevent execution of untrusted input @@ -82,7 +82,7 @@ def test_running_build_creates_source_and_wheel_distros( # Check that Code passes Metadata Checks out of the box # run `tox -e check` and make sure we first do clean up before throwing an error res = my_run_subprocess( # tox -e check - *['tox', '-r', '-vv', '-e', 'check'], + *[sys.executable, '-m', 'tox', '-r', '-vv', '-e', 'check'], cwd=snapshot_dir, check=False, # prevent raising exception, so we can do clean up shell=False, # prevent execution of untrusted input diff --git a/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py b/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py index 5d870245..164d5058 100644 --- a/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py +++ b/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py @@ -1,3 +1,5 @@ +import sys + import pytest @@ -23,7 +25,7 @@ def test_running_lint_passes(snapshot_name, my_run_subprocess, test_root): # Programmatically run Lint, with the entrypoint we suggest, for a Dev to run res = my_run_subprocess( # tox -e lint - *['tox', '-vv', '-e', 'lint'], + *[sys.executable, '-m', 'tox', '-vv', '-e', 'lint'], cwd=snapshot_dir, check=False, # prevent raising exception, so we can do clean up shell=False, # prevent execution of untrusted input @@ -64,7 +66,7 @@ def test_running_ruff_passes(snapshot_name, my_run_subprocess, test_root): # Programmatically run Lint, with the entrypoint we suggest, for a Dev to run res = my_run_subprocess( # tox -e ruff - *['tox', '-vv', '-e', 'ruff'], + *[sys.executable, '-m', 'tox', '-vv', '-e', 'ruff'], cwd=snapshot_dir, check=False, # prevent raising exception, so we can do clean up shell=False, # prevent execution of untrusted input From f84e25e6a76b7ee3e88268525ab4dc7b2dbc34b6 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Tue, 28 Oct 2025 01:58:47 +0200 Subject: [PATCH 14/35] revert test_build_backend_sdist to use sys.executable --- tests/test_build_backend_sdist.py | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/tests/test_build_backend_sdist.py b/tests/test_build_backend_sdist.py index cb511879..b726844a 100644 --- a/tests/test_build_backend_sdist.py +++ b/tests/test_build_backend_sdist.py @@ -598,28 +598,7 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: # invoke build module as frontend to whatever [build-system] is in pyproject.toml import sys - # Use the most appropriate Python executable to avoid CI path validation errors - # Priority: 1) Current virtual env if within project, 2) Project .venv, 3) Fallback - current_python = sys.executable - - # Check if current python is within the project directory (safe for Poetry) - try: - Path(current_python).relative_to(project_path) - PYTHON = current_python # Safe to use current python - print(f"[Worker {worker_id}] Using current Python (within project): {PYTHON}") - except ValueError: - # Current python is outside project (e.g., CI hostedtoolcache) - # Try project .venv python instead - venv_python = project_path / ".venv" / "bin" / "python" - if venv_python.exists(): - PYTHON = str(venv_python) - print( - f"[Worker {worker_id}] Using .venv Python (current outside project): {PYTHON}" - ) - else: - # Last resort: use current python and hope for the best - PYTHON = current_python - print(f"[Worker {worker_id}] Using current Python (no .venv found): {PYTHON}") + PYTHON = sys.executable # python from virtualenv COMMAND_LINE_ARGS: t.List[str] = [ PYTHON, From e2a957531bcc263fadcb8f316d9126a0bc2830de Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Tue, 28 Oct 2025 22:42:18 +0200 Subject: [PATCH 15/35] feat: create Observability Stack for Live Grafana / Loki Application Logs" --- src/cookiecutter_python/cookiecutter.json | 3 +- .../hooks/post_gen_project.py | 17 +- .../README.md" | 166 +++++++++ .../docker-compose.observability.yml" | 69 ++++ .../grafana/dashboards/dashboard.yml" | 12 + .../grafana/datasources/loki.yml" | 9 + .../loki/loki-config.yml" | 48 +++ .../promtail/promtail-config.yml" | 55 +++ tests/test_observability.py | 318 ++++++++++++++++++ 9 files changed, 690 insertions(+), 7 deletions(-) create mode 100644 "src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/README.md" create mode 100644 "src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/docker-compose.observability.yml" create mode 100644 "src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/grafana/dashboards/dashboard.yml" create mode 100644 "src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/grafana/datasources/loki.yml" create mode 100644 "src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/loki/loki-config.yml" create mode 100644 "src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/promtail/promtail-config.yml" create mode 100644 tests/test_observability.py diff --git a/src/cookiecutter_python/cookiecutter.json b/src/cookiecutter_python/cookiecutter.json index 9460f3bc..66cd7c66 100644 --- a/src/cookiecutter_python/cookiecutter.json +++ b/src/cookiecutter_python/cookiecutter.json @@ -28,5 +28,6 @@ }, "docs_builder": ["sphinx", "mkdocs"], "rtd_python_version": ["3.8", "3.9", "3.10", "3.11", "3.12"], - "cicd": ["stable", "experimental"] + "cicd": ["stable", "experimental"], + "include_observability": ["no", "yes"] } diff --git a/src/cookiecutter_python/hooks/post_gen_project.py b/src/cookiecutter_python/hooks/post_gen_project.py index ae3c75a4..e8dbc7a6 100644 --- a/src/cookiecutter_python/hooks/post_gen_project.py +++ b/src/cookiecutter_python/hooks/post_gen_project.py @@ -133,21 +133,26 @@ def post_file_removal(request): for path_components in CICD_DELETE[request.vars['cicd']] ] _delete_files(irrelevant_ci_cd_files) + # Remove generated docs folders, but the input builder (ie mkdocs/sphinx) selected - _remove_irrelevant_docs_folders(request.project_dir) + # remove Grafana stuff if opted-out of Observability + _remove_irrelevant_rendered_folders(request.project_dir) # Remove some top-level files depending on input (ie mkdocs.yml) _remove_irrelevant_top_level_files(request) -def _remove_irrelevant_docs_folders(gen_project_dir: str): +def _remove_irrelevant_rendered_folders(gen_project_dir: str): """Remove generated docs folders that are not relevant to the selected docs builder.""" - # find top-level folders and delte the ones with name 'PyGen_TO_DELETE' - for docs_folder_to_delete in ( + + # find top-level folders and delete the ones with name 'PyGen_TO_DELETE' or 'PyGen_TO_DELETE_OBSERVABILITY' + TO_DELETE_TEXT: str = 'TO_DELETE' + + for folder_to_delete in ( folder for folder in Path(gen_project_dir).iterdir() - if folder.is_dir() and folder.name == 'PyGen_TO_DELETE' + if folder.is_dir() and TO_DELETE_TEXT in str(folder.name) ): - shutil.rmtree(docs_folder_to_delete, ignore_errors=True) + shutil.rmtree(folder_to_delete, ignore_errors=True) def _remove_irrelevant_top_level_files(request): diff --git "a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/README.md" "b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/README.md" new file mode 100644 index 00000000..e36d7cca --- /dev/null +++ "b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/README.md" @@ -0,0 +1,166 @@ +# 📊 {{ cookiecutter.project_name }} - Observability Stack + +This directory contains the observability infrastructure for **{{ cookiecutter.project_name }}**, providing log aggregation and visualization capabilities. + +## 🚀 Quick Start + +### Prerequisites +- Docker and Docker Compose installed +- Port 3000 available for Grafana web interface + +### Start Observability Stack + +```bash +# Start all observability services +cd observability +docker-compose -f docker-compose.observability.yml up -d + +# Check services are running +docker-compose -f docker-compose.observability.yml ps +``` + +### Access Grafana Dashboard + +- **URL**: http://localhost:3000 +- **Username**: `admin` +- **Password**: `admin123` + +## 📈 What's Included + +### Services +- **Grafana** (port 3000): Web-based visualization and dashboards +- **Loki** (port 3100): Log aggregation and storage +- **Promtail**: Log collection agent + +### Log Sources +- **Application Logs**: JSON structured logs from {{ cookiecutter.pkg_name }} +- **Test Logs**: Plain text logs from pytest, tox, and other tools + +## 📊 Using the Dashboard + +### Viewing Logs +1. Open Grafana at http://localhost:3000 +2. Navigate to **Explore** in the sidebar +3. Select **Loki** as the data source +4. Use these queries to get started: + +```logql +# All application logs +{job="{{ cookiecutter.pkg_name }}-logs"} + +# Error logs only +{job="{{ cookiecutter.pkg_name }}-logs",level="ERROR"} + +# Log rate by level (logs per minute) +sum by (level) (rate({job="{{ cookiecutter.pkg_name }}-logs"}[1m])) +``` + +### Creating Dashboards +1. Click **"+"** → **Dashboard** → **Add new panel** +2. Select **Loki** as data source +3. Enter LogQL queries to visualize your data +4. Customize visualization type (time series, logs, stat, etc.) + +## 📝 Generating Logs + +### Application Logs (JSON Format) +```python +import json +import logging + +# Configure JSON logging +def setup_logging(): + logging.basicConfig( + format='{"timestamp":"%(asctime)s","level":"%(levelname)s","message":"%(message)s","component":"{{ cookiecutter.pkg_name }}"}', + level=logging.INFO + ) + +# Generate sample logs +logger = logging.getLogger(__name__) +logger.info("Application started") +logger.warning("Configuration deprecated") +logger.error("Processing failed") +``` + +### Quick Test Logs +```bash +# Generate sample logs for testing +mkdir -p logs +echo '{"timestamp":"2024-01-01T12:00:00Z","level":"INFO","message":"Application started","component":"{{ cookiecutter.pkg_name }}"}' >> logs/app.log +echo '{"timestamp":"2024-01-01T12:00:01Z","level":"ERROR","message":"Something went wrong","component":"validator"}' >> logs/app.log + +# Run tests to generate test logs +python -m pytest -v 2>&1 | tee logs/pytest.log +``` + +## 🛠️ Configuration + +### Loki Configuration +- **File**: `loki/loki-config.yml` +- **Storage**: Local filesystem storage +- **Retention**: Configurable in limits_config + +### Promtail Configuration +- **File**: `promtail/promtail-config.yml` +- **Watched Paths**: + - `/logs/*.log` (application logs) + - `/workspace/logs/*.log` (test logs) +- **Log Parsing**: Automatic JSON and regex-based level extraction + +### Grafana Configuration +- **Data Sources**: Auto-provisioned Loki connection +- **Dashboards**: Configurable dashboard provisioning +- **Persistence**: Data persisted in Docker volumes + +## 🔧 Customization + +### Adding Custom Log Sources +Edit `promtail/promtail-config.yml` to add new log file patterns: + +```yaml +scrape_configs: + - job_name: "my-custom-logs" + static_configs: + - targets: [localhost] + labels: + job: "custom" + __path__: "/path/to/my/logs/*.log" +``` + +### Creating Custom Dashboards +1. Create dashboards in Grafana UI +2. Export dashboard JSON +3. Save to `grafana/dashboards/` directory +4. Restart Grafana service to load automatically + +## 🚨 Troubleshooting + +### Services Not Starting +```bash +# Check service logs +docker-compose -f docker-compose.observability.yml logs grafana +docker-compose -f docker-compose.observability.yml logs loki +docker-compose -f docker-compose.observability.yml logs promtail +``` + +### No Logs Appearing +1. Check if log files exist in `logs/` directory +2. Verify Promtail is reading files: `docker logs {{ cookiecutter.project_slug }}-promtail` +3. Check Loki is receiving data: http://localhost:3100/metrics + +### Port Conflicts +If port 3000 is in use, edit `docker-compose.observability.yml`: +```yaml +grafana: + ports: + - "3001:3000" # Use port 3001 instead +``` + +## 🎯 Next Steps + +1. **Create Custom Dashboards**: Build visualizations specific to your application +2. **Set Up Alerts**: Configure Grafana alerts for critical log patterns +3. **Add Metrics**: Consider adding Prometheus for application metrics +4. **Production Setup**: Configure proper authentication and security for production use + +For more information, visit the [Grafana Documentation](https://grafana.com/docs/) and [Loki Documentation](https://grafana.com/docs/loki/). \ No newline at end of file diff --git "a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/docker-compose.observability.yml" "b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/docker-compose.observability.yml" new file mode 100644 index 00000000..412aae43 --- /dev/null +++ "b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/docker-compose.observability.yml" @@ -0,0 +1,69 @@ +version: '3.8' + +services: + grafana: + image: grafana/grafana:latest + container_name: "{{ cookiecutter.project_slug }}-grafana" + ports: + - "3000:3000" + environment: + # Default admin credentials + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin123 + # Allow anonymous access for easier development + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + # Disable user signup + - GF_USERS_ALLOW_SIGN_UP=false + # Set timezone + - TZ=UTC + volumes: + # Persist Grafana data + - grafana-storage:/var/lib/grafana + # Mount logs directory for file-based data source + - "./logs:/var/log/{{ cookiecutter.pkg_name }}:ro" + # Mount project directory + - .:/workspace:ro + # Mount dashboards + - ./observability/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro + - ./observability/grafana/datasources:/etc/grafana/provisioning/datasources:ro + restart: unless-stopped + networks: + - observability-network + + # Loki for log aggregation + loki: + image: grafana/loki:latest + container_name: "{{ cookiecutter.project_slug }}-loki" + ports: + - "3100:3100" + volumes: + - ./observability/loki/loki-config.yml:/etc/loki/local-config.yaml:ro + - loki-storage:/loki + command: -config.file=/etc/loki/local-config.yaml + restart: unless-stopped + networks: + - observability-network + + # Promtail for log collection + promtail: + image: grafana/promtail:latest + container_name: "{{ cookiecutter.project_slug }}-promtail" + volumes: + - ./observability/promtail/promtail-config.yml:/etc/promtail/config.yml:ro + - "./logs:/var/log/{{ cookiecutter.pkg_name }}:ro" + - .:/workspace:ro + command: -config.file=/etc/promtail/config.yml + restart: unless-stopped + networks: + - observability-network + depends_on: + - loki + +volumes: + grafana-storage: + loki-storage: + +networks: + observability-network: + driver: bridge \ No newline at end of file diff --git "a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/grafana/dashboards/dashboard.yml" "b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/grafana/dashboards/dashboard.yml" new file mode 100644 index 00000000..80bea3b1 --- /dev/null +++ "b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/grafana/dashboards/dashboard.yml" @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: 'default' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards \ No newline at end of file diff --git "a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/grafana/datasources/loki.yml" "b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/grafana/datasources/loki.yml" new file mode 100644 index 00000000..e9bcf5d3 --- /dev/null +++ "b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/grafana/datasources/loki.yml" @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + isDefault: true + editable: true \ No newline at end of file diff --git "a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/loki/loki-config.yml" "b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/loki/loki-config.yml" new file mode 100644 index 00000000..3d8b2ed4 --- /dev/null +++ "b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/loki/loki-config.yml" @@ -0,0 +1,48 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + +common: + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + instance_addr: 127.0.0.1 + kvstore: + store: inmemory + +# Fix for schema compatibility issues +limits_config: + allow_structured_metadata: false + reject_old_samples: true + reject_old_samples_max_age: 168h + +query_range: + results_cache: + cache: + embedded_cache: + enabled: true + max_size_mb: 100 + +schema_config: + configs: + - from: 2020-10-24 + store: boltdb-shipper + object_store: filesystem + schema: v11 + index: + prefix: index_ + period: 24h + +ruler: + alertmanager_url: http://localhost:9093 + +# By default, Loki will send anonymous usage data to Grafana Labs +# Disable this for development +analytics: + reporting_enabled: false \ No newline at end of file diff --git "a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/promtail/promtail-config.yml" "b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/promtail/promtail-config.yml" new file mode 100644 index 00000000..f252702f --- /dev/null +++ "b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/promtail/promtail-config.yml" @@ -0,0 +1,55 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + # Scrape application logs (JSON format) + - job_name: "{{ cookiecutter.pkg_name }}-app-logs" + static_configs: + - targets: + - localhost + labels: + job: "{{ cookiecutter.pkg_name }}-logs" + __path__: "/var/log/{{ cookiecutter.pkg_name }}/*.log" + pipeline_stages: + # Parse JSON logs to extract log level + - json: + expressions: + level: level + timestamp: timestamp + message: message + component: component + # Add log level as a label for easy filtering + - labels: + level: + component: + # Set timestamp from log entry + - timestamp: + source: timestamp + format: RFC3339 + + # Scrape plain text logs (pytest, tox, etc.) + - job_name: "{{ cookiecutter.pkg_name }}-test-logs" + static_configs: + - targets: + - localhost + labels: + job: "{{ cookiecutter.pkg_name }}-test-logs" + __path__: /workspace/logs/*.log + pipeline_stages: + # Extract log level from plain text logs + - regex: + expression: '(?i)(?P(DEBUG|INFO|WARNING|ERROR|CRITICAL))' + # Add extracted level as label + - labels: + level: + # If no level found, default to INFO + - template: + source: level + template: '{{ "{{" }} if .level {{ "}}" }}{{ "{{" }} .level {{ "}}" }}{{ "{{" }} else {{ "}}" }}INFO{{ "{{" }} end {{ "}}" }}' \ No newline at end of file diff --git a/tests/test_observability.py b/tests/test_observability.py new file mode 100644 index 00000000..b8f25b17 --- /dev/null +++ b/tests/test_observability.py @@ -0,0 +1,318 @@ +"""Test observability integration with the cookiecutter template. + +This test suite validates that the observability stack (Grafana + Loki + Promtail) +is correctly integrated into generated projects when the include_observability +parameter is set to 'yes'. +""" + +import os +import tempfile +import typing as t +import yaml +from pathlib import Path + +import pytest + + +@pytest.fixture +def generate_test_project(): + """Helper fixture to generate a test project with observability configuration. + + Returns a function that can be called with specific parameters to generate + a project for testing purposes. + """ + temp_dirs = [] # Keep track of temp directories to clean up + + def _generate_test_project( + include_observability: str = "yes", + project_type: str = "module", + config_extra: t.Optional[t.Dict[str, str]] = None, + ) -> str: + from cookiecutter_python.backend.main import generate + + # Create temporary directory but don't auto-clean it yet + temp_dir = tempfile.mkdtemp() + temp_dirs.append(temp_dir) + + config_file = os.path.join(temp_dir, 'test_config.yaml') + + # Generate unique project name to avoid collisions + project_name = f'Unit Test Observability Project' + + # Base configuration + config = { + 'default_context': { + 'project_name': project_name, + 'include_observability': include_observability, + 'project_type': project_type, + 'cli_entry_point': 'test_observability', + 'repo_hosting_domain': 'github.com', + 'interpreters': {"supported-interpreters": ["3.8", "3.9", "3.10", "3.11"]}, + } + } + + # Add any extra configuration + if config_extra: + config['default_context'].update(config_extra) + + # Write config file + with open(config_file, 'w') as f: + yaml.dump(config, f) + + # Generate project + output_dir = os.path.join(temp_dir, 'output') + os.makedirs(output_dir, exist_ok=True) + + return generate( + config_file=config_file, + output_dir=output_dir, + no_input=True, + offline=True + ) + + yield _generate_test_project + + # Cleanup temporary directories after test + import shutil + for temp_dir in temp_dirs: + try: + shutil.rmtree(temp_dir) + except OSError: + pass # Ignore cleanup errors + + +def test_observability_directory_created_when_enabled( + # GIVEN a project generator configured with observability enabled + generate_test_project, +): + # WHEN we generate a project with include_observability set to 'yes' + project_path = generate_test_project(include_observability="yes") + obs_dir = Path(project_path) / 'observability' + + # THEN the observability directory should be created + assert obs_dir.exists(), "Observability directory should exist when enabled" + assert obs_dir.is_dir(), "Observability path should be a directory" + + +def test_observability_directory_not_created_when_disabled( + # GIVEN a project generator configured with observability disabled + generate_test_project, +): + # WHEN we generate a project with include_observability set to 'no' + project_path = generate_test_project(include_observability="no") + obs_dir = Path(project_path) / 'observability' + + # THEN the observability directory should not be created + assert not obs_dir.exists(), "Observability directory should not exist when disabled" + + +def test_docker_compose_observability_file_exists( + # GIVEN a project generator with observability enabled + generate_test_project, +): + # WHEN we generate a project with observability stack + project_path = generate_test_project(include_observability="yes") + docker_compose_file = Path(project_path) / 'observability' / 'docker-compose.observability.yml' + + # THEN the docker-compose observability file should be created + assert docker_compose_file.exists(), "docker-compose.observability.yml should exist" + assert docker_compose_file.is_file(), "docker-compose.observability.yml should be a file" + + +def test_loki_config_file_exists( + # GIVEN a project generator with observability enabled + generate_test_project, +): + # WHEN we generate a project with observability stack + project_path = generate_test_project(include_observability="yes") + loki_config = Path(project_path) / 'observability' / 'loki' / 'loki-config.yml' + + # THEN the Loki configuration file should be created + assert loki_config.exists(), "loki-config.yml should exist" + assert loki_config.is_file(), "loki-config.yml should be a file" + + +def test_promtail_config_file_exists( + # GIVEN a project generator with observability enabled + generate_test_project, +): + # WHEN we generate a project with observability stack + project_path = generate_test_project(include_observability="yes") + promtail_config = Path(project_path) / 'observability' / 'promtail' / 'promtail-config.yml' + + # THEN the Promtail configuration file should be created + assert promtail_config.exists(), "promtail-config.yml should exist" + assert promtail_config.is_file(), "promtail-config.yml should be a file" + + +def test_grafana_datasource_config_exists( + # GIVEN a project generator with observability enabled + generate_test_project, +): + # WHEN we generate a project with observability stack + project_path = generate_test_project(include_observability="yes") + datasource_config = Path(project_path) / 'observability' / 'grafana' / 'datasources' / 'loki.yml' + + # THEN the Grafana datasource configuration should be created + assert datasource_config.exists(), "Grafana loki.yml should exist" + assert datasource_config.is_file(), "Grafana loki.yml should be a file" + + +def test_observability_readme_exists( + # GIVEN a project generator with observability enabled + generate_test_project, +): + # WHEN we generate a project with observability stack + project_path = generate_test_project(include_observability="yes") + readme_file = Path(project_path) / 'observability' / 'README.md' + + # THEN the observability README documentation should be created + assert readme_file.exists(), "Observability README.md should exist" + assert readme_file.is_file(), "Observability README.md should be a file" + + +def test_docker_compose_has_valid_yaml_syntax( + # GIVEN a project with observability stack generated + generate_test_project, +): + # WHEN we check the generated docker-compose file + project_path = generate_test_project(include_observability="yes") + docker_compose_file = Path(project_path) / 'observability' / 'docker-compose.observability.yml' + + # THEN the file should have valid YAML syntax + with open(docker_compose_file, 'r') as f: + yaml.safe_load(f) # Should not raise an exception if YAML is valid + + +def test_loki_config_has_valid_yaml_syntax( + # GIVEN a project with observability stack generated + generate_test_project, +): + # WHEN we check the generated Loki configuration file + project_path = generate_test_project(include_observability="yes") + loki_config = Path(project_path) / 'observability' / 'loki' / 'loki-config.yml' + + # THEN the file should have valid YAML syntax + with open(loki_config, 'r') as f: + yaml.safe_load(f) # Should not raise an exception if YAML is valid + + +def test_promtail_config_has_valid_yaml_syntax( + # GIVEN a project with observability stack generated + generate_test_project, +): + # WHEN we check the generated Promtail configuration file + project_path = generate_test_project(include_observability="yes") + promtail_config = Path(project_path) / 'observability' / 'promtail' / 'promtail-config.yml' + + # THEN the file should have valid YAML syntax + with open(promtail_config, 'r') as f: + yaml.safe_load(f) # Should not raise an exception if YAML is valid + + +def test_grafana_datasource_has_valid_yaml_syntax( + # GIVEN a project with observability stack generated + generate_test_project, +): + # WHEN we check the generated Grafana datasource configuration + project_path = generate_test_project(include_observability="yes") + datasource_config = Path(project_path) / 'observability' / 'grafana' / 'datasources' / 'loki.yml' + + # THEN the file should have valid YAML syntax + with open(datasource_config, 'r') as f: + yaml.safe_load(f) # Should not raise an exception if YAML is valid + + +def test_docker_compose_contains_project_specific_names( + # GIVEN a project with observability stack generated + generate_test_project, +): + # WHEN we examine the docker-compose file content + project_path = generate_test_project(include_observability="yes") + docker_compose_file = Path(project_path) / 'observability' / 'docker-compose.observability.yml' + content = docker_compose_file.read_text() + + # Extract the actual project slug from the generated project path + project_slug = Path(project_path).name + + # THEN it should contain project-specific container names + assert f'{project_slug}-grafana' in content, f"Should contain project-specific Grafana container name with slug: {project_slug}" + assert f'{project_slug}-loki' in content, f"Should contain project-specific Loki container name with slug: {project_slug}" + assert f'{project_slug}-promtail' in content, f"Should contain project-specific Promtail container name with slug: {project_slug}" + + +def test_observability_works_with_different_project_types( + # GIVEN different project types supported by the generator + generate_test_project, +): + project_types = ["module", "module+cli", "pytest-plugin"] + + for project_type in project_types: + # WHEN we generate a project of each type with observability enabled + project_path = generate_test_project( + include_observability="yes", + project_type=project_type + ) + obs_dir = Path(project_path) / 'observability' + + # THEN the observability stack should be generated for all project types + assert obs_dir.exists(), f"Observability should work with {project_type} projects" + + +def test_loki_config_contains_expected_content( + # GIVEN a project with observability stack generated + generate_test_project, +): + # WHEN we examine the Loki configuration content + project_path = generate_test_project(include_observability="yes") + loki_config = Path(project_path) / 'observability' / 'loki' / 'loki-config.yml' + + with open(loki_config, 'r') as f: + config = yaml.safe_load(f) + + # THEN it should contain all required Loki configuration sections + assert 'auth_enabled' in config, "Loki config should have auth_enabled setting" + assert 'server' in config, "Loki config should have server section" + assert 'common' in config, "Loki config should have common section" + assert 'schema_config' in config, "Loki config should have schema_config section" + assert 'limits_config' in config, "Loki config should have limits_config section" + assert 'schema_config' in config, "Loki config should have schema_config section" + + +def test_promtail_config_contains_expected_content( + # GIVEN a project with observability stack generated + generate_test_project, +): + # WHEN we examine the Promtail configuration content + project_path = generate_test_project(include_observability="yes") + promtail_config = Path(project_path) / 'observability' / 'promtail' / 'promtail-config.yml' + + with open(promtail_config, 'r') as f: + config = yaml.safe_load(f) + + # THEN it should contain all required Promtail configuration sections + assert 'server' in config, "Promtail config should have server section" + assert 'clients' in config, "Promtail config should have clients section" + assert 'scrape_configs' in config, "Promtail config should have scrape_configs section" + + +def test_grafana_datasource_points_to_loki( + # GIVEN a project with observability stack generated + generate_test_project, +): + # WHEN we examine the Grafana datasource configuration + project_path = generate_test_project(include_observability="yes") + datasource_config = Path(project_path) / 'observability' / 'grafana' / 'datasources' / 'loki.yml' + + with open(datasource_config, 'r') as f: + config = yaml.safe_load(f) + + # THEN it should be properly configured to connect to the Loki service + datasources = config.get('datasources', []) + assert len(datasources) > 0, "Should have at least one datasource" + + loki_datasource = datasources[0] + assert loki_datasource['type'] == 'loki', "Datasource should be of type 'loki'" + assert 'loki:3100' in loki_datasource['url'], "Should point to Loki service on standard port" + assert loki_datasource['name'] == 'Loki', "Should have proper display name" + assert loki_datasource['isDefault'] is True, "Should be the default datasource" \ No newline at end of file From eaa78dd8a9867d7df48d1b024550a4a07e64071d Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Tue, 28 Oct 2025 22:43:08 +0200 Subject: [PATCH 16/35] emphemeral: enable only Integration Tests in PR Validation Pipeline --- .github/workflows/dev_pr_validation.yml | 398 ++++++++++++------------ 1 file changed, 200 insertions(+), 198 deletions(-) diff --git a/.github/workflows/dev_pr_validation.yml b/.github/workflows/dev_pr_validation.yml index e02f9f68..38e72c90 100644 --- a/.github/workflows/dev_pr_validation.yml +++ b/.github/workflows/dev_pr_validation.yml @@ -55,219 +55,221 @@ jobs: uv run pytest -ra -n auto --run-requires_uv --run-slow --run-network_bound -vvs -k 'test_cli or build_backend_sdist or test_build_creates_artifacts or test_lint_passes' # CROSS PLATFORM TESTING: 15s on Ubuntu, 25 on mac, 35 on windows - cross_platform_tests: - runs-on: ${{ matrix.platform }} - # trigger if event is pr to dev - strategy: - fail-fast: false - matrix: - platform: [ubuntu-latest, macos-latest] - # platform: [windows-latest] - python-version: ['3.10'] - steps: - - - uses: actions/checkout@v4 - # windows cannot read new style docs template folders with exotic name, so maybe try sparse checkout to igore cloning those dirs - # until then windows CI is shut off. - # with: - # sparse-checkout: | - # .github - # src - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install uv - if: matrix.platform != 'windows-latest' - run: curl -LsSf https://astral.sh/uv/install.sh | sh - - - name: Install the latest version of uv - if: matrix.platform == 'windows-latest' - uses: astral-sh/setup-uv@v5 - with: - version: "latest" - - - # Build Package Wheel - - name: Build Package Wheel and Sdist Distributions - id: build - shell: bash - run: | - uv build --out-dir dist - - WHEEL_FILE=$(ls dist/cookiecutter_python-*.whl) - WHEEL_NAME="$(basename $WHEEL_FILE)" - - PEP_VERSION=$(echo $WHEEL_NAME | cut -d'-' -f2 | sed 's/-/./g') - echo "PEP_VERSION=$PEP_VERSION" >> $GITHUB_OUTPUT - - # Install exact Prod + Test dependencies - - name: 'Export pinned Prod + Test dependencies' - run: uv export --no-emit-project --no-dev --extra test --frozen --format requirements-txt -o requirements.txt - - - name: Install exact 'Prod + Test' Dependencies - shell: bash - run: | - uv venv - if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then - . .venv/Scripts/activate - fi - uv pip install --no-deps -r requirements.txt - - # Install the Package Wheel - - name: Install Package Wheel - shell: bash # to solve issue on windows ci - run: | - if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then - . .venv/Scripts/activate - fi - uv pip install --no-deps dist/cookiecutter_python-*.whl - - #### Run Test Suite against the package and measure Coverage #### - - name: Run Tests and measure Coverage - shell: bash - env: - # log deletion post hook fails on windows, due to permission error! (other process is using the file, so removing is denied) - # windows spawn multiple processes, so log deletion is not possible, even when running 1 Single Unit Test - BUG_LOG_DEL_WIN: 'permission_error' # required on Windows Job - PY_WHEEL: 1 # required on Windows Job - COVERAGE_FILE: '.coverage.wheel' - run: | - if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then - . .venv/Scripts/activate - else - # Linux and MacOS - . .venv/bin/activate - fi - - pytest -ra --run-requires_uv --run-network_bound -vvs --cov --cov-report=term-missing \ - --cov-report=html:test-wheel/htmlcov --cov-context=test \ - --cov-report=xml:coverage.test-wheel.xml \ - -n auto tests - - ## Code Coverage ## TODO; enable combine by moving each .coverage file to other dir to avoid override of subsequent coverage invocations with pytest - - name: "Discover and Combine Coverage data into XML Reports" - if: always() - env: - COVERAGE_FILE: .coverage - OUTPUT_XML: coverage-${{ matrix.platform }}-${{ matrix.python-version }}-wheel.xml - shell: bash - run: | - uv venv cov-env - if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then - . cov-env/Scripts/activate - else - # Linux and MacOS - . cov-env/bin/activate - fi - - uv pip install 'coverage[toml] >= 5.1' 'diff_cover >=6' - - echo '[INFO] Running coverage' - uv run --active coverage combine --keep - uv run --active coverage report --skip-covered --show-missing -i - - uv run --active coverage xml -o ./${{ env.OUTPUT_XML }} -i - # uv run --active coverage html -d ./htmlcov -i - - echo "OUTPUT_XML=${{ env.OUTPUT_XML }}" >> $GITHUB_OUTPUT - id: coverage - - - name: "Upload Test Coverage as Artifacts" - if: always() - uses: actions/upload-artifact@v4 - with: - overwrite: false - name: '${{ steps.coverage.outputs.OUTPUT_XML }}' - path: '${{ steps.coverage.outputs.OUTPUT_XML }}' - if-no-files-found: error - - - name: Upload Source & Wheel distributions as Artefacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: dist-${{ matrix.platform }}-${{ matrix.python-version }} - path: dist - if-no-files-found: error - - outputs: - PEP_VERSION: ${{ steps.build.outputs.PEP_VERSION }} + # cross_platform_tests: + # runs-on: ${{ matrix.platform }} + # # trigger if event is pr to dev + # strategy: + # fail-fast: false + # matrix: + # platform: [ubuntu-latest, macos-latest] + # # platform: [windows-latest] + # python-version: ['3.10'] + # steps: + # + # - uses: actions/checkout@v4 + # # windows cannot read new style docs template folders with exotic name, so maybe try sparse checkout to igore cloning those dirs + # # until then windows CI is shut off. + # # with: + # # sparse-checkout: | + # # .github + # # src + # + # - name: Set up Python ${{ matrix.python-version }} + # uses: actions/setup-python@v5 + # with: + # python-version: ${{ matrix.python-version }} + # + # - name: Install uv + # if: matrix.platform != 'windows-latest' + # run: curl -LsSf https://astral.sh/uv/install.sh | sh + # + # - name: Install the latest version of uv + # if: matrix.platform == 'windows-latest' + # uses: astral-sh/setup-uv@v5 + # with: + # version: "latest" + # + # + # # Build Package Wheel + # - name: Build Package Wheel and Sdist Distributions + # id: build + # shell: bash + # run: | + # uv build --out-dir dist + # + # WHEEL_FILE=$(ls dist/cookiecutter_python-*.whl) + # WHEEL_NAME="$(basename $WHEEL_FILE)" + # + # PEP_VERSION=$(echo $WHEEL_NAME | cut -d'-' -f2 | sed 's/-/./g') + # echo "PEP_VERSION=$PEP_VERSION" >> $GITHUB_OUTPUT + # + # # Install exact Prod + Test dependencies + # - name: 'Export pinned Prod + Test dependencies' + # run: uv export --no-emit-project --no-dev --extra test --frozen --format requirements-txt -o requirements.txt + # + # - name: Install exact 'Prod + Test' Dependencies + # shell: bash + # run: | + # uv venv + # if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then + # . .venv/Scripts/activate + # fi + # uv pip install --no-deps -r requirements.txt + # + # # Install the Package Wheel + # - name: Install Package Wheel + # shell: bash # to solve issue on windows ci + # run: | + # if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then + # . .venv/Scripts/activate + # fi + # uv pip install --no-deps dist/cookiecutter_python-*.whl + # + # #### Run Test Suite against the package and measure Coverage #### + # - name: Run Tests and measure Coverage + # shell: bash + # env: + # # log deletion post hook fails on windows, due to permission error! (other process is using the file, so removing is denied) + # # windows spawn multiple processes, so log deletion is not possible, even when running 1 Single Unit Test + # BUG_LOG_DEL_WIN: 'permission_error' # required on Windows Job + # PY_WHEEL: 1 # required on Windows Job + # COVERAGE_FILE: '.coverage.wheel' + # run: | + # if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then + # . .venv/Scripts/activate + # else + # # Linux and MacOS + # . .venv/bin/activate + # fi + # + # pytest -ra --run-requires_uv --run-network_bound -vvs --cov --cov-report=term-missing \ + # --cov-report=html:test-wheel/htmlcov --cov-context=test \ + # --cov-report=xml:coverage.test-wheel.xml \ + # -n auto tests + # + # ## Code Coverage ## TODO; enable combine by moving each .coverage file to other dir to avoid override of subsequent coverage invocations with pytest + # - name: "Discover and Combine Coverage data into XML Reports" + # if: always() + # env: + # COVERAGE_FILE: .coverage + # OUTPUT_XML: coverage-${{ matrix.platform }}-${{ matrix.python-version }}-wheel.xml + # shell: bash + # run: | + # uv venv cov-env + # if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then + # . cov-env/Scripts/activate + # else + # # Linux and MacOS + # . cov-env/bin/activate + # fi + # + # uv pip install 'coverage[toml] >= 5.1' 'diff_cover >=6' + # + # echo '[INFO] Running coverage' + # uv run --active coverage combine --keep + # uv run --active coverage report --skip-covered --show-missing -i + # + # uv run --active coverage xml -o ./${{ env.OUTPUT_XML }} -i + # # uv run --active coverage html -d ./htmlcov -i + # + # echo "OUTPUT_XML=${{ env.OUTPUT_XML }}" >> $GITHUB_OUTPUT + # id: coverage + # + # - name: "Upload Test Coverage as Artifacts" + # if: always() + # uses: actions/upload-artifact@v4 + # with: + # overwrite: false + # name: '${{ steps.coverage.outputs.OUTPUT_XML }}' + # path: '${{ steps.coverage.outputs.OUTPUT_XML }}' + # if-no-files-found: error + # + # - name: Upload Source & Wheel distributions as Artefacts + # if: always() + # uses: actions/upload-artifact@v4 + # with: + # name: dist-${{ matrix.platform }}-${{ matrix.python-version }} + # path: dist + # if-no-files-found: error + # + # outputs: + # PEP_VERSION: ${{ steps.build.outputs.PEP_VERSION }} # CODECOV Upload of COVERAGE reports - codecov: - if: always() && contains(fromJSON('["success", "failure"]'), needs.cross_platform_tests.result) - needs: cross_platform_tests - uses: ./.github/workflows/codecov-job.yml - secrets: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + # codecov: + # if: env.INTEGRATION_ONLY != 'true' && always() && contains(fromJSON('["success", "failure"]'), needs.cross_platform_tests.result) + # needs: cross_platform_tests + # uses: ./.github/workflows/codecov-job.yml + # secrets: + # CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} # STATIC CODE ANALYSIS: mypy, ruff, isort, black, bandit, mccabe, prospector, etc - sca: - uses: ./.github/workflows/sca-job.yml - with: - python_version: '3.10' - allow_failure: ${{ github.event_name != 'pull_request' || github.base_ref != 'dev' }} - # Acceptance Criteria - force_styles: ${{ github.event_name == 'pull_request' && github.base_ref == 'dev' }} - bandit: '{"h": "0", "m": "0", "l": "4"}' # Automated Acceptance Criteria for Bandit + # sca: + # if: env.INTEGRATION_ONLY != 'true' + # uses: ./.github/workflows/sca-job.yml + # with: + # python_version: '3.10' + # allow_failure: ${{ github.event_name != 'pull_request' || github.base_ref != 'dev' }} + # # Acceptance Criteria + # force_styles: ${{ github.event_name == 'pull_request' && github.base_ref == 'dev' }} + # bandit: '{"h": "0", "m": "0", "l": "4"}' # Automated Acceptance Criteria for Bandit # PYDEPS - pydeps: - uses: ./.github/workflows/pydeps-job.yml - with: - default_trigger: true - override: '${{ vars.OV_PYDEPS }}' - python_version: '3.10' + # pydeps: + # if: env.INTEGRATION_ONLY != 'true' + # uses: ./.github/workflows/pydeps-job.yml + # with: + # default_trigger: true + # override: '${{ vars.OV_PYDEPS }}' + # python_version: '3.10' # TYPE CHECK - type_check: - uses: ./.github/workflows/type-check-job.yml - with: - default_trigger: true - override: '${{ vars.OV_TYPE_CHECK }}' - python_version: '3.10' + # type_check: + # uses: ./.github/workflows/type-check-job.yml + # with: + # default_trigger: true + # override: '${{ vars.OV_TYPE_CHECK }}' + # python_version: '3.10' # DOCS - docs: - uses: ./.github/workflows/docs-job.yml - with: - default_trigger: true - override: '${{ vars.OV_DOCS }}' - python_version: '3.11' + # docs: + # uses: ./.github/workflows/docs-job.yml + # with: + # default_trigger: true + # override: '${{ vars.OV_DOCS }}' + # python_version: '3.11' ## DOCKER BUILD and PUBLISH ON DOCKERHUB ## # Ref Page: https://automated-workflows.readthedocs.io/en/main/ref_docker/ - docker_build: - uses: boromir674/automated-workflows/.github/workflows/docker.yml@v1.14.0 - if: github.event_name == 'pull_request' && github.base_ref == 'release' - with: - acceptance_policy: '1' - image_slug: "generate-python" - image_tag: "latest" - # target_stage: "some_stage_alias" # no stage, means no `--target` flag, on build - tests_pass: true - tests_run: true - DOCKER_USER: ${{ vars.DOCKER_USER }} - secrets: - DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + # docker_build: + # uses: boromir674/automated-workflows/.github/workflows/docker.yml@v1.14.0 + # if: github.event_name == 'pull_request' && github.base_ref == 'release' + # with: + # acceptance_policy: '1' + # image_slug: "generate-python" + # image_tag: "latest" + # # target_stage: "some_stage_alias" # no stage, means no `--target` flag, on build + # tests_pass: true + # tests_run: true + # DOCKER_USER: ${{ vars.DOCKER_USER }} + # secrets: + # DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} ## JOB: PYPI UPLOAD ## - pypi_publish: - needs: cross_platform_tests - uses: boromir674/automated-workflows/.github/workflows/pypi_env.yml@test - # Automatic .tar.gz and .whl discovery but with performance trade-off - # also with risk of uploading the wrong file - with: - should_trigger: ${{ github.event_name == 'pull_request' && github.base_ref == 'release' }} - distro_name: cookiecutter_python - distro_version: '${{ needs.cross_platform_tests.outputs.PEP_VERSION }}' - pypi_env: TEST_DEPLOYMENT - require_wheel: true - allow_existing: true - secrets: - TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} + # pypi_publish: + # needs: cross_platform_tests + # uses: boromir674/automated-workflows/.github/workflows/pypi_env.yml@test + # # Automatic .tar.gz and .whl discovery but with performance trade-off + # # also with risk of uploading the wrong file + # with: + # should_trigger: ${{ github.event_name == 'pull_request' && github.base_ref == 'release' }} + # distro_name: cookiecutter_python + # distro_version: '${{ needs.cross_platform_tests.outputs.PEP_VERSION }}' + # pypi_env: TEST_DEPLOYMENT + # require_wheel: true + # allow_existing: true + # secrets: + # TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} From b691fc592df0d123649b3447f3483daefa4fb3ba Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Thu, 30 Oct 2025 01:00:07 +0200 Subject: [PATCH 17/35] test: fix --- tests/conftest.py | 89 ++++++++++++++++++++++-------- tests/test_cookiecutter_context.py | 11 +++- 2 files changed, 75 insertions(+), 25 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 429d0eac..62827fdb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ import os import sys import typing as t +import logging if sys.version_info >= (3, 8): @@ -17,6 +18,9 @@ my_dir: str = os.path.dirname(os.path.realpath(__file__)) +logger = logging.getLogger(__name__) + + @pytest.fixture def test_root() -> Path: """Root directory Path of the test suite; ie /projects/my-project/tests/""" @@ -370,6 +374,7 @@ def user_config(distro_loc: Path) -> ConfigInterfaceProtocol: def _load_context_json(file_path: PathLike) -> t.Dict: with open(file_path, 'r') as fp: data = json.load(fp) + assert type(data['cicd']) is list, f"'cicd' must be a list in {file_path}; found {type(data['cicd'])}" return data def _load_context_yaml(file_path: PathLike) -> t.MutableMapping[str, t.Any]: @@ -413,7 +418,7 @@ class ConfigData: 'without-interpreters': 'tests/data/biskotaki-without-interpreters.yaml', } - # When no Config File suppied values are derived from Ccookiecutter.json + # When no Config File suppied values are derived from Cookiecutter.json default_parameters: t.ClassVar[DefParams] = { 'data_file': distro_loc / 'cookiecutter.json', 'data_loader': lambda cls: lambda json_file_string_path: cls.transorm_json_data( @@ -436,7 +441,9 @@ def __attrs_post_init__(self): _load_context_yaml(yaml_file_string_path) ) ) - self._config_file_arg = _data_file_path if self.path is not None else None + if self.path is not None: + # Store the config file path that was used to create this ConfigData + self._config_file_arg = _data_file_path assert _data_file_path.exists() assert _data_file_path.is_file() @@ -454,6 +461,10 @@ def data(self) -> t.Mapping: @staticmethod def transorm_json_data(data: t.Dict[str, t.Any]): + """Handles case that no User Config gile was passed; ie derive values from + cookiecutter.json""" + + # 1. Manual transformations that need custom logic data['project_slug'] = data['project_name'].lower().replace(' ', '-') data['docker_image'] = data['project_slug'] data['project_type'] = data['project_type'][0] @@ -462,12 +473,20 @@ def transorm_json_data(data: t.Dict[str, t.Any]): data['initialize_git_repo'] = {'yes': True}.get( data['initialize_git_repo'][0], False ) + + assert type(data['cicd']) is list, f"'cicd' must be a list; found {type(data['cicd'])}" + # 2. Auto-process choice variables (arrays) - use first element as default + for key, value in data.items(): + if isinstance(value, list) and len(value) > 0: + data[key] = value[0] + # Docs Building # - data['docs_builder'] = data['docs_builder'][0] # choice variable + # data['docs_builder'] = data['docs_builder'][0] # choice variable # RTD CI Python Version # - data['rtd_python_version'] = data['rtd_python_version'][0] # choice variable + # data['rtd_python_version'] = data['rtd_python_version'][0] # choice variable # CICD Pipeline Design old/new , stable/experimental - data['cicd'] = data['cicd'][0] # choice variable + # data['cicd'] = data['cicd'][0] # choice variable + return data @staticmethod @@ -620,7 +639,6 @@ def _get_expected_generated_files(config): user_docs_builder_id: str = config.data['docs_builder'] expected_gen_files: t.Set[Path] = set() - expected_to_find: t.Set = set() ## DERIVE the EXPECTED files to be removed, varying across 'Project Type' # we leverage the same production logic @@ -652,20 +670,21 @@ def b(docs_builder_id): # Find where each Docs Builder 'stores' its Template Files (ie source docs) selected_docs_template_dir: str = b(user_docs_builder_id) - builder_docs_folder_name: str = selected_docs_template_dir - source_docs_template_content_dir: Path = ( + _source_docs_template_content_dir: Path = ( distro_loc / r'{{ cookiecutter.project_slug }}' / selected_docs_template_dir ) + _expected_to_find: t.Set = set() + # those docs template dir, are expected to be found under 'docs' folder for file_path in iter( - (x for x in source_docs_template_content_dir.rglob('*') if x.is_file()) + (x for x in _source_docs_template_content_dir.rglob('*') if x.is_file()) ): assert isinstance(file_path, Path) # assert file_path is relative to docs_template_dir - rp = file_path.relative_to(source_docs_template_content_dir) - assert file_path.relative_to(source_docs_template_content_dir) - expected_to_find.add(Path('docs') / rp) + rp = file_path.relative_to(_source_docs_template_content_dir) + assert file_path.relative_to(_source_docs_template_content_dir) + _expected_to_find.add(Path('docs') / rp) # Now expected_to_find, should be populated with files names/paths that # should be expected to be rendered for the requested docs builder in the config @@ -674,18 +693,37 @@ def b(docs_builder_id): # pre-emptively any .pyc file from expected_to_find, since a bug was reported # where the .pyc file was not removed - expected_to_find = { + _expected_to_find = { x - for x in expected_to_find + for x in _expected_to_find if not str(x).endswith('.pyc') and not str(x).endswith('__pycache__') } assert not any( - [str(x).endswith('.pyc') for x in expected_to_find] - ), f"Sanity check fail: {expected_to_find}" - - ## DERIVE the EXPECTED root-level files for post removal, based on docs-builer type - # we leverage the same production mapping of builder_id to files + [str(x).endswith('.pyc') for x in _expected_to_find] + ), f"Sanity check fail: {_expected_to_find}" + + + # DERIVE ALL EXPECTED FILES FOR POST GENERATION REMOVAL and EXCLUDE FROM EXPECTATIONS + + ### 1. DO NOT EXPECT the folders with a TO_DELETE substring in their names ### + from cookiecutter_python.hooks.post_gen_project import TO_DELETE_TEXT + + template_root = distro_loc / r'{{ cookiecutter.project_slug }}' + expected_for_post_removal = [d for d in template_root.rglob('*') if d.is_dir() and TO_DELETE_TEXT in str(d)] + + for dir_path in expected_for_post_removal: + # Add the directory itself + files_to_remove.add(str(dir_path.relative_to(template_root))) + + # ADD THIS: Also add all files within the directory + for file_path in dir_path.rglob('*'): + if file_path.is_file(): + files_to_remove.add(str(file_path.relative_to(template_root))) + + ### 2. DO NOT EXPECT TO FIND root-level Docs Building Extra Files for other Docs Builders ### + ## DERIVE the EXPECTED root-level files for post removal after rendering + # we leverage single sourth of truth objects same as in production code from cookiecutter_python.hooks.post_gen_project import DOCS_FILES_EXTRA for docs_builder_id, builder_files in ( @@ -699,6 +737,8 @@ def b(docs_builder_id): [isinstance(x, str) for x in files_to_remove] ), f"Temporary Requirement of Test Code: files_to_remove must be a list of strings, not {files_to_remove}" + + ### 3. DO NOT EXPECT TO FIND THE DOCS FOLDERS FOR OTHER DOCS BUILDERS ### ## Remove all Template Docs files from Expectations, because in the Template Project # 2 dedicated foldres are used to maintain mkdocs and spinx Docs (which get moved to ./docs in post hook) for ( @@ -739,6 +779,7 @@ def b(docs_builder_id): str(file_path.relative_to(distro_loc / r'{{ cookiecutter.project_slug }}')) ) + assert all( [isinstance(x, str) for x in files_to_remove] ), f"Temporary Requirement of Test Code: files_to_remove must be a list of strings, not {files_to_remove}" @@ -772,8 +813,8 @@ def b(docs_builder_id): ] ) assert ( - len(set([type(x) for x in expected_to_find])) == 1 - ), f"Sanity check fail: {expected_to_find}" + len(set([type(x) for x in _expected_to_find])) == 1 + ), f"Sanity check fail: {_expected_to_find}" assert ( len(set([type(x) for x in files_to_remove])) == 1 ), f"Sanity check fail: {files_to_remove}" @@ -799,7 +840,7 @@ def b(docs_builder_id): # update based on derived post renamings to happen so_far: int = len(expected_gen_files) - expected_gen_files.update(expected_to_find) + expected_gen_files.update(_expected_to_find) assert not any( [str(x).endswith('.pyc') for x in expected_gen_files] @@ -814,8 +855,8 @@ def b(docs_builder_id): ), f"Sanity check fail: {expected_gen_files}" assert len(expected_gen_files) == so_far + len( - expected_to_find - ), "Our logic for deriving the expected files is wrong." + _expected_to_find + ), "Our logic, in the test code, for deriving the expected files is wrong." ## Inject Values in TEMPLATE placeholders ## # TODO: use comprenhesoin once stable, and then ease maintainace, with some automation diff --git a/tests/test_cookiecutter_context.py b/tests/test_cookiecutter_context.py index c5d2ad88..187088d8 100644 --- a/tests/test_cookiecutter_context.py +++ b/tests/test_cookiecutter_context.py @@ -134,6 +134,7 @@ def gen(): ), pytest.param( # TEST CASE 2 - Production Template included in Distribution + # whenever src/cookiecutter_python/cookiecutter.json is updated, the below needs syncing ( PROD_TEMPLATE, # GIVEN the prod Template (cookiecutter.json + jinja Template project) 'BISKOTAKI_CONFIG', # and the .github/biskotaki.yaml User Config YAML @@ -144,7 +145,7 @@ def gen(): ( 'cookiecutter', OrderedDict( - [ + [ # KEY, Default/First choice ('project_name', 'Biskotaki'), ('project_type', 'module'), ("project_slug", "biskotaki"), @@ -184,6 +185,7 @@ def gen(): ("docs_builder", "sphinx"), ("rtd_python_version", "3.10"), ('cicd', 'experimental'), + ('include_observability', 'no'), # since the below is expected to be put in the extra context before calling cookiecutter, it gets below the rest of Variables # ('_template', str(cookie)), ] @@ -240,6 +242,7 @@ def gen(): "3.12", ], 'cicd': ['experimental', 'stable'], + 'include_observability': ['no', 'yes'], }, ), ] @@ -252,6 +255,7 @@ def gen(): ), pytest.param( # TEST CASE 3 - Production Template included in Distribution + # whenever src/cookiecutter_python/cookiecutter.json is updated, the below needs syncing ( PROD_TEMPLATE, # GIVEN the prod Template (cookiecutter.json + jinja Template project) 'TEST_TIME_BISKOTAKI_CONFIG', # and a User Config File that "should be identical" to .github/biskotaki.yaml @@ -302,6 +306,7 @@ def gen(): ("docs_builder", "sphinx"), ("rtd_python_version", "3.10"), ('cicd', 'experimental'), + ('include_observability', 'no'), # since the below is expected to be put in the extra context before calling cookiecutter, it gets below the rest of Variables # ('_template', str(cookie)), ] @@ -359,6 +364,7 @@ def gen(): "3.12", ], 'cicd': ['experimental', 'stable'], + 'include_observability': ['no', 'yes'], }, ), ] @@ -371,6 +377,7 @@ def gen(): ), ( # TEST CASE 4 - Production Template + Gold Standard User Config + # whenever src/cookiecutter_python/cookiecutter.json is updated, the below needs syncing PROD_TEMPLATE, # GIVEN the prod Template (cookiecutter.json + jinja Template project) 'GOLD_STANDARD_CONFIG', # and the tests/data/gold-standard.yml # EXPECTED CONTEXT @@ -417,6 +424,7 @@ def gen(): ("docs_builder", "mkdocs"), ("rtd_python_version", "3.11"), ('cicd', 'experimental'), + ('include_observability', 'no'), ] ), ), @@ -468,6 +476,7 @@ def gen(): "3.12", ], 'cicd': ['experimental', 'stable'], + 'include_observability': ['no', 'yes'], }, ), ] From f99b1a2a703f4e6f309dddaf545f02f3273df71b Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Thu, 30 Oct 2025 01:00:57 +0200 Subject: [PATCH 18/35] add include_observability in dialog system --- src/cookiecutter_python/backend/helpers.py | 2 ++ .../handle/dialogs/lib/project_name.py | 10 +++++++++- src/cookiecutter_python/hooks/post_gen_project.py | 7 +++++-- .../README.md" | 2 +- tests/test_build_backend_sdist.py | 9 +++++++++ 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/cookiecutter_python/backend/helpers.py b/src/cookiecutter_python/backend/helpers.py index 4c1317a9..acadaecc 100644 --- a/src/cookiecutter_python/backend/helpers.py +++ b/src/cookiecutter_python/backend/helpers.py @@ -116,6 +116,8 @@ def parse_context(config_file: str): 'docs_builder', 'rtd_python_version', 'cicd', + # Include Grafana, Loki stack no/yes + 'include_observability', # ["no", "yes"] ] for field in choice_fields: diff --git a/src/cookiecutter_python/handle/dialogs/lib/project_name.py b/src/cookiecutter_python/handle/dialogs/lib/project_name.py index 017f1c2a..9ceb57c1 100644 --- a/src/cookiecutter_python/handle/dialogs/lib/project_name.py +++ b/src/cookiecutter_python/handle/dialogs/lib/project_name.py @@ -175,7 +175,7 @@ def dialog(self, cookie_vars) -> Mapping[str, str]: 'choices': cookie_vars['rtd_python_version']['choices'], 'default': cookie_vars['rtd_python_version']['default'], }, - # CICD Pipeline design: stable, experimental + # CICD Pipeline design: stable, experimental (the good one) { 'type': 'select', 'name': 'cicd', @@ -183,5 +183,13 @@ def dialog(self, cookie_vars) -> Mapping[str, str]: 'choices': cookie_vars['cicd']['choices'], 'default': cookie_vars['cicd']['default'], }, + # Grafana / Loki compose stack inclusion + { + 'type': 'select', + 'name': 'include_observability', + 'message': 'Include Observability Compose Stack (Grafana, Loki)?', + 'choices': cookie_vars['include_observability']['choices'], + 'default': cookie_vars['include_observability']['default'], + }, ] ) diff --git a/src/cookiecutter_python/hooks/post_gen_project.py b/src/cookiecutter_python/hooks/post_gen_project.py index e8dbc7a6..e56bc858 100644 --- a/src/cookiecutter_python/hooks/post_gen_project.py +++ b/src/cookiecutter_python/hooks/post_gen_project.py @@ -141,11 +141,14 @@ def post_file_removal(request): _remove_irrelevant_top_level_files(request) +# find top-level folders and delete the ones with name 'PyGen_TO_DELETE' or 'PyGen_TO_DELETE_OBSERVABILITY' +TO_DELETE_TEXT: str = 'TO_DELETE' +"""Signature text to identify folders that must be deleted after generation.""" + + def _remove_irrelevant_rendered_folders(gen_project_dir: str): """Remove generated docs folders that are not relevant to the selected docs builder.""" - # find top-level folders and delete the ones with name 'PyGen_TO_DELETE' or 'PyGen_TO_DELETE_OBSERVABILITY' - TO_DELETE_TEXT: str = 'TO_DELETE' for folder_to_delete in ( folder diff --git "a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/README.md" "b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/README.md" index e36d7cca..0c9cf86e 100644 --- "a/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/README.md" +++ "b/src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == \"yes\" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/README.md" @@ -6,7 +6,7 @@ This directory contains the observability infrastructure for **{{ cookiecutter.p ### Prerequisites - Docker and Docker Compose installed -- Port 3000 available for Grafana web interface +- Port 3000 available on host for Grafana web interface ### Start Observability Stack diff --git a/tests/test_build_backend_sdist.py b/tests/test_build_backend_sdist.py index b726844a..e0c1b177 100644 --- a/tests/test_build_backend_sdist.py +++ b/tests/test_build_backend_sdist.py @@ -75,6 +75,14 @@ def sdist_expected_correct_file_structure(): 'src/cookiecutter_python/{{ cookiecutter.project_slug }}/scripts/visualize-ga-workflow.py', 'src/cookiecutter_python/{{ cookiecutter.project_slug }}/setup.cfg', 'src/cookiecutter_python/{{ cookiecutter.project_slug }}/tox.ini', + # OBSERVABILITY COMPOSE STACK: GRAFANA, LOKI + 'src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == "yes" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/README.md', + 'src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == "yes" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/docker-compose.observability.yml', + 'src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == "yes" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/grafana/dashboards/dashboard.yml', + 'src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == "yes" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/grafana/datasources/loki.yml', + 'src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == "yes" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/loki/loki-config.yml', + 'src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == "yes" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/promtail/promtail-config.yml', + 'src/cookiecutter_python/_find_lib.py', 'src/cookiecutter_python/__init__.py', 'src/cookiecutter_python/__main__.py', @@ -146,6 +154,7 @@ def sdist_expected_correct_file_structure(): # 'src/stubs/requests_futures/sessions.pyi', ) TESTS = ( + 'tests/test_observability.py', 'tests/test_version_string.py', 'tests/test_git_sdk.py', 'tests/test_git_porcelain.py', From 5daa4b090ba36ff739c6ad02e6294600503e88a8 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Thu, 30 Oct 2025 01:15:16 +0200 Subject: [PATCH 19/35] test: add debug print statements for integration tests --- tests/test_build_backend_sdist.py | 355 +++++++++++++++++++++++++++--- 1 file changed, 324 insertions(+), 31 deletions(-) diff --git a/tests/test_build_backend_sdist.py b/tests/test_build_backend_sdist.py index e0c1b177..135ae1a6 100644 --- a/tests/test_build_backend_sdist.py +++ b/tests/test_build_backend_sdist.py @@ -624,68 +624,350 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: env["BUILD_BACKEND_ISOLATION"] = "true" env["SETUPTOOLS_SCM_DEBUG"] = "1" if worker_id != "master" else "0" - print(f"\n[Worker {worker_id}] Building with command: {' '.join(COMMAND_LINE_ARGS)}") - print(f"[Worker {worker_id}] Output directory: {OUT_DIR}") - print(f"[Worker {worker_id}] Project path: {project_path}") - print(f"[Worker {worker_id}] Python executable: {PYTHON}") - print(f"[Worker {worker_id}] Current working directory: {os.getcwd()}") - + # ========== COMPREHENSIVE ENVIRONMENT DEBUGGING ========== + print(f"\n{'='*80}") + print(f"COMPREHENSIVE ENVIRONMENT DEBUG INFO [Worker {worker_id}]") + print(f"{'='*80}") + + # Python runtime information + print(f"Python executable: {PYTHON}") + print(f"Python version: {sys.version}") + print(f"Python platform: {sys.platform}") + print(f"Python implementation: {sys.implementation.name}") + + # File system and paths + print(f"Current working directory: {os.getcwd()}") + print(f"Project path: {project_path}") + print(f"Project path exists: {project_path.exists()}") + print(f"Project path is directory: {project_path.is_dir()}") + print(f"Output directory: {OUT_DIR}") + print(f"Temp directory: {temp_dir}") + + # Build command + print(f"Build command: {' '.join(COMMAND_LINE_ARGS)}") + + # Environment variables of interest + print("\nCRITICAL ENVIRONMENT VARIABLES:") + critical_env_vars = [ + "PYTHONPATH", "PATH", "HOME", "USER", "PWD", "TMPDIR", "TMP", "TEMP", + "BUILD_BACKEND_ISOLATION", "SETUPTOOLS_SCM_DEBUG", "PIP_CACHE_DIR", + "PYTEST_XDIST_WORKER", "CI", "GITHUB_ACTIONS", "RUNNER_OS", "RUNNER_ARCH", + "VIRTUAL_ENV", "CONDA_DEFAULT_ENV", "POETRY_ACTIVE", "UV_CACHE_DIR" + ] + for var in critical_env_vars: + value = env.get(var, 'NOT SET') + print(f" {var}: {value}") + + # File system structure + print(f"\nFILE SYSTEM STRUCTURE:") + print(f"Project root contents (first 20 items):") + try: + items = list(project_path.iterdir())[:20] + for item in sorted(items): + print(f" {item.name} ({'dir' if item.is_dir() else 'file'})") + if len(list(project_path.iterdir())) > 20: + print(f" ... and {len(list(project_path.iterdir())) - 20} more items") + except Exception as e: + print(f" Error listing project contents: {e}") + + # Check critical files + critical_files = [ + project_path / "pyproject.toml", + project_path / "poetry.lock", + project_path / "uv.lock", + project_path / "setup.py", + project_path / "setup.cfg", + project_path / "MANIFEST.in", + project_path / "src", + project_path / "src" / "cookiecutter_python", + project_path / "src" / "cookiecutter_python" / "__init__.py" + ] + print(f"\nCRITICAL FILES CHECK:") + for file_path in critical_files: + exists = file_path.exists() + if exists: + if file_path.is_file(): + size = file_path.stat().st_size + print(f" {file_path.relative_to(project_path)}: EXISTS (file, {size} bytes)") + else: + print(f" {file_path.relative_to(project_path)}: EXISTS (directory)") + else: + print(f" {file_path.relative_to(project_path)}: MISSING") + # Check for potential lock files that could cause conflicts lock_files = [ project_path / "poetry.lock", project_path / ".build-lock", project_path / "pyproject.toml.lock", ] + print(f"\nLOCK FILES CHECK:") for lock_file in lock_files: if lock_file.exists(): - print(f"[Worker {worker_id}] Lock file exists: {lock_file}") + size = lock_file.stat().st_size + mtime = lock_file.stat().st_mtime + print(f" {lock_file.relative_to(project_path)}: EXISTS ({size} bytes, mtime: {mtime})") + else: + print(f" {lock_file.relative_to(project_path)}: NOT FOUND") + + # Python packages information + print(f"\nPYTHON PACKAGES INFO:") + try: + import subprocess + pip_list_result = subprocess.run([PYTHON, "-m", "pip", "list"], + capture_output=True, text=True, timeout=30) + if pip_list_result.returncode == 0: + lines = pip_list_result.stdout.strip().split('\n') + print(f" Total installed packages: {len(lines) - 2}") # Subtract header lines + # Show build-related packages + build_packages = ["build", "setuptools", "wheel", "poetry", "uv", "pip", "hatchling", "flit"] + for line in lines: + for pkg in build_packages: + if line.lower().startswith(pkg.lower()): + print(f" {line}") + else: + print(f" Failed to get pip list: {pip_list_result.stderr}") + except Exception as e: + print(f" Error getting pip list: {e}") + + # Python import paths + print(f"\nPYTHON IMPORT PATHS:") + for i, path in enumerate(sys.path): + print(f" {i}: {path}") + + # Check if our package is importable and get its location + print(f"\nPACKAGE IMPORT CHECK:") + try: + import cookiecutter_python + print(f" cookiecutter_python.__file__: {cookiecutter_python.__file__}") + print(f" cookiecutter_python.__version__: {cookiecutter_python.__version__}") + print(f" Package directory: {Path(cookiecutter_python.__file__).parent}") + except Exception as e: + print(f" Error importing cookiecutter_python: {e}") + + # Check build tools availability + print(f"\nBUILD TOOLS AVAILABILITY:") + build_tools = ["build", "setuptools", "wheel", "hatchling"] + for tool in build_tools: + try: + __import__(tool) + print(f" {tool}: AVAILABLE") + except ImportError as e: + print(f" {tool}: NOT AVAILABLE ({e})") + except Exception as e: + print(f" {tool}: ERROR ({e})") + + # Setuptools-scm specific debugging + print(f"\nSETUPTOOLS-SCM DEBUG:") + try: + import setuptools_scm + print(f" setuptools_scm version: {setuptools_scm.__version__}") + # Try to get version from project + try: + version = setuptools_scm.get_version(root=str(project_path)) + print(f" Project version from setuptools-scm: {version}") + except Exception as e: + print(f" Error getting version from setuptools-scm: {e}") + except ImportError: + print(f" setuptools_scm: NOT AVAILABLE") + except Exception as e: + print(f" setuptools_scm: ERROR ({e})") + + # Git repository information (important for setuptools-scm) + print(f"\nGIT REPOSITORY INFO:") + try: + import subprocess + # Check if git is available + git_version_result = subprocess.run(["git", "--version"], + capture_output=True, text=True, timeout=10) + if git_version_result.returncode == 0: + print(f" Git version: {git_version_result.stdout.strip()}") + + # Check git status in project directory + git_status_result = subprocess.run(["git", "status", "--porcelain"], + cwd=project_path, capture_output=True, text=True, timeout=10) + if git_status_result.returncode == 0: + status_lines = git_status_result.stdout.strip().split('\n') if git_status_result.stdout.strip() else [] + print(f" Git working directory status: {len(status_lines)} modified files") + if status_lines and status_lines[0]: # Only show if there are actual changes + print(f" First few changes:") + for line in status_lines[:5]: + print(f" {line}") + else: + print(f" Git status failed: {git_status_result.stderr}") + + # Check current branch/commit + git_branch_result = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=project_path, capture_output=True, text=True, timeout=10) + if git_branch_result.returncode == 0: + print(f" Current branch: {git_branch_result.stdout.strip()}") + + git_commit_result = subprocess.run(["git", "rev-parse", "--short", "HEAD"], + cwd=project_path, capture_output=True, text=True, timeout=10) + if git_commit_result.returncode == 0: + print(f" Current commit: {git_commit_result.stdout.strip()}") + + # Check if we're in a git repository + git_root_result = subprocess.run(["git", "rev-parse", "--show-toplevel"], + cwd=project_path, capture_output=True, text=True, timeout=10) + if git_root_result.returncode == 0: + git_root = git_root_result.stdout.strip() + print(f" Git repository root: {git_root}") + print(f" Project path relative to git root: {project_path.relative_to(Path(git_root)) if Path(git_root) in project_path.parents or Path(git_root) == project_path else 'Not within git root'}") + else: + print(f" Not in a git repository: {git_root_result.stderr}") + + else: + print(f" Git not available: {git_version_result.stderr}") + except FileNotFoundError: + print(f" Git command not found") + except Exception as e: + print(f" Error checking git: {e}") + + # Disk space information + print(f"\nDISK SPACE INFO:") + try: + import shutil + total, used, free = shutil.disk_usage(project_path) + print(f" Project path disk - Total: {total//1024//1024} MB, Used: {used//1024//1024} MB, Free: {free//1024//1024} MB") + total_tmp, used_tmp, free_tmp = shutil.disk_usage(temp_dir) + print(f" Temp dir disk - Total: {total_tmp//1024//1024} MB, Used: {used_tmp//1024//1024} MB, Free: {free_tmp//1024//1024} MB") + except Exception as e: + print(f" Error getting disk usage: {e}") + + print(f"{'='*80}") + print(f"END ENVIRONMENT DEBUG INFO") + print(f"{'='*80}\n") + + # Start timing the build process + import time + start_time = time.time() result = my_run_subprocess(*COMMAND_LINE_ARGS, check=False, env=env) + # End timing + end_time = time.time() + execution_time = end_time - start_time + print() - print("=" * 60) - print(f"[Worker {worker_id}] BUILD RESULT - Exit code: {result.exit_code}") - print("=" * 60) - print("STDOUT:") + print("=" * 80) + print(f"BUILD EXECUTION RESULTS [Worker {worker_id}]") + print("=" * 80) + print(f"Exit code: {result.exit_code}") + print(f"Command executed: {' '.join(COMMAND_LINE_ARGS)}") + print(f"Execution time: {execution_time:.2f} seconds") + print("=" * 80) + print("STDOUT (raw):") stdout_content = result.stdout if result.stdout else "(empty)" + print(f"Length: {len(stdout_content)} characters") print(repr(stdout_content)) # Use repr to see hidden characters + print("STDOUT (formatted):") print(stdout_content) - print("=" * 60) - print("STDERR:") + print("=" * 80) + print("STDERR (raw):") stderr_content = result.stderr if result.stderr else "(empty)" + print(f"Length: {len(stderr_content)} characters") print(repr(stderr_content)) # Use repr to see hidden characters + print("STDERR (formatted):") print(stderr_content) - print("=" * 60) + print("=" * 80) + + # POST-BUILD FILE SYSTEM ANALYSIS + print(f"POST-BUILD FILE SYSTEM ANALYSIS:") + print(f"Output directory exists: {OUT_DIR.exists()}") + if OUT_DIR.exists(): + print(f"Output directory is_dir: {OUT_DIR.is_dir()}") + if OUT_DIR.is_dir(): + try: + contents = list(OUT_DIR.iterdir()) + print(f"Output directory contents ({len(contents)} items):") + for item in contents: + if item.is_file(): + size = item.stat().st_size + print(f" {item.name} (file, {size} bytes)") + else: + print(f" {item.name} (directory)") + except Exception as e: + print(f" Error listing output directory: {e}") + else: + print(f" Output directory is not a directory!") + else: + print(f" Output directory does not exist!") + + # Check if any tar.gz files exist anywhere in temp area + print(f"Searching for .tar.gz files in temp area:") + try: + import glob + temp_path = Path(temp_dir) + tar_files = list(temp_path.glob("**/*.tar.gz")) + if tar_files: + print(f" Found {len(tar_files)} .tar.gz files:") + for tar_file in tar_files: + size = tar_file.stat().st_size + print(f" {tar_file} ({size} bytes)") + else: + print(f" No .tar.gz files found in temp area") + except Exception as e: + print(f" Error searching for tar.gz files: {e}") if result.exit_code != 0: - print(f"[Worker {worker_id}] BUILD FAILED!") + print(f"\nBUILD FAILURE DETAILED ANALYSIS [Worker {worker_id}]:") print(f"Command: {' '.join(COMMAND_LINE_ARGS)}") print(f"Working directory: {project_path}") - print(f"Output directory exists: {OUT_DIR.exists()}") - if OUT_DIR.exists(): - print( - f"Output directory contents: {list(OUT_DIR.iterdir()) if OUT_DIR.is_dir() else 'Not a directory'}" - ) + + # Check if pyproject.toml is readable + pyproject_path = project_path / "pyproject.toml" + if pyproject_path.exists(): + try: + content = pyproject_path.read_text() + print(f"pyproject.toml size: {len(content)} characters") + # Look for build-system section + if "[build-system]" in content: + print("pyproject.toml contains [build-system] section") + else: + print("WARNING: pyproject.toml does not contain [build-system] section") + except Exception as e: + print(f"Error reading pyproject.toml: {e}") + + # Check permissions + try: + import stat + project_stat = project_path.stat() + print(f"Project directory permissions: {stat.filemode(project_stat.st_mode)}") + if OUT_DIR.parent.exists(): + parent_stat = OUT_DIR.parent.stat() + print(f"Output parent directory permissions: {stat.filemode(parent_stat.st_mode)}") + except Exception as e: + print(f"Error checking permissions: {e}") # Additional debugging for CI environment - print("Environment variables of interest:") + print("Critical environment variables:") debug_env_vars = [ - "PYTHONPATH", - "PATH", - "HOME", - "BUILD_BACKEND_ISOLATION", - "SETUPTOOLS_SCM_DEBUG", + "PYTHONPATH", "PATH", "HOME", "TMPDIR", "BUILD_BACKEND_ISOLATION", + "SETUPTOOLS_SCM_DEBUG", "CI", "GITHUB_ACTIONS", "RUNNER_OS" ] for var in debug_env_vars: print(f" {var}: {env.get(var, 'NOT SET')}") + print("=" * 80) + assert result.exit_code == 0, ( - f"[Worker {worker_id}] Build failed with exit code {result.exit_code}\n" + f"BUILD FAILED [Worker {worker_id}] - Exit code: {result.exit_code}\n" + f"{'='*60}\n" f"Command: {' '.join(COMMAND_LINE_ARGS)}\n" f"Working directory: {project_path}\n" + f"Output directory: {OUT_DIR}\n" + f"Python executable: {PYTHON}\n" f"Environment: PYTEST_XDIST_WORKER={worker_id}\n" - f"STDOUT: {repr(result.stdout)}\n" - f"STDERR: {repr(result.stderr)}" + f"{'='*60}\n" + f"STDOUT ({len(repr(result.stdout))} chars):\n{repr(result.stdout)}\n" + f"{'='*60}\n" + f"STDERR ({len(repr(result.stderr))} chars):\n{repr(result.stderr)}\n" + f"{'='*60}\n" + f"Output dir exists: {OUT_DIR.exists()}\n" + f"Output dir contents: {list(OUT_DIR.iterdir()) if OUT_DIR.exists() and OUT_DIR.is_dir() else 'N/A'}\n" + f"Project dir exists: {project_path.exists()}\n" + f"pyproject.toml exists: {(project_path / 'pyproject.toml').exists()}\n" + f"{'='*60}" ) import re @@ -696,9 +978,20 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: # After build, retrieve the tar.gz file tar_gz_file = list(OUT_DIR.glob("*.tar.gz")) - assert len(tar_gz_file) == 1, f"Expected 1 tar.gz file, got {len(tar_gz_file)}" + + print(f"\nFINAL BUILD ARTIFACTS SUMMARY:") + print(f"Found {len(tar_gz_file)} tar.gz files in output directory") + for i, tar_file in enumerate(tar_gz_file): + size = tar_file.stat().st_size + print(f" {i+1}. {tar_file.name} ({size} bytes, {size/1024:.1f} KB)") + + assert len(tar_gz_file) == 1, f"Expected 1 tar.gz file, got {len(tar_gz_file)}. Files found: {[f.name for f in tar_gz_file]}" assert tar_gz_file[0].is_file(), f"Expected {tar_gz_file[0]} to be a file" - return tar_gz_file[0] + + final_file = tar_gz_file[0] + print(f"SUCCESS: Built {final_file.name} ({final_file.stat().st_size} bytes) in {execution_time:.2f} seconds") + + return final_file ## Test SDist Tar GZ file Size is within Acceptable Limits From c926f7a45148631a51d792355f2132372a4eccb2 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Thu, 30 Oct 2025 23:54:58 +0200 Subject: [PATCH 20/35] feat: add tee logging and artifacts to integration tests - Capture all pytest output using tee for debugging CI failures - Upload combined test logs with clear headers for each invocation - Include via_build_module, test_build_creates_artifacts, and full suite logs - Enable artifact download with gh cli for easier debugging --- .github/workflows/dev_pr_validation.yml | 38 ++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dev_pr_validation.yml b/.github/workflows/dev_pr_validation.yml index 38e72c90..6b3dd4a4 100644 --- a/.github/workflows/dev_pr_validation.yml +++ b/.github/workflows/dev_pr_validation.yml @@ -42,17 +42,47 @@ jobs: # Run Integration tests - name: 'Run Integration tests' + shell: bash run: | + set -o pipefail # leverage uv to install other runtime test dependencies in the system site-packages! uv pip install 'tox<4.0' # integration tests dependency # Isolate flaky tests - uv run pytest -ra -vvs --run-slow -k via_build_module - uv run pytest -ra -vvs --run-slow -k test_build_creates_artifacts + uv run pytest -ra -vvs --run-slow -k via_build_module 2>&1 | tee integration_test_via_build_module.log + uv run pytest -ra -vvs --run-slow -k test_build_creates_artifacts 2>&1 | tee integration_test_build_creates_artifacts.log - # Run eveything once again for sanity - uv run pytest -ra -n auto --run-requires_uv --run-slow --run-network_bound -vvs -k 'test_cli or build_backend_sdist or test_build_creates_artifacts or test_lint_passes' + # Run rest of Integratoin Tests and Unit Tests for sanity + uv run pytest -ra -n auto --run-requires_uv --run-slow --run-network_bound -vvs -k 'test_cli or build_backend_sdist or test_build_creates_artifacts or test_lint_passes' 2>&1 | tee integration_test_full_suite.log + + - name: "Combine Integration Test Logs with Headers" + if: always() # Process logs even if tests fail + run: | + echo "================================================================================" > combined_integration_tests.log + echo "PYTEST INVOCATION 1: via_build_module tests" >> combined_integration_tests.log + echo "================================================================================" >> combined_integration_tests.log + cat integration_test_via_build_module.log >> combined_integration_tests.log + echo "" >> combined_integration_tests.log + + echo "================================================================================" >> combined_integration_tests.log + echo "PYTEST INVOCATION 2: test_build_creates_artifacts tests" >> combined_integration_tests.log + echo "================================================================================" >> combined_integration_tests.log + cat integration_test_build_creates_artifacts.log >> combined_integration_tests.log + echo "" >> combined_integration_tests.log + + echo "================================================================================" >> combined_integration_tests.log + echo "PYTEST INVOCATION 3: Full test suite (test_cli, build_backend_sdist, etc.)" >> combined_integration_tests.log + echo "================================================================================" >> combined_integration_tests.log + cat integration_test_full_suite.log >> combined_integration_tests.log + + - name: "Upload Combined Integration Test Log as Artifact" + if: always() # Upload logs even if tests fail + uses: actions/upload-artifact@v4 + with: + name: integration-tests-log + path: combined_integration_tests.log + if-no-files-found: warn # CROSS PLATFORM TESTING: 15s on Ubuntu, 25 on mac, 35 on windows # cross_platform_tests: From 889d2ae583ca79363d93ddbbe1dba4e6a4381f64 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Fri, 31 Oct 2025 00:13:25 +0200 Subject: [PATCH 21/35] test: fix sdist build sanity test --- tests/test_build_backend_sdist.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/test_build_backend_sdist.py b/tests/test_build_backend_sdist.py index 135ae1a6..67398a29 100644 --- a/tests/test_build_backend_sdist.py +++ b/tests/test_build_backend_sdist.py @@ -607,7 +607,28 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: # invoke build module as frontend to whatever [build-system] is in pyproject.toml import sys - PYTHON = sys.executable # python from virtualenv + # Use the most appropriate Python executable to avoid CI path validation errors + # Priority: 1) Current virtual env if within project, 2) Project .venv, 3) Fallback + current_python = sys.executable + + # Check if current python is within the project directory (safe for Poetry) + try: + Path(current_python).relative_to(project_path) + PYTHON = current_python # Safe to use current python + print(f"[Worker {worker_id}] Using current Python (within project): {PYTHON}") + except ValueError: + # Current python is outside project (e.g., CI hostedtoolcache) + # Try project .venv python instead + venv_python = project_path / ".venv" / "bin" / "python" + if venv_python.exists(): + PYTHON = str(venv_python) + print( + f"[Worker {worker_id}] Using .venv Python (current outside project): {PYTHON}" + ) + else: + # Last resort: use current python and hope for the best + PYTHON = current_python + print(f"[Worker {worker_id}] Using current Python (no .venv found): {PYTHON}") COMMAND_LINE_ARGS: t.List[str] = [ PYTHON, From 392e97a6bc095de81017fb1d69780ad04ca101ee Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Fri, 31 Oct 2025 00:30:37 +0200 Subject: [PATCH 22/35] fix: Exclude .tox directories from Poetry file discovery Prevents CI test failures on pytest-xdist worker gw1 by defensively excluding .tox directories in test snapshots from Poetry's file discovery process. These directories can contain broken symlinks or missing paths that cause FileNotFoundError during parallel test execution. Resolves worker isolation race condition without modifying test fixture scopes, providing minimal defensive mitigation. --- pyproject.toml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5b0b7623..68734949 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,16 +48,16 @@ exclude = [ "docs/*", "requirements/*", "scripts/*.py", - "tox.ini", - ".circleci/config.yml", - ".coveragerc", - ".DS_Store", - ".gitignore", - ".prospector.yml", - ".pylintrc", - ".readthedocs.yml", - ".scrutinizer.yml", - ".travis.yml" + + # Defensive exclusion: Prevent Poetry from scanning .tox directories in test snapshots + # These directories can contain broken symlinks or missing paths from test artifacts + # that cause build failures during Poetry's file discovery process (especially in CI + # with pytest-xdist parallel workers). This mitigates FileNotFoundError exceptions + # when Poetry encounters paths like pip._vendor that may not exist consistently. + "tests/**/.tox", + "tests/**/.tox/**", + "**/.tox", + "**/.tox/**", ] [tool.poetry.scripts] From 5d6541bb3f33e0174a641011cc2cf98b3fc1945d Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Fri, 31 Oct 2025 00:59:09 +0200 Subject: [PATCH 23/35] ci: fix integration test race condition caused by .tox dir when running otx from pytest --- .github/workflows/dev_pr_validation.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dev_pr_validation.yml b/.github/workflows/dev_pr_validation.yml index 6b3dd4a4..9a801369 100644 --- a/.github/workflows/dev_pr_validation.yml +++ b/.github/workflows/dev_pr_validation.yml @@ -53,8 +53,12 @@ jobs: uv run pytest -ra -vvs --run-slow -k via_build_module 2>&1 | tee integration_test_via_build_module.log uv run pytest -ra -vvs --run-slow -k test_build_creates_artifacts 2>&1 | tee integration_test_build_creates_artifacts.log - # Run rest of Integratoin Tests and Unit Tests for sanity - uv run pytest -ra -n auto --run-requires_uv --run-slow --run-network_bound -vvs -k 'test_cli or build_backend_sdist or test_build_creates_artifacts or test_lint_passes' 2>&1 | tee integration_test_full_suite.log + + # Run rest of Integration Tests and Unit Tests for sanity (without lint tests) + uv run pytest -ra -n auto --run-requires_uv --run-slow --run-network_bound -vvs -k 'test_cli or build_backend_sdist or test_build_creates_artifacts' 2>&1 | tee integration_test_full_suite.log + + # Isolate lint tests to prevent race condition with build tests + uv run pytest -ra -vvs --run-slow -k test_lint_passes 2>&1 | tee integration_test_lint_passes.log - name: "Combine Integration Test Logs with Headers" if: always() # Process logs even if tests fail @@ -72,7 +76,13 @@ jobs: echo "" >> combined_integration_tests.log echo "================================================================================" >> combined_integration_tests.log - echo "PYTEST INVOCATION 3: Full test suite (test_cli, build_backend_sdist, etc.)" >> combined_integration_tests.log + echo "PYTEST INVOCATION 3: test_lint_passes tests (isolated to prevent race condition)" >> combined_integration_tests.log + echo "================================================================================" >> combined_integration_tests.log + cat integration_test_lint_passes.log >> combined_integration_tests.log + echo "" >> combined_integration_tests.log + + echo "================================================================================" >> combined_integration_tests.log + echo "PYTEST INVOCATION 4: Remaining test suite (test_cli, build_backend_sdist)" >> combined_integration_tests.log echo "================================================================================" >> combined_integration_tests.log cat integration_test_full_suite.log >> combined_integration_tests.log From c54d39c82b3ad0cb738c2d01cf192909041323fb Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Fri, 31 Oct 2025 01:11:06 +0200 Subject: [PATCH 24/35] ci: isolate build_backend_sdist tests for debugging Poetry path error - Run build_backend_sdist tests in separate invocation to isolate Poetry ValueError - Keep lint tests isolated to prevent race condition - Add dedicated logs for easier debugging of specific test failures --- .github/workflows/dev_pr_validation.yml | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dev_pr_validation.yml b/.github/workflows/dev_pr_validation.yml index 9a801369..10330878 100644 --- a/.github/workflows/dev_pr_validation.yml +++ b/.github/workflows/dev_pr_validation.yml @@ -53,13 +53,15 @@ jobs: uv run pytest -ra -vvs --run-slow -k via_build_module 2>&1 | tee integration_test_via_build_module.log uv run pytest -ra -vvs --run-slow -k test_build_creates_artifacts 2>&1 | tee integration_test_build_creates_artifacts.log - - # Run rest of Integration Tests and Unit Tests for sanity (without lint tests) - uv run pytest -ra -n auto --run-requires_uv --run-slow --run-network_bound -vvs -k 'test_cli or build_backend_sdist or test_build_creates_artifacts' 2>&1 | tee integration_test_full_suite.log - # Isolate lint tests to prevent race condition with build tests uv run pytest -ra -vvs --run-slow -k test_lint_passes 2>&1 | tee integration_test_lint_passes.log + # Isolate problematic build_backend_sdist tests for debugging + uv run pytest -ra -vvs --run-slow -k build_backend_sdist 2>&1 | tee integration_test_build_backend_sdist.log + + # Run remaining Integration Tests and Unit Tests for sanity + uv run pytest -ra -n auto --run-requires_uv --run-slow --run-network_bound -vvs -k 'test_cli' 2>&1 | tee integration_test_remaining.log + - name: "Combine Integration Test Logs with Headers" if: always() # Process logs even if tests fail run: | @@ -82,9 +84,15 @@ jobs: echo "" >> combined_integration_tests.log echo "================================================================================" >> combined_integration_tests.log - echo "PYTEST INVOCATION 4: Remaining test suite (test_cli, build_backend_sdist)" >> combined_integration_tests.log + echo "PYTEST INVOCATION 4: build_backend_sdist tests (isolated for debugging)" >> combined_integration_tests.log + echo "================================================================================" >> combined_integration_tests.log + cat integration_test_build_backend_sdist.log >> combined_integration_tests.log + echo "" >> combined_integration_tests.log + + echo "================================================================================" >> combined_integration_tests.log + echo "PYTEST INVOCATION 5: Remaining test suite (test_cli only)" >> combined_integration_tests.log echo "================================================================================" >> combined_integration_tests.log - cat integration_test_full_suite.log >> combined_integration_tests.log + cat integration_test_remaining.log >> combined_integration_tests.log - name: "Upload Combined Integration Test Log as Artifact" if: always() # Upload logs even if tests fail From 1b6a18b1b1ffbf3941e66aabd8b81280e8a1cd9d Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Fri, 31 Oct 2025 01:14:17 +0200 Subject: [PATCH 25/35] fix: exclude ruff cache files from source distribution - Add .ruff_cache exclusion patterns to pyproject.toml - Prevents ruff cache files created by lint tests from being included in sdist - Resolves test failure: 'extra runtime files compared to expectations' --- pyproject.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 68734949..76fd9561 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,14 @@ exclude = [ "tests/**/.tox/**", "**/.tox", "**/.tox/**", + + # Exclude ruff cache files created during lint tests + # The test_running_ruff_passes creates .ruff_cache directories in test snapshots + # which should not be included in the source distribution + "tests/**/.ruff_cache", + "tests/**/.ruff_cache/**", + "**/.ruff_cache", + "**/.ruff_cache/**", ] [tool.poetry.scripts] From 29a22a4e55434b455d0167414d4b73780fc47060 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Fri, 31 Oct 2025 01:20:58 +0200 Subject: [PATCH 26/35] fix: clean up ruff cache files in lint tests - Add .ruff_cache cleanup to both test_running_lint_passes and test_running_ruff_passes - Prevents ruff cache files from persisting in snapshot directories - Resolves sdist build test failure caused by unexpected cache files - Cache files created by ruff during test execution should not remain in snapshots --- .../snapshot/biskotaki_ci_no_input/test_lint_passes.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py b/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py index 164d5058..d31b9f5c 100644 --- a/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py +++ b/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py @@ -39,6 +39,10 @@ def test_running_lint_passes(snapshot_name, my_run_subprocess, test_root): shutil.rmtree(snapshot_dir / '.tox' / 'lint') + # Also clean up .ruff_cache directory created by ruff during linting + if (snapshot_dir / '.ruff_cache').exists(): + shutil.rmtree(snapshot_dir / '.ruff_cache') + # Check that Code passes Lint out of the box assert ( res.exit_code == 0 @@ -82,4 +86,9 @@ def test_running_ruff_passes(snapshot_name, my_run_subprocess, test_root): shutil.rmtree(snapshot_dir / '.tox' / 'ruff') + # Also clean up .ruff_cache directory created by ruff during linting + if (snapshot_dir / '.ruff_cache').exists(): + import shutil + shutil.rmtree(snapshot_dir / '.ruff_cache') + # VERIFIED that Generator emits python code that pass Ruff out of the box !! From 81b8519ea6bfda925fdfe4584066826a3bd0cf1f Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Fri, 31 Oct 2025 01:26:41 +0200 Subject: [PATCH 27/35] revert pyproject.toml --- pyproject.toml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 76fd9561..68734949 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,14 +58,6 @@ exclude = [ "tests/**/.tox/**", "**/.tox", "**/.tox/**", - - # Exclude ruff cache files created during lint tests - # The test_running_ruff_passes creates .ruff_cache directories in test snapshots - # which should not be included in the source distribution - "tests/**/.ruff_cache", - "tests/**/.ruff_cache/**", - "**/.ruff_cache", - "**/.ruff_cache/**", ] [tool.poetry.scripts] From 8c870a490535f4c71ae8de3e136baa456b18b45c Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Fri, 31 Oct 2025 01:32:47 +0200 Subject: [PATCH 28/35] revert pyproject.toml --- pyproject.toml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 68734949..bcd05000 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,16 +48,6 @@ exclude = [ "docs/*", "requirements/*", "scripts/*.py", - - # Defensive exclusion: Prevent Poetry from scanning .tox directories in test snapshots - # These directories can contain broken symlinks or missing paths from test artifacts - # that cause build failures during Poetry's file discovery process (especially in CI - # with pytest-xdist parallel workers). This mitigates FileNotFoundError exceptions - # when Poetry encounters paths like pip._vendor that may not exist consistently. - "tests/**/.tox", - "tests/**/.tox/**", - "**/.tox", - "**/.tox/**", ] [tool.poetry.scripts] From 9e564357765d3d035f5a68b40bab2b0633fafea0 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Fri, 31 Oct 2025 01:54:07 +0200 Subject: [PATCH 29/35] put PR Validation in prod state --- .github/workflows/dev_pr_validation.yml | 406 ++++++++++++------------ 1 file changed, 202 insertions(+), 204 deletions(-) diff --git a/.github/workflows/dev_pr_validation.yml b/.github/workflows/dev_pr_validation.yml index 10330878..9cb39e42 100644 --- a/.github/workflows/dev_pr_validation.yml +++ b/.github/workflows/dev_pr_validation.yml @@ -70,25 +70,25 @@ jobs: echo "================================================================================" >> combined_integration_tests.log cat integration_test_via_build_module.log >> combined_integration_tests.log echo "" >> combined_integration_tests.log - + echo "================================================================================" >> combined_integration_tests.log echo "PYTEST INVOCATION 2: test_build_creates_artifacts tests" >> combined_integration_tests.log echo "================================================================================" >> combined_integration_tests.log cat integration_test_build_creates_artifacts.log >> combined_integration_tests.log echo "" >> combined_integration_tests.log - + echo "================================================================================" >> combined_integration_tests.log echo "PYTEST INVOCATION 3: test_lint_passes tests (isolated to prevent race condition)" >> combined_integration_tests.log echo "================================================================================" >> combined_integration_tests.log cat integration_test_lint_passes.log >> combined_integration_tests.log echo "" >> combined_integration_tests.log - + echo "================================================================================" >> combined_integration_tests.log echo "PYTEST INVOCATION 4: build_backend_sdist tests (isolated for debugging)" >> combined_integration_tests.log echo "================================================================================" >> combined_integration_tests.log cat integration_test_build_backend_sdist.log >> combined_integration_tests.log echo "" >> combined_integration_tests.log - + echo "================================================================================" >> combined_integration_tests.log echo "PYTEST INVOCATION 5: Remaining test suite (test_cli only)" >> combined_integration_tests.log echo "================================================================================" >> combined_integration_tests.log @@ -103,221 +103,219 @@ jobs: if-no-files-found: warn # CROSS PLATFORM TESTING: 15s on Ubuntu, 25 on mac, 35 on windows - # cross_platform_tests: - # runs-on: ${{ matrix.platform }} - # # trigger if event is pr to dev - # strategy: - # fail-fast: false - # matrix: - # platform: [ubuntu-latest, macos-latest] - # # platform: [windows-latest] - # python-version: ['3.10'] - # steps: - # - # - uses: actions/checkout@v4 - # # windows cannot read new style docs template folders with exotic name, so maybe try sparse checkout to igore cloning those dirs - # # until then windows CI is shut off. - # # with: - # # sparse-checkout: | - # # .github - # # src - # - # - name: Set up Python ${{ matrix.python-version }} - # uses: actions/setup-python@v5 - # with: - # python-version: ${{ matrix.python-version }} - # - # - name: Install uv - # if: matrix.platform != 'windows-latest' - # run: curl -LsSf https://astral.sh/uv/install.sh | sh - # - # - name: Install the latest version of uv - # if: matrix.platform == 'windows-latest' - # uses: astral-sh/setup-uv@v5 - # with: - # version: "latest" - # - # - # # Build Package Wheel - # - name: Build Package Wheel and Sdist Distributions - # id: build - # shell: bash - # run: | - # uv build --out-dir dist - # - # WHEEL_FILE=$(ls dist/cookiecutter_python-*.whl) - # WHEEL_NAME="$(basename $WHEEL_FILE)" - # - # PEP_VERSION=$(echo $WHEEL_NAME | cut -d'-' -f2 | sed 's/-/./g') - # echo "PEP_VERSION=$PEP_VERSION" >> $GITHUB_OUTPUT - # - # # Install exact Prod + Test dependencies - # - name: 'Export pinned Prod + Test dependencies' - # run: uv export --no-emit-project --no-dev --extra test --frozen --format requirements-txt -o requirements.txt - # - # - name: Install exact 'Prod + Test' Dependencies - # shell: bash - # run: | - # uv venv - # if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then - # . .venv/Scripts/activate - # fi - # uv pip install --no-deps -r requirements.txt - # - # # Install the Package Wheel - # - name: Install Package Wheel - # shell: bash # to solve issue on windows ci - # run: | - # if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then - # . .venv/Scripts/activate - # fi - # uv pip install --no-deps dist/cookiecutter_python-*.whl - # - # #### Run Test Suite against the package and measure Coverage #### - # - name: Run Tests and measure Coverage - # shell: bash - # env: - # # log deletion post hook fails on windows, due to permission error! (other process is using the file, so removing is denied) - # # windows spawn multiple processes, so log deletion is not possible, even when running 1 Single Unit Test - # BUG_LOG_DEL_WIN: 'permission_error' # required on Windows Job - # PY_WHEEL: 1 # required on Windows Job - # COVERAGE_FILE: '.coverage.wheel' - # run: | - # if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then - # . .venv/Scripts/activate - # else - # # Linux and MacOS - # . .venv/bin/activate - # fi - # - # pytest -ra --run-requires_uv --run-network_bound -vvs --cov --cov-report=term-missing \ - # --cov-report=html:test-wheel/htmlcov --cov-context=test \ - # --cov-report=xml:coverage.test-wheel.xml \ - # -n auto tests - # - # ## Code Coverage ## TODO; enable combine by moving each .coverage file to other dir to avoid override of subsequent coverage invocations with pytest - # - name: "Discover and Combine Coverage data into XML Reports" - # if: always() - # env: - # COVERAGE_FILE: .coverage - # OUTPUT_XML: coverage-${{ matrix.platform }}-${{ matrix.python-version }}-wheel.xml - # shell: bash - # run: | - # uv venv cov-env - # if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then - # . cov-env/Scripts/activate - # else - # # Linux and MacOS - # . cov-env/bin/activate - # fi - # - # uv pip install 'coverage[toml] >= 5.1' 'diff_cover >=6' - # - # echo '[INFO] Running coverage' - # uv run --active coverage combine --keep - # uv run --active coverage report --skip-covered --show-missing -i - # - # uv run --active coverage xml -o ./${{ env.OUTPUT_XML }} -i - # # uv run --active coverage html -d ./htmlcov -i - # - # echo "OUTPUT_XML=${{ env.OUTPUT_XML }}" >> $GITHUB_OUTPUT - # id: coverage - # - # - name: "Upload Test Coverage as Artifacts" - # if: always() - # uses: actions/upload-artifact@v4 - # with: - # overwrite: false - # name: '${{ steps.coverage.outputs.OUTPUT_XML }}' - # path: '${{ steps.coverage.outputs.OUTPUT_XML }}' - # if-no-files-found: error - # - # - name: Upload Source & Wheel distributions as Artefacts - # if: always() - # uses: actions/upload-artifact@v4 - # with: - # name: dist-${{ matrix.platform }}-${{ matrix.python-version }} - # path: dist - # if-no-files-found: error - # - # outputs: - # PEP_VERSION: ${{ steps.build.outputs.PEP_VERSION }} + cross_platform_tests: + runs-on: ${{ matrix.platform }} + # trigger if event is pr to dev + strategy: + fail-fast: false + matrix: + platform: [ubuntu-latest, macos-latest] + # platform: [windows-latest] + python-version: ['3.10'] + steps: + + - uses: actions/checkout@v4 + # windows cannot read new style docs template folders with exotic name, so maybe try sparse checkout to igore cloning those dirs + # until then windows CI is shut off. + # with: + # sparse-checkout: | + # .github + # src + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + if: matrix.platform != 'windows-latest' + run: curl -LsSf https://astral.sh/uv/install.sh | sh + + - name: Install the latest version of uv + if: matrix.platform == 'windows-latest' + uses: astral-sh/setup-uv@v5 + with: + version: "latest" + + + # Build Package Wheel + - name: Build Package Wheel and Sdist Distributions + id: build + shell: bash + run: | + uv build --out-dir dist + + WHEEL_FILE=$(ls dist/cookiecutter_python-*.whl) + WHEEL_NAME="$(basename $WHEEL_FILE)" + + PEP_VERSION=$(echo $WHEEL_NAME | cut -d'-' -f2 | sed 's/-/./g') + echo "PEP_VERSION=$PEP_VERSION" >> $GITHUB_OUTPUT + + # Install exact Prod + Test dependencies + - name: 'Export pinned Prod + Test dependencies' + run: uv export --no-emit-project --no-dev --extra test --frozen --format requirements-txt -o requirements.txt + + - name: Install exact 'Prod + Test' Dependencies + shell: bash + run: | + uv venv + if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then + . .venv/Scripts/activate + fi + uv pip install --no-deps -r requirements.txt + + # Install the Package Wheel + - name: Install Package Wheel + shell: bash # to solve issue on windows ci + run: | + if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then + . .venv/Scripts/activate + fi + uv pip install --no-deps dist/cookiecutter_python-*.whl + + #### Run Test Suite against the package and measure Coverage #### + - name: Run Tests and measure Coverage + shell: bash + env: + # log deletion post hook fails on windows, due to permission error! (other process is using the file, so removing is denied) + # windows spawn multiple processes, so log deletion is not possible, even when running 1 Single Unit Test + BUG_LOG_DEL_WIN: 'permission_error' # required on Windows Job + PY_WHEEL: 1 # required on Windows Job + COVERAGE_FILE: '.coverage.wheel' + run: | + if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then + . .venv/Scripts/activate + else + # Linux and MacOS + . .venv/bin/activate + fi + + pytest -ra --run-requires_uv --run-network_bound -vvs --cov --cov-report=term-missing \ + --cov-report=html:test-wheel/htmlcov --cov-context=test \ + --cov-report=xml:coverage.test-wheel.xml \ + -n auto tests + + ## Code Coverage ## TODO; enable combine by moving each .coverage file to other dir to avoid override of subsequent coverage invocations with pytest + - name: "Discover and Combine Coverage data into XML Reports" + if: always() + env: + COVERAGE_FILE: .coverage + OUTPUT_XML: coverage-${{ matrix.platform }}-${{ matrix.python-version }}-wheel.xml + shell: bash + run: | + uv venv cov-env + if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then + . cov-env/Scripts/activate + else + # Linux and MacOS + . cov-env/bin/activate + fi + + uv pip install 'coverage[toml] >= 5.1' 'diff_cover >=6' + + echo '[INFO] Running coverage' + uv run --active coverage combine --keep + uv run --active coverage report --skip-covered --show-missing -i + + uv run --active coverage xml -o ./${{ env.OUTPUT_XML }} -i + # uv run --active coverage html -d ./htmlcov -i + + echo "OUTPUT_XML=${{ env.OUTPUT_XML }}" >> $GITHUB_OUTPUT + id: coverage + + - name: "Upload Test Coverage as Artifacts" + if: always() + uses: actions/upload-artifact@v4 + with: + overwrite: false + name: '${{ steps.coverage.outputs.OUTPUT_XML }}' + path: '${{ steps.coverage.outputs.OUTPUT_XML }}' + if-no-files-found: error + + - name: Upload Source & Wheel distributions as Artefacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: dist-${{ matrix.platform }}-${{ matrix.python-version }} + path: dist + if-no-files-found: error + + outputs: + PEP_VERSION: ${{ steps.build.outputs.PEP_VERSION }} # CODECOV Upload of COVERAGE reports - # codecov: - # if: env.INTEGRATION_ONLY != 'true' && always() && contains(fromJSON('["success", "failure"]'), needs.cross_platform_tests.result) - # needs: cross_platform_tests - # uses: ./.github/workflows/codecov-job.yml - # secrets: - # CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + codecov: + if: always() && contains(fromJSON('["success", "failure"]'), needs.cross_platform_tests.result) + needs: cross_platform_tests + uses: ./.github/workflows/codecov-job.yml + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} # STATIC CODE ANALYSIS: mypy, ruff, isort, black, bandit, mccabe, prospector, etc - # sca: - # if: env.INTEGRATION_ONLY != 'true' - # uses: ./.github/workflows/sca-job.yml - # with: - # python_version: '3.10' - # allow_failure: ${{ github.event_name != 'pull_request' || github.base_ref != 'dev' }} - # # Acceptance Criteria - # force_styles: ${{ github.event_name == 'pull_request' && github.base_ref == 'dev' }} - # bandit: '{"h": "0", "m": "0", "l": "4"}' # Automated Acceptance Criteria for Bandit + sca: + uses: ./.github/workflows/sca-job.yml + with: + python_version: '3.10' + allow_failure: ${{ github.event_name != 'pull_request' || github.base_ref != 'dev' }} + # Acceptance Criteria + force_styles: ${{ github.event_name == 'pull_request' && github.base_ref == 'dev' }} + bandit: '{"h": "0", "m": "0", "l": "4"}' # Automated Acceptance Criteria for Bandit # PYDEPS - # pydeps: - # if: env.INTEGRATION_ONLY != 'true' - # uses: ./.github/workflows/pydeps-job.yml - # with: - # default_trigger: true - # override: '${{ vars.OV_PYDEPS }}' - # python_version: '3.10' + pydeps: + uses: ./.github/workflows/pydeps-job.yml + with: + default_trigger: true + override: '${{ vars.OV_PYDEPS }}' + python_version: '3.10' # TYPE CHECK - # type_check: - # uses: ./.github/workflows/type-check-job.yml - # with: - # default_trigger: true - # override: '${{ vars.OV_TYPE_CHECK }}' - # python_version: '3.10' + type_check: + uses: ./.github/workflows/type-check-job.yml + with: + default_trigger: true + override: '${{ vars.OV_TYPE_CHECK }}' + python_version: '3.10' # DOCS - # docs: - # uses: ./.github/workflows/docs-job.yml - # with: - # default_trigger: true - # override: '${{ vars.OV_DOCS }}' - # python_version: '3.11' + docs: + uses: ./.github/workflows/docs-job.yml + with: + default_trigger: true + override: '${{ vars.OV_DOCS }}' + python_version: '3.11' ## DOCKER BUILD and PUBLISH ON DOCKERHUB ## # Ref Page: https://automated-workflows.readthedocs.io/en/main/ref_docker/ - # docker_build: - # uses: boromir674/automated-workflows/.github/workflows/docker.yml@v1.14.0 - # if: github.event_name == 'pull_request' && github.base_ref == 'release' - # with: - # acceptance_policy: '1' - # image_slug: "generate-python" - # image_tag: "latest" - # # target_stage: "some_stage_alias" # no stage, means no `--target` flag, on build - # tests_pass: true - # tests_run: true - # DOCKER_USER: ${{ vars.DOCKER_USER }} - # secrets: - # DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + docker_build: + uses: boromir674/automated-workflows/.github/workflows/docker.yml@v1.14.0 + if: github.event_name == 'pull_request' && github.base_ref == 'release' + with: + acceptance_policy: '1' + image_slug: "generate-python" + image_tag: "latest" + # target_stage: "some_stage_alias" # no stage, means no `--target` flag, on build + tests_pass: true + tests_run: true + DOCKER_USER: ${{ vars.DOCKER_USER }} + secrets: + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} ## JOB: PYPI UPLOAD ## - # pypi_publish: - # needs: cross_platform_tests - # uses: boromir674/automated-workflows/.github/workflows/pypi_env.yml@test - # # Automatic .tar.gz and .whl discovery but with performance trade-off - # # also with risk of uploading the wrong file - # with: - # should_trigger: ${{ github.event_name == 'pull_request' && github.base_ref == 'release' }} - # distro_name: cookiecutter_python - # distro_version: '${{ needs.cross_platform_tests.outputs.PEP_VERSION }}' - # pypi_env: TEST_DEPLOYMENT - # require_wheel: true - # allow_existing: true - # secrets: - # TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} + pypi_publish: + needs: cross_platform_tests + uses: boromir674/automated-workflows/.github/workflows/pypi_env.yml@test + # Automatic .tar.gz and .whl discovery but with performance trade-off + # also with risk of uploading the wrong file + with: + should_trigger: ${{ github.event_name == 'pull_request' && github.base_ref == 'release' }} + distro_name: cookiecutter_python + distro_version: '${{ needs.cross_platform_tests.outputs.PEP_VERSION }}' + pypi_env: TEST_DEPLOYMENT + require_wheel: true + allow_existing: true + secrets: + TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} From 3bb029e744a184871b7e8e44a717d9634971127f Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Sun, 2 Nov 2025 19:51:09 +0200 Subject: [PATCH 30/35] test: verify more cookiecutter behaviors / sanity --- .../conftest.py} | 208 +++------------ tests/context/test_cookiecutter_context.py | 222 ++++++++++++++++ tests/context/test_generate_context_unit.py | 60 +++++ tests/test_yes_no_in_cookiecutter_json.py | 241 ++++++++++++++++++ 4 files changed, 552 insertions(+), 179 deletions(-) rename tests/{test_cookiecutter_context.py => context/conftest.py} (80%) create mode 100644 tests/context/test_cookiecutter_context.py create mode 100644 tests/context/test_generate_context_unit.py create mode 100644 tests/test_yes_no_in_cookiecutter_json.py diff --git a/tests/test_cookiecutter_context.py b/tests/context/conftest.py similarity index 80% rename from tests/test_cookiecutter_context.py rename to tests/context/conftest.py index 187088d8..471cd1ff 100644 --- a/tests/test_cookiecutter_context.py +++ b/tests/context/conftest.py @@ -38,7 +38,7 @@ TEST_TIME_BISKOTAKI_CONFIG = None SHOULD_SKIP = False -if not (MY_DIR.parent / '.github' / 'biskotaki.yaml').exists(): +if not (MY_DIR.parent.parent / '.github' / 'biskotaki.yaml').exists(): SHOULD_SKIP = True # Create a temporary file to use as a test config and PRESERVE it on close! with tempfile.NamedTemporaryFile(mode='w+b', delete=False) as fp: @@ -72,38 +72,15 @@ TEST_TIME_BISKOTAKI_CONFIG = Path(fp.name) -@pytest.fixture -def get_expected_context(): - def _get_expected_context(expected_context: t.Dict[str, t.Any], folder_path: Path): - def gen(): - # generator keys an inject _output_dir - for k, v in expected_context[C_KEY].items(): - # if key is not _template, yield it - if k != '_template': - yield k, v - # if key is _template, yield it and then yield _output_dir - else: - yield k, v - yield '_output_dir', str(folder_path.absolute()) - - return OrderedDict( - [ - (C_KEY, OrderedDict([(k, v) for k, v in gen()])), - ('_cookiecutter', expected_context['_cookiecutter']), - ] - ) - - return _get_expected_context - @pytest.fixture( params=[ ( # TEST CASE 1 - Simple Template with Cookiecutter Choice Variable included # GIVEN a simple Cookiecutter Template (cookiecutter.json + jinja Template project) - MY_DIR / 'data' / 'rendering' / 'only_list_template', + MY_DIR / '..' / 'data' / 'rendering' / 'only_list_template', # GIVEN a User Config YAML - MY_DIR / 'data' / 'rendering' / 'user_config.yml', + MY_DIR / '..' / 'data' / 'rendering' / 'user_config.yml', # THEN expected Context that cookiecutter will generate under the hood is OrderedDict( [ @@ -490,11 +467,7 @@ def gen(): 'gold_standard', # TEST CASE 4 ), ) -def template_test_case( - request, - get_expected_context, - distro_loc: Path, -): +def template_test_case(request, distro_loc: Path): # handles cookiecutters dedicated for testing and the one included in the distribution COOKIE_TEMPLATE_DIR: Path = ( distro_loc if request.param[0] == 'PROD_TEMPLATE' else request.param[0] @@ -502,10 +475,10 @@ def template_test_case( # Set User Config YAML ALIAS_TO_CONFIG_FILE: t.Dict[str, t.Optional[Path]] = { - 'BISKOTAKI_CONFIG': MY_DIR / '..' / '.github' / 'biskotaki.yaml', + 'BISKOTAKI_CONFIG': MY_DIR / '..' / '..' / '.github' / 'biskotaki.yaml', 'TEST_TIME_BISKOTAKI_CONFIG': TEST_TIME_BISKOTAKI_CONFIG, - 'GOLD_STANDARD_CONFIG': MY_DIR / 'data' / 'gold-standard.yml', - 'PYTEST_PLUGIN_CONFIG': MY_DIR / 'data' / 'pytest-fixture.yaml', + 'GOLD_STANDARD_CONFIG': MY_DIR / '..' / 'data' / 'gold-standard.yml', + 'PYTEST_PLUGIN_CONFIG': MY_DIR / '..' / 'data' / 'pytest-fixture.yaml', } USER_CONFIG_FILE = ALIAS_TO_CONFIG_FILE.get(request.param[1], request.param[1]) @@ -559,6 +532,28 @@ def template_test_case( and len(interpreters['supported-interpreters']) > 0 ) + # Helper Function for dynamically adding one key into expected context OrderedDict + def get_expected_context(expected_context: t.Dict[str, t.Any], folder_path: Path): + """Greate Expected Context as OrderedDict, given expected_context and generated project dir path""" + def gen(): + """Generate key/values for expected context, ensuring _output_dir comes right after _template""" + # generator keys an inject _output_dir + for k, v in expected_context[C_KEY].items(): + # if key is not _template, yield it + if k != '_template': + yield k, v + # if key is _template, yield it and then yield _output_dir + else: + yield k, v + yield '_output_dir', str(folder_path.absolute()) + + return OrderedDict( + [ + (C_KEY, OrderedDict([(k, v) for k, v in gen()])), + ('_cookiecutter', expected_context['_cookiecutter']), + ] + ) + return { 'cookie': COOKIE_TEMPLATE_DIR, 'user_config': USER_CONFIG_FILE, @@ -609,148 +604,3 @@ def _get_cookiecutter_callable_mapping(config_file: Path, cookiecutter_template_ return cookiecutter_callable_mapping return _get_cookiecutter_callable_mapping - - -@patch('cookiecutter.main.generate_context') -def test_cookiecutter_generates_context_with_expected_values( - generate_context_mock, # magic mock object - template_test_case, # fixture with request.param, with Test Case Data - cookiecutter_callable_mapping, # support to call cookiecutter - tmp_path: Path, # pytest fixture -): - """Verify generated Jinja Context, given Cookiecutter Template and User Config YAML.""" - # GIVEN a Cookiecutter Template: Dir with cookiecutter.json + {{ cookiecutter.project_name }}/ - cookie: Path = template_test_case['cookie'] - # GIVEN a User Config YAML, which overrides a default Choice Variable - config_yaml: Path = template_test_case['user_config'] - - # GIVEN target Gen Project dir has no files inside - gen_proj_dir: Path = tmp_path - assert gen_proj_dir.exists() and len(list(gen_proj_dir.iterdir())) == 0 - - # GIVEN the EXPECTED CONTEXT DISCTIONARY - EXPECTED_CONTEXT = template_test_case['get_expected_context'](gen_proj_dir) - - # GIVEN a way to "track" the input passed at runtime to cookiecutter's generate_context function - from cookiecutter.config import get_config - - user_config_dict: t.MutableMapping[str, t.Any] = get_config(config_yaml) - expected_default_context_passed: t.Dict = user_config_dict['default_context'] - - expected_extra_context_passed = None - - from cookiecutter.generate import generate_context - - # SANITY no calls to generate_context yet - assert generate_context_mock.call_count == 0 - - prod_result = generate_context( - context_file=str(cookie / 'cookiecutter.json'), - default_context=expected_default_context_passed, - extra_context=expected_extra_context_passed, - ) - - # SANITY no calls to generate_context yet - assert generate_context_mock.call_count == 0 - - # assert prod_result == EXPECTED_CONTEXT - - # WHEN cookiecutter is called (either directly or via Generator) on a Template - # and with the User Config YAML - generate_context_mock.return_value = prod_result - - callback_mapping = cookiecutter_callable_mapping(config_yaml, cookie) - - callback_mapping[template_test_case['alias_of_template_used']]( - # str(cookie), # template dir path - config_file=str(config_yaml), - default_config=False, - output_dir=gen_proj_dir, - # extra_context={'interpreters': EXPECTED_CONTEXT['cookiecutter']['interpreters']} if 'interpreters' in EXPECTED_CONTEXT['cookiecutter'] else None, - no_input=True, # non interactive - checkout=False, - replay=False, - ) - - # THEN the generate_context was called once - assert generate_context_mock.call_count == 1 - generate_context_mock.assert_called_once() - - # AND we check the runtime input passed to cookiecutter's generate_context function - # THEN the internal generate_context of coociecutter was called with expected runtime input values - - # THEN internally cookiecutter passes the expected arguments to the - # cookiecutter.generate.generate_context function - generate_context_mock.assert_called_with( - context_file=str( - Path(EXPECTED_CONTEXT['cookiecutter']['_template']) / 'cookiecutter.json' - ), - default_context=expected_default_context_passed, - extra_context=expected_extra_context_passed, - ) - - import yaml - - # SANITY check User Config YAML data passed as Dict to 'default_context' kwarg of generate_context - assert expected_default_context_passed == OrderedDict( - [(k, v) for k, v in yaml.safe_load(config_yaml.read_text())['default_context'].items()] - ) - # AND Cookiecutter inserts 2 keys into Jinja Context: 'cookiecutter' and '_cookiecutter' - assert set(prod_result.keys()) == {C_KEY, '_cookiecutter'} - - # AND the Template Variables in 'cookiecutter' key is an OrderedDict - assert isinstance(prod_result[C_KEY], OrderedDict) - - # AND the internal data in jinja context map under 'cookiecutter' key are as expected - assert len(prod_result[C_KEY]) == len(EXPECTED_CONTEXT[C_KEY]) - for p1, p2 in zip(prod_result[C_KEY].items(), EXPECTED_CONTEXT[C_KEY].items()): - assert p1[0] == p2[0], ( - "All PROD Keys: [\n" - + '\n'.join(prod_result[C_KEY].keys()) - + '\n]\n\nAll TEST Keys: [\n' - + '\n'.join(EXPECTED_CONTEXT[C_KEY].keys()) - + "\n]" - ) - if p1[0] == 'release_date': - # ACCOUNT for different timezones we accept date difference of 1 day - runtime_date = p1[1] - expected_date = p2[1] - # verify diff is at most 1 day - assert ( - abs( - datetime.datetime.strptime(runtime_date, '%Y-%m-%d') - - datetime.datetime.strptime(expected_date, '%Y-%m-%d') - ).days - <= 1 - ), f"Context Missmatch at '{C_KEY}' -> '{p1[0]}': Runtime: '{runtime_date}', Expected: '{expected_date}'" - else: - assert ( - p1[1] == p2[1] - ), f"Context Missmatch at '{C_KEY}' -> '{p1[0]}': Runtime: '{p1[1]}', Expected: '{p2[1]}'" - - # THEN the internal data in jinja context map under 'cookiecutter' key are as expected - assert prod_result[C_KEY] == dict( - EXPECTED_CONTEXT[C_KEY], - **( - { - 'release_date': prod_result[C_KEY]['release_date'], - } - if 'release_date' in prod_result[C_KEY] - else {} - ), - ) - - # SANITY the back-up/copy of raw data is place under '_cookiecutter' key as Dict - assert isinstance(prod_result['_cookiecutter'], dict) - # AND the internal data in jinja context map under '_cookiecutter' key are as expected - assert len(prod_result['_cookiecutter']) == len(EXPECTED_CONTEXT['_cookiecutter']) - for p1, p2 in zip( - prod_result['_cookiecutter'].items(), - EXPECTED_CONTEXT['_cookiecutter'].items(), - ): - assert p1[0] == p2[0] - # if p1[0] in {'project_short_description', 'pypi_subtitle'}: - # continue - assert ( - p1[1] == p2[1] - ), f"Error at key {p1[0]} with value '{p1[1]}'. Expected '{p2[1]}'!" diff --git a/tests/context/test_cookiecutter_context.py b/tests/context/test_cookiecutter_context.py new file mode 100644 index 00000000..8b02a836 --- /dev/null +++ b/tests/context/test_cookiecutter_context.py @@ -0,0 +1,222 @@ +import datetime +import tempfile +import typing as t +from collections import OrderedDict +from pathlib import Path +from unittest.mock import patch + +import pytest + + +MY_DIR = Path(__file__).parent +C_KEY = 'cookiecutter' # COOKIECUTTER_KEY +# offest by 2 hours to match Jinja 'now' expression: {% now 'utc', '%Y-%m-%d' %} +# RELEASE_DATE = (datetime.datetime.now() - datetime.timedelta(hours=2)).strftime('%Y-%m-%d') +RELEASE_DATE = datetime.datetime.now().strftime('%Y-%m-%d') + + +# AUTOMATED TESTs for Cookiecutter Context Generation +# - GIVEN Inputs to cookiecutter (callable) +# - Cookiecutter Template: Dir with cookiecutter.json file and {{ cookiecutter.project_name }}/ dir +# - User Config YAML: File with User Config data passed as --config-file to cookiecutter +# - GIVEN expectations: +# - Jinja Context object expected to be generated by cookiecutter under the hood + +# Verifies cookiecutter.generate_context is called with expected input parameter values +# Verifies the Jinja Context object generated has the 'cookiecutter' and '_cookiecutter' keys +# Verifies values of keys are == "exactly equal" to expected values + + +# TEST CASE 2 depends on finding the .github/biskotaki.yaml in local checkout, +# because its automatic discovery mechanism relies on relative path from this file + + +@patch('cookiecutter.main.generate_context') +def test_cookiecutter_generates_context_with_expected_values( + generate_context_mock, # magic mock object + template_test_case, # fixture with request.param, with Test Case Data + cookiecutter_callable_mapping, # support to call cookiecutter + tmp_path: Path, # pytest fixture +): + """Verify generated Jinja Context, given Cookiecutter Template and User Config YAML.""" + import yaml + + # GIVEN a Cookiecutter Template: Dir with cookiecutter.json + {{ cookiecutter.project_name }}/ + cookie: Path = template_test_case['cookie'] + # GIVEN a User Config YAML, which overrides a default Choice Variable + config_yaml: Path = template_test_case['user_config'] + + # GIVEN target Gen Project dir has no files inside + gen_proj_dir: Path = tmp_path + assert gen_proj_dir.exists() and len(list(gen_proj_dir.iterdir())) == 0 + + # GIVEN the EXPECTED CONTEXT DISCTIONARY + EXPECTED_CONTEXT = template_test_case['get_expected_context'](gen_proj_dir) + + # GIVEN a way to "track" the input passed at runtime to cookiecutter's generate_context function + from cookiecutter.config import get_config + + user_config_dict: t.MutableMapping[str, t.Any] = get_config(config_yaml) + expected_default_context_passed: t.Dict = user_config_dict['default_context'] + # SANITY check User Config YAML data passed as Dict to 'default_context' kwarg of generate_context + assert expected_default_context_passed == OrderedDict( + [(k, v) for k, v in yaml.safe_load(config_yaml.read_text())['default_context'].items()] + ) + + expected_extra_context_passed = None + + + # WHEN we call cookiecutter.generate_context like in production + + from cookiecutter.generate import generate_context + # from cookiecutter.main import generate_context # increases call_count by 1 !!! + + # SANITY no calls to generate_context yet + assert generate_context_mock.call_count == 0 + + # safe-keep output of production generate_context call + prod_result = generate_context( + context_file=str(cookie / 'cookiecutter.json'), + default_context=expected_default_context_passed, # user's config yaml + extra_context=expected_extra_context_passed, # None + ) + + import copy + _gen_output_og_object = copy.deepcopy(prod_result) + + # SANITY no calls to generate_context yet + assert generate_context_mock.call_count == 0 + + # # THEN the production generate_context call returns expected Context + # assert prod_result == EXPECTED_CONTEXT + + # AND the Template Variables in 'cookiecutter' key is an OrderedDict + # AND the internal data in jinja context map under 'cookiecutter' key are as expected + assert set(prod_result.keys()) == {C_KEY} and isinstance(prod_result[C_KEY], OrderedDict) + + # sanity that dict created by generate_context is being augmented before running jinja rendering + assert len(prod_result[C_KEY]) == len(EXPECTED_CONTEXT[C_KEY]) - 4 + + # SANITY generate_context does one Job and creates the cookiecutter key in context (used in downstream jinja rendering) + + # AND the internal generate context function received expected input parameter values + + # enable tracking of the "context" dict returned by the generate_context function + # when called from cookiecutter + + # track returned value of generate_context from above: generator -> cookiecutter -> generate_context + + generate_context_mock.return_value = prod_result # exact output from app code generate_context function + # see cookiecutter.main where generate_context is called + + # Run cookiecutter directly, or by running Pkg Gen + # in source code, cookiecutter.main:cookiecutter calls generate_context + # but here our mock will trigger instead and ensure + # 1. same object is returned from mock as from og generate_context source code + # 2. prod_result references internal object x as in x = generate_context() ! + # 3. prod_result can be queried on-demand + callback_mapping = cookiecutter_callable_mapping(config_yaml, cookie) + callback_mapping[template_test_case['alias_of_template_used']]( + # str(cookie), # template dir path + config_file=str(config_yaml), + default_config=False, + output_dir=gen_proj_dir, + # extra_context={'interpreters': EXPECTED_CONTEXT['cookiecutter']['interpreters']} if 'interpreters' in EXPECTED_CONTEXT['cookiecutter'] else None, + no_input=True, # non interactive + checkout=False, + replay=False, + ) + + # SANITY tracked generate_context was called once + assert generate_context_mock.call_count == 1 + generate_context_mock.assert_called_once() + + + # we check the runtime input parameters passed to cookiecutter's generate_context function + # and verify that internal generate_context of coociecutter's was called with expected runtime input values + + # cookiecutter lib passes the expected arguments to the + # cookiecutter.generate.generate_context function + generate_context_mock.assert_called_with( + context_file=str( + Path(EXPECTED_CONTEXT['cookiecutter']['_template']) / 'cookiecutter.json' + ), + default_context=expected_default_context_passed, + extra_context=expected_extra_context_passed, + ) + + + + # AND Cookiecutter inserts 2 keys into Jinja Context: 'cookiecutter' and '_cookiecutter' + assert set(prod_result.keys()) == {C_KEY, '_cookiecutter'} + + # AND the Template Variables in 'cookiecutter' key is an OrderedDict + assert isinstance(prod_result[C_KEY], OrderedDict) + + # AND the internal data in jinja context map under 'cookiecutter' key are as expected + assert len(prod_result[C_KEY]) == len(EXPECTED_CONTEXT[C_KEY]) + for p1, p2 in zip(prod_result[C_KEY].items(), EXPECTED_CONTEXT[C_KEY].items()): + assert p1[0] == p2[0], ( + "All PROD Keys: [\n" + + '\n'.join(prod_result[C_KEY].keys()) + + '\n]\n\nAll TEST Keys: [\n' + + '\n'.join(EXPECTED_CONTEXT[C_KEY].keys()) + + "\n]" + ) + if p1[0] == 'release_date': + # ACCOUNT for different timezones we accept date difference of 1 day + runtime_date = p1[1] + expected_date = p2[1] + # verify diff is at most 1 day + assert ( + abs( + datetime.datetime.strptime(runtime_date, '%Y-%m-%d') + - datetime.datetime.strptime(expected_date, '%Y-%m-%d') + ).days + <= 1 + ), f"Context Missmatch at '{C_KEY}' -> '{p1[0]}': Runtime: '{runtime_date}', Expected: '{expected_date}'" + else: + assert ( + p1[1] == p2[1] + ), f"Context Missmatch at '{C_KEY}' -> '{p1[0]}': Runtime: '{p1[1]}', Expected: '{p2[1]}'" + + # THEN the internal data in jinja context map under 'cookiecutter' key are as expected + assert prod_result[C_KEY] == dict( + EXPECTED_CONTEXT[C_KEY], + **( + { + 'release_date': prod_result[C_KEY]['release_date'], + } + if 'release_date' in prod_result[C_KEY] + else {} + ), + ) + + # SANITY the back-up/copy of raw data is place under '_cookiecutter' key as Dict + assert isinstance(prod_result['_cookiecutter'], dict) + # AND the internal data in jinja context map under '_cookiecutter' key are as expected + assert len(prod_result['_cookiecutter']) == len(EXPECTED_CONTEXT['_cookiecutter']) + for p1, p2 in zip( + prod_result['_cookiecutter'].items(), + EXPECTED_CONTEXT['_cookiecutter'].items(), + ): + assert p1[0] == p2[0] + # if p1[0] in {'project_short_description', 'pypi_subtitle'}: + # continue + assert ( + p1[1] == p2[1] + ), f"Error at key {p1[0]} with value '{p1[1]}'. Expected '{p2[1]}'!" + + # VERIFY that cookiecutter stores original output of generate_context (before adding 4 extra keys and converting choices to str or yes/no to bool) + # under the '_cookiecutter' context key + assert _gen_output_og_object['cookiecutter'] == EXPECTED_CONTEXT['_cookiecutter'] + assert set(prod_result.keys()) == {'cookiecutter', '_cookiecutter'} + assert set(_gen_output_og_object.keys()) == {'cookiecutter'} + + # VERIFY that generate_context never converts to bool anything + assert all([type(x) != bool for x in _gen_output_og_object['cookiecutter'].values()]) + # exclude '_checkout' keys added iunder 'cookiecutter' context key + assert all([type(v) != bool for k, v in prod_result['cookiecutter'].items() if k not in {'_checkout'}]), f"Keys {[k for k,v in prod_result['cookiecutter'].items() if type(v) == bool]} got converted to bool unexpectedly!" + + # ONLY in no_input = False, then cookiecutter converts to bool the yes/no ! + # very error-prone cookiecutter behavior !!! diff --git a/tests/context/test_generate_context_unit.py b/tests/context/test_generate_context_unit.py new file mode 100644 index 00000000..dccee250 --- /dev/null +++ b/tests/context/test_generate_context_unit.py @@ -0,0 +1,60 @@ +"""Unit test for generate_context function - pure function testing. + +Although generate_context is a critical function, it is not called by our app +directly. This meanis +""" + +import pytest +import typing as t +from pathlib import Path +from collections import OrderedDict +from unittest.mock import patch +import datetime + +from cookiecutter.generate import generate_context + + +C_KEY = 'cookiecutter' # COOKIECUTTER_KEY +_C_KEY = '_cookiecutter' +RELEASE_DATE = datetime.datetime.now().strftime('%Y-%m-%d') + + +# Use the existing template_test_case fixture - no parametrization needed! +# The fixture already has multiple test cases built-in +def test_generate_context_returns_expected_structure( + template_test_case, + tmp_path, +): + """Test that generate_context returns the expected context structure. + + This is a pure unit test that verifies generate_context output + without any cookiecutter execution or mocking side effects. + """ + # GIVEN: Template configuration and user config + from cookiecutter.config import get_config + + expected_default_context_passed: t.Dict = get_config(template_test_case['user_config'])['default_context'] + expected_extra_context_passed = None # as per original test + + # WHEN: We call generate_context directly + result = generate_context( + context_file=str(template_test_case['cookie'] / 'cookiecutter.json'), + default_context=expected_default_context_passed, + extra_context=expected_extra_context_passed, + ) + + # THEN: The context dict contains only the 'cookiecutter' key (no '_cookiecutter' yet) + assert isinstance(result, OrderedDict) + assert set(result.keys()) == {'cookiecutter'} + + # AND: The cookiecutter section is an OrderedDict with expected structure + assert isinstance(result['cookiecutter'], OrderedDict) + + # AND Choice variables are (still) lists (downstream cookiecutter will pick one later, which is tested in other test module)) + + # AND the internal data in jinja context map under 'cookiecutter' key are as expected + # GIVEN the EXPECTED CONTEXT DISCTIONARY + EXPECTED_CONTEXT = template_test_case['get_expected_context'](tmp_path) + + # THEN the production generate_context call returns expected Context + assert result[C_KEY] == EXPECTED_CONTEXT[_C_KEY] diff --git a/tests/test_yes_no_in_cookiecutter_json.py b/tests/test_yes_no_in_cookiecutter_json.py new file mode 100644 index 00000000..7e6a1bf8 --- /dev/null +++ b/tests/test_yes_no_in_cookiecutter_json.py @@ -0,0 +1,241 @@ +"""Test cookiecutter context handling of yes/no choice variables vs multi-choice variables. + +This test verifies that cookiecutter properly handles different types of choice variables +in the generated context, specifically: +- Boolean-style yes/no variables should remain as strings (not converted to booleans) +- Multi-choice variables should remain as lists in _cookiecutter context +- Selected values should appear as strings in cookiecutter context +""" +import json +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + + +def test_cookiecutter_context_preserves_choice_variable_types(tmp_path: Path): + """Test that cookiecutter preserves variable types correctly in context. + + GIVEN a cookiecutter.json file with: + - A yes/no choice variable v1 + - A multi-choice variable v2 with options a/b/c + WHEN cookiecutter reads/parses/renders the template + THEN the context should contain: + - v1 as string value (not boolean) in 'cookiecutter' context + - v1 as list of choices in '_cookiecutter' context + - v2 as string value (first choice) in 'cookiecutter' context + - v2 as list of choices in '_cookiecutter' context + """ + + # GIVEN: Create a minimal cookiecutter template + template_dir = tmp_path / "test_template" + template_dir.mkdir() + + # Create cookiecutter.json with yes/no and multi-choice variables + cookiecutter_json = { + "project_name": "Test Project", + "enable_feature": ["no", "yes"], # yes/no choice variable (v1) + "build_system": ["poetry", "setuptools", "flit"], # multi-choice variable (v2) + "multiple_choice": ["c1", "c2", "c3"], # multi-choice variable (v2) + "multiple_choice_2": ["c1", "c2", "c3"] # multi-choice variable (v2) + } + + cookiecutter_json_file = template_dir / "cookiecutter.json" + with open(cookiecutter_json_file, 'w') as f: + json.dump(cookiecutter_json, f, indent=2) + + # Create a minimal template project structure + project_template_dir = template_dir / "{{ cookiecutter.project_name|lower|replace(' ', '-') }}" + project_template_dir.mkdir() + + # Create a simple template file that uses both variables + template_file = project_template_dir / "config.txt" + template_content = """Project: {{ cookiecutter.project_name }} +Feature Enabled: {{ cookiecutter.enable_feature }} +Build System: {{ cookiecutter.build_system }} +""" + with open(template_file, 'w') as f: + f.write(template_content) + + # Create user config to override defaults + user_config = { + "default_context": { + "project_name": "My Test Project", + "enable_feature": "yes", # Override to "yes" + "build_system": "setuptools", # Override to "setuptools" + "multiple_choice_2": "c4", + } + } + + user_config_file = tmp_path / "user_config.yaml" + with open(user_config_file, 'w') as f: + import yaml + yaml.dump(user_config, f) + + # WHEN: Use cookiecutter to generate context (without full rendering) + from cookiecutter.generate import generate_context + + context = generate_context( + context_file=str(cookiecutter_json_file), + default_context=user_config["default_context"], + extra_context=None + ) + + # THEN it has only the 'cookiecutter' key (no _cookiecutter yet) + assert 'cookiecutter' in context + assert '_cookiecutter' not in context + + cookiecutter_vars = context['cookiecutter'] + + # Test v1 (yes/no choice variable) + # In cookiecutter context: should be the selected string value + assert isinstance(cookiecutter_vars['enable_feature'], list) + assert cookiecutter_vars['enable_feature'] == ["yes", "no"] + + # Test v2 (multi-choice variable) + # In cookiecutter context: should be the selected string value + assert isinstance(cookiecutter_vars['build_system'], list) + assert cookiecutter_vars['build_system'] == ["setuptools", "poetry", "flit"] + + + assert isinstance(cookiecutter_vars['multiple_choice'], list) + assert cookiecutter_vars['multiple_choice'] == ["c1", "c2", "c3"] + + # THEN cookiecutter.json arrays remain as lists in context + # AND if user config no choice or unknown choice, then no effect data + assert isinstance(cookiecutter_vars['multiple_choice_2'], list) + assert cookiecutter_vars['multiple_choice_2'] == ["c1", "c2", "c3"] + + +def test_cookiecutter_context_preserves_choice_variable_types_v2(tmp_path: Path): + """Test that cookiecutter preserves variable types correctly in context. + + GIVEN a cookiecutter.json file with: + - A yes/no choice variable v1 + - A multi-choice variable v2 with options a/b/c + WHEN cookiecutter reads/parses/renders the template + THEN the context should contain: + - v1 as string value (not boolean) in 'cookiecutter' context + - v1 as list of choices in '_cookiecutter' context + - v2 as string value (first choice) in 'cookiecutter' context + - v2 as list of choices in '_cookiecutter' context + """ + + # GIVEN: Create a minimal cookiecutter template + template_dir = tmp_path / "test_template" + template_dir.mkdir() + + # Create cookiecutter.json with yes/no and multi-choice variables + cookiecutter_json = { + "project_name": "Test Project", + "enable_feature": ["no", "yes"], # yes/no choice variable (v1) + "build_system": ["poetry", "setuptools", "flit"] # multi-choice variable (v2) + } + + cookiecutter_json_file = template_dir / "cookiecutter.json" + with open(cookiecutter_json_file, 'w') as f: + json.dump(cookiecutter_json, f, indent=2) + + # Create a minimal template project structure + project_template_dir = template_dir / "{{ cookiecutter.project_name|lower|replace(' ', '-') }}" + project_template_dir.mkdir() + + # Create a simple template file that uses both variables + template_file = project_template_dir / "config.txt" + template_content = """Project: {{ cookiecutter.project_name }} +Feature Enabled: {{ cookiecutter.enable_feature }} +Build System: {{ cookiecutter.build_system }} +""" + with open(template_file, 'w') as f: + f.write(template_content) + + + # WHEN: Use cookiecutter to generate context (without full rendering) + from cookiecutter.generate import generate_context + + context = generate_context( + context_file=str(cookiecutter_json_file), + ) + + # THEN it has only the 'cookiecutter' key (no _cookiecutter yet) + assert 'cookiecutter' in context + assert '_cookiecutter' not in context + + cookiecutter_vars = context['cookiecutter'] + + # Test v1 (yes/no choice variable) + # In cookiecutter context: should be the selected string value + assert cookiecutter_vars['enable_feature'] == ["no", "yes"] + + # Test v2 (multi-choice variable) + # In cookiecutter context: should be the selected string value + assert cookiecutter_vars['build_system'] == ["poetry", "setuptools", "flit"] + + # Additional verification: ensure no automatic boolean conversion happened + # The yes/no should remain as strings, not be converted to True/False + # assert cookiecutter_vars['enable_feature'] != True + # assert cookiecutter_vars['enable_feature'] != False + # assert cookiecutter_vars['enable_feature'] == 'yes' + + + + + +def test_cookiecutter_full_rendering_with_choice_variables(tmp_path: Path): + """Test internal cookiecutter lib rendering process with choice variables. + + This test verifies that cookiecutter renders both yes/no and a/b/c choice vars as strings. + + GIVEN a 'Cookiecutter Template': cookiecutter.json + (jinja) template files + """ + # GIVEN a cookiecutter.json with yes/no and multi-choice variables + template_dir = tmp_path / "test_template" + template_dir.mkdir() + + cookiecutter_json = { + "project_name": "Test Suite Project", + "enable_feature": ["yes", "no"], + "docs_build_system": ["mkdocs", "sphinx", "whatever"] + } + + with open(template_dir / "cookiecutter.json", 'w') as f: + json.dump(cookiecutter_json, f, indent=2) + + project_template_dir = template_dir / "{{ cookiecutter.project_name|lower|replace(' ', '-') }}" + project_template_dir.mkdir() + + template_content = """Project: {{ cookiecutter.project_name }} +Feature Enabled: {{ cookiecutter.enable_feature }} +Build System: {{ cookiecutter.docs_build_system }} +""" + with open(project_template_dir / "config.txt", 'w') as f: + f.write(template_content) + + # WHEN cookiecutter is called to render the Template + from cookiecutter.main import cookiecutter + + output_dir = tmp_path / "output" + output_dir.mkdir() + + project_path = cookiecutter( + str(template_dir), + output_dir=str(output_dir), + no_input=True, + # extra_context=extra_context + ) + + # THEN cookiecutter picks 1st option from 'choice vars' and render as strings + generated_project = Path(project_path) + assert generated_project.exists() + assert generated_project.name == "test-suite-project" + + config_file = generated_project / "config.txt" + assert config_file.exists() + + rendered_content = config_file.read_text() + expected_content = """Project: Test Suite Project +Feature Enabled: yes +Build System: mkdocs +""" + assert rendered_content == expected_content From 5756614ee92bd7ef3ac68d8e9f83c7b2a04b1579 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Sun, 2 Nov 2025 20:31:54 +0200 Subject: [PATCH 31/35] refactor: apply isort and black --- .../biskotaki_ci_no_input/test_lint_passes.py | 1 + tests/conftest.py | 29 +-- tests/context/conftest.py | 2 +- tests/context/test_cookiecutter_context.py | 23 ++- tests/context/test_generate_context_unit.py | 16 +- tests/test_build_backend_sdist.py | 193 ++++++++++++------ tests/test_observability.py | 113 +++++----- tests/test_yes_no_in_cookiecutter_json.py | 114 +++++------ 8 files changed, 297 insertions(+), 194 deletions(-) diff --git a/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py b/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py index d31b9f5c..08b5f99b 100644 --- a/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py +++ b/tests/biskotaki_ci/snapshot/biskotaki_ci_no_input/test_lint_passes.py @@ -89,6 +89,7 @@ def test_running_ruff_passes(snapshot_name, my_run_subprocess, test_root): # Also clean up .ruff_cache directory created by ruff during linting if (snapshot_dir / '.ruff_cache').exists(): import shutil + shutil.rmtree(snapshot_dir / '.ruff_cache') # VERIFIED that Generator emits python code that pass Ruff out of the box !! diff --git a/tests/conftest.py b/tests/conftest.py index 62827fdb..5c42514c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ +import logging import os import sys import typing as t -import logging if sys.version_info >= (3, 8): @@ -347,8 +347,7 @@ class ConfigProtocol(Protocol): class ConfigInterfaceProtocol(t.Protocol): - def __getitem__(self, file_path_str: t.Union[str, None]) -> ConfigProtocol: - ... + def __getitem__(self, file_path_str: t.Union[str, None]) -> ConfigProtocol: ... @pytest.fixture @@ -374,7 +373,9 @@ def user_config(distro_loc: Path) -> ConfigInterfaceProtocol: def _load_context_json(file_path: PathLike) -> t.Dict: with open(file_path, 'r') as fp: data = json.load(fp) - assert type(data['cicd']) is list, f"'cicd' must be a list in {file_path}; found {type(data['cicd'])}" + assert ( + type(data['cicd']) is list + ), f"'cicd' must be a list in {file_path}; found {type(data['cicd'])}" return data def _load_context_yaml(file_path: PathLike) -> t.MutableMapping[str, t.Any]: @@ -474,7 +475,9 @@ def transorm_json_data(data: t.Dict[str, t.Any]): data['initialize_git_repo'][0], False ) - assert type(data['cicd']) is list, f"'cicd' must be a list; found {type(data['cicd'])}" + assert ( + type(data['cicd']) is list + ), f"'cicd' must be a list; found {type(data['cicd'])}" # 2. Auto-process choice variables (arrays) - use first element as default for key, value in data.items(): if isinstance(value, list) and len(value) > 0: @@ -518,8 +521,7 @@ def config_file(self) -> t.Union[Path, None]: class RelativePathsGenerator(Protocol): """Generate relative paths from a given root folder.""" - def relative_file_paths(self) -> t.Iterator[Path]: - ... + def relative_file_paths(self) -> t.Iterator[Path]: ... @pytest.fixture @@ -703,19 +705,20 @@ def b(docs_builder_id): [str(x).endswith('.pyc') for x in _expected_to_find] ), f"Sanity check fail: {_expected_to_find}" - # DERIVE ALL EXPECTED FILES FOR POST GENERATION REMOVAL and EXCLUDE FROM EXPECTATIONS ### 1. DO NOT EXPECT the folders with a TO_DELETE substring in their names ### from cookiecutter_python.hooks.post_gen_project import TO_DELETE_TEXT - + template_root = distro_loc / r'{{ cookiecutter.project_slug }}' - expected_for_post_removal = [d for d in template_root.rglob('*') if d.is_dir() and TO_DELETE_TEXT in str(d)] - + expected_for_post_removal = [ + d for d in template_root.rglob('*') if d.is_dir() and TO_DELETE_TEXT in str(d) + ] + for dir_path in expected_for_post_removal: # Add the directory itself files_to_remove.add(str(dir_path.relative_to(template_root))) - + # ADD THIS: Also add all files within the directory for file_path in dir_path.rglob('*'): if file_path.is_file(): @@ -737,7 +740,6 @@ def b(docs_builder_id): [isinstance(x, str) for x in files_to_remove] ), f"Temporary Requirement of Test Code: files_to_remove must be a list of strings, not {files_to_remove}" - ### 3. DO NOT EXPECT TO FIND THE DOCS FOLDERS FOR OTHER DOCS BUILDERS ### ## Remove all Template Docs files from Expectations, because in the Template Project # 2 dedicated foldres are used to maintain mkdocs and spinx Docs (which get moved to ./docs in post hook) @@ -779,7 +781,6 @@ def b(docs_builder_id): str(file_path.relative_to(distro_loc / r'{{ cookiecutter.project_slug }}')) ) - assert all( [isinstance(x, str) for x in files_to_remove] ), f"Temporary Requirement of Test Code: files_to_remove must be a list of strings, not {files_to_remove}" diff --git a/tests/context/conftest.py b/tests/context/conftest.py index 471cd1ff..209aaee0 100644 --- a/tests/context/conftest.py +++ b/tests/context/conftest.py @@ -72,7 +72,6 @@ TEST_TIME_BISKOTAKI_CONFIG = Path(fp.name) - @pytest.fixture( params=[ ( @@ -535,6 +534,7 @@ def template_test_case(request, distro_loc: Path): # Helper Function for dynamically adding one key into expected context OrderedDict def get_expected_context(expected_context: t.Dict[str, t.Any], folder_path: Path): """Greate Expected Context as OrderedDict, given expected_context and generated project dir path""" + def gen(): """Generate key/values for expected context, ensuring _output_dir comes right after _template""" # generator keys an inject _output_dir diff --git a/tests/context/test_cookiecutter_context.py b/tests/context/test_cookiecutter_context.py index 8b02a836..9c5170cc 100644 --- a/tests/context/test_cookiecutter_context.py +++ b/tests/context/test_cookiecutter_context.py @@ -40,7 +40,7 @@ def test_cookiecutter_generates_context_with_expected_values( ): """Verify generated Jinja Context, given Cookiecutter Template and User Config YAML.""" import yaml - + # GIVEN a Cookiecutter Template: Dir with cookiecutter.json + {{ cookiecutter.project_name }}/ cookie: Path = template_test_case['cookie'] # GIVEN a User Config YAML, which overrides a default Choice Variable @@ -64,13 +64,12 @@ def test_cookiecutter_generates_context_with_expected_values( ) expected_extra_context_passed = None - # WHEN we call cookiecutter.generate_context like in production from cookiecutter.generate import generate_context - # from cookiecutter.main import generate_context # increases call_count by 1 !!! + # from cookiecutter.main import generate_context # increases call_count by 1 !!! # SANITY no calls to generate_context yet assert generate_context_mock.call_count == 0 @@ -80,8 +79,9 @@ def test_cookiecutter_generates_context_with_expected_values( default_context=expected_default_context_passed, # user's config yaml extra_context=expected_extra_context_passed, # None ) - + import copy + _gen_output_og_object = copy.deepcopy(prod_result) # SANITY no calls to generate_context yet @@ -106,7 +106,9 @@ def test_cookiecutter_generates_context_with_expected_values( # track returned value of generate_context from above: generator -> cookiecutter -> generate_context - generate_context_mock.return_value = prod_result # exact output from app code generate_context function + generate_context_mock.return_value = ( + prod_result # exact output from app code generate_context function + ) # see cookiecutter.main where generate_context is called # Run cookiecutter directly, or by running Pkg Gen @@ -131,7 +133,6 @@ def test_cookiecutter_generates_context_with_expected_values( assert generate_context_mock.call_count == 1 generate_context_mock.assert_called_once() - # we check the runtime input parameters passed to cookiecutter's generate_context function # and verify that internal generate_context of coociecutter's was called with expected runtime input values @@ -145,8 +146,6 @@ def test_cookiecutter_generates_context_with_expected_values( extra_context=expected_extra_context_passed, ) - - # AND Cookiecutter inserts 2 keys into Jinja Context: 'cookiecutter' and '_cookiecutter' assert set(prod_result.keys()) == {C_KEY, '_cookiecutter'} @@ -216,7 +215,13 @@ def test_cookiecutter_generates_context_with_expected_values( # VERIFY that generate_context never converts to bool anything assert all([type(x) != bool for x in _gen_output_og_object['cookiecutter'].values()]) # exclude '_checkout' keys added iunder 'cookiecutter' context key - assert all([type(v) != bool for k, v in prod_result['cookiecutter'].items() if k not in {'_checkout'}]), f"Keys {[k for k,v in prod_result['cookiecutter'].items() if type(v) == bool]} got converted to bool unexpectedly!" + assert all( + [ + type(v) != bool + for k, v in prod_result['cookiecutter'].items() + if k not in {'_checkout'} + ] + ), f"Keys {[k for k,v in prod_result['cookiecutter'].items() if type(v) == bool]} got converted to bool unexpectedly!" # ONLY in no_input = False, then cookiecutter converts to bool the yes/no ! # very error-prone cookiecutter behavior !!! diff --git a/tests/context/test_generate_context_unit.py b/tests/context/test_generate_context_unit.py index dccee250..ca642097 100644 --- a/tests/context/test_generate_context_unit.py +++ b/tests/context/test_generate_context_unit.py @@ -4,13 +4,13 @@ directly. This meanis """ -import pytest +import datetime import typing as t -from pathlib import Path from collections import OrderedDict +from pathlib import Path from unittest.mock import patch -import datetime +import pytest from cookiecutter.generate import generate_context @@ -26,14 +26,16 @@ def test_generate_context_returns_expected_structure( tmp_path, ): """Test that generate_context returns the expected context structure. - + This is a pure unit test that verifies generate_context output without any cookiecutter execution or mocking side effects. """ # GIVEN: Template configuration and user config from cookiecutter.config import get_config - expected_default_context_passed: t.Dict = get_config(template_test_case['user_config'])['default_context'] + expected_default_context_passed: t.Dict = get_config(template_test_case['user_config'])[ + 'default_context' + ] expected_extra_context_passed = None # as per original test # WHEN: We call generate_context directly @@ -46,11 +48,11 @@ def test_generate_context_returns_expected_structure( # THEN: The context dict contains only the 'cookiecutter' key (no '_cookiecutter' yet) assert isinstance(result, OrderedDict) assert set(result.keys()) == {'cookiecutter'} - + # AND: The cookiecutter section is an OrderedDict with expected structure assert isinstance(result['cookiecutter'], OrderedDict) - # AND Choice variables are (still) lists (downstream cookiecutter will pick one later, which is tested in other test module)) + # AND Choice variables are (still) lists (downstream cookiecutter will pick one later, which is tested in other test module)) # AND the internal data in jinja context map under 'cookiecutter' key are as expected # GIVEN the EXPECTED CONTEXT DISCTIONARY diff --git a/tests/test_build_backend_sdist.py b/tests/test_build_backend_sdist.py index 67398a29..192637cc 100644 --- a/tests/test_build_backend_sdist.py +++ b/tests/test_build_backend_sdist.py @@ -82,7 +82,6 @@ def sdist_expected_correct_file_structure(): 'src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == "yes" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/grafana/datasources/loki.yml', 'src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == "yes" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/loki/loki-config.yml', 'src/cookiecutter_python/{{ cookiecutter.project_slug }}/{% if cookiecutter.include_observability == "yes" %}observability{% else %}PyGen_TO_DELETE_OBSERVABILITY{% endif %}/promtail/promtail-config.yml', - 'src/cookiecutter_python/_find_lib.py', 'src/cookiecutter_python/__init__.py', 'src/cookiecutter_python/__main__.py', @@ -649,13 +648,13 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: print(f"\n{'='*80}") print(f"COMPREHENSIVE ENVIRONMENT DEBUG INFO [Worker {worker_id}]") print(f"{'='*80}") - + # Python runtime information print(f"Python executable: {PYTHON}") print(f"Python version: {sys.version}") print(f"Python platform: {sys.platform}") print(f"Python implementation: {sys.implementation.name}") - + # File system and paths print(f"Current working directory: {os.getcwd()}") print(f"Project path: {project_path}") @@ -663,22 +662,38 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: print(f"Project path is directory: {project_path.is_dir()}") print(f"Output directory: {OUT_DIR}") print(f"Temp directory: {temp_dir}") - + # Build command print(f"Build command: {' '.join(COMMAND_LINE_ARGS)}") - + # Environment variables of interest print("\nCRITICAL ENVIRONMENT VARIABLES:") critical_env_vars = [ - "PYTHONPATH", "PATH", "HOME", "USER", "PWD", "TMPDIR", "TMP", "TEMP", - "BUILD_BACKEND_ISOLATION", "SETUPTOOLS_SCM_DEBUG", "PIP_CACHE_DIR", - "PYTEST_XDIST_WORKER", "CI", "GITHUB_ACTIONS", "RUNNER_OS", "RUNNER_ARCH", - "VIRTUAL_ENV", "CONDA_DEFAULT_ENV", "POETRY_ACTIVE", "UV_CACHE_DIR" + "PYTHONPATH", + "PATH", + "HOME", + "USER", + "PWD", + "TMPDIR", + "TMP", + "TEMP", + "BUILD_BACKEND_ISOLATION", + "SETUPTOOLS_SCM_DEBUG", + "PIP_CACHE_DIR", + "PYTEST_XDIST_WORKER", + "CI", + "GITHUB_ACTIONS", + "RUNNER_OS", + "RUNNER_ARCH", + "VIRTUAL_ENV", + "CONDA_DEFAULT_ENV", + "POETRY_ACTIVE", + "UV_CACHE_DIR", ] for var in critical_env_vars: value = env.get(var, 'NOT SET') print(f" {var}: {value}") - + # File system structure print(f"\nFILE SYSTEM STRUCTURE:") print(f"Project root contents (first 20 items):") @@ -690,18 +705,18 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: print(f" ... and {len(list(project_path.iterdir())) - 20} more items") except Exception as e: print(f" Error listing project contents: {e}") - + # Check critical files critical_files = [ project_path / "pyproject.toml", - project_path / "poetry.lock", + project_path / "poetry.lock", project_path / "uv.lock", project_path / "setup.py", project_path / "setup.cfg", project_path / "MANIFEST.in", project_path / "src", project_path / "src" / "cookiecutter_python", - project_path / "src" / "cookiecutter_python" / "__init__.py" + project_path / "src" / "cookiecutter_python" / "__init__.py", ] print(f"\nCRITICAL FILES CHECK:") for file_path in critical_files: @@ -714,7 +729,7 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: print(f" {file_path.relative_to(project_path)}: EXISTS (directory)") else: print(f" {file_path.relative_to(project_path)}: MISSING") - + # Check for potential lock files that could cause conflicts lock_files = [ project_path / "poetry.lock", @@ -726,21 +741,34 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: if lock_file.exists(): size = lock_file.stat().st_size mtime = lock_file.stat().st_mtime - print(f" {lock_file.relative_to(project_path)}: EXISTS ({size} bytes, mtime: {mtime})") + print( + f" {lock_file.relative_to(project_path)}: EXISTS ({size} bytes, mtime: {mtime})" + ) else: print(f" {lock_file.relative_to(project_path)}: NOT FOUND") - + # Python packages information print(f"\nPYTHON PACKAGES INFO:") try: import subprocess - pip_list_result = subprocess.run([PYTHON, "-m", "pip", "list"], - capture_output=True, text=True, timeout=30) + + pip_list_result = subprocess.run( + [PYTHON, "-m", "pip", "list"], capture_output=True, text=True, timeout=30 + ) if pip_list_result.returncode == 0: lines = pip_list_result.stdout.strip().split('\n') print(f" Total installed packages: {len(lines) - 2}") # Subtract header lines # Show build-related packages - build_packages = ["build", "setuptools", "wheel", "poetry", "uv", "pip", "hatchling", "flit"] + build_packages = [ + "build", + "setuptools", + "wheel", + "poetry", + "uv", + "pip", + "hatchling", + "flit", + ] for line in lines: for pkg in build_packages: if line.lower().startswith(pkg.lower()): @@ -749,22 +777,23 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: print(f" Failed to get pip list: {pip_list_result.stderr}") except Exception as e: print(f" Error getting pip list: {e}") - + # Python import paths print(f"\nPYTHON IMPORT PATHS:") for i, path in enumerate(sys.path): print(f" {i}: {path}") - + # Check if our package is importable and get its location print(f"\nPACKAGE IMPORT CHECK:") try: import cookiecutter_python + print(f" cookiecutter_python.__file__: {cookiecutter_python.__file__}") print(f" cookiecutter_python.__version__: {cookiecutter_python.__version__}") print(f" Package directory: {Path(cookiecutter_python.__file__).parent}") except Exception as e: print(f" Error importing cookiecutter_python: {e}") - + # Check build tools availability print(f"\nBUILD TOOLS AVAILABILITY:") build_tools = ["build", "setuptools", "wheel", "hatchling"] @@ -776,11 +805,12 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: print(f" {tool}: NOT AVAILABLE ({e})") except Exception as e: print(f" {tool}: ERROR ({e})") - + # Setuptools-scm specific debugging print(f"\nSETUPTOOLS-SCM DEBUG:") try: import setuptools_scm + print(f" setuptools_scm version: {setuptools_scm.__version__}") # Try to get version from project try: @@ -792,22 +822,33 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: print(f" setuptools_scm: NOT AVAILABLE") except Exception as e: print(f" setuptools_scm: ERROR ({e})") - + # Git repository information (important for setuptools-scm) print(f"\nGIT REPOSITORY INFO:") try: import subprocess + # Check if git is available - git_version_result = subprocess.run(["git", "--version"], - capture_output=True, text=True, timeout=10) + git_version_result = subprocess.run( + ["git", "--version"], capture_output=True, text=True, timeout=10 + ) if git_version_result.returncode == 0: print(f" Git version: {git_version_result.stdout.strip()}") - + # Check git status in project directory - git_status_result = subprocess.run(["git", "status", "--porcelain"], - cwd=project_path, capture_output=True, text=True, timeout=10) + git_status_result = subprocess.run( + ["git", "status", "--porcelain"], + cwd=project_path, + capture_output=True, + text=True, + timeout=10, + ) if git_status_result.returncode == 0: - status_lines = git_status_result.stdout.strip().split('\n') if git_status_result.stdout.strip() else [] + status_lines = ( + git_status_result.stdout.strip().split('\n') + if git_status_result.stdout.strip() + else [] + ) print(f" Git working directory status: {len(status_lines)} modified files") if status_lines and status_lines[0]: # Only show if there are actual changes print(f" First few changes:") @@ -815,52 +856,75 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: print(f" {line}") else: print(f" Git status failed: {git_status_result.stderr}") - + # Check current branch/commit - git_branch_result = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], - cwd=project_path, capture_output=True, text=True, timeout=10) + git_branch_result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=project_path, + capture_output=True, + text=True, + timeout=10, + ) if git_branch_result.returncode == 0: print(f" Current branch: {git_branch_result.stdout.strip()}") - - git_commit_result = subprocess.run(["git", "rev-parse", "--short", "HEAD"], - cwd=project_path, capture_output=True, text=True, timeout=10) + + git_commit_result = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + cwd=project_path, + capture_output=True, + text=True, + timeout=10, + ) if git_commit_result.returncode == 0: print(f" Current commit: {git_commit_result.stdout.strip()}") - + # Check if we're in a git repository - git_root_result = subprocess.run(["git", "rev-parse", "--show-toplevel"], - cwd=project_path, capture_output=True, text=True, timeout=10) + git_root_result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=project_path, + capture_output=True, + text=True, + timeout=10, + ) if git_root_result.returncode == 0: git_root = git_root_result.stdout.strip() print(f" Git repository root: {git_root}") - print(f" Project path relative to git root: {project_path.relative_to(Path(git_root)) if Path(git_root) in project_path.parents or Path(git_root) == project_path else 'Not within git root'}") + print( + f" Project path relative to git root: {project_path.relative_to(Path(git_root)) if Path(git_root) in project_path.parents or Path(git_root) == project_path else 'Not within git root'}" + ) else: print(f" Not in a git repository: {git_root_result.stderr}") - + else: print(f" Git not available: {git_version_result.stderr}") except FileNotFoundError: print(f" Git command not found") except Exception as e: print(f" Error checking git: {e}") - + # Disk space information print(f"\nDISK SPACE INFO:") try: import shutil + total, used, free = shutil.disk_usage(project_path) - print(f" Project path disk - Total: {total//1024//1024} MB, Used: {used//1024//1024} MB, Free: {free//1024//1024} MB") + print( + f" Project path disk - Total: {total//1024//1024} MB, Used: {used//1024//1024} MB, Free: {free//1024//1024} MB" + ) total_tmp, used_tmp, free_tmp = shutil.disk_usage(temp_dir) - print(f" Temp dir disk - Total: {total_tmp//1024//1024} MB, Used: {used_tmp//1024//1024} MB, Free: {free_tmp//1024//1024} MB") + print( + f" Temp dir disk - Total: {total_tmp//1024//1024} MB, Used: {used_tmp//1024//1024} MB, Free: {free_tmp//1024//1024} MB" + ) except Exception as e: print(f" Error getting disk usage: {e}") - + print(f"{'='*80}") print(f"END ENVIRONMENT DEBUG INFO") print(f"{'='*80}\n") # Start timing the build process import time + start_time = time.time() result = my_run_subprocess(*COMMAND_LINE_ARGS, check=False, env=env) @@ -913,11 +977,12 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: print(f" Output directory is not a directory!") else: print(f" Output directory does not exist!") - + # Check if any tar.gz files exist anywhere in temp area print(f"Searching for .tar.gz files in temp area:") try: import glob + temp_path = Path(temp_dir) tar_files = list(temp_path.glob("**/*.tar.gz")) if tar_files: @@ -934,7 +999,7 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: print(f"\nBUILD FAILURE DETAILED ANALYSIS [Worker {worker_id}]:") print(f"Command: {' '.join(COMMAND_LINE_ARGS)}") print(f"Working directory: {project_path}") - + # Check if pyproject.toml is readable pyproject_path = project_path / "pyproject.toml" if pyproject_path.exists(): @@ -948,23 +1013,33 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: print("WARNING: pyproject.toml does not contain [build-system] section") except Exception as e: print(f"Error reading pyproject.toml: {e}") - + # Check permissions try: import stat + project_stat = project_path.stat() print(f"Project directory permissions: {stat.filemode(project_stat.st_mode)}") if OUT_DIR.parent.exists(): parent_stat = OUT_DIR.parent.stat() - print(f"Output parent directory permissions: {stat.filemode(parent_stat.st_mode)}") + print( + f"Output parent directory permissions: {stat.filemode(parent_stat.st_mode)}" + ) except Exception as e: print(f"Error checking permissions: {e}") # Additional debugging for CI environment print("Critical environment variables:") debug_env_vars = [ - "PYTHONPATH", "PATH", "HOME", "TMPDIR", "BUILD_BACKEND_ISOLATION", - "SETUPTOOLS_SCM_DEBUG", "CI", "GITHUB_ACTIONS", "RUNNER_OS" + "PYTHONPATH", + "PATH", + "HOME", + "TMPDIR", + "BUILD_BACKEND_ISOLATION", + "SETUPTOOLS_SCM_DEBUG", + "CI", + "GITHUB_ACTIONS", + "RUNNER_OS", ] for var in debug_env_vars: print(f" {var}: {env.get(var, 'NOT SET')}") @@ -999,19 +1074,23 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: # After build, retrieve the tar.gz file tar_gz_file = list(OUT_DIR.glob("*.tar.gz")) - + print(f"\nFINAL BUILD ARTIFACTS SUMMARY:") print(f"Found {len(tar_gz_file)} tar.gz files in output directory") for i, tar_file in enumerate(tar_gz_file): size = tar_file.stat().st_size print(f" {i+1}. {tar_file.name} ({size} bytes, {size/1024:.1f} KB)") - - assert len(tar_gz_file) == 1, f"Expected 1 tar.gz file, got {len(tar_gz_file)}. Files found: {[f.name for f in tar_gz_file]}" + + assert ( + len(tar_gz_file) == 1 + ), f"Expected 1 tar.gz file, got {len(tar_gz_file)}. Files found: {[f.name for f in tar_gz_file]}" assert tar_gz_file[0].is_file(), f"Expected {tar_gz_file[0]} to be a file" - + final_file = tar_gz_file[0] - print(f"SUCCESS: Built {final_file.name} ({final_file.stat().st_size} bytes) in {execution_time:.2f} seconds") - + print( + f"SUCCESS: Built {final_file.name} ({final_file.stat().st_size} bytes) in {execution_time:.2f} seconds" + ) + return final_file diff --git a/tests/test_observability.py b/tests/test_observability.py index b8f25b17..6a1b974f 100644 --- a/tests/test_observability.py +++ b/tests/test_observability.py @@ -1,44 +1,44 @@ """Test observability integration with the cookiecutter template. This test suite validates that the observability stack (Grafana + Loki + Promtail) -is correctly integrated into generated projects when the include_observability +is correctly integrated into generated projects when the include_observability parameter is set to 'yes'. """ import os import tempfile import typing as t -import yaml from pathlib import Path import pytest +import yaml @pytest.fixture def generate_test_project(): """Helper fixture to generate a test project with observability configuration. - + Returns a function that can be called with specific parameters to generate a project for testing purposes. """ temp_dirs = [] # Keep track of temp directories to clean up - + def _generate_test_project( include_observability: str = "yes", project_type: str = "module", config_extra: t.Optional[t.Dict[str, str]] = None, ) -> str: from cookiecutter_python.backend.main import generate - + # Create temporary directory but don't auto-clean it yet temp_dir = tempfile.mkdtemp() temp_dirs.append(temp_dir) - + config_file = os.path.join(temp_dir, 'test_config.yaml') - + # Generate unique project name to avoid collisions project_name = f'Unit Test Observability Project' - + # Base configuration config = { 'default_context': { @@ -50,30 +50,28 @@ def _generate_test_project( 'interpreters': {"supported-interpreters": ["3.8", "3.9", "3.10", "3.11"]}, } } - + # Add any extra configuration if config_extra: config['default_context'].update(config_extra) - + # Write config file with open(config_file, 'w') as f: yaml.dump(config, f) - + # Generate project output_dir = os.path.join(temp_dir, 'output') os.makedirs(output_dir, exist_ok=True) - + return generate( - config_file=config_file, - output_dir=output_dir, - no_input=True, - offline=True + config_file=config_file, output_dir=output_dir, no_input=True, offline=True ) - + yield _generate_test_project - + # Cleanup temporary directories after test import shutil + for temp_dir in temp_dirs: try: shutil.rmtree(temp_dir) @@ -88,7 +86,7 @@ def test_observability_directory_created_when_enabled( # WHEN we generate a project with include_observability set to 'yes' project_path = generate_test_project(include_observability="yes") obs_dir = Path(project_path) / 'observability' - + # THEN the observability directory should be created assert obs_dir.exists(), "Observability directory should exist when enabled" assert obs_dir.is_dir(), "Observability path should be a directory" @@ -101,7 +99,7 @@ def test_observability_directory_not_created_when_disabled( # WHEN we generate a project with include_observability set to 'no' project_path = generate_test_project(include_observability="no") obs_dir = Path(project_path) / 'observability' - + # THEN the observability directory should not be created assert not obs_dir.exists(), "Observability directory should not exist when disabled" @@ -112,8 +110,10 @@ def test_docker_compose_observability_file_exists( ): # WHEN we generate a project with observability stack project_path = generate_test_project(include_observability="yes") - docker_compose_file = Path(project_path) / 'observability' / 'docker-compose.observability.yml' - + docker_compose_file = ( + Path(project_path) / 'observability' / 'docker-compose.observability.yml' + ) + # THEN the docker-compose observability file should be created assert docker_compose_file.exists(), "docker-compose.observability.yml should exist" assert docker_compose_file.is_file(), "docker-compose.observability.yml should be a file" @@ -126,7 +126,7 @@ def test_loki_config_file_exists( # WHEN we generate a project with observability stack project_path = generate_test_project(include_observability="yes") loki_config = Path(project_path) / 'observability' / 'loki' / 'loki-config.yml' - + # THEN the Loki configuration file should be created assert loki_config.exists(), "loki-config.yml should exist" assert loki_config.is_file(), "loki-config.yml should be a file" @@ -139,7 +139,7 @@ def test_promtail_config_file_exists( # WHEN we generate a project with observability stack project_path = generate_test_project(include_observability="yes") promtail_config = Path(project_path) / 'observability' / 'promtail' / 'promtail-config.yml' - + # THEN the Promtail configuration file should be created assert promtail_config.exists(), "promtail-config.yml should exist" assert promtail_config.is_file(), "promtail-config.yml should be a file" @@ -151,8 +151,10 @@ def test_grafana_datasource_config_exists( ): # WHEN we generate a project with observability stack project_path = generate_test_project(include_observability="yes") - datasource_config = Path(project_path) / 'observability' / 'grafana' / 'datasources' / 'loki.yml' - + datasource_config = ( + Path(project_path) / 'observability' / 'grafana' / 'datasources' / 'loki.yml' + ) + # THEN the Grafana datasource configuration should be created assert datasource_config.exists(), "Grafana loki.yml should exist" assert datasource_config.is_file(), "Grafana loki.yml should be a file" @@ -165,7 +167,7 @@ def test_observability_readme_exists( # WHEN we generate a project with observability stack project_path = generate_test_project(include_observability="yes") readme_file = Path(project_path) / 'observability' / 'README.md' - + # THEN the observability README documentation should be created assert readme_file.exists(), "Observability README.md should exist" assert readme_file.is_file(), "Observability README.md should be a file" @@ -177,8 +179,10 @@ def test_docker_compose_has_valid_yaml_syntax( ): # WHEN we check the generated docker-compose file project_path = generate_test_project(include_observability="yes") - docker_compose_file = Path(project_path) / 'observability' / 'docker-compose.observability.yml' - + docker_compose_file = ( + Path(project_path) / 'observability' / 'docker-compose.observability.yml' + ) + # THEN the file should have valid YAML syntax with open(docker_compose_file, 'r') as f: yaml.safe_load(f) # Should not raise an exception if YAML is valid @@ -191,7 +195,7 @@ def test_loki_config_has_valid_yaml_syntax( # WHEN we check the generated Loki configuration file project_path = generate_test_project(include_observability="yes") loki_config = Path(project_path) / 'observability' / 'loki' / 'loki-config.yml' - + # THEN the file should have valid YAML syntax with open(loki_config, 'r') as f: yaml.safe_load(f) # Should not raise an exception if YAML is valid @@ -204,7 +208,7 @@ def test_promtail_config_has_valid_yaml_syntax( # WHEN we check the generated Promtail configuration file project_path = generate_test_project(include_observability="yes") promtail_config = Path(project_path) / 'observability' / 'promtail' / 'promtail-config.yml' - + # THEN the file should have valid YAML syntax with open(promtail_config, 'r') as f: yaml.safe_load(f) # Should not raise an exception if YAML is valid @@ -216,8 +220,10 @@ def test_grafana_datasource_has_valid_yaml_syntax( ): # WHEN we check the generated Grafana datasource configuration project_path = generate_test_project(include_observability="yes") - datasource_config = Path(project_path) / 'observability' / 'grafana' / 'datasources' / 'loki.yml' - + datasource_config = ( + Path(project_path) / 'observability' / 'grafana' / 'datasources' / 'loki.yml' + ) + # THEN the file should have valid YAML syntax with open(datasource_config, 'r') as f: yaml.safe_load(f) # Should not raise an exception if YAML is valid @@ -229,16 +235,24 @@ def test_docker_compose_contains_project_specific_names( ): # WHEN we examine the docker-compose file content project_path = generate_test_project(include_observability="yes") - docker_compose_file = Path(project_path) / 'observability' / 'docker-compose.observability.yml' + docker_compose_file = ( + Path(project_path) / 'observability' / 'docker-compose.observability.yml' + ) content = docker_compose_file.read_text() - + # Extract the actual project slug from the generated project path project_slug = Path(project_path).name - + # THEN it should contain project-specific container names - assert f'{project_slug}-grafana' in content, f"Should contain project-specific Grafana container name with slug: {project_slug}" - assert f'{project_slug}-loki' in content, f"Should contain project-specific Loki container name with slug: {project_slug}" - assert f'{project_slug}-promtail' in content, f"Should contain project-specific Promtail container name with slug: {project_slug}" + assert ( + f'{project_slug}-grafana' in content + ), f"Should contain project-specific Grafana container name with slug: {project_slug}" + assert ( + f'{project_slug}-loki' in content + ), f"Should contain project-specific Loki container name with slug: {project_slug}" + assert ( + f'{project_slug}-promtail' in content + ), f"Should contain project-specific Promtail container name with slug: {project_slug}" def test_observability_works_with_different_project_types( @@ -246,15 +260,14 @@ def test_observability_works_with_different_project_types( generate_test_project, ): project_types = ["module", "module+cli", "pytest-plugin"] - + for project_type in project_types: # WHEN we generate a project of each type with observability enabled project_path = generate_test_project( - include_observability="yes", - project_type=project_type + include_observability="yes", project_type=project_type ) obs_dir = Path(project_path) / 'observability' - + # THEN the observability stack should be generated for all project types assert obs_dir.exists(), f"Observability should work with {project_type} projects" @@ -286,10 +299,10 @@ def test_promtail_config_contains_expected_content( # WHEN we examine the Promtail configuration content project_path = generate_test_project(include_observability="yes") promtail_config = Path(project_path) / 'observability' / 'promtail' / 'promtail-config.yml' - + with open(promtail_config, 'r') as f: config = yaml.safe_load(f) - + # THEN it should contain all required Promtail configuration sections assert 'server' in config, "Promtail config should have server section" assert 'clients' in config, "Promtail config should have clients section" @@ -302,7 +315,9 @@ def test_grafana_datasource_points_to_loki( ): # WHEN we examine the Grafana datasource configuration project_path = generate_test_project(include_observability="yes") - datasource_config = Path(project_path) / 'observability' / 'grafana' / 'datasources' / 'loki.yml' + datasource_config = ( + Path(project_path) / 'observability' / 'grafana' / 'datasources' / 'loki.yml' + ) with open(datasource_config, 'r') as f: config = yaml.safe_load(f) @@ -313,6 +328,8 @@ def test_grafana_datasource_points_to_loki( loki_datasource = datasources[0] assert loki_datasource['type'] == 'loki', "Datasource should be of type 'loki'" - assert 'loki:3100' in loki_datasource['url'], "Should point to Loki service on standard port" + assert ( + 'loki:3100' in loki_datasource['url'] + ), "Should point to Loki service on standard port" assert loki_datasource['name'] == 'Loki', "Should have proper display name" - assert loki_datasource['isDefault'] is True, "Should be the default datasource" \ No newline at end of file + assert loki_datasource['isDefault'] is True, "Should be the default datasource" diff --git a/tests/test_yes_no_in_cookiecutter_json.py b/tests/test_yes_no_in_cookiecutter_json.py index 7e6a1bf8..15bed296 100644 --- a/tests/test_yes_no_in_cookiecutter_json.py +++ b/tests/test_yes_no_in_cookiecutter_json.py @@ -2,10 +2,11 @@ This test verifies that cookiecutter properly handles different types of choice variables in the generated context, specifically: -- Boolean-style yes/no variables should remain as strings (not converted to booleans) +- Boolean-style yes/no variables should remain as strings (not converted to booleans) - Multi-choice variables should remain as lists in _cookiecutter context - Selected values should appear as strings in cookiecutter context """ + import json import tempfile from pathlib import Path @@ -16,39 +17,41 @@ def test_cookiecutter_context_preserves_choice_variable_types(tmp_path: Path): """Test that cookiecutter preserves variable types correctly in context. - + GIVEN a cookiecutter.json file with: - - A yes/no choice variable v1 + - A yes/no choice variable v1 - A multi-choice variable v2 with options a/b/c WHEN cookiecutter reads/parses/renders the template THEN the context should contain: - v1 as string value (not boolean) in 'cookiecutter' context - - v1 as list of choices in '_cookiecutter' context + - v1 as list of choices in '_cookiecutter' context - v2 as string value (first choice) in 'cookiecutter' context - v2 as list of choices in '_cookiecutter' context """ - + # GIVEN: Create a minimal cookiecutter template template_dir = tmp_path / "test_template" template_dir.mkdir() - + # Create cookiecutter.json with yes/no and multi-choice variables cookiecutter_json = { "project_name": "Test Project", "enable_feature": ["no", "yes"], # yes/no choice variable (v1) "build_system": ["poetry", "setuptools", "flit"], # multi-choice variable (v2) "multiple_choice": ["c1", "c2", "c3"], # multi-choice variable (v2) - "multiple_choice_2": ["c1", "c2", "c3"] # multi-choice variable (v2) + "multiple_choice_2": ["c1", "c2", "c3"], # multi-choice variable (v2) } - + cookiecutter_json_file = template_dir / "cookiecutter.json" with open(cookiecutter_json_file, 'w') as f: json.dump(cookiecutter_json, f, indent=2) - + # Create a minimal template project structure - project_template_dir = template_dir / "{{ cookiecutter.project_name|lower|replace(' ', '-') }}" + project_template_dir = ( + template_dir / "{{ cookiecutter.project_name|lower|replace(' ', '-') }}" + ) project_template_dir.mkdir() - + # Create a simple template file that uses both variables template_file = project_template_dir / "config.txt" template_content = """Project: {{ cookiecutter.project_name }} @@ -57,50 +60,51 @@ def test_cookiecutter_context_preserves_choice_variable_types(tmp_path: Path): """ with open(template_file, 'w') as f: f.write(template_content) - + # Create user config to override defaults user_config = { "default_context": { "project_name": "My Test Project", "enable_feature": "yes", # Override to "yes" "build_system": "setuptools", # Override to "setuptools" + "multiple_choice": "c2", "multiple_choice_2": "c4", } } - + user_config_file = tmp_path / "user_config.yaml" with open(user_config_file, 'w') as f: import yaml + yaml.dump(user_config, f) - + # WHEN: Use cookiecutter to generate context (without full rendering) from cookiecutter.generate import generate_context - + context = generate_context( context_file=str(cookiecutter_json_file), default_context=user_config["default_context"], - extra_context=None + extra_context=None, ) - + # THEN it has only the 'cookiecutter' key (no _cookiecutter yet) assert 'cookiecutter' in context assert '_cookiecutter' not in context - + cookiecutter_vars = context['cookiecutter'] - + # Test v1 (yes/no choice variable) # In cookiecutter context: should be the selected string value assert isinstance(cookiecutter_vars['enable_feature'], list) assert cookiecutter_vars['enable_feature'] == ["yes", "no"] - # Test v2 (multi-choice variable) + # Test v2 (multi-choice variable) # In cookiecutter context: should be the selected string value assert isinstance(cookiecutter_vars['build_system'], list) assert cookiecutter_vars['build_system'] == ["setuptools", "poetry", "flit"] - assert isinstance(cookiecutter_vars['multiple_choice'], list) - assert cookiecutter_vars['multiple_choice'] == ["c1", "c2", "c3"] + assert cookiecutter_vars['multiple_choice'] == ["c2", "c1", "c3"] # THEN cookiecutter.json arrays remain as lists in context # AND if user config no choice or unknown choice, then no effect data @@ -110,37 +114,39 @@ def test_cookiecutter_context_preserves_choice_variable_types(tmp_path: Path): def test_cookiecutter_context_preserves_choice_variable_types_v2(tmp_path: Path): """Test that cookiecutter preserves variable types correctly in context. - + GIVEN a cookiecutter.json file with: - - A yes/no choice variable v1 + - A yes/no choice variable v1 - A multi-choice variable v2 with options a/b/c WHEN cookiecutter reads/parses/renders the template THEN the context should contain: - v1 as string value (not boolean) in 'cookiecutter' context - - v1 as list of choices in '_cookiecutter' context + - v1 as list of choices in '_cookiecutter' context - v2 as string value (first choice) in 'cookiecutter' context - v2 as list of choices in '_cookiecutter' context """ - + # GIVEN: Create a minimal cookiecutter template template_dir = tmp_path / "test_template" template_dir.mkdir() - + # Create cookiecutter.json with yes/no and multi-choice variables cookiecutter_json = { "project_name": "Test Project", "enable_feature": ["no", "yes"], # yes/no choice variable (v1) - "build_system": ["poetry", "setuptools", "flit"] # multi-choice variable (v2) + "build_system": ["poetry", "setuptools", "flit"], # multi-choice variable (v2) } - + cookiecutter_json_file = template_dir / "cookiecutter.json" with open(cookiecutter_json_file, 'w') as f: json.dump(cookiecutter_json, f, indent=2) - + # Create a minimal template project structure - project_template_dir = template_dir / "{{ cookiecutter.project_name|lower|replace(' ', '-') }}" + project_template_dir = ( + template_dir / "{{ cookiecutter.project_name|lower|replace(' ', '-') }}" + ) project_template_dir.mkdir() - + # Create a simple template file that uses both variables template_file = project_template_dir / "config.txt" template_content = """Project: {{ cookiecutter.project_name }} @@ -149,37 +155,27 @@ def test_cookiecutter_context_preserves_choice_variable_types_v2(tmp_path: Path) """ with open(template_file, 'w') as f: f.write(template_content) - # WHEN: Use cookiecutter to generate context (without full rendering) from cookiecutter.generate import generate_context - + context = generate_context( context_file=str(cookiecutter_json_file), ) - + # THEN it has only the 'cookiecutter' key (no _cookiecutter yet) assert 'cookiecutter' in context assert '_cookiecutter' not in context - + cookiecutter_vars = context['cookiecutter'] - + # Test v1 (yes/no choice variable) # In cookiecutter context: should be the selected string value assert cookiecutter_vars['enable_feature'] == ["no", "yes"] - # Test v2 (multi-choice variable) + # Test v2 (multi-choice variable) # In cookiecutter context: should be the selected string value assert cookiecutter_vars['build_system'] == ["poetry", "setuptools", "flit"] - - # Additional verification: ensure no automatic boolean conversion happened - # The yes/no should remain as strings, not be converted to True/False - # assert cookiecutter_vars['enable_feature'] != True - # assert cookiecutter_vars['enable_feature'] != False - # assert cookiecutter_vars['enable_feature'] == 'yes' - - - def test_cookiecutter_full_rendering_with_choice_variables(tmp_path: Path): @@ -190,49 +186,51 @@ def test_cookiecutter_full_rendering_with_choice_variables(tmp_path: Path): GIVEN a 'Cookiecutter Template': cookiecutter.json + (jinja) template files """ # GIVEN a cookiecutter.json with yes/no and multi-choice variables - template_dir = tmp_path / "test_template" + template_dir = tmp_path / "test_template" template_dir.mkdir() - + cookiecutter_json = { "project_name": "Test Suite Project", "enable_feature": ["yes", "no"], - "docs_build_system": ["mkdocs", "sphinx", "whatever"] + "docs_build_system": ["mkdocs", "sphinx", "whatever"], } with open(template_dir / "cookiecutter.json", 'w') as f: json.dump(cookiecutter_json, f, indent=2) - project_template_dir = template_dir / "{{ cookiecutter.project_name|lower|replace(' ', '-') }}" + project_template_dir = ( + template_dir / "{{ cookiecutter.project_name|lower|replace(' ', '-') }}" + ) project_template_dir.mkdir() - + template_content = """Project: {{ cookiecutter.project_name }} Feature Enabled: {{ cookiecutter.enable_feature }} Build System: {{ cookiecutter.docs_build_system }} """ with open(project_template_dir / "config.txt", 'w') as f: f.write(template_content) - + # WHEN cookiecutter is called to render the Template from cookiecutter.main import cookiecutter - + output_dir = tmp_path / "output" output_dir.mkdir() - + project_path = cookiecutter( str(template_dir), output_dir=str(output_dir), no_input=True, # extra_context=extra_context ) - + # THEN cookiecutter picks 1st option from 'choice vars' and render as strings generated_project = Path(project_path) assert generated_project.exists() assert generated_project.name == "test-suite-project" - + config_file = generated_project / "config.txt" assert config_file.exists() - + rendered_content = config_file.read_text() expected_content = """Project: Test Suite Project Feature Enabled: yes From 1bdf1e9793da77de2a4a90ad0e38ba85e4e8442f Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Sun, 2 Nov 2025 20:32:47 +0200 Subject: [PATCH 32/35] refactor: apply ruff --- tests/context/conftest.py | 1 - tests/context/test_cookiecutter_context.py | 8 ++-- tests/context/test_generate_context_unit.py | 3 -- tests/test_build_backend_sdist.py | 43 ++++++++++----------- tests/test_observability.py | 2 +- tests/test_yes_no_in_cookiecutter_json.py | 3 -- 6 files changed, 25 insertions(+), 35 deletions(-) diff --git a/tests/context/conftest.py b/tests/context/conftest.py index 209aaee0..d5635f2a 100644 --- a/tests/context/conftest.py +++ b/tests/context/conftest.py @@ -3,7 +3,6 @@ import typing as t from collections import OrderedDict from pathlib import Path -from unittest.mock import patch import pytest diff --git a/tests/context/test_cookiecutter_context.py b/tests/context/test_cookiecutter_context.py index 9c5170cc..55be5f2d 100644 --- a/tests/context/test_cookiecutter_context.py +++ b/tests/context/test_cookiecutter_context.py @@ -1,11 +1,9 @@ import datetime -import tempfile import typing as t from collections import OrderedDict from pathlib import Path from unittest.mock import patch -import pytest MY_DIR = Path(__file__).parent @@ -213,15 +211,15 @@ def test_cookiecutter_generates_context_with_expected_values( assert set(_gen_output_og_object.keys()) == {'cookiecutter'} # VERIFY that generate_context never converts to bool anything - assert all([type(x) != bool for x in _gen_output_og_object['cookiecutter'].values()]) + assert all([type(x) is not bool for x in _gen_output_og_object['cookiecutter'].values()]) # exclude '_checkout' keys added iunder 'cookiecutter' context key assert all( [ - type(v) != bool + type(v) is not bool for k, v in prod_result['cookiecutter'].items() if k not in {'_checkout'} ] - ), f"Keys {[k for k,v in prod_result['cookiecutter'].items() if type(v) == bool]} got converted to bool unexpectedly!" + ), f"Keys {[k for k, v in prod_result['cookiecutter'].items() if type(v) is bool]} got converted to bool unexpectedly!" # ONLY in no_input = False, then cookiecutter converts to bool the yes/no ! # very error-prone cookiecutter behavior !!! diff --git a/tests/context/test_generate_context_unit.py b/tests/context/test_generate_context_unit.py index ca642097..5a4186b8 100644 --- a/tests/context/test_generate_context_unit.py +++ b/tests/context/test_generate_context_unit.py @@ -7,10 +7,7 @@ import datetime import typing as t from collections import OrderedDict -from pathlib import Path -from unittest.mock import patch -import pytest from cookiecutter.generate import generate_context diff --git a/tests/test_build_backend_sdist.py b/tests/test_build_backend_sdist.py index 192637cc..3cc43837 100644 --- a/tests/test_build_backend_sdist.py +++ b/tests/test_build_backend_sdist.py @@ -695,8 +695,8 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: print(f" {var}: {value}") # File system structure - print(f"\nFILE SYSTEM STRUCTURE:") - print(f"Project root contents (first 20 items):") + print("\nFILE SYSTEM STRUCTURE:") + print("Project root contents (first 20 items):") try: items = list(project_path.iterdir())[:20] for item in sorted(items): @@ -718,7 +718,7 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: project_path / "src" / "cookiecutter_python", project_path / "src" / "cookiecutter_python" / "__init__.py", ] - print(f"\nCRITICAL FILES CHECK:") + print("\nCRITICAL FILES CHECK:") for file_path in critical_files: exists = file_path.exists() if exists: @@ -736,7 +736,7 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: project_path / ".build-lock", project_path / "pyproject.toml.lock", ] - print(f"\nLOCK FILES CHECK:") + print("\nLOCK FILES CHECK:") for lock_file in lock_files: if lock_file.exists(): size = lock_file.stat().st_size @@ -748,7 +748,7 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: print(f" {lock_file.relative_to(project_path)}: NOT FOUND") # Python packages information - print(f"\nPYTHON PACKAGES INFO:") + print("\nPYTHON PACKAGES INFO:") try: import subprocess @@ -779,12 +779,12 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: print(f" Error getting pip list: {e}") # Python import paths - print(f"\nPYTHON IMPORT PATHS:") + print("\nPYTHON IMPORT PATHS:") for i, path in enumerate(sys.path): print(f" {i}: {path}") # Check if our package is importable and get its location - print(f"\nPACKAGE IMPORT CHECK:") + print("\nPACKAGE IMPORT CHECK:") try: import cookiecutter_python @@ -795,7 +795,7 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: print(f" Error importing cookiecutter_python: {e}") # Check build tools availability - print(f"\nBUILD TOOLS AVAILABILITY:") + print("\nBUILD TOOLS AVAILABILITY:") build_tools = ["build", "setuptools", "wheel", "hatchling"] for tool in build_tools: try: @@ -807,7 +807,7 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: print(f" {tool}: ERROR ({e})") # Setuptools-scm specific debugging - print(f"\nSETUPTOOLS-SCM DEBUG:") + print("\nSETUPTOOLS-SCM DEBUG:") try: import setuptools_scm @@ -819,12 +819,12 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: except Exception as e: print(f" Error getting version from setuptools-scm: {e}") except ImportError: - print(f" setuptools_scm: NOT AVAILABLE") + print(" setuptools_scm: NOT AVAILABLE") except Exception as e: print(f" setuptools_scm: ERROR ({e})") # Git repository information (important for setuptools-scm) - print(f"\nGIT REPOSITORY INFO:") + print("\nGIT REPOSITORY INFO:") try: import subprocess @@ -851,7 +851,7 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: ) print(f" Git working directory status: {len(status_lines)} modified files") if status_lines and status_lines[0]: # Only show if there are actual changes - print(f" First few changes:") + print(" First few changes:") for line in status_lines[:5]: print(f" {line}") else: @@ -898,12 +898,12 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: else: print(f" Git not available: {git_version_result.stderr}") except FileNotFoundError: - print(f" Git command not found") + print(" Git command not found") except Exception as e: print(f" Error checking git: {e}") # Disk space information - print(f"\nDISK SPACE INFO:") + print("\nDISK SPACE INFO:") try: import shutil @@ -919,7 +919,7 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: print(f" Error getting disk usage: {e}") print(f"{'='*80}") - print(f"END ENVIRONMENT DEBUG INFO") + print("END ENVIRONMENT DEBUG INFO") print(f"{'='*80}\n") # Start timing the build process @@ -957,7 +957,7 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: print("=" * 80) # POST-BUILD FILE SYSTEM ANALYSIS - print(f"POST-BUILD FILE SYSTEM ANALYSIS:") + print("POST-BUILD FILE SYSTEM ANALYSIS:") print(f"Output directory exists: {OUT_DIR.exists()}") if OUT_DIR.exists(): print(f"Output directory is_dir: {OUT_DIR.is_dir()}") @@ -974,14 +974,13 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: except Exception as e: print(f" Error listing output directory: {e}") else: - print(f" Output directory is not a directory!") + print(" Output directory is not a directory!") else: - print(f" Output directory does not exist!") + print(" Output directory does not exist!") # Check if any tar.gz files exist anywhere in temp area - print(f"Searching for .tar.gz files in temp area:") + print("Searching for .tar.gz files in temp area:") try: - import glob temp_path = Path(temp_dir) tar_files = list(temp_path.glob("**/*.tar.gz")) @@ -991,7 +990,7 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: size = tar_file.stat().st_size print(f" {tar_file} ({size} bytes)") else: - print(f" No .tar.gz files found in temp area") + print(" No .tar.gz files found in temp area") except Exception as e: print(f" Error searching for tar.gz files: {e}") @@ -1075,7 +1074,7 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: # After build, retrieve the tar.gz file tar_gz_file = list(OUT_DIR.glob("*.tar.gz")) - print(f"\nFINAL BUILD ARTIFACTS SUMMARY:") + print("\nFINAL BUILD ARTIFACTS SUMMARY:") print(f"Found {len(tar_gz_file)} tar.gz files in output directory") for i, tar_file in enumerate(tar_gz_file): size = tar_file.stat().st_size diff --git a/tests/test_observability.py b/tests/test_observability.py index 6a1b974f..2d23f84a 100644 --- a/tests/test_observability.py +++ b/tests/test_observability.py @@ -37,7 +37,7 @@ def _generate_test_project( config_file = os.path.join(temp_dir, 'test_config.yaml') # Generate unique project name to avoid collisions - project_name = f'Unit Test Observability Project' + project_name = 'Unit Test Observability Project' # Base configuration config = { diff --git a/tests/test_yes_no_in_cookiecutter_json.py b/tests/test_yes_no_in_cookiecutter_json.py index 15bed296..32142231 100644 --- a/tests/test_yes_no_in_cookiecutter_json.py +++ b/tests/test_yes_no_in_cookiecutter_json.py @@ -8,11 +8,8 @@ """ import json -import tempfile from pathlib import Path -from unittest.mock import patch -import pytest def test_cookiecutter_context_preserves_choice_variable_types(tmp_path: Path): From 7355524c608276b1f9e5351fc1ec6ab42f16b2cc Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Sun, 2 Nov 2025 20:34:54 +0200 Subject: [PATCH 33/35] apply lint --- tests/context/test_cookiecutter_context.py | 1 - tests/test_yes_no_in_cookiecutter_json.py | 1 - 2 files changed, 2 deletions(-) diff --git a/tests/context/test_cookiecutter_context.py b/tests/context/test_cookiecutter_context.py index 55be5f2d..26e08370 100644 --- a/tests/context/test_cookiecutter_context.py +++ b/tests/context/test_cookiecutter_context.py @@ -5,7 +5,6 @@ from unittest.mock import patch - MY_DIR = Path(__file__).parent C_KEY = 'cookiecutter' # COOKIECUTTER_KEY # offest by 2 hours to match Jinja 'now' expression: {% now 'utc', '%Y-%m-%d' %} diff --git a/tests/test_yes_no_in_cookiecutter_json.py b/tests/test_yes_no_in_cookiecutter_json.py index 32142231..3da3f41d 100644 --- a/tests/test_yes_no_in_cookiecutter_json.py +++ b/tests/test_yes_no_in_cookiecutter_json.py @@ -11,7 +11,6 @@ from pathlib import Path - def test_cookiecutter_context_preserves_choice_variable_types(tmp_path: Path): """Test that cookiecutter preserves variable types correctly in context. From d8dfac86e9a3a54e1e28c9e274dd082e739f09f0 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Sun, 2 Nov 2025 20:37:28 +0200 Subject: [PATCH 34/35] apply black --- src/cookiecutter_python/hooks/post_gen_project.py | 1 - tests/conftest.py | 6 ++++-- tests/test_build_backend_sdist.py | 1 - 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cookiecutter_python/hooks/post_gen_project.py b/src/cookiecutter_python/hooks/post_gen_project.py index e56bc858..f101e934 100644 --- a/src/cookiecutter_python/hooks/post_gen_project.py +++ b/src/cookiecutter_python/hooks/post_gen_project.py @@ -149,7 +149,6 @@ def post_file_removal(request): def _remove_irrelevant_rendered_folders(gen_project_dir: str): """Remove generated docs folders that are not relevant to the selected docs builder.""" - for folder_to_delete in ( folder for folder in Path(gen_project_dir).iterdir() diff --git a/tests/conftest.py b/tests/conftest.py index 5c42514c..0652a9d1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -347,7 +347,8 @@ class ConfigProtocol(Protocol): class ConfigInterfaceProtocol(t.Protocol): - def __getitem__(self, file_path_str: t.Union[str, None]) -> ConfigProtocol: ... + def __getitem__(self, file_path_str: t.Union[str, None]) -> ConfigProtocol: + ... @pytest.fixture @@ -521,7 +522,8 @@ def config_file(self) -> t.Union[Path, None]: class RelativePathsGenerator(Protocol): """Generate relative paths from a given root folder.""" - def relative_file_paths(self) -> t.Iterator[Path]: ... + def relative_file_paths(self) -> t.Iterator[Path]: + ... @pytest.fixture diff --git a/tests/test_build_backend_sdist.py b/tests/test_build_backend_sdist.py index 3cc43837..c059d975 100644 --- a/tests/test_build_backend_sdist.py +++ b/tests/test_build_backend_sdist.py @@ -981,7 +981,6 @@ def sdist_built_at_runtime_with_build(my_run_subprocess) -> Path: # Check if any tar.gz files exist anywhere in temp area print("Searching for .tar.gz files in temp area:") try: - temp_path = Path(temp_dir) tar_files = list(temp_path.glob("**/*.tar.gz")) if tar_files: From 1a0083e052c094b2ca12de67bd6c630355a167f3 Mon Sep 17 00:00:00 2001 From: Konstantinos Lampridis Date: Sun, 2 Nov 2025 20:44:31 +0200 Subject: [PATCH 35/35] fix sdist sanity test --- tests/{ => context}/test_yes_no_in_cookiecutter_json.py | 0 tests/test_build_backend_sdist.py | 5 ++++- 2 files changed, 4 insertions(+), 1 deletion(-) rename tests/{ => context}/test_yes_no_in_cookiecutter_json.py (100%) diff --git a/tests/test_yes_no_in_cookiecutter_json.py b/tests/context/test_yes_no_in_cookiecutter_json.py similarity index 100% rename from tests/test_yes_no_in_cookiecutter_json.py rename to tests/context/test_yes_no_in_cookiecutter_json.py diff --git a/tests/test_build_backend_sdist.py b/tests/test_build_backend_sdist.py index c059d975..1ec2eeab 100644 --- a/tests/test_build_backend_sdist.py +++ b/tests/test_build_backend_sdist.py @@ -297,7 +297,10 @@ def sdist_expected_correct_file_structure(): 'tests/test_ci_pipeline_generation.py', 'tests/test_cli.py', 'tests/test_cookiecutter_choice_var.py', - 'tests/test_cookiecutter_context.py', + 'tests/context/conftest.py', + 'tests/context/test_cookiecutter_context.py', + 'tests/context/test_generate_context_unit.py', + 'tests/context/test_yes_no_in_cookiecutter_json.py', 'tests/test_dialog_system.py', 'tests/test_docs_gen_feat_compatibillity.py', 'tests/test_error_classifier.py',