""" Release validation utilities. This module provides validation functions for release readiness. """ import subprocess from pathlib import Path from typing import List, Tuple, Optional from ..git.manager import GitManager class ReleaseValidator: """Validates release readiness and requirements.""" def __init__(self, project_root: Optional[Path] = None): """Initialize release validator. Args: project_root: Root directory of the project """ self.project_root = project_root or Path.cwd() self.git_manager = GitManager(project_root) def validate_release_state(self, force: bool = False) -> Tuple[bool, List[str]]: """Validate that repository is ready for release. Args: force: Skip validation checks if True Returns: Tuple of (is_valid, list_of_issues) """ if force: return True, [] issues = [] # Git repository validation git_issues = self._validate_git_state() issues.extend(git_issues) # Project structure validation structure_issues = self._validate_project_structure() issues.extend(structure_issues) # Configuration validation config_issues = self._validate_configuration() issues.extend(config_issues) # CHANGELOG validation changelog_issues = self._validate_changelog() issues.extend(changelog_issues) return len(issues) == 0, issues def _validate_git_state(self) -> List[str]: """Validate git repository state. Returns: List of git-related issues """ issues = [] status = self.git_manager.get_repository_status() if not status['is_repo']: issues.append("Not in a git repository") return issues if status['has_changes']: issues.append("Repository has uncommitted changes") if status['branch'] != 'main': issues.append(f"Not on main branch (currently on {status['branch']})") # Check if remote exists remote_url = self.git_manager.get_remote_url() if not remote_url: issues.append("No git remote 'origin' configured") return issues def _validate_project_structure(self) -> List[str]: """Validate project structure for releases. Returns: List of project structure issues """ issues = [] # Check for required files required_files = ['pyproject.toml'] for file_name in required_files: file_path = self.project_root / file_name if not file_path.exists(): issues.append(f"Missing required file: {file_name}") # Check for setuptools-scm configuration pyproject_path = self.project_root / 'pyproject.toml' if pyproject_path.exists(): try: import tomllib except ImportError: try: import tomli as tomllib except ImportError: issues.append("Cannot read pyproject.toml (tomllib/tomli not available)") return issues try: with open(pyproject_path, 'rb') as f: config = tomllib.load(f) # Check for setuptools-scm configuration build_system = config.get('build-system', {}) if 'setuptools-scm' not in str(build_system.get('requires', [])): issues.append("setuptools-scm not found in build-system.requires") # Check for dynamic version project_config = config.get('project', {}) if 'version' in project_config: issues.append("Static version found in project config. Use dynamic versioning with setuptools-scm.") dynamic = project_config.get('dynamic', []) if 'version' not in dynamic: issues.append("'version' not in project.dynamic. Add it for setuptools-scm.") except Exception as e: issues.append(f"Error reading pyproject.toml: {e}") return issues def _validate_configuration(self) -> List[str]: """Validate release configuration. Returns: List of configuration issues """ issues = [] # Check for environment variables that might be needed import os # Check for common auth tokens (warn, don't fail) auth_vars = ['GITEA_API_TOKEN', 'PYPI_TOKEN', 'GITHUB_TOKEN'] available_auth = [var for var in auth_vars if os.getenv(var)] if not available_auth: issues.append("No authentication tokens found in environment. " "Consider setting GITEA_API_TOKEN, PYPI_TOKEN, or GITHUB_TOKEN " "for package publishing.") return issues def validate_version_string(self, version_string: str) -> Tuple[bool, List[str]]: """Validate a version string for release. Args: version_string: Version string to validate Returns: Tuple of (is_valid, list_of_issues) """ issues = [] if not version_string: issues.append("Version string cannot be empty") return False, issues # Check basic format if not version_string.replace('.', '').replace('-', '').replace('+', '').replace('a', '').replace('b', '').replace('rc', '').isalnum(): issues.append("Version string contains invalid characters") # Check for development markers in release dev_markers = ['dev', '.dev', '+dev'] if any(marker in version_string.lower() for marker in dev_markers): issues.append("Development versions should not be released") # Check for reasonable version format (semantic versioning) try: from packaging import version version.Version(version_string) except Exception: issues.append("Version string is not valid according to PEP 440") # Check if version already exists as git tag tag_name = version_string if version_string.startswith('v') else f'v{version_string}' if self.git_manager.tag_exists(tag_name): issues.append(f"Git tag {tag_name} already exists") return len(issues) == 0, issues def _validate_changelog(self) -> List[str]: """Validate CHANGELOG.md using changelog schema. Returns: List of CHANGELOG-related issues """ issues = [] changelog_path = self.project_root / 'CHANGELOG.md' # Check if CHANGELOG exists if not changelog_path.exists(): issues.append("Missing CHANGELOG.md file") return issues # Check if changelog schema exists schema_path = self.project_root / 'markitect' / 'schemas' / 'changelog-schema-v1.0.md' if not schema_path.exists(): # Schema doesn't exist, skip validation return issues # Validate CHANGELOG with schema using markitect validate command try: result = subprocess.run( [ 'markitect', 'validate', str(changelog_path), '--schema', str(schema_path), '--semantic' ], capture_output=True, text=True, cwd=self.project_root ) if result.returncode != 0: issues.append("CHANGELOG.md validation failed against schema") # Parse output for specific errors if 'Unreleased section' in result.stdout: issues.append(" - Missing [Unreleased] section in CHANGELOG") if 'version format' in result.stdout.lower(): issues.append(" - Invalid version format in CHANGELOG") except FileNotFoundError: # markitect command not available issues.append("Cannot validate CHANGELOG (markitect command not found)") except Exception as e: issues.append(f"Error validating CHANGELOG: {e}") return issues def validate_changelog_version(self, version: str) -> Tuple[bool, List[str]]: """Validate that CHANGELOG has section for specified version. Args: version: Version to check (e.g., "0.10.0") Returns: Tuple of (is_valid, list_of_issues) """ issues = [] changelog_path = self.project_root / 'CHANGELOG.md' if not changelog_path.exists(): issues.append("CHANGELOG.md not found") return False, issues try: content = changelog_path.read_text() # Check for version section version_header = f"## [{version}]" if version_header not in content: issues.append(f"CHANGELOG missing section for version {version}") # Check for Unreleased section if "## [Unreleased]" not in content: issues.append("CHANGELOG missing [Unreleased] section") # Check if version section has a date import re date_pattern = rf"## \[{re.escape(version)}\] - \d{{4}}-\d{{2}}-\d{{2}}" if not re.search(date_pattern, content): issues.append(f"Version {version} section missing date or has invalid date format") except Exception as e: issues.append(f"Error reading CHANGELOG: {e}") return len(issues) == 0, issues def check_version_tag_consistency(self, version: str) -> Tuple[bool, List[str]]: """Check consistency between CHANGELOG version and git tags. Args: version: Version to check (e.g., "0.10.0") Returns: Tuple of (is_consistent, list_of_issues) """ issues = [] # Check CHANGELOG has the version changelog_valid, changelog_issues = self.validate_changelog_version(version) if not changelog_valid: issues.extend(changelog_issues) # Check git tag exists tag_name = version if version.startswith('v') else f'v{version}' if not self.git_manager.tag_exists(tag_name): issues.append(f"Git tag {tag_name} doesn't exist for version in CHANGELOG") return len(issues) == 0, issues def get_validation_summary(self) -> dict: """Get a comprehensive validation summary. Returns: Dictionary with validation results """ is_valid, issues = self.validate_release_state() return { 'is_valid': is_valid, 'issues': issues, 'git_status': self.git_manager.get_repository_status(), 'recommendations': self._get_recommendations(issues) } def _get_recommendations(self, issues: List[str]) -> List[str]: """Get recommendations based on validation issues. Args: issues: List of validation issues Returns: List of recommendations """ recommendations = [] if any('uncommitted changes' in issue for issue in issues): recommendations.append("Commit or stash your changes before releasing") if any('not on main branch' in issue for issue in issues): recommendations.append("Switch to main branch: git checkout main") if any('setuptools-scm' in issue for issue in issues): recommendations.append("Configure setuptools-scm in pyproject.toml") if any('authentication' in issue.lower() for issue in issues): recommendations.append("Set up authentication tokens for package publishing") if any('CHANGELOG' in issue for issue in issues): recommendations.append("Fix CHANGELOG.md format and ensure [Unreleased] section exists") recommendations.append("Validate with: markitect validate CHANGELOG.md --schema changelog-schema-v1.0.md --semantic") if not issues: recommendations.append("Repository is ready for release!") return recommendations