diff --git a/.github/workflows/dev_pr_validation.yml b/.github/workflows/dev_pr_validation.yml index e02f9f68..9cb39e42 100644 --- a/.github/workflows/dev_pr_validation.yml +++ b/.github/workflows/dev_pr_validation.yml @@ -42,17 +42,65 @@ 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 + + # 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 - # 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' + - 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: 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 + 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 + 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: diff --git a/pyproject.toml b/pyproject.toml index 9eed9bcd..bcd05000 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,23 +41,13 @@ include = [ "LICENSE", "README.md", "CONTRIBUTING.md", - "CHANGELOG.rst", + "CHANGELOG.md", # "Dockerfile", ] exclude = [ "docs/*", "requirements/*", "scripts/*.py", - "tox.ini", - ".circleci/config.yml", - ".coveragerc", - ".DS_Store", - ".gitignore", - ".prospector.yml", - ".pylintrc", - ".readthedocs.yml", - ".scrutinizer.yml", - ".travis.yml" ] [tool.poetry.scripts] diff --git a/src/cookiecutter_python/backend/helpers.py b/src/cookiecutter_python/backend/helpers.py index 523b9771..acadaecc 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,73 @@ 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', + # Include Grafana, Loki stack no/yes + 'include_observability', # ["no", "yes"] + ] + + 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/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/handle/dialogs/lib/project_name.py b/src/cookiecutter_python/handle/dialogs/lib/project_name.py index 265f1a2d..9ceb57c1 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 { @@ -158,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', @@ -166,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/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/src/cookiecutter_python/hooks/post_gen_project.py b/src/cookiecutter_python/hooks/post_gen_project.py index ae3c75a4..f101e934 100644 --- a/src/cookiecutter_python/hooks/post_gen_project.py +++ b/src/cookiecutter_python/hooks/post_gen_project.py @@ -133,21 +133,28 @@ 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): +# 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 delte the ones with name 'PyGen_TO_DELETE' - for docs_folder_to_delete in ( + + 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 }}/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.
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..0c9cf86e --- /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 on host 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/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..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 @@ -1,3 +1,4 @@ +import os import sys import pytest @@ -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..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 @@ -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,10 @@ 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 !! diff --git a/tests/conftest.py b/tests/conftest.py index 429d0eac..0652a9d1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import logging import os import sys import typing as t @@ -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,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'])}" return data def _load_context_yaml(file_path: PathLike) -> t.MutableMapping[str, t.Any]: @@ -413,7 +420,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 +443,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 +463,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 +475,22 @@ 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 +643,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 +674,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 +697,38 @@ 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}" + [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))) - ## 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 + ### 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 +742,7 @@ 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 ( @@ -772,8 +816,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 +843,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 +858,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/context/conftest.py similarity index 80% rename from tests/test_cookiecutter_context.py rename to tests/context/conftest.py index c5d2ad88..d5635f2a 100644 --- a/tests/test_cookiecutter_context.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 @@ -38,7 +37,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 +71,14 @@ 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( [ @@ -134,6 +109,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 +120,7 @@ def gen(): ( 'cookiecutter', OrderedDict( - [ + [ # KEY, Default/First choice ('project_name', 'Biskotaki'), ('project_type', 'module'), ("project_slug", "biskotaki"), @@ -184,6 +160,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 +217,7 @@ def gen(): "3.12", ], 'cicd': ['experimental', 'stable'], + 'include_observability': ['no', 'yes'], }, ), ] @@ -252,6 +230,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 +281,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 +339,7 @@ def gen(): "3.12", ], 'cicd': ['experimental', 'stable'], + 'include_observability': ['no', 'yes'], }, ), ] @@ -371,6 +352,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 +399,7 @@ def gen(): ("docs_builder", "mkdocs"), ("rtd_python_version", "3.11"), ('cicd', 'experimental'), + ('include_observability', 'no'), ] ), ), @@ -468,6 +451,7 @@ def gen(): "3.12", ], 'cicd': ['experimental', 'stable'], + 'include_observability': ['no', 'yes'], }, ), ] @@ -481,11 +465,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] @@ -493,10 +473,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]) @@ -550,6 +530,29 @@ 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, @@ -600,148 +603,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..26e08370 --- /dev/null +++ b/tests/context/test_cookiecutter_context.py @@ -0,0 +1,224 @@ +import datetime +import typing as t +from collections import OrderedDict +from pathlib import Path +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' %} +# 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) is not bool for x in _gen_output_og_object['cookiecutter'].values()]) + # exclude '_checkout' keys added iunder 'cookiecutter' context key + assert all( + [ + 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) 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 new file mode 100644 index 00000000..5a4186b8 --- /dev/null +++ b/tests/context/test_generate_context_unit.py @@ -0,0 +1,59 @@ +"""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 datetime +import typing as t +from collections import OrderedDict + +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/context/test_yes_no_in_cookiecutter_json.py b/tests/context/test_yes_no_in_cookiecutter_json.py new file mode 100644 index 00000000..3da3f41d --- /dev/null +++ b/tests/context/test_yes_no_in_cookiecutter_json.py @@ -0,0 +1,235 @@ +"""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 +from pathlib import Path + + +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": "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, + ) + + # 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'] == ["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 + 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"] + + +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 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 diff --git a/tests/test_build_backend_sdist.py b/tests/test_build_backend_sdist.py index 2cdfdf82..1ec2eeab 100644 --- a/tests/test_build_backend_sdist.py +++ b/tests/test_build_backend_sdist.py @@ -75,6 +75,13 @@ 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 +153,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', @@ -289,12 +297,16 @@ 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', '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', @@ -308,7 +320,7 @@ def sdist_expected_correct_file_structure(): 'pyproject.toml', 'README.md', 'LICENSE', - 'CHANGELOG.rst', + 'CHANGELOG.md', 'CONTRIBUTING.md', ) # Injected by Build Backend (Process) @@ -580,10 +592,13 @@ 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 - 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' @@ -594,7 +609,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", @@ -604,27 +641,458 @@ 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" + + # ========== 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("\nFILE SYSTEM STRUCTURE:") + print("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("\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("\nLOCK FILES CHECK:") + for lock_file in lock_files: + 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})" + ) + else: + print(f" {lock_file.relative_to(project_path)}: NOT FOUND") + + # Python packages information + print("\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("\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("\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("\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("\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(" setuptools_scm: NOT AVAILABLE") + except Exception as e: + print(f" setuptools_scm: ERROR ({e})") + + # Git repository information (important for setuptools-scm) + print("\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(" 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(" Git command not found") + except Exception as e: + print(f" Error checking git: {e}") + + # Disk space information + print("\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("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("==========") - print(result.stdout) - print("==========") - print(result.stderr) - print("==========") - assert result.exit_code == 0, f"Expected exit code 0, got {result.exit_code}" + 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("=" * 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("=" * 80) + + # 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()}") + 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(" Output directory is not a directory!") + else: + print(" Output directory does not exist!") + + # 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: + 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(" 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"\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(): + 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("Critical environment variables:") + debug_env_vars = [ + "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"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"{'='*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 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 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("\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 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())}" + ) diff --git a/tests/test_observability.py b/tests/test_observability.py new file mode 100644 index 00000000..2d23f84a --- /dev/null +++ b/tests/test_observability.py @@ -0,0 +1,335 @@ +"""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 +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 = '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" 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