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