From 85b1ecb3070b46480a413b758956cee36b3ce1be Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Fri, 9 Jan 2026 07:27:31 -0600 Subject: [PATCH 01/36] gitlab-pkg-upload: Add initial package structure and shared module The project previously existed only as a standalone script without proper packaging infrastructure. This made it difficult to install as a Python package, distribute via PyPI, or integrate into larger projects. Shared GitLab functionality was also duplicated across multiple scripts. Introduce a standard Python package structure with src layout, pyproject.toml configuration, and a shared gitlab_common module. The package structure follows modern Python packaging conventions with setuptools, while the shared module consolidates Git repository detection, project resolution, and error handling logic that was previously scattered or duplicated. This enables installation via pip while maintaining backward compatibility with direct script execution. Signed-off-by: Javier Tia --- .gitignore | 72 + gitlab_common.py | 1931 +++++++++++++++++++ pyproject.toml | 81 + src/gitlab_pkg_upload/__init__.py | 45 + src/gitlab_pkg_upload/cli.py | 31 + src/gitlab_pkg_upload/duplicate_detector.py | 280 +++ src/gitlab_pkg_upload/models.py | 380 ++++ 7 files changed, 2820 insertions(+) create mode 100644 .gitignore create mode 100644 gitlab_common.py create mode 100644 pyproject.toml create mode 100644 src/gitlab_pkg_upload/__init__.py create mode 100644 src/gitlab_pkg_upload/cli.py create mode 100644 src/gitlab_pkg_upload/duplicate_detector.py create mode 100644 src/gitlab_pkg_upload/models.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0b8d4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Type checking +.mypy_cache/ +.dmypy.json +dmypy.json + +# Linting +.ruff_cache/ + +# OS +.DS_Store +Thumbs.db diff --git a/gitlab_common.py b/gitlab_common.py new file mode 100644 index 0000000..a78a245 --- /dev/null +++ b/gitlab_common.py @@ -0,0 +1,1931 @@ +#!/usr/bin/env python3 +""" +GitLab Common Module + +Shared functionality for GitLab repository detection, project resolution, and error handling. +This module provides common classes and functions used by both the upload script and test script +to eliminate code duplication and ensure consistent behavior. + +Classes: + - GitAutoDetector: Handles automatic Git project detection + - ProjectResolver: Resolves GitLab project IDs from URLs or paths + - GitRemoteInfo: Represents Git remote information + - ProjectInfo: Represents parsed project information + +Functions: + - parse_https_git_url: Parse HTTPS Git URLs + - parse_ssh_git_url: Parse SSH Git URLs + - is_gitlab_url: Check if URL is a GitLab instance + - get_gitlab_token: Get GitLab token from environment or CLI + - validate_project_input: Validate project input with auto-detection fallback + - enhance_error_message: Enhance error messages with context + - handle_*_error: Specific error handling functions + - setup_logging: Configure consistent logging across scripts +""" + +import argparse +import logging +import os +import time +from dataclasses import dataclass +from typing import Optional +from urllib.parse import urlparse + +import git + +# Constants +DEFAULT_GITLAB_URL = "https://gitlab.com" +MAX_RETRIES = 3 +RETRY_DELAYS = [1, 2, 4] # Exponential backoff in seconds +RATE_LIMIT_RETRY_DELAY = 60 # Seconds to wait for rate limit reset + +# Setup logging +logger = logging.getLogger(__name__) + + +# Logging Configuration + + +def setup_logging(console=None, level=logging.INFO, verbose=False): + """ + Setup consistent logging configuration across both scripts. + + Args: + console: Rich console instance (optional) + level: Logging level (default: INFO) + verbose: Enable verbose logging (sets level to DEBUG) + """ + if verbose: + level = logging.DEBUG + + # Only configure if not already configured + if not logging.getLogger().handlers: + try: + from rich.console import Console + from rich.logging import RichHandler + + if console is None: + console = Console() + + logging.basicConfig( + level=level, + format="%(message)s", + handlers=[RichHandler(console=console, rich_tracebacks=True)], + ) + except ImportError: + # Fallback to basic logging if rich is not available + logging.basicConfig( + level=level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + else: + # Update existing logger level + logging.getLogger().setLevel(level) + + if verbose: + logger.debug("Verbose logging enabled") + + +# Data Models + + +@dataclass +class ProjectInfo: + """Represents parsed project information from URLs.""" + + gitlab_url: str # Base GitLab instance URL + namespace: str # Project namespace/group + project_name: str # Project name + project_path: str # Full project path (namespace/project) + original_url: str # Original URL provided by user + + +@dataclass +class ProjectResolutionResult: + """Represents the result of project ID resolution.""" + + success: bool + project_id: Optional[int] + error_message: Optional[str] + project_info: Optional[ProjectInfo] + gitlab_url: str + + +@dataclass +class GitRemoteInfo: + """Represents Git remote information.""" + + name: str # Remote name (e.g., 'origin') + url: str # Remote URL + gitlab_url: str # Extracted GitLab instance URL + project_path: str # Extracted project path + + +# Git Repository Detection Classes + + +class GitAutoDetector: + """Handles automatic Git project detection.""" + + def __init__(self, working_directory: str = "."): + """Initialize with working directory.""" + self.working_directory = working_directory + + def find_git_repository(self) -> Optional[git.Repo]: + """ + Find Git repository in current or parent directories. + + Returns: + Git repository object if found, None otherwise + + Raises: + ValueError: If Git repository access fails due to permissions or corruption + """ + try: + # GitPython's Repo.search_parent_directories will find the repo + # in the current directory or any parent directory + repo = git.Repo(self.working_directory, search_parent_directories=True) + logger.debug(f"Found Git repository at: {repo.working_dir}") + return repo + except git.InvalidGitRepositoryError: + logger.debug( + f"No Git repository found starting from: {self.working_directory}" + ) + return None + except PermissionError as e: + error_msg = ( + "Permission denied accessing Git repository.\n\n" + "Please check the following:\n" + " • You have read permissions for the current directory and parent directories\n" + " • The .git directory is accessible\n" + " • No file system restrictions are blocking access\n\n" + f"Working directory: {self.working_directory}\n" + f"Error details: {e}\n\n" + "To resolve this issue:\n" + f" 1. Check directory permissions: ls -la {self.working_directory}\n" + " 2. Ensure you can read the .git directory\n" + " 3. Try running from a different directory with proper permissions\n" + " 4. Use --project-url or --project-path to specify project manually" + ) + raise ValueError(error_msg) + except git.GitCommandError as e: + error_msg = ( + "Git command failed while searching for repository.\n\n" + "This may indicate:\n" + " • Corrupted Git repository\n" + " • Git is not properly installed\n" + " • File system issues\n\n" + f"Working directory: {self.working_directory}\n" + f"Git error: {e}\n\n" + "To resolve this issue:\n" + " 1. Verify Git installation: git --version\n" + " 2. Check repository integrity: git fsck (from repository root)\n" + " 3. Try re-cloning the repository if corrupted\n" + " 4. Use --project-url or --project-path to specify project manually" + ) + raise ValueError(error_msg) + except OSError as e: + error_msg = ( + "File system error while searching for Git repository.\n\n" + "This may indicate:\n" + " • Network drive connectivity issues\n" + " • Disk space or I/O problems\n" + " • Path length limitations\n\n" + f"Working directory: {self.working_directory}\n" + f"System error: {e}\n\n" + "To resolve this issue:\n" + " 1. Check disk space and file system health\n" + " 2. Verify network drive connectivity (if applicable)\n" + " 3. Try from a local directory with shorter path\n" + " 4. Use --project-url or --project-path to specify project manually" + ) + raise ValueError(error_msg) + except Exception as e: + error_msg = ( + "Unexpected error while searching for Git repository.\n\n" + f"Working directory: {self.working_directory}\n" + f"Error: {e}\n\n" + "To resolve this issue:\n" + " 1. Ensure Git is properly installed and accessible\n" + " 2. Check that the current directory is accessible\n" + " 3. Try running from a different directory\n" + " 4. Use --project-url or --project-path to specify project manually" + ) + raise ValueError(error_msg) + + def get_gitlab_remotes(self, repo: git.Repo) -> list[GitRemoteInfo]: + """ + Extract GitLab remotes from repository. + + Args: + repo: Git repository object + + Returns: + List of GitRemoteInfo objects for GitLab remotes + + Raises: + ValueError: If remote access fails or no GitLab remotes are found + """ + gitlab_remotes = [] + all_remotes = [] + + try: + # First, try to get all remotes + try: + remotes_list = list(repo.remotes) + if not remotes_list: + error_msg = ( + "No Git remotes found in repository.\n\n" + f"Repository location: {repo.working_dir}\n\n" + "This usually means:\n" + " • Repository was created locally without remotes\n" + " • Repository was cloned but remotes were removed\n" + " • Repository is in an incomplete state\n\n" + "To resolve this issue:\n" + " 1. Add a GitLab remote: git remote add origin \n" + " 2. Check existing remotes: git remote -v\n" + " 3. Clone from GitLab if this is a local-only repository\n" + " 4. Use --project-url or --project-path to specify project manually\n\n" + "Examples of adding GitLab remotes:\n" + " • git remote add origin https://gitlab.com/namespace/project.git\n" + " • git remote add origin git@gitlab.com:namespace/project.git" + ) + raise ValueError(error_msg) + except git.GitCommandError as e: + error_msg = ( + f"Failed to read Git remotes from repository.\n\n" + f"Repository location: {repo.working_dir}\n" + f"Git error: {e}\n\n" + f"This may indicate:\n" + f" • Corrupted Git repository\n" + f" • Git configuration issues\n" + f" • File system problems\n\n" + f"To resolve this issue:\n" + f" 1. Check repository integrity: git fsck\n" + f" 2. Verify Git configuration: git config --list\n" + f" 3. Try re-cloning the repository\n" + f" 4. Use --project-url or --project-path to specify project manually" + ) + raise ValueError(error_msg) + + # Process each remote + for remote in remotes_list: + try: + # Get the first URL if multiple URLs exist + if remote.urls: + remote_url = next(iter(remote.urls)) + all_remotes.append(f"{remote.name}: {remote_url}") + logger.debug(f"Found remote '{remote.name}': {remote_url}") + + # Try to parse as GitLab URL + parsed_info = self.parse_git_url(remote_url) + if parsed_info: + gitlab_url, project_path = parsed_info + gitlab_remote = GitRemoteInfo( + name=remote.name, + url=remote_url, + gitlab_url=gitlab_url, + project_path=project_path, + ) + gitlab_remotes.append(gitlab_remote) + logger.debug( + f"Parsed GitLab remote '{remote.name}': {gitlab_url}/{project_path}" + ) + else: + logger.debug( + f"Remote '{remote.name}' is not a GitLab URL: {remote_url}" + ) + else: + logger.warning(f"Remote '{remote.name}' has no URLs configured") + all_remotes.append(f"{remote.name}: ") + + except Exception as e: + logger.warning(f"Error processing remote '{remote.name}': {e}") + all_remotes.append(f"{remote.name}: ") + continue + + # Check if we found any GitLab remotes + if not gitlab_remotes: + error_msg = ( + f"No GitLab remotes found in repository.\n\n" + f"Repository location: {repo.working_dir}\n" + f"Found {len(all_remotes)} remote(s), but none point to GitLab instances:\n" + ) + + for remote_info in all_remotes: + error_msg += f" • {remote_info}\n" + + error_msg += ( + "\nThis usually means:\n" + " • Repository remotes point to GitHub, Bitbucket, or other Git services\n" + " • Repository remotes point to private Git servers that aren't GitLab\n" + " • Remote URLs are malformed or unrecognizable\n\n" + "To resolve this issue:\n" + " 1. Add a GitLab remote: git remote add gitlab \n" + " 2. Change existing remote to GitLab: git remote set-url origin \n" + " 3. Use --project-url or --project-path to specify project manually\n\n" + "Examples of supported GitLab remote formats:\n" + " • HTTPS: https://gitlab.com/namespace/project.git\n" + " • SSH: git@gitlab.com:namespace/project.git\n" + " • Custom GitLab: https://gitlab.example.com/namespace/project.git" + ) + raise ValueError(error_msg) + + logger.info( + f"Found {len(gitlab_remotes)} GitLab remote(s): {[r.name for r in gitlab_remotes]}" + ) + + # Handle multiple GitLab remotes - prioritize 'origin' + if len(gitlab_remotes) > 1: + origin_remote = next( + (r for r in gitlab_remotes if r.name == "origin"), None + ) + if origin_remote: + logger.info("Multiple GitLab remotes found, prioritizing 'origin'") + other_remotes = [ + r.name for r in gitlab_remotes if r.name != "origin" + ] + logger.info(f"Other GitLab remotes available: {other_remotes}") + return [origin_remote] + else: + # No 'origin' remote, use first one but warn about the choice + selected_remote = gitlab_remotes[0] + other_remotes = [r.name for r in gitlab_remotes[1:]] + logger.warning( + f"Multiple GitLab remotes found without 'origin'. " + f"Using '{selected_remote.name}', others available: {other_remotes}" + ) + logger.info( + f"To specify a different remote, you can:\n" + f" 1. Rename your preferred remote to 'origin': git remote rename {selected_remote.name} origin\n" + f" 2. Use --project-url or --project-path to specify project manually" + ) + return [selected_remote] + + return gitlab_remotes + + except ValueError: + # Re-raise ValueError exceptions (our custom error messages) + raise + except Exception as e: + error_msg = ( + f"Unexpected error reading Git remotes.\n\n" + f"Repository location: {repo.working_dir}\n" + f"Error: {e}\n\n" + f"To resolve this issue:\n" + f" 1. Check repository integrity: git fsck\n" + f" 2. Verify you can access remotes: git remote -v\n" + f" 3. Try re-cloning the repository\n" + f" 4. Use --project-url or --project-path to specify project manually" + ) + raise ValueError(error_msg) + + def parse_git_url(self, remote_url: str) -> Optional[tuple[str, str]]: + """ + Parse Git URL to extract GitLab URL and project path. + + Args: + remote_url: Git remote URL + + Returns: + Tuple of (gitlab_url, project_path) if successful, None otherwise + + Raises: + ValueError: If URL format is unrecognized but appears to be intended for GitLab + """ + if not remote_url: + return None + + logger.debug(f"Parsing Git URL: {remote_url}") + + # Try parsing as HTTPS URL first + result = parse_https_git_url(remote_url) + if result: + gitlab_url, project_path = result + if is_gitlab_url(gitlab_url): + logger.debug( + f"Successfully parsed HTTPS Git URL: {gitlab_url}/{project_path}" + ) + return result + else: + logger.debug( + f"HTTPS URL parsed but not a GitLab instance: {gitlab_url}" + ) + + # Try parsing as SSH URL + result = parse_ssh_git_url(remote_url) + if result: + gitlab_url, project_path = result + if is_gitlab_url(gitlab_url): + logger.debug( + f"Successfully parsed SSH Git URL: {gitlab_url}/{project_path}" + ) + return result + else: + logger.debug(f"SSH URL parsed but not a GitLab instance: {gitlab_url}") + + # Check if this looks like it might be a GitLab URL but in an unrecognized format + if self._looks_like_gitlab_url(remote_url): + error_msg = ( + f"Unrecognized GitLab URL format: {remote_url}\n\n" + f"This URL appears to be for GitLab but is in an unsupported format.\n\n" + f"Supported GitLab remote formats:\n" + f" • HTTPS: https://gitlab.com/namespace/project.git\n" + f" • HTTPS (no .git): https://gitlab.com/namespace/project\n" + f" • SSH: git@gitlab.com:namespace/project.git\n" + f" • SSH (no .git): git@gitlab.com:namespace/project\n\n" + f"To resolve this issue:\n" + f" 1. Update remote URL to supported format: git remote set-url origin \n" + f" 2. Use --project-url or --project-path to specify project manually\n\n" + f"Examples of correct formats:\n" + f" • git remote set-url origin https://gitlab.com/namespace/project.git\n" + f" • git remote set-url origin git@gitlab.com:namespace/project.git" + ) + raise ValueError(error_msg) + + logger.debug(f"Could not parse Git URL as GitLab URL: {remote_url}") + return None + + def _looks_like_gitlab_url(self, url: str) -> bool: + """ + Check if URL looks like it might be intended for GitLab but in wrong format. + + Args: + url: URL to check + + Returns: + True if URL appears to be GitLab-related but unparseable + """ + if not url: + return False + + url_lower = url.lower() + + # Check for GitLab-related keywords in the URL + gitlab_indicators = [ + "gitlab.com", + "gitlab.", + ".gitlab.", + "git.lab", + ] + + return any(indicator in url_lower for indicator in gitlab_indicators) + + +class ProjectResolver: + """Core component responsible for parsing URLs and resolving project IDs from GitLab API.""" + + def __init__(self, gitlab_client): + """ + Initialize ProjectResolver with GitLab client. + + Args: + gitlab_client: Authenticated GitLab client + """ + self.gl = gitlab_client + self.project_cache: dict[str, int] = {} + + def parse_project_url(self, url: str) -> ProjectInfo: + """ + Parse GitLab project URL into components. + + Args: + url: GitLab project URL to parse + + Returns: + ProjectInfo with parsed components + + Raises: + ValueError: If URL format is invalid + """ + if not url or not isinstance(url, str): + raise ValueError("URL must be a non-empty string") + + # Normalize URL - remove trailing slashes + normalized_url = url.rstrip("/") + + try: + parsed = urlparse(normalized_url) + except Exception as e: + raise ValueError(f"Invalid URL format: {e}") + + # Validate protocol + if parsed.scheme not in ["http", "https"]: + raise ValueError( + f"Unsupported protocol '{parsed.scheme}'. Only HTTP and HTTPS are supported." + ) + + # Validate that we have a hostname + if not parsed.netloc: + raise ValueError("URL must include a hostname") + + # Extract path components + path = parsed.path.strip("/") + if not path: + raise ValueError("URL must include a project path") + + # Split path into components + path_parts = path.split("/") + if len(path_parts) < 2: + raise ValueError( + "URL must include both namespace and project name (e.g., /namespace/project)" + ) + + # Extract namespace and project name + # Handle cases where there might be additional path components after the project name + namespace = path_parts[0] + project_name = path_parts[1] + + if not namespace or not project_name: + raise ValueError("Both namespace and project name must be non-empty") + + # Construct GitLab instance URL + gitlab_url = f"{parsed.scheme}://{parsed.netloc}" + + # Construct project path + project_path = f"{namespace}/{project_name}" + + return ProjectInfo( + gitlab_url=gitlab_url, + namespace=namespace, + project_name=project_name, + project_path=project_path, + original_url=url, + ) + + def resolve_project_id(self, gitlab_url: str, project_path: str) -> int: + """ + Resolve project ID from GitLab API with enhanced retry logic. + + Args: + gitlab_url: GitLab instance URL + project_path: Project path (namespace/project) + + Returns: + Numeric project ID + + Raises: + ValueError: If project cannot be resolved + """ + # Check cache first + cache_key = f"{gitlab_url}/{project_path}" + if cache_key in self.project_cache: + logger.info(f"Using cached project ID for {project_path}") + return self.project_cache[cache_key] + + logger.info(f"Resolving project ID for {project_path} from {gitlab_url}") + + def _resolve_project(): + """Internal function to resolve project ID.""" + project = self.gl.projects.get(project_path) + return project.id + + try: + project_id = handle_network_error_with_retry( + operation_name=f"Project resolution for {project_path}", + operation_func=_resolve_project, + ) + + # Cache the result + self.project_cache[cache_key] = project_id + logger.info( + f"Successfully resolved project ID {project_id} for {project_path}" + ) + return project_id + + except Exception as e: + # Use enhanced error handling with context + context = { + "project_path": project_path, + "gitlab_url": gitlab_url, + "operation": "project resolution", + } + enhanced_message = enhance_error_message(e, context) + raise ValueError(enhanced_message) + + def validate_project_access(self, project_id: int) -> bool: + """ + Validate that the user has access to the project. + + Args: + project_id: GitLab project ID + + Returns: + True if user has access, False otherwise + """ + try: + logger.debug(f"Validating access to project ID {project_id}") + + def _validate_access(): + """Internal function to validate project access.""" + project = self.gl.projects.get(project_id) + # Check if we can access basic project information + project_name = getattr(project, "name", None) + project_path = getattr(project, "path_with_namespace", None) + return project_name, project_path + + project_name, project_path = handle_network_error_with_retry( + operation_name=f"Access validation for project ID {project_id}", + operation_func=_validate_access, + ) + + if project_name and project_path: + logger.info( + f"Access validated for project: {project_path} (ID: {project_id})" + ) + return True + else: + logger.warning( + f"Project {project_id} exists but has limited metadata access" + ) + return False + + except Exception as e: + error_msg = str(e) + logger.warning( + f"Access validation failed for project ID {project_id}: {error_msg}" + ) + + # Provide specific guidance based on error type + context = { + "project_path": f"project ID {project_id}", + "gitlab_url": self.gl.api_url.replace("/api/v4", ""), + "operation": "access validation", + } + + enhanced_message = enhance_error_message(e, context) + logger.error(enhanced_message) + + return False + + +# URL Parsing Functions + + +def parse_https_git_url(url: str) -> Optional[tuple[str, str]]: + """ + Parse HTTPS Git URL to extract GitLab URL and project path. + + Args: + url: HTTPS Git URL (e.g., https://gitlab.com/namespace/project.git) + + Returns: + Tuple of (gitlab_url, project_path) if successful, None otherwise + + Note: + Returns None for invalid formats. For detailed error messages, + use the GitAutoDetector.parse_git_url method which provides + comprehensive error handling. + """ + if not url or not isinstance(url, str): + return None + + try: + parsed = urlparse(url.strip()) + + # Validate HTTPS protocol + if parsed.scheme != "https": + return None + + # Validate hostname exists + if not parsed.netloc: + return None + + # Extract path and remove leading/trailing slashes + path = parsed.path.strip("/") + if not path: + return None + + # Remove .git suffix if present + if path.endswith(".git"): + path = path[:-4] + + # Split path into components + path_parts = path.split("/") + if len(path_parts) < 2: + return None + + # Use all path components to construct the full project path + # This handles multi-level paths like "LinaroLtd/iotil/meta-onelab" + project_path = "/".join(path_parts) + + # Construct GitLab instance URL + gitlab_url = f"{parsed.scheme}://{parsed.netloc}" + + return gitlab_url, project_path + + except Exception: + return None + + +def parse_ssh_git_url(url: str) -> Optional[tuple[str, str]]: + """ + Parse SSH Git URL to extract GitLab URL and project path. + + Args: + url: SSH Git URL (e.g., git@gitlab.com:namespace/project.git) + + Returns: + Tuple of (gitlab_url, project_path) if successful, None otherwise + + Note: + Returns None for invalid formats. For detailed error messages, + use the GitAutoDetector.parse_git_url method which provides + comprehensive error handling. + """ + if not url or not isinstance(url, str): + return None + + try: + url = url.strip() + + # Check for SSH format: git@hostname:path + if not url.startswith("git@") or ":" not in url: + return None + + # Split on the first colon to separate host and path + host_part, path_part = url.split(":", 1) + + # Extract hostname from git@hostname + if not host_part.startswith("git@"): + return None + + hostname = host_part[4:] # Remove "git@" prefix + if not hostname: + return None + + # Process path part + path = path_part.strip("/") + if not path: + return None + + # Remove .git suffix if present + if path.endswith(".git"): + path = path[:-4] + + # Split path into components + path_parts = path.split("/") + if len(path_parts) < 2: + return None + + # Use all path components to construct the full project path + # This handles multi-level paths like "LinaroLtd/iotil/meta-onelab" + project_path = "/".join(path_parts) + + # Construct GitLab instance URL (assume HTTPS) + gitlab_url = f"https://{hostname}" + + return gitlab_url, project_path + + except Exception: + return None + + +def is_gitlab_url(url: str) -> bool: + """ + Check if URL is a GitLab instance. + + Args: + url: URL to check + + Returns: + True if URL appears to be a GitLab instance, False otherwise + """ + if not url or not isinstance(url, str): + return False + + try: + parsed = urlparse(url.strip()) + + # Must have valid scheme and hostname + if parsed.scheme not in ["http", "https"] or not parsed.netloc: + return False + + hostname = parsed.netloc.lower() + + # Explicitly exclude known non-GitLab services + non_gitlab_services = [ + "github.com", + "bitbucket.org", + "sourceforge.net", + "codeberg.org", + ] + + for service in non_gitlab_services: + if hostname == service or hostname.endswith(f".{service}"): + return False + + # Check for common GitLab hostnames + gitlab_indicators = [ + "gitlab.com", + "gitlab.", + ".gitlab.", + "git.lab", + ] + + # Check if hostname contains GitLab indicators + for indicator in gitlab_indicators: + if indicator in hostname: + return True + + # If no obvious indicators, assume it could be a GitLab instance + # This is a permissive approach since many organizations use custom domains + return True + + except Exception: + return False + + +# Utility Functions + + +def get_gitlab_token(cli_token: str | None) -> str: + """ + Get GitLab token from environment variable or CLI argument. + + Priority: + 1. CLI argument (--token) - explicit user choice takes precedence + 2. GITLAB_TOKEN environment variable - fallback + + Args: + cli_token: Token provided via CLI argument + + Returns: + GitLab authentication token + + Raises: + ValueError: If no token is provided + """ + if cli_token: + logger.info("Using GitLab token from CLI argument") + return cli_token + + token = os.environ.get("GITLAB_TOKEN") + if token: + logger.info("Using GitLab token from GITLAB_TOKEN environment variable") + return token + + raise ValueError( + "No GitLab token provided. Set GITLAB_TOKEN environment variable or use --token argument" + ) + + +def validate_project_input( + args: argparse.Namespace, +) -> tuple[Optional[str], Optional[str]]: + """ + Validate project URL or path input and return GitLab URL and project path. + Attempts Git auto-detection when no project is explicitly specified. + + Args: + args: Parsed command-line arguments + + Returns: + Tuple of (gitlab_url, project_path) or (None, None) if no project input provided and auto-detection fails + + Raises: + ValueError: If project input is invalid + """ + if args.project_url: + logger.info(f"Validating project URL: {args.project_url}") + validation_result = validate_url_format(args.project_url) + + if not validation_result.is_valid: + raise ValueError(f"Invalid project URL: {validation_result.error_message}") + + gitlab_url = validation_result.parsed_components["gitlab_url"] + project_path = validation_result.parsed_components["project_path"] + + logger.info(f"Parsed GitLab URL: {gitlab_url}") + logger.info(f"Parsed project path: {project_path}") + + return gitlab_url, project_path + + elif args.project_path: + logger.info(f"Validating project path: {args.project_path}") + + try: + normalized_path = normalize_project_path(args.project_path) + logger.info(f"Normalized project path: {normalized_path}") + + # Use the provided GitLab URL or default + gitlab_url = getattr(args, "gitlab_url", DEFAULT_GITLAB_URL) + logger.info(f"Using GitLab URL: {gitlab_url}") + + return gitlab_url, normalized_path + + except ValueError as e: + raise ValueError(f"Invalid project path: {e}") + + else: + # No project URL or path provided - attempt Git auto-detection + logger.info("No project specified, attempting Git auto-detection...") + + try: + detector = GitAutoDetector() + repo = detector.find_git_repository() + + if not repo: + logger.info("No Git repository found for auto-detection") + return None, None + + logger.info(f"Found Git repository at: {repo.working_dir}") + + gitlab_remotes = detector.get_gitlab_remotes(repo) + + if not gitlab_remotes: + logger.info("No GitLab remotes found in repository") + return None, None + + # Use the first (prioritized) GitLab remote + selected_remote = gitlab_remotes[0] + logger.info( + f"Auto-detected project from Git remote '{selected_remote.name}': {selected_remote.gitlab_url}/{selected_remote.project_path}" + ) + + return selected_remote.gitlab_url, selected_remote.project_path + + except Exception as e: + logger.warning(f"Git auto-detection failed: {e}") + return None, None + + +# Network Error Handling Functions + + +def is_network_error(exception: Exception) -> bool: + """ + Determine if an exception is a network-related error that should be retried. + + Args: + exception: Exception to check + + Returns: + True if the exception is a network error that should be retried + """ + try: + from gitlab.exceptions import GitlabError + + # Check for GitLab-specific network errors + if isinstance(exception, GitlabError): + error_msg = str(exception).lower() + # Network-related GitLab errors + if any( + keyword in error_msg + for keyword in [ + "connection", + "timeout", + "network", + "unreachable", + "temporary", + "service unavailable", + "502", + "503", + "504", + ] + ): + return True + except ImportError: + # gitlab module not available, skip GitLab-specific checks + pass + + # Check for generic network-related error messages + error_msg = str(exception).lower() + network_keywords = [ + "connection refused", + "connection reset", + "connection timeout", + "network is unreachable", + "temporary failure", + "service unavailable", + "bad gateway", + "gateway timeout", + "connection aborted", + "connection error", + "timeout", + "dns", + ] + + return any(keyword in error_msg for keyword in network_keywords) + + +def is_rate_limit_error(exception: Exception) -> bool: + """ + Determine if an exception is a rate limiting error. + + Args: + exception: Exception to check + + Returns: + True if the exception is a rate limiting error + """ + error_msg = str(exception).lower() + rate_limit_keywords = [ + "rate limit", + "too many requests", + "429", + "quota exceeded", + "api rate limit exceeded", + "rate limited", + ] + + return any(keyword in error_msg for keyword in rate_limit_keywords) + + +def calculate_retry_delay(attempt: int, base_delays: list[int] = None) -> int: + """ + Calculate retry delay with exponential backoff. + + Args: + attempt: Current attempt number (0-based) + base_delays: List of base delays for exponential backoff + + Returns: + Delay in seconds + """ + if base_delays is None: + base_delays = RETRY_DELAYS + + if attempt < len(base_delays): + return base_delays[attempt] + else: + # For attempts beyond the base delays, use exponential backoff + return base_delays[-1] * (2 ** (attempt - len(base_delays) + 1)) + + +def handle_network_error_with_retry( + operation_name: str, operation_func, max_retries: int = MAX_RETRIES, *args, **kwargs +): + """ + Execute an operation with comprehensive network error handling and retry logic. + + Args: + operation_name: Human-readable name of the operation for logging + operation_func: Function to execute + max_retries: Maximum number of retry attempts + *args: Arguments to pass to operation_func + **kwargs: Keyword arguments to pass to operation_func + + Returns: + Result of operation_func + + Raises: + Exception: If operation fails after all retries + """ + last_exception = None + + for attempt in range(max_retries): + try: + logger.debug(f"{operation_name} attempt {attempt + 1}/{max_retries}") + return operation_func(*args, **kwargs) + + except Exception as e: + last_exception = e + error_msg = str(e) + + logger.warning( + f"{operation_name} attempt {attempt + 1} failed: {error_msg}" + ) + + # Check if this is a rate limit error + if is_rate_limit_error(e): + if attempt < max_retries - 1: + logger.warning( + f"Rate limit detected. Waiting {RATE_LIMIT_RETRY_DELAY} seconds before retry..." + ) + time.sleep(RATE_LIMIT_RETRY_DELAY) + continue + else: + raise ValueError( + f"{operation_name} failed due to rate limiting after {max_retries} attempts. " + f"Please wait before retrying. Last error: {error_msg}" + ) + + # Check if this is a network error that should be retried + elif is_network_error(e): + if attempt < max_retries - 1: + delay = calculate_retry_delay(attempt) + logger.info( + f"Network error detected. Retrying {operation_name} in {delay} seconds..." + ) + time.sleep(delay) + continue + else: + raise ValueError( + f"{operation_name} failed due to persistent network errors after {max_retries} attempts. " + f"Please check your network connection and GitLab instance availability. " + f"Last error: {error_msg}" + ) + + # For non-network errors, don't retry but provide context + else: + raise e + + # This should never be reached, but just in case + if last_exception: + raise last_exception + else: + raise RuntimeError(f"{operation_name} failed unexpectedly") + + +# Specific Error Handling Functions + + +def handle_project_not_found_error( + project_path: str, gitlab_url: str, original_error: str +) -> str: + """ + Generate helpful error message for project not found errors. + + Args: + project_path: Project path that was not found + gitlab_url: GitLab instance URL + original_error: Original error message + + Returns: + Enhanced error message with suggestions + """ + return ( + f"Project '{project_path}' not found at {gitlab_url}.\n\n" + f"Please check the following:\n" + f" • Project path format is correct (should be: namespace/project-name)\n" + f" • Project exists and is accessible at {gitlab_url}\n" + f" • You have permission to view the project\n" + f" • GitLab instance URL is correct\n" + f" • Project is not private (if using public access)\n\n" + f"Examples of valid project paths:\n" + f" • mycompany/my-project\n" + f" • group/subgroup/project-name\n" + f" • username/personal-project\n\n" + f"You can verify the project exists by visiting:\n" + f" {gitlab_url}/{project_path}\n\n" + f"Original error: {original_error}" + ) + + +def handle_authentication_error( + project_path: str, gitlab_url: str, original_error: str +) -> str: + """ + Generate helpful error message for authentication failures. + + Args: + project_path: Project path being accessed + gitlab_url: GitLab instance URL + original_error: Original error message + + Returns: + Enhanced error message with guidance + """ + return ( + f"Authentication failed for project '{project_path}' at {gitlab_url}.\n\n" + f"Please check the following:\n" + f" • GitLab token is valid and not expired\n" + f" • Token has appropriate permissions (minimum: 'read_api' scope)\n" + f" • Token is configured for the correct GitLab instance\n" + f" • You have access to the project '{project_path}'\n" + f" • Project is not private (if token lacks permissions)\n\n" + f"Token configuration:\n" + f" • Set GITLAB_TOKEN environment variable, or\n" + f" • Use --token command line argument\n\n" + f"To create a new token:\n" + f" 1. Visit: {gitlab_url}/-/profile/personal_access_tokens\n" + f" 2. Create token with 'api' or 'read_api' scope\n" + f" 3. Set GITLAB_TOKEN environment variable\n\n" + f"Original error: {original_error}" + ) + + +def handle_permission_error( + project_path: str, gitlab_url: str, operation: str, original_error: str +) -> str: + """ + Generate helpful error message for permission errors. + + Args: + project_path: Project path being accessed + gitlab_url: GitLab instance URL + operation: Operation that failed (e.g., "upload", "read packages") + original_error: Original error message + + Returns: + Enhanced error message with guidance + """ + return ( + f"Permission denied for {operation} in project '{project_path}' at {gitlab_url}.\n\n" + f"Please check the following:\n" + f" • You have the required permissions for this operation\n" + f" • Your GitLab token has sufficient scope (may need 'api' instead of 'read_api')\n" + f" • You are a member of the project with appropriate role\n" + f" • Project settings allow the requested operation\n\n" + f"Required permissions for {operation}:\n" + f" • Package uploads: Developer role or higher\n" + f" • Package downloads: Reporter role or higher\n" + f" • Project access: Guest role or higher\n\n" + f"To check your permissions:\n" + f" 1. Visit: {gitlab_url}/{project_path}/-/project_members\n" + f" 2. Verify your role and permissions\n" + f" 3. Contact project maintainer if access is needed\n\n" + f"Original error: {original_error}" + ) + + +def handle_network_connectivity_error(gitlab_url: str, original_error: str) -> str: + """ + Generate helpful error message for network connectivity issues. + + Args: + gitlab_url: GitLab instance URL + original_error: Original error message + + Returns: + Enhanced error message with troubleshooting steps + """ + return ( + f"Network connectivity issue with GitLab instance at {gitlab_url}.\n\n" + f"Please check the following:\n" + f" • Internet connection is working\n" + f" • GitLab instance URL is correct and accessible\n" + f" • No firewall or proxy blocking the connection\n" + f" • GitLab instance is not experiencing downtime\n\n" + f"Troubleshooting steps:\n" + f" 1. Test connectivity: curl -I {gitlab_url}\n" + f" 2. Check GitLab status page (if available)\n" + f" 3. Try accessing {gitlab_url} in a web browser\n" + f" 4. Verify DNS resolution: nslookup {gitlab_url.replace('https://', '').replace('http://', '')}\n\n" + f"If using a corporate network:\n" + f" • Check proxy settings\n" + f" • Verify SSL certificate trust\n" + f" • Contact IT support if needed\n\n" + f"Original error: {original_error}" + ) + + +def enhance_error_message(error: Exception, context: dict) -> str: + """ + Enhance error messages with context and helpful suggestions. + + Args: + error: Original exception + context: Context dictionary with keys like 'project_path', 'gitlab_url', 'operation' + + Returns: + Enhanced error message + """ + error_msg = str(error).lower() + original_error = str(error) + + project_path = context.get("project_path", "unknown") + gitlab_url = context.get("gitlab_url", "unknown") + operation = context.get("operation", "operation") + + # Handle specific error types + if "404" in error_msg or "not found" in error_msg: + return handle_project_not_found_error(project_path, gitlab_url, original_error) + + elif any( + keyword in error_msg + for keyword in ["401", "403", "authentication", "unauthorized"] + ): + if "permission" in error_msg or "forbidden" in error_msg: + return handle_permission_error( + project_path, gitlab_url, operation, original_error + ) + else: + return handle_authentication_error(project_path, gitlab_url, original_error) + + elif any( + keyword in error_msg + for keyword in [ + "connection", + "network", + "timeout", + "unreachable", + "dns", + "resolve", + ] + ): + return handle_network_connectivity_error(gitlab_url, original_error) + + elif "rate limit" in error_msg or "too many requests" in error_msg: + return ( + f"GitLab API rate limit exceeded.\n\n" + f"Please wait a few minutes before retrying.\n" + f"Rate limits help ensure fair usage of GitLab resources.\n\n" + f"If you frequently hit rate limits:\n" + f" • Reduce the frequency of API calls\n" + f" • Consider using GitLab Premium for higher limits\n" + f" • Contact GitLab support for assistance\n\n" + f"Original error: {original_error}" + ) + + else: + # Generic enhancement with context + return ( + f"Operation failed: {operation}\n" + f"Project: {project_path}\n" + f"GitLab URL: {gitlab_url}\n\n" + f"Error details: {original_error}\n\n" + f"If this error persists:\n" + f" • Check GitLab instance status\n" + f" • Verify your network connection\n" + f" • Review your authentication and permissions\n" + f" • Contact support with the error details above" + ) + + +# Helper functions for URL validation (used by validate_project_input) + + +@dataclass +class URLValidationResult: + """Represents URL parsing and validation results.""" + + is_valid: bool + error_message: Optional[str] + suggested_format: Optional[str] + parsed_components: Optional[dict[str, str]] + + +def parse_gitlab_url(url: str) -> tuple[str, str]: + """ + Extract GitLab instance URL and project path from full URL. + + Args: + url: Full GitLab project URL + + Returns: + Tuple of (gitlab_instance_url, project_path) + + Raises: + ValueError: If URL format is invalid + """ + if not url or not isinstance(url, str): + raise ValueError( + f"URL must be a non-empty string.\n" + f"Attempted URL: '{url}'\n\n" + f"Valid format examples:\n" + f" - https://gitlab.com/namespace/project\n" + f" - http://gitlab.example.com/group/subgroup/project" + ) + + # Normalize URL - remove trailing slashes + normalized_url = url.rstrip("/") + + try: + parsed = urlparse(normalized_url) + except Exception as e: + raise ValueError( + f"Invalid URL format: {e}\n" + f"Attempted URL: '{url}'\n\n" + f"Valid format examples:\n" + f" - https://gitlab.com/namespace/project\n" + f" - http://gitlab.example.com/group/subgroup/project" + ) + + # Validate protocol + if parsed.scheme not in ["http", "https"]: + raise ValueError( + f"Unsupported protocol '{parsed.scheme}'. Only HTTP and HTTPS are supported.\n" + f"Attempted URL: '{url}'\n\n" + f"Valid format examples:\n" + f" - https://gitlab.com/namespace/project\n" + f" - http://gitlab.example.com/group/subgroup/project" + ) + + # Validate that we have a hostname + if not parsed.netloc: + raise ValueError( + f"URL must include a hostname.\n" + f"Attempted URL: '{url}'\n\n" + f"Valid format examples:\n" + f" - https://gitlab.com/namespace/project\n" + f" - http://gitlab.example.com/group/subgroup/project" + ) + + # Extract path components + path = parsed.path.strip("/") + if not path: + raise ValueError( + f"URL must include a project path.\n" + f"Attempted URL: '{url}'\n\n" + f"Valid format examples:\n" + f" - https://gitlab.com/namespace/project\n" + f" - http://gitlab.example.com/group/subgroup/project" + ) + + # Split path into components + path_parts = path.split("/") + if len(path_parts) < 2: + raise ValueError( + f"URL must include both namespace and project name.\n" + f"Attempted URL: '{url}'\n\n" + f"Valid format examples:\n" + f" - https://gitlab.com/namespace/project\n" + f" - http://gitlab.example.com/group/subgroup/project" + ) + + # Extract namespace and project name + namespace = path_parts[0] + project_name = path_parts[1] + + if not namespace or not project_name: + raise ValueError( + f"Both namespace and project name must be non-empty.\n" + f"Attempted URL: '{url}'\n\n" + f"Valid format examples:\n" + f" - https://gitlab.com/namespace/project\n" + f" - http://gitlab.example.com/group/subgroup/project" + ) + + # Construct GitLab instance URL + gitlab_url = f"{parsed.scheme}://{parsed.netloc}" + + # Construct project path + project_path = f"{namespace}/{project_name}" + + return gitlab_url, project_path + + +def validate_url_format(url: str) -> URLValidationResult: + """ + Validate that URL follows expected GitLab project URL format. + + Args: + url: URL to validate + + Returns: + URLValidationResult with validation status and details + """ + try: + gitlab_url, project_path = parse_gitlab_url(url) + return URLValidationResult( + is_valid=True, + error_message=None, + suggested_format=None, + parsed_components={"gitlab_url": gitlab_url, "project_path": project_path}, + ) + except ValueError as e: + return URLValidationResult( + is_valid=False, + error_message=str(e), + suggested_format="https://gitlab.com/namespace/project", + parsed_components=None, + ) + + +def normalize_project_path(path: str) -> str: + """ + Normalize project path handling URL encoding and special characters. + + Args: + path: Project path to normalize + + Returns: + Normalized project path + + Raises: + ValueError: If path format is invalid + """ + if not path or not isinstance(path, str): + raise ValueError( + f"Project path must be a non-empty string.\n" + f"Attempted path: '{path}'\n\n" + f"Valid format examples:\n" + f" - namespace/project\n" + f" - group/subgroup/project" + ) + + # Remove leading/trailing slashes and whitespace + normalized = path.strip().strip("/") + + if not normalized: + raise ValueError( + f"Project path cannot be empty.\n" + f"Attempted path: '{path}'\n\n" + f"Valid format examples:\n" + f" - namespace/project\n" + f" - group/subgroup/project" + ) + + # Split into components + parts = normalized.split("/") + if len(parts) < 2: + raise ValueError( + f"Project path must include both namespace and project name.\n" + f"Attempted path: '{path}'\n\n" + f"Valid format examples:\n" + f" - namespace/project\n" + f" - group/subgroup/project" + ) + + # Validate that all parts are non-empty + for i, part in enumerate(parts): + if not part.strip(): + raise ValueError( + f"Project path component {i + 1} cannot be empty.\n" + f"Attempted path: '{path}'\n\n" + f"Valid format examples:\n" + f" - namespace/project\n" + f" - group/subgroup/project" + ) + + return normalized + + +# Configuration Validation Functions + + +def validate_dependencies() -> None: + """ + Validate that all required dependencies are available. + + Raises: + ValueError: If required dependencies are missing with specific resolution steps + """ + logger.debug("Validating required dependencies...") + + # Check Python version + import sys + + if sys.version_info < (3, 12): + raise ValueError( + f"Python 3.12 or higher is required. Current version: {sys.version}\n\n" + "SOLUTION:\n" + "1. Install Python 3.12 or higher:\n" + " • Ubuntu/Debian: sudo apt update && sudo apt install python3.12\n" + " • macOS: brew install python@3.12\n" + " • Windows: Download from https://python.org/downloads/\n\n" + "2. Use pyenv to manage Python versions:\n" + " • Install pyenv: curl https://pyenv.run | bash\n" + " • Install Python: pyenv install 3.12\n" + " • Set local version: pyenv local 3.12\n\n" + "3. Use uv to run with correct Python version:\n" + " • Install uv: pip install uv\n" + " • Run script: uv run --python 3.12 ./gitlab/gitlab-pkg-upload.py\n\n" + "For more help, see: https://docs.python.org/3/installing/" + ) + + # Check required modules + required_modules = { + "gitlab": "python-gitlab>=4.0.0", + "git": "GitPython>=3.1.0", + "rich": "rich>=13.0.0", + } + + missing_modules = [] + for module_name, package_spec in required_modules.items(): + try: + __import__(module_name) + logger.debug(f"✓ Module {module_name} available") + except ImportError: + missing_modules.append((module_name, package_spec)) + logger.debug(f"✗ Module {module_name} not available") + + if missing_modules: + error_msg = "Required dependencies are not available:\n" + for module_name, package_spec in missing_modules: + error_msg += f" • {module_name} (install: {package_spec})\n" + + error_msg += ( + "\nSOLUTION:\n" + "1. If using uv (recommended):\n" + " • Ensure script has proper shebang: #!/usr/bin/env -S uv run --script\n" + " • Run directly: ./gitlab/gitlab-pkg-upload.py\n" + " • uv will automatically install dependencies\n\n" + "2. Manual installation with pip:\n" + ) + + for module_name, package_spec in missing_modules: + error_msg += f" pip install '{package_spec}'\n" + + error_msg += ( + "\n3. Install all at once:\n" + " pip install python-gitlab>=4.0.0 rich>=13.0.0 GitPython>=3.1.0\n\n" + "4. Using virtual environment (recommended):\n" + " python -m venv venv\n" + " source venv/bin/activate # On Windows: venv\\Scripts\\activate\n" + " pip install python-gitlab>=4.0.0 rich>=13.0.0 GitPython>=3.1.0\n\n" + "TROUBLESHOOTING:\n" + "• Check Python version: python --version\n" + "• Check pip version: pip --version\n" + "• Update pip: pip install --upgrade pip\n" + "• For corporate networks: pip install --trusted-host pypi.org --trusted-host pypi.python.org\n\n" + "For more help: https://packaging.python.org/tutorials/installing-packages/" + ) + + raise ValueError(error_msg) + + logger.debug("✓ All required dependencies are available") + + +def validate_gitlab_token(token: str, gitlab_url: str = DEFAULT_GITLAB_URL) -> None: + """ + Validate GitLab token availability and basic validity. + + Args: + token: GitLab authentication token + gitlab_url: GitLab instance URL + + Raises: + ValueError: If token validation fails with specific resolution steps + """ + logger.debug("Validating GitLab token...") + + if not token or not isinstance(token, str): + raise ValueError( + "GitLab token is required but not provided.\n\n" + "SOLUTION:\n" + "1. Create a GitLab personal access token:\n" + f" • Visit: {gitlab_url}/-/profile/personal_access_tokens\n" + " • Click 'Add new token'\n" + " • Name: 'Package Upload Token'\n" + " • Scopes: Select 'api' (required for package operations)\n" + " • Expiration: Set appropriate date\n" + " • Click 'Create personal access token'\n" + " • Copy the generated token immediately\n\n" + "2. Set the token as environment variable:\n" + " export GITLAB_TOKEN='your-token-here'\n\n" + "3. Or use command line argument:\n" + " --token your-token-here\n\n" + "4. For CI/CD pipelines:\n" + " export GITLAB_TOKEN=$CI_JOB_TOKEN\n\n" + "IMPORTANT:\n" + "• Token must have 'api' scope (not just 'read_api')\n" + "• Token must not be expired\n" + "• Keep token secure and never commit to version control\n\n" + "TROUBLESHOOTING:\n" + "• Check token format: should be 20+ characters\n" + "• Verify token hasn't expired\n" + "• Test token manually: curl -H 'PRIVATE-TOKEN: your-token' {gitlab_url}/api/v4/user\n\n" + "For more help: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html" + ) + + # Basic token format validation + token = token.strip() + if len(token) < 20: + raise ValueError( + f"GitLab token appears to be invalid (too short: {len(token)} characters).\n\n" + "SOLUTION:\n" + "1. Verify you copied the complete token:\n" + " • GitLab personal access tokens are typically 20+ characters\n" + " • Ensure no whitespace or truncation occurred\n" + " • Check for copy/paste errors\n\n" + "2. Generate a new token if needed:\n" + f" • Visit: {gitlab_url}/-/profile/personal_access_tokens\n" + " • Create new token with 'api' scope\n" + " • Copy the complete token\n\n" + "3. Test token format:\n" + f" echo $GITLAB_TOKEN | wc -c # Should be 20+ characters\n\n" + "For more help: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html" + ) + + # Check for common token format issues + if token.startswith("glpat-") and len(token) < 26: + raise ValueError( + f"GitLab personal access token appears incomplete.\n" + f"Token length: {len(token)} characters (expected 26+ for glpat- tokens)\n\n" + "SOLUTION:\n" + "1. Verify complete token was copied:\n" + " • Personal access tokens start with 'glpat-' and are 26+ characters\n" + " • Check for truncation during copy/paste\n" + " • Ensure no line breaks or extra characters\n\n" + "2. Generate new token if corrupted:\n" + f" • Visit: {gitlab_url}/-/profile/personal_access_tokens\n" + " • Revoke old token if compromised\n" + " • Create new token with 'api' scope\n\n" + "For more help: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html" + ) + + logger.debug("✓ GitLab token format validation passed") + + +def validate_git_installation() -> None: + """ + Validate that Git is installed and accessible. + + Raises: + ValueError: If Git installation validation fails with specific resolution steps + """ + logger.debug("Validating Git installation...") + + try: + import subprocess + + result = subprocess.run( + ["git", "--version"], capture_output=True, text=True, timeout=10 + ) + + if result.returncode != 0: + raise ValueError( + f"Git command failed with exit code {result.returncode}.\n" + f"Error output: {result.stderr}\n\n" + "SOLUTION:\n" + "1. Install Git:\n" + " • Ubuntu/Debian: sudo apt update && sudo apt install git\n" + " • CentOS/RHEL: sudo yum install git\n" + " • macOS: brew install git (or install Xcode Command Line Tools)\n" + " • Windows: Download from https://git-scm.com/download/windows\n\n" + "2. Verify installation:\n" + " git --version\n\n" + "3. Check PATH configuration:\n" + " which git # On Unix-like systems\n" + " where git # On Windows\n\n" + "For more help: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git" + ) + + git_version = result.stdout.strip() + logger.debug(f"✓ Git is available: {git_version}") + + except FileNotFoundError: + raise ValueError( + "Git is not installed or not available in PATH.\n\n" + "SOLUTION:\n" + "1. Install Git:\n" + " • Ubuntu/Debian: sudo apt update && sudo apt install git\n" + " • CentOS/RHEL: sudo yum install git\n" + " • macOS: brew install git (or install Xcode Command Line Tools)\n" + " • Windows: Download from https://git-scm.com/download/windows\n\n" + "2. Add Git to PATH (if installed but not in PATH):\n" + " • Find Git installation directory\n" + " • Add to PATH environment variable\n" + " • Restart terminal/command prompt\n\n" + "3. Verify installation:\n" + " git --version\n\n" + "TROUBLESHOOTING:\n" + "• Check if Git is installed: ls /usr/bin/git\n" + "• Check PATH: echo $PATH\n" + "• For Windows: Check 'Program Files\\Git\\bin' is in PATH\n\n" + "For more help: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git" + ) + + except subprocess.TimeoutExpired: + raise ValueError( + "Git command timed out. This may indicate system issues.\n\n" + "SOLUTION:\n" + "1. Check system resources:\n" + " • Ensure sufficient memory and CPU available\n" + " • Check for system overload\n\n" + "2. Verify Git installation:\n" + " git --version\n\n" + "3. Try running Git commands manually:\n" + " git status\n\n" + "If problem persists, consider reinstalling Git." + ) + + except Exception as e: + raise ValueError( + f"Unexpected error checking Git installation: {e}\n\n" + "SOLUTION:\n" + "1. Verify Git is properly installed:\n" + " git --version\n\n" + "2. Check system permissions:\n" + " • Ensure user can execute Git commands\n" + " • Check file permissions on Git executable\n\n" + "3. Reinstall Git if necessary:\n" + " • Download from https://git-scm.com/downloads\n" + " • Follow installation instructions for your OS\n\n" + "For more help: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git" + ) + + +def validate_git_repository_access(working_directory: str = ".") -> None: + """ + Validate Git repository access when Git operations are needed. + + Args: + working_directory: Directory to check for Git repository + + Raises: + ValueError: If Git repository access validation fails with specific resolution steps + """ + logger.debug(f"Validating Git repository access in: {working_directory}") + + try: + detector = GitAutoDetector(working_directory) + repo = detector.find_git_repository() + + if repo: + logger.debug(f"✓ Git repository found at: {repo.working_dir}") + + # Test basic repository operations + try: + # Try to read repository configuration + repo.config_reader() # Just verify it's accessible + logger.debug("✓ Git repository configuration accessible") + + # Try to read remotes + remotes = list(repo.remotes) + logger.debug(f"✓ Git remotes accessible: {len(remotes)} remote(s)") + + except Exception as e: + raise ValueError( + f"Git repository found but not fully accessible: {e}\n\n" + "SOLUTION:\n" + "1. Check repository integrity:\n" + " git fsck\n\n" + "2. Check file permissions:\n" + f" ls -la {repo.working_dir}/.git\n" + " • Ensure .git directory is readable\n" + " • Check ownership and permissions\n\n" + "3. Try repository repair:\n" + " git gc --prune=now\n" + " git repack -ad\n\n" + "4. If corrupted, consider re-cloning:\n" + " • Backup any uncommitted changes\n" + " • Clone fresh copy from remote\n\n" + "TROUBLESHOOTING:\n" + "• Check disk space: df -h\n" + "• Check file system errors: dmesg | grep -i error\n" + "• Verify Git version: git --version\n\n" + "For more help: https://git-scm.com/docs/git-fsck" + ) + else: + logger.debug( + "No Git repository found - this is acceptable for manual project specification" + ) + + except ValueError: + # Re-raise ValueError exceptions (our custom error messages) + raise + except Exception as e: + raise ValueError( + f"Unexpected error validating Git repository access: {e}\n\n" + "SOLUTION:\n" + "1. Ensure you're in a Git repository:\n" + " git status\n\n" + "2. Initialize repository if needed:\n" + " git init\n" + " git remote add origin \n\n" + "3. Check directory permissions:\n" + f" ls -la {working_directory}\n" + " • Ensure directory is readable and accessible\n\n" + "4. Use manual project specification if Git auto-detection isn't needed:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project\n\n" + "For more help: https://git-scm.com/docs/git-init" + ) + + +def validate_configuration( + token: Optional[str] = None, + gitlab_url: str = DEFAULT_GITLAB_URL, + require_git: bool = False, + working_directory: str = ".", +) -> None: + """ + Comprehensive configuration validation for GitLab scripts. + + Args: + token: GitLab authentication token (if None, will try to get from environment) + gitlab_url: GitLab instance URL + require_git: Whether Git operations are required + working_directory: Working directory for Git operations + + Raises: + ValueError: If any configuration validation fails + """ + logger.info("Starting configuration validation...") + + # 1. Validate dependencies + try: + validate_dependencies() + logger.info("✓ Dependencies validation passed") + except ValueError: + logger.error("✗ Dependencies validation failed") + raise + + # 2. Validate GitLab token + try: + if token is None: + token = get_gitlab_token(None) + validate_gitlab_token(token, gitlab_url) + logger.info("✓ GitLab token validation passed") + except ValueError: + logger.error("✗ GitLab token validation failed") + raise + + # 3. Validate Git installation (always check since it might be needed) + try: + validate_git_installation() + logger.info("✓ Git installation validation passed") + except ValueError as e: + if require_git: + logger.error("✗ Git installation validation failed") + raise + else: + logger.warning( + "⚠ Git installation validation failed (not required for this operation)" + ) + logger.debug(f"Git validation error: {e}") + + # 4. Validate Git repository access (only if Git operations are required) + if require_git: + try: + validate_git_repository_access(working_directory) + logger.info("✓ Git repository access validation passed") + except ValueError: + logger.error("✗ Git repository access validation failed") + raise + + logger.info("✓ Configuration validation completed successfully") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9dde100 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,81 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "gitlab-pkg-upload" +version = "0.1.0" +description = "A CLI tool for uploading files to GitLab's Generic Package Registry" +authors = [{name = "Javier Tia"}] +license = {text = "MIT"} +requires-python = ">=3.11" +readme = "README.md" +keywords = ["gitlab", "package", "upload", "cli", "generic-package-registry"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] + +dependencies = [ + "python-gitlab>=4.0.0", + "rich>=13.0.0", + "GitPython>=3.1.0", + "tenacity>=8.0.0", + "argcomplete>=3.0.0", +] + +[project.optional-dependencies] +dev = [ + "ruff", + "mypy", + "pre-commit", +] +test = [ + "pytest", + "pytest-xdist", + "pytest-timeout", + "pytest-sugar", +] + +[project.scripts] +gitlab-pkg-upload = "gitlab_pkg_upload.cli:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-v", + "--tb=short", + "--strict-markers", +] +markers = [ + "unit: Unit tests that don't require external dependencies", + "integration: Integration tests requiring GitLab API access", + "fast: Fast-running tests", + "slow: Slow-running tests", +] diff --git a/src/gitlab_pkg_upload/__init__.py b/src/gitlab_pkg_upload/__init__.py new file mode 100644 index 0000000..b646eb5 --- /dev/null +++ b/src/gitlab_pkg_upload/__init__.py @@ -0,0 +1,45 @@ +"""GitLab Generic Package Upload Tool.""" + +__version__ = "0.1.0" + +# Export key models and exceptions for convenience +from .duplicate_detector import DuplicateDetector +from .models import ( + DuplicatePolicy, + FileFingerprint, + RemoteFile, + UploadResult, + ProjectInfo, + ProjectResolutionResult, + GitRemoteInfo, + UploadConfig, + UploadContext, + GitLabUploadError, + AuthenticationError, + ConfigurationError, + ProjectResolutionError, + FileValidationError, + NetworkError, + ChecksumValidationError, +) + +__all__ = [ + "__version__", + "DuplicateDetector", + "DuplicatePolicy", + "FileFingerprint", + "RemoteFile", + "UploadResult", + "ProjectInfo", + "ProjectResolutionResult", + "GitRemoteInfo", + "UploadConfig", + "UploadContext", + "GitLabUploadError", + "AuthenticationError", + "ConfigurationError", + "ProjectResolutionError", + "FileValidationError", + "NetworkError", + "ChecksumValidationError", +] diff --git a/src/gitlab_pkg_upload/cli.py b/src/gitlab_pkg_upload/cli.py new file mode 100644 index 0000000..ae751bc --- /dev/null +++ b/src/gitlab_pkg_upload/cli.py @@ -0,0 +1,31 @@ +"""CLI entry point for gitlab-pkg-upload. + +This module provides the main() function that serves as the entry point +for the installed console script. +""" + +import runpy +import sys +from pathlib import Path + + +def main() -> None: + """Main entry point for the gitlab-pkg-upload CLI. + + This function runs the standalone script as the main module. + It handles the case where the script is installed as a package. + """ + # Locate the standalone script relative to this module + repo_root = Path(__file__).parent.parent.parent + script_path = repo_root / "gitlab-pkg-upload.py" + + if not script_path.exists(): + print(f"Error: Could not find gitlab-pkg-upload.py at {script_path}", file=sys.stderr) + sys.exit(1) + + # Run the script as __main__ + runpy.run_path(str(script_path), run_name="__main__") + + +if __name__ == "__main__": + main() diff --git a/src/gitlab_pkg_upload/duplicate_detector.py b/src/gitlab_pkg_upload/duplicate_detector.py new file mode 100644 index 0000000..ca3b315 --- /dev/null +++ b/src/gitlab_pkg_upload/duplicate_detector.py @@ -0,0 +1,280 @@ +"""Duplicate detection for gitlab-pkg-upload.""" + +from __future__ import annotations + +import logging +import time +from pathlib import Path +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from gitlab import Gitlab + +from .models import FileFingerprint, RemoteFile + +logger = logging.getLogger(__name__) + + +def calculate_sha256(file_path: Path) -> str: + """ + Calculate SHA256 checksum of a file. + + Args: + file_path: Path to the file + + Returns: + SHA256 checksum as hex string + """ + import hashlib + + sha256_hash = hashlib.sha256() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256_hash.update(chunk) + return sha256_hash.hexdigest() + + +def handle_network_error_with_retry( + operation_name: str, + operation_func, + max_retries: int = 3, + retry_delays: list[int] | None = None, +): + """ + Execute an operation with retry logic for network errors. + + Args: + operation_name: Name of the operation for logging + operation_func: Function to execute + max_retries: Maximum number of retries + retry_delays: List of delays between retries + + Returns: + Result of operation_func + + Raises: + Exception: If all retries are exhausted + """ + if retry_delays is None: + retry_delays = [1, 2, 4] + + last_exception = None + for attempt in range(max_retries + 1): + try: + return operation_func() + except Exception as e: + last_exception = e + if attempt < max_retries: + delay = retry_delays[min(attempt, len(retry_delays) - 1)] + logger.warning( + f"{operation_name} failed (attempt {attempt + 1}/{max_retries + 1}): {e}" + ) + logger.info(f"Retrying in {delay} seconds...") + time.sleep(delay) + else: + logger.error(f"{operation_name} failed after {max_retries + 1} attempts: {e}") + + raise last_exception + + +class DuplicateDetector: + """Core component responsible for detecting duplicates both locally (within session) and remotely (in GitLab registry).""" + + def __init__(self, gitlab_client: Gitlab, project_id: int): + """ + Initialize DuplicateDetector with GitLab client and project ID. + + Args: + gitlab_client: Authenticated GitLab client + project_id: GitLab project ID + """ + self.gl = gitlab_client + self.project_id = project_id + self.session_registry: dict[str, FileFingerprint] = {} + + def check_session_duplicate( + self, file_path: Path, target_filename: str + ) -> Optional[FileFingerprint]: + """ + Check if file was already processed in current session. + + Args: + file_path: Path to the source file + target_filename: Target filename in registry + + Returns: + FileFingerprint if duplicate found, None otherwise + """ + logger.debug(f"Checking session duplicate for: {target_filename}") + + # Check if target filename already exists in session registry + if target_filename in self.session_registry: + existing_fingerprint = self.session_registry[target_filename] + logger.debug(f"Found existing session entry for {target_filename}") + + # Calculate checksum of current file to compare + current_checksum = calculate_sha256(file_path) + + # Compare checksums to determine if it's truly a duplicate + if existing_fingerprint.sha256_checksum == current_checksum: + logger.info( + f"Session duplicate detected: {target_filename} (checksum: {current_checksum})" + ) + logger.info( + f"Original source: {existing_fingerprint.source_path}, Current source: {file_path}" + ) + return existing_fingerprint + else: + logger.warning( + f"Same target filename {target_filename} but different content detected" + ) + logger.warning( + f"Existing checksum: {existing_fingerprint.sha256_checksum}, Current checksum: {current_checksum}" + ) + else: + logger.debug(f"No session duplicate found for {target_filename}") + + return None + + def check_remote_duplicate( + self, package_name: str, version: str, filename: str, checksum: str + ) -> Optional[RemoteFile]: + """ + Check if file exists in GitLab registry with enhanced retry logic. + + Args: + package_name: Package name in registry + version: Package version + filename: Target filename + checksum: SHA256 checksum to compare + + Returns: + RemoteFile if duplicate found, None otherwise + """ + logger.info( + f"Starting remote duplicate check for {filename} in {package_name} v{version}" + ) + logger.debug(f"Local checksum to compare: {checksum}") + + def _check_remote_duplicate(): + """Internal function to check remote duplicate.""" + project = self.gl.projects.get(self.project_id) + packages = project.packages.list(package_name=package_name, get_all=True) + + # Find the target package version + target_package = next((p for p in packages if p.version == version), None) + + if not target_package: + logger.debug( + f"Package {package_name} v{version} not found - no remote duplicate" + ) + return None + + logger.debug( + f"Found package {package_name} v{version} (ID: {target_package.id})" + ) + + # Get package files + package_obj = project.packages.get(target_package.id) + package_files = package_obj.package_files.list(get_all=True) + + logger.debug(f"Found {len(package_files)} files in package") + + # Find files with matching filename + matching_files = [f for f in package_files if f.file_name == filename] + + if not matching_files: + logger.debug( + f"No files named {filename} found in remote package - no duplicate" + ) + return None + + logger.debug( + f"Found {len(matching_files)} file(s) with matching filename {filename}" + ) + + # Check for checksum matches + for pkg_file in matching_files: + remote_sha256 = getattr(pkg_file, "file_sha256", None) + + if remote_sha256: + logger.debug( + f"Comparing checksums - Remote: {remote_sha256}, Local: {checksum}" + ) + if remote_sha256.lower() == checksum.lower(): + logger.info( + f"Remote duplicate detected: {filename} (checksum: {checksum})" + ) + logger.info( + f"Remote file ID: {pkg_file.id}, Size: {getattr(pkg_file, 'size', 'unknown')}" + ) + + # Generate download URL + download_url = ( + f"{self.gl.api_url.replace('/api/v4', '')}/api/v4/projects/{self.project_id}/packages/generic/" + f"{package_name}/{version}/{filename}" + ) + + return RemoteFile( + file_id=pkg_file.id, + filename=filename, + sha256_checksum=remote_sha256, + file_size=getattr(pkg_file, "size", 0), + download_url=download_url, + package_name=package_name, + version=version, + ) + else: + logger.debug( + f"File {filename} exists but checksum differs (remote: {remote_sha256}, local: {checksum})" + ) + else: + # Handle incomplete metadata gracefully - use file size as fallback + logger.warning( + f"Remote checksum not available for {filename}, using file size comparison" + ) + logger.debug( + f"Cannot verify duplicate without checksum for {filename}" + ) + + logger.debug( + f"No matching checksums found for {filename} - no remote duplicate" + ) + return None + + try: + return handle_network_error_with_retry( + operation_name=f"Remote duplicate check for {filename}", + operation_func=_check_remote_duplicate, + ) + except Exception as e: + logger.error(f"Remote duplicate check failed for {filename}: {e}") + logger.warning(f"Proceeding without duplicate detection for {filename}") + return None + + def register_file(self, file_path: Path, target_filename: str, checksum: str): + """ + Register file as processed in current session. + + Args: + file_path: Path to the source file + target_filename: Target filename in registry + checksum: SHA256 checksum of the file + """ + file_stats = file_path.stat() + + fingerprint = FileFingerprint( + source_path=str(file_path), + target_filename=target_filename, + sha256_checksum=checksum, + file_size=file_stats.st_size, + timestamp=time.time(), + ) + + self.session_registry[target_filename] = fingerprint + logger.info( + f"Registered file in session: {target_filename} (checksum: {checksum})" + ) + logger.debug( + f"Session registry now contains {len(self.session_registry)} file(s)" + ) diff --git a/src/gitlab_pkg_upload/models.py b/src/gitlab_pkg_upload/models.py new file mode 100644 index 0000000..8d847f3 --- /dev/null +++ b/src/gitlab_pkg_upload/models.py @@ -0,0 +1,380 @@ +"""Data models, enums, and exceptions for gitlab-pkg-upload.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from gitlab import Gitlab + + from gitlab_pkg_upload.duplicate_detector import DuplicateDetector + + +# Enums + + +class DuplicatePolicy(Enum): + """Defines how the system should handle detected duplicates.""" + + SKIP = "skip" # Skip uploading duplicates (default) + REPLACE = "replace" # Delete existing and upload new + ERROR = "error" # Fail with error on duplicates + + +# Data Models - File Operations + + +@dataclass +class FileFingerprint: + """Represents a unique identifier for files to enable accurate duplicate detection.""" + + source_path: str + target_filename: str + sha256_checksum: str + file_size: int + timestamp: float + + +@dataclass +class RemoteFile: + """Represents a file that exists in the GitLab package registry.""" + + file_id: int + filename: str + sha256_checksum: Optional[str] + file_size: int + download_url: str + package_name: str + version: str + + +@dataclass +class UploadResult: + """Enhanced upload result structure with duplicate detection information.""" + + source_path: str + target_filename: str + success: bool + result: str # URL on success, error message on failure + was_duplicate: bool = False + duplicate_action: Optional[str] = None # "skipped", "replaced", "error" + existing_url: Optional[str] = None + + +# Data Models - Project Resolution + + +@dataclass +class ProjectInfo: + """Represents parsed project information from URLs.""" + + gitlab_url: str # Base GitLab instance URL + namespace: str # Project namespace/group + project_name: str # Project name + project_path: str # Full project path (namespace/project) + original_url: str # Original URL provided by user + + +@dataclass +class ProjectResolutionResult: + """Represents the result of project ID resolution.""" + + success: bool + project_id: Optional[int] + error_message: Optional[str] + project_info: Optional[ProjectInfo] + gitlab_url: str + + +@dataclass +class GitRemoteInfo: + """Represents Git remote information.""" + + name: str # Remote name (e.g., 'origin') + url: str # Remote URL + gitlab_url: str # Extracted GitLab instance URL + project_path: str # Extracted project path + + +# Data Models - Configuration + + +@dataclass +class UploadConfig: + """User configuration for upload operation.""" + + package_name: str + version: str + duplicate_policy: DuplicatePolicy + retry_count: int + verbosity: str # 'normal', 'verbose', 'quiet', 'debug' + dry_run: bool + fail_fast: bool + json_output: bool + plain_output: bool + gitlab_url: str + token: Optional[str] + + +@dataclass +class UploadContext: + """Runtime context for upload operations.""" + + gl: Gitlab + config: UploadConfig + detector: DuplicateDetector + project_id: int + project_path: str + + +# Exception Hierarchy + + +class GitLabUploadError(Exception): + """Base exception for GitLab upload errors.""" + + exit_code: int = 1 + + +class AuthenticationError(GitLabUploadError): + """Authentication failed.""" + + exit_code: int = 2 + + +class ConfigurationError(GitLabUploadError): + """Configuration error.""" + + exit_code: int = 3 + + +class ProjectResolutionError(GitLabUploadError): + """Project resolution failed.""" + + exit_code: int = 4 + + +class FileValidationError(GitLabUploadError): + """File validation failed.""" + + exit_code: int = 5 + + +class NetworkError(GitLabUploadError): + """Network error.""" + + exit_code: int = 6 + + +class ChecksumValidationError(GitLabUploadError): + """Checksum validation failed.""" + + exit_code: int = 7 + + +# Error Enhancement Functions + + +def handle_project_not_found_error( + project_path: str, gitlab_url: str, original_error: str +) -> str: + """ + Generate helpful error message for project not found errors. + + Args: + project_path: Project path that was not found + gitlab_url: GitLab instance URL + original_error: Original error message + + Returns: + Enhanced error message with suggestions + """ + return ( + f"Project '{project_path}' not found at {gitlab_url}.\n\n" + f"Please check the following:\n" + f" • Project path format is correct (should be: namespace/project-name)\n" + f" • Project exists and is accessible at {gitlab_url}\n" + f" • You have permission to view the project\n" + f" • GitLab instance URL is correct\n" + f" • Project is not private (if using public access)\n\n" + f"Examples of valid project paths:\n" + f" • mycompany/my-project\n" + f" • group/subgroup/project-name\n" + f" • username/personal-project\n\n" + f"You can verify the project exists by visiting:\n" + f" {gitlab_url}/{project_path}\n\n" + f"Original error: {original_error}" + ) + + +def handle_authentication_error( + project_path: str, gitlab_url: str, original_error: str +) -> str: + """ + Generate helpful error message for authentication failures. + + Args: + project_path: Project path being accessed + gitlab_url: GitLab instance URL + original_error: Original error message + + Returns: + Enhanced error message with guidance + """ + return ( + f"Authentication failed for project '{project_path}' at {gitlab_url}.\n\n" + f"Please check the following:\n" + f" • GitLab token is valid and not expired\n" + f" • Token has appropriate permissions (minimum: 'read_api' scope)\n" + f" • Token is configured for the correct GitLab instance\n" + f" • You have access to the project '{project_path}'\n" + f" • Project is not private (if token lacks permissions)\n\n" + f"Token configuration:\n" + f" • Set GITLAB_TOKEN environment variable, or\n" + f" • Use --token command line argument\n\n" + f"To create a new token:\n" + f" 1. Visit: {gitlab_url}/-/profile/personal_access_tokens\n" + f" 2. Create token with 'api' or 'read_api' scope\n" + f" 3. Set GITLAB_TOKEN environment variable\n\n" + f"Original error: {original_error}" + ) + + +def handle_permission_error( + project_path: str, gitlab_url: str, operation: str, original_error: str +) -> str: + """ + Generate helpful error message for permission errors. + + Args: + project_path: Project path being accessed + gitlab_url: GitLab instance URL + operation: Operation that failed (e.g., "upload", "read packages") + original_error: Original error message + + Returns: + Enhanced error message with guidance + """ + return ( + f"Permission denied for {operation} in project '{project_path}' at {gitlab_url}.\n\n" + f"Please check the following:\n" + f" • You have the required permissions for this operation\n" + f" • Your GitLab token has sufficient scope (may need 'api' instead of 'read_api')\n" + f" • You are a member of the project with appropriate role\n" + f" • Project settings allow the requested operation\n\n" + f"Required permissions for {operation}:\n" + f" • Package uploads: Developer role or higher\n" + f" • Package downloads: Reporter role or higher\n" + f" • Project access: Guest role or higher\n\n" + f"To check your permissions:\n" + f" 1. Visit: {gitlab_url}/{project_path}/-/project_members\n" + f" 2. Verify your role and permissions\n" + f" 3. Contact project maintainer if access is needed\n\n" + f"Original error: {original_error}" + ) + + +def handle_network_connectivity_error(gitlab_url: str, original_error: str) -> str: + """ + Generate helpful error message for network connectivity issues. + + Args: + gitlab_url: GitLab instance URL + original_error: Original error message + + Returns: + Enhanced error message with troubleshooting steps + """ + return ( + f"Network connectivity issue with GitLab instance at {gitlab_url}.\n\n" + f"Please check the following:\n" + f" • Internet connection is working\n" + f" • GitLab instance URL is correct and accessible\n" + f" • No firewall or proxy blocking the connection\n" + f" • GitLab instance is not experiencing downtime\n\n" + f"Troubleshooting steps:\n" + f" 1. Test connectivity: curl -I {gitlab_url}\n" + f" 2. Check GitLab status page (if available)\n" + f" 3. Try accessing {gitlab_url} in a web browser\n" + f" 4. Verify DNS resolution: nslookup {gitlab_url.replace('https://', '').replace('http://', '')}\n\n" + f"If using a corporate network:\n" + f" • Check proxy settings\n" + f" • Verify SSL certificate trust\n" + f" • Contact IT support if needed\n\n" + f"Original error: {original_error}" + ) + + +def enhance_error_message(error: Exception, context: dict) -> str: + """ + Enhance error messages with context and helpful suggestions. + + Args: + error: Original exception + context: Context dictionary with keys like 'project_path', 'gitlab_url', 'operation' + + Returns: + Enhanced error message + """ + error_msg = str(error).lower() + original_error = str(error) + + project_path = context.get("project_path", "unknown") + gitlab_url = context.get("gitlab_url", "unknown") + operation = context.get("operation", "operation") + + # Handle specific error types + if "404" in error_msg or "not found" in error_msg: + return handle_project_not_found_error(project_path, gitlab_url, original_error) + + elif any( + keyword in error_msg + for keyword in ["401", "403", "authentication", "unauthorized"] + ): + if "permission" in error_msg or "forbidden" in error_msg: + return handle_permission_error( + project_path, gitlab_url, operation, original_error + ) + else: + return handle_authentication_error(project_path, gitlab_url, original_error) + + elif any( + keyword in error_msg + for keyword in [ + "connection", + "network", + "timeout", + "unreachable", + "dns", + "resolve", + ] + ): + return handle_network_connectivity_error(gitlab_url, original_error) + + elif "rate limit" in error_msg or "too many requests" in error_msg: + return ( + f"GitLab API rate limit exceeded.\n\n" + f"Please wait a few minutes before retrying.\n" + f"Rate limits help ensure fair usage of GitLab resources.\n\n" + f"If you frequently hit rate limits:\n" + f" • Reduce the frequency of API calls\n" + f" • Consider using GitLab Premium for higher limits\n" + f" • Contact GitLab support for assistance\n\n" + f"Original error: {original_error}" + ) + + else: + # Generic enhancement with context + return ( + f"Operation failed: {operation}\n" + f"Project: {project_path}\n" + f"GitLab URL: {gitlab_url}\n\n" + f"Error details: {original_error}\n\n" + f"If this error persists:\n" + f" • Check GitLab instance status\n" + f" • Verify your network connection\n" + f" • Review your authentication and permissions\n" + f" • Contact support with the error details above" + ) From 90911cb258bb539b6012ece838ba0177d7e3362b Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Fri, 9 Jan 2026 07:39:46 -0600 Subject: [PATCH 02/36] gitlab-pkg-upload: Add file validation utilities for package uploads GitLab's Generic Package Registry enforces strict filename requirements that are not immediately obvious to users. Uploads with non-ASCII characters or special symbols fail with cryptic API errors, making it difficult to diagnose why a package upload was rejected. Introduce dedicated validation functions that check filenames against GitLab's allowed character set and verify file accessibility before attempting upload. Early validation with clear error messages helps users identify and fix issues locally rather than discovering them during the upload process. Signed-off-by: Javier Tia --- src/gitlab_pkg_upload/validators.py | 79 +++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/gitlab_pkg_upload/validators.py diff --git a/src/gitlab_pkg_upload/validators.py b/src/gitlab_pkg_upload/validators.py new file mode 100644 index 0000000..e0a50d9 --- /dev/null +++ b/src/gitlab_pkg_upload/validators.py @@ -0,0 +1,79 @@ +"""File validation utilities for GitLab package uploads.""" + +from __future__ import annotations + +import re +from pathlib import Path + +from gitlab_pkg_upload.models import FileValidationError + + +def validate_filename(filename: str) -> None: + """ + Validate filename contains only ASCII characters and allowed patterns for GitLab Generic Package Registry. + + GitLab's API restricts filenames to ASCII-safe characters only. This function checks + if the provided filename complies with these restrictions. + + Args: + filename: Target filename to validate + + Raises: + FileValidationError: If filename contains non-ASCII or disallowed characters + + Examples: + Valid: "package.tar.gz", "my-file_v1.0.bin", "subdir/file.txt" + Invalid: "café.tar.gz", "文件.bin", "file™.txt" + """ + # Check if filename is ASCII + if not filename.isascii(): + raise FileValidationError( + f"GitLab Generic Package Registry does not support non-ASCII characters in filenames. " + f"Problematic filename: '{filename}'. " + f"Allowed characters: letters (a-z, A-Z), digits (0-9), dots (.), hyphens (-), " + f"underscores (_), and forward slashes (/) for directory paths." + ) + + # Additional validation: check for allowed characters only + # Allowed: letters, digits, dots, hyphens, underscores, forward slashes + allowed_pattern = re.compile(r"^[a-zA-Z0-9._/-]+$") + + if not allowed_pattern.match(filename): + raise FileValidationError( + f"GitLab Generic Package Registry does not support special characters in filenames. " + f"Problematic filename: '{filename}'. " + f"Allowed characters: letters (a-z, A-Z), digits (0-9), dots (.), hyphens (-), " + f"underscores (_), and forward slashes (/) for directory paths." + ) + + +def validate_file_exists(file_path: Path) -> None: + """ + Validate that file exists, is a regular file, and is readable. + + Args: + file_path: Path object pointing to the file to validate + + Raises: + FileValidationError: If file doesn't exist, is not a regular file, or is not readable + + Examples: + Valid: Path("package.tar.gz") (existing readable file) + Invalid: Path("nonexistent.bin"), Path("/some/directory"), Path("unreadable.txt") (no permissions) + """ + # Check if path exists + if not file_path.exists(): + raise FileValidationError(f"File not found: {file_path}") + + # Check if path is a file + if not file_path.is_file(): + raise FileValidationError(f"Path is not a file: {file_path}") + + # Check if file is readable + try: + with open(file_path, "rb"): + pass + except (PermissionError, OSError): + raise FileValidationError( + f"File is not readable: {file_path}. Check file permissions." + ) From 8a7a0b1e9e0f483d347d31fa8195de53eff04497 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Fri, 9 Jan 2026 09:21:50 -0600 Subject: [PATCH 03/36] gitlab_pkg_upload: Add file collection and checksum utilities The package upload workflow requires several file handling capabilities that don't exist yet: calculating checksums for integrity verification, mapping local filenames to different remote names during upload, and collecting files from either explicit paths or directories. Add utility functions that provide these foundational capabilities. The checksum function uses chunked reading for memory efficiency with large files. The file collection function supports two input modes (explicit file list or directory scan) with batch error handling that continues processing valid files when some fail validation. This approach enables callers to report all validation issues at once rather than failing on the first error. Signed-off-by: Javier Tia --- src/gitlab_pkg_upload/validators.py | 243 +++++++++++++++++++++++++++- 1 file changed, 242 insertions(+), 1 deletion(-) diff --git a/src/gitlab_pkg_upload/validators.py b/src/gitlab_pkg_upload/validators.py index e0a50d9..5090dcf 100644 --- a/src/gitlab_pkg_upload/validators.py +++ b/src/gitlab_pkg_upload/validators.py @@ -2,10 +2,11 @@ from __future__ import annotations +import hashlib import re from pathlib import Path -from gitlab_pkg_upload.models import FileValidationError +from gitlab_pkg_upload.models import ConfigurationError, FileValidationError def validate_filename(filename: str) -> None: @@ -77,3 +78,243 @@ def validate_file_exists(file_path: Path) -> None: raise FileValidationError( f"File is not readable: {file_path}. Check file permissions." ) + + +def calculate_sha256(file_path: Path) -> str: + """ + Calculate SHA256 checksum of a file. + + Reads the file in chunks for memory efficiency, making it suitable + for large files. + + Args: + file_path: Path to the file to calculate checksum for + + Returns: + Hexadecimal SHA256 digest string (64 characters) + + Raises: + FileValidationError: If the file cannot be read + + Examples: + >>> checksum = calculate_sha256(Path("package.tar.gz")) + >>> print(checksum) + 'a1b2c3d4e5f6...' # 64-character hex string + """ + sha256_hash = hashlib.sha256() + + try: + with open(file_path, "rb") as f: + # Read in chunks for memory efficiency + for chunk in iter(lambda: f.read(8192), b""): + sha256_hash.update(chunk) + except (IOError, OSError) as e: + raise FileValidationError(f"Failed to read file for checksum calculation: {file_path}. Error: {e}") + + return sha256_hash.hexdigest() + + +def parse_file_mapping(mappings: list[str], files: list[str]) -> dict[str, str]: + """ + Parse file mapping strings into a dictionary. + + File mappings allow renaming files during upload using the format + 'source:target' where source is the local filename and target is + the desired remote filename. + + Args: + mappings: List of mapping strings in 'source:target' format + files: List of file paths that mappings should reference + + Returns: + Dictionary mapping local filenames to remote filenames + + Raises: + ConfigurationError: If mapping format is invalid (not exactly one colon) + or if a local name in mapping doesn't exist in the files list + + Examples: + Valid: + >>> parse_file_mapping(["local.bin:remote.bin"], ["path/to/local.bin"]) + {'local.bin': 'remote.bin'} + + Invalid (wrong format): + >>> parse_file_mapping(["invalid_mapping"], ["file.bin"]) + ConfigurationError: Invalid file mapping format... + + Invalid (file not in list): + >>> parse_file_mapping(["missing.bin:remote.bin"], ["other.bin"]) + ConfigurationError: File mapping references 'missing.bin'... + """ + file_mappings: dict[str, str] = {} + + for mapping in mappings: + if mapping.count(":") != 1: + raise ConfigurationError( + f"Invalid file mapping format '{mapping}'. " + "Expected format: 'local.bin:remote.bin'" + ) + local_name, remote_name = mapping.split(":", 1) + file_mappings[local_name] = remote_name + + # Validate that file mappings reference files in the files list + if file_mappings: + files_set = {Path(f).name for f in files} + for local_name in file_mappings.keys(): + if local_name not in files_set: + raise ConfigurationError( + f"File mapping references '{local_name}' which is not in the files list" + ) + + return file_mappings + + +def collect_files( + files: list[str] | None = None, + directory: str | None = None, + file_mappings: dict[str, str] | list[str] | None = None, +) -> tuple[list[tuple[Path, str]], list[dict]]: + """ + Collect files to upload based on input mode (files list or directory). + + Supports two modes: + - Files mode: Explicitly list files to upload, with optional renaming via file_mappings + - Directory mode: Upload all files from a directory (top-level only) + + Validates that all filenames contain only ASCII characters supported by GitLab. + File validation errors are collected rather than raised immediately, allowing + batch processing to continue with valid files. + + Args: + files: List of file paths to upload (files mode) + directory: Directory path to upload files from (directory mode) + file_mappings: Optional dictionary mapping local filenames to remote filenames, + or a list of mapping strings in 'source:target' format. + Only applicable in files mode. + + Returns: + Tuple of (files_to_upload, file_errors) where: + - files_to_upload: List of tuples containing (source_path, target_filename) + - file_errors: List of dicts with keys: source_path, target_filename, + error_message, error_type + + Raises: + ConfigurationError: If directory doesn't exist, isn't a directory, + duplicate target filenames are detected, both files and directory + are provided, neither files nor directory is provided, or + file_mappings is an unsupported type. + + Examples: + Files mode: + >>> files_to_upload, errors = collect_files( + ... files=["path/to/file1.bin", "path/to/file2.bin"], + ... file_mappings={"file1.bin": "renamed.bin"} + ... ) + + Directory mode: + >>> files_to_upload, errors = collect_files(directory="/path/to/uploads") + """ + files_to_upload: list[tuple[Path, str]] = [] + file_errors: list[dict] = [] + + # Validate mutually exclusive inputs + if files and directory: + raise ConfigurationError( + "Cannot specify both 'files' and 'directory'. They are mutually exclusive." + ) + if not files and not directory: + raise ConfigurationError( + "Either 'files' or 'directory' must be provided." + ) + + # Handle file_mappings type conversion + if file_mappings is None: + file_mappings = {} + elif isinstance(file_mappings, list): + # Convert list of mapping strings to dict via parse_file_mapping + file_mappings = parse_file_mapping(file_mappings, files or []) + elif not isinstance(file_mappings, dict): + raise ConfigurationError( + f"file_mappings must be a dict or list of strings, got {type(file_mappings).__name__}" + ) + + if files: + # Files mode: process each file explicitly + for file_path_str in files: + source_path = Path(file_path_str) + + # Determine target filename (apply mapping if exists) + target_filename = file_mappings.get(source_path.name, source_path.name) + + # Validate file existence and type + try: + validate_file_exists(source_path) + except FileValidationError as e: + file_errors.append( + { + "source_path": str(source_path), + "target_filename": target_filename, + "error_message": str(e), + "error_type": "FileValidationError", + } + ) + continue + + # Validate filename for GitLab API compatibility + try: + validate_filename(target_filename) + except FileValidationError as e: + file_errors.append( + { + "source_path": str(source_path), + "target_filename": target_filename, + "error_message": str(e), + "error_type": "FileValidationError", + } + ) + continue + + files_to_upload.append((source_path, target_filename)) + + elif directory: + # Directory mode: collect all top-level files + directory_path = Path(directory) + + if not directory_path.exists(): + raise ConfigurationError(f"Directory not found: {directory_path}") + if not directory_path.is_dir(): + raise ConfigurationError(f"Path is not a directory: {directory_path}") + + # Collect only top-level files (not subdirectories) + for item in directory_path.iterdir(): + if item.is_file(): + # Validate filename for GitLab API compatibility + try: + validate_filename(item.name) + except FileValidationError as e: + file_errors.append( + { + "source_path": str(item), + "target_filename": item.name, + "error_message": str(e), + "error_type": "FileValidationError", + } + ) + continue + + files_to_upload.append((item, item.name)) + + if not files_to_upload and not file_errors: + # Log warning - no files found (caller may want to handle this) + pass + + # Check for duplicate target filenames + target_filenames = [target for _, target in files_to_upload] + duplicates = [name for name in target_filenames if target_filenames.count(name) > 1] + if duplicates: + unique_duplicates = list(set(duplicates)) + raise ConfigurationError( + f"Duplicate target filenames detected: {', '.join(unique_duplicates)}" + ) + + return files_to_upload, file_errors From ef10f34874444a62f9264e05eda0b9bedd0021f8 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Fri, 9 Jan 2026 10:03:14 -0600 Subject: [PATCH 04/36] gitlab_pkg_upload: Add Git URL parsing and configuration utilities The validators module only handles file validation, but the package upload workflow also requires parsing Git remote URLs and retrieving GitLab tokens. These operations are currently missing, forcing callers to implement their own URL parsing and token resolution logic, which leads to inconsistent error handling and duplicated validation code. Add utility functions for Git URL parsing, GitLab URL normalization, and token retrieval. The URL parsers handle both SSH and HTTPS formats, extracting the GitLab instance URL and project path with consistent error messages. Token retrieval follows a priority chain from CLI argument to environment variable. This centralizes configuration logic in the validators module where related validation already exists, ensuring uniform error handling through ConfigurationError exceptions. Signed-off-by: Javier Tia --- src/gitlab_pkg_upload/validators.py | 284 +++++++++++++++++++++++++++- 1 file changed, 283 insertions(+), 1 deletion(-) diff --git a/src/gitlab_pkg_upload/validators.py b/src/gitlab_pkg_upload/validators.py index 5090dcf..2a97ec8 100644 --- a/src/gitlab_pkg_upload/validators.py +++ b/src/gitlab_pkg_upload/validators.py @@ -1,10 +1,16 @@ -"""File validation utilities for GitLab package uploads.""" +"""Validation and utility functions for GitLab package uploads. + +This module provides file validation, Git URL parsing, and configuration utilities +for the GitLab package upload workflow. +""" from __future__ import annotations import hashlib +import os import re from pathlib import Path +from urllib.parse import urlparse from gitlab_pkg_upload.models import ConfigurationError, FileValidationError @@ -318,3 +324,279 @@ def collect_files( ) return files_to_upload, file_errors + + +def parse_git_url(url: str) -> tuple[str, str]: + """ + Parse a Git remote URL and extract GitLab instance URL and project path. + + Supports both HTTPS and SSH Git URL formats. Extracts the GitLab instance + base URL and the project path (namespace/project) from the remote URL. + + Args: + url: Git remote URL in HTTPS or SSH format + + Returns: + Tuple of (gitlab_url, project_path) where: + - gitlab_url: Base URL of the GitLab instance (e.g., "https://gitlab.com") + - project_path: Project path including namespace (e.g., "namespace/project") + + Raises: + ConfigurationError: If the URL format is invalid or cannot be parsed + + Examples: + HTTPS format: + >>> parse_git_url("https://gitlab.com/namespace/project.git") + ('https://gitlab.com', 'namespace/project') + + SSH format: + >>> parse_git_url("git@gitlab.com:namespace/project.git") + ('https://gitlab.com', 'namespace/project') + + Invalid format: + >>> parse_git_url("invalid-url") + ConfigurationError: Invalid Git URL format... + """ + if not url or not isinstance(url, str): + raise ConfigurationError( + "Git URL must be a non-empty string. " + "Expected formats: 'https://gitlab.com/namespace/project.git' or " + "'git@gitlab.com:namespace/project.git'" + ) + + url = url.strip() + + try: + # Detect URL format: SSH starts with 'git@', otherwise assume HTTPS + if url.startswith("git@"): + # SSH format: git@hostname:namespace/project.git + if ":" not in url: + raise ConfigurationError( + f"Invalid SSH Git URL format: '{url}'. " + "Expected format: 'git@gitlab.com:namespace/project.git'" + ) + + # Split on first ':' to separate host from path + host_part, path_part = url.split(":", 1) + + # Extract hostname by removing 'git@' prefix + hostname = host_part[4:] # Remove 'git@' + if not hostname: + raise ConfigurationError( + f"Invalid SSH Git URL: missing hostname in '{url}'. " + "Expected format: 'git@gitlab.com:namespace/project.git'" + ) + + # Process path: strip slashes, remove .git suffix + path = path_part.strip("/") + if path.endswith(".git"): + path = path[:-4] + + # Validate path has at least namespace/project + path_components = path.split("/") + if len(path_components) < 2 or not all(path_components[:2]): + raise ConfigurationError( + f"Invalid Git URL path: '{path}'. " + "Path must contain at least namespace/project. " + "Expected format: 'git@gitlab.com:namespace/project.git'" + ) + + gitlab_url = f"https://{hostname}" + project_path = "/".join(path_components) + + return gitlab_url, project_path + + else: + # HTTPS format: https://gitlab.com/namespace/project.git + parsed = urlparse(url) + + if parsed.scheme != "https": + raise ConfigurationError( + f"Invalid Git URL scheme: '{parsed.scheme}'. " + "Expected 'https' for HTTPS Git URLs. " + "Example: 'https://gitlab.com/namespace/project.git'" + ) + + if not parsed.netloc: + raise ConfigurationError( + f"Invalid Git URL: missing hostname in '{url}'. " + "Expected format: 'https://gitlab.com/namespace/project.git'" + ) + + # Process path: strip slashes, remove .git suffix + path = parsed.path.strip("/") + if path.endswith(".git"): + path = path[:-4] + + # Validate path has at least namespace/project + path_components = path.split("/") + if len(path_components) < 2 or not all(path_components[:2]): + raise ConfigurationError( + f"Invalid Git URL path: '{path}'. " + "Path must contain at least namespace/project. " + "Expected format: 'https://gitlab.com/namespace/project.git'" + ) + + gitlab_url = f"{parsed.scheme}://{parsed.netloc}" + project_path = "/".join(path_components) + + return gitlab_url, project_path + + except ConfigurationError: + raise + except Exception as e: + raise ConfigurationError( + f"Failed to parse Git URL '{url}': {e}. " + "Expected formats: 'https://gitlab.com/namespace/project.git' or " + "'git@gitlab.com:namespace/project.git'" + ) + + +def normalize_gitlab_url(url: str) -> tuple[str, str]: + """ + Normalize a GitLab project URL by extracting instance URL and project path. + + Standardizes GitLab project URLs by parsing and validating the URL structure, + then returning the base instance URL and the project path. + + Args: + url: GitLab project URL (e.g., "https://gitlab.com/namespace/project") + + Returns: + Tuple of (gitlab_url, project_path) where: + - gitlab_url: Base URL of the GitLab instance (e.g., "https://gitlab.com") + - project_path: Project path including namespace (e.g., "namespace/project") + + Raises: + ConfigurationError: If the URL format is invalid, missing required components, + or uses an unsupported scheme + + Examples: + Valid URL: + >>> normalize_gitlab_url("https://gitlab.com/namespace/project") + ('https://gitlab.com', 'namespace/project') + + With trailing slash: + >>> normalize_gitlab_url("https://gitlab.com/namespace/project/") + ('https://gitlab.com', 'namespace/project') + + Invalid (missing project): + >>> normalize_gitlab_url("https://gitlab.com/namespace") + ConfigurationError: Invalid GitLab URL path... + """ + if not url or not isinstance(url, str): + raise ConfigurationError( + "GitLab URL must be a non-empty string. " + "Expected format: 'https://gitlab.com/namespace/project'" + ) + + # Strip trailing slashes + url = url.rstrip("/") + + try: + parsed = urlparse(url) + except Exception as e: + raise ConfigurationError( + f"Failed to parse GitLab URL '{url}': {e}. " + "Expected format: 'https://gitlab.com/namespace/project'" + ) + + # Validate scheme + if parsed.scheme not in ("http", "https"): + raise ConfigurationError( + f"Invalid GitLab URL scheme: '{parsed.scheme}'. " + "Expected 'http' or 'https'. " + "Example: 'https://gitlab.com/namespace/project'" + ) + + # Validate hostname + if not parsed.netloc: + raise ConfigurationError( + f"Invalid GitLab URL: missing hostname in '{url}'. " + "Expected format: 'https://gitlab.com/namespace/project'" + ) + + # Extract and validate path + path = parsed.path.strip("/") + if not path: + raise ConfigurationError( + f"Invalid GitLab URL: missing project path in '{url}'. " + "Expected format: 'https://gitlab.com/namespace/project'" + ) + + # Split path into components + path_components = path.split("/") + if len(path_components) < 2: + raise ConfigurationError( + f"Invalid GitLab URL path: '{path}'. " + "Path must contain at least namespace/project. " + "Expected format: 'https://gitlab.com/namespace/project'" + ) + + namespace = path_components[0] + project_name = path_components[1] + + # Validate namespace and project are non-empty + if not namespace: + raise ConfigurationError( + f"Invalid GitLab URL: empty namespace in '{url}'. " + "Expected format: 'https://gitlab.com/namespace/project'" + ) + if not project_name: + raise ConfigurationError( + f"Invalid GitLab URL: empty project name in '{url}'. " + "Expected format: 'https://gitlab.com/namespace/project'" + ) + + gitlab_url = f"{parsed.scheme}://{parsed.netloc}" + project_path = f"{namespace}/{project_name}" + + return gitlab_url, project_path + + +def get_gitlab_token(cli_token: str | None = None) -> str: + """ + Retrieve GitLab API token from CLI argument or environment variable. + + Token sources are checked in priority order: + 1. CLI argument (cli_token parameter) + 2. GITLAB_TOKEN environment variable + + Args: + cli_token: Optional token provided via CLI argument. Takes precedence + over environment variable if provided. + + Returns: + GitLab API token string + + Raises: + ConfigurationError: If no token is found from any source + + Examples: + CLI token provided: + >>> get_gitlab_token("glpat-xxxxxxxxxxxxxxxxxxxx") + 'glpat-xxxxxxxxxxxxxxxxxxxx' + + Environment variable (when cli_token is None): + >>> os.environ["GITLAB_TOKEN"] = "glpat-yyyyyyyyyyyyyyyyyyyy" + >>> get_gitlab_token() + 'glpat-yyyyyyyyyyyyyyyyyyyy' + + No token available: + >>> get_gitlab_token() + ConfigurationError: No GitLab token provided... + """ + # CLI argument takes precedence + if cli_token: + return cli_token + + # Check environment variable + env_token = os.environ.get("GITLAB_TOKEN") + if env_token: + return env_token + + # No token found + raise ConfigurationError( + "No GitLab token provided. " + "Set GITLAB_TOKEN environment variable or use --token argument" + ) From a21ec5d2f92553ce0238ed8f6d3d97cb4a519d78 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Fri, 9 Jan 2026 10:36:44 -0600 Subject: [PATCH 05/36] gitlab_pkg_upload: Add comprehensive configuration validation functions The validators module only handles file and URL validation, leaving configuration validation (Python version, dependencies, tokens, Git setup) scattered or missing entirely. Users encountering setup issues receive generic errors without guidance on resolution, making troubleshooting difficult and increasing support burden. Add a suite of validation functions that check each configuration aspect with detailed, actionable error messages. Each validator follows a consistent pattern: detect the specific issue, explain what failed, and provide platform-appropriate resolution steps. The orchestrating validate_configuration function runs all checks in sequence with appropriate handling for optional Git requirements, allowing callers to validate their entire setup with a single call. Signed-off-by: Javier Tia --- src/gitlab_pkg_upload/validators.py | 691 +++++++++++++++++++++++++++- 1 file changed, 688 insertions(+), 3 deletions(-) diff --git a/src/gitlab_pkg_upload/validators.py b/src/gitlab_pkg_upload/validators.py index 2a97ec8..e9d4888 100644 --- a/src/gitlab_pkg_upload/validators.py +++ b/src/gitlab_pkg_upload/validators.py @@ -1,18 +1,34 @@ """Validation and utility functions for GitLab package uploads. -This module provides file validation, Git URL parsing, and configuration utilities -for the GitLab package upload workflow. +This module provides comprehensive validation capabilities for the GitLab package +upload workflow including: + +- File validation (existence, readability, filename format) +- Git URL parsing and normalization +- Configuration validation (dependencies, tokens, Git installation) +- Git repository validation +- Project specification validation """ from __future__ import annotations import hashlib +import logging import os import re +import subprocess +import sys from pathlib import Path +from typing import Optional from urllib.parse import urlparse -from gitlab_pkg_upload.models import ConfigurationError, FileValidationError +from gitlab_pkg_upload.models import ConfigurationError, FileValidationError, ProjectResolutionError + +# Module-level logger +logger = logging.getLogger(__name__) + +# Constants +DEFAULT_GITLAB_URL = "https://gitlab.com" def validate_filename(filename: str) -> None: @@ -600,3 +616,672 @@ def get_gitlab_token(cli_token: str | None = None) -> str: "No GitLab token provided. " "Set GITLAB_TOKEN environment variable or use --token argument" ) + + +def validate_dependencies() -> None: + """ + Validate that all required dependencies are available. + + Checks Python version (requires 3.11+) and required modules (gitlab, git, rich). + Provides detailed installation instructions for missing dependencies. + + Raises: + ConfigurationError: If Python version is insufficient or required modules + are missing, with specific installation instructions. + + Examples: + >>> validate_dependencies() # Success - all dependencies available + + >>> validate_dependencies() # Python version too low + ConfigurationError: Python 3.11 or higher is required... + + >>> validate_dependencies() # Missing module + ConfigurationError: Required dependencies are not available... + """ + logger.debug("Validating required dependencies...") + + # Check Python version + if sys.version_info < (3, 11): + raise ConfigurationError( + f"Python 3.11 or higher is required. Current version: {sys.version}\n\n" + "SOLUTION:\n" + "1. Install Python 3.11 or higher:\n" + " • Ubuntu/Debian: sudo apt update && sudo apt install python3.11\n" + " • macOS: brew install python@3.11\n" + " • Windows: Download from https://python.org/downloads/\n\n" + "2. Use pyenv to manage Python versions:\n" + " • Install pyenv: curl https://pyenv.run | bash\n" + " • Install Python: pyenv install 3.11\n" + " • Set local version: pyenv local 3.11\n\n" + "3. Use uv to run with correct Python version:\n" + " • Install uv: pip install uv\n" + " • Run script: uv run --python 3.11 ./gitlab/gitlab-pkg-upload.py\n\n" + "For more help, see: https://docs.python.org/3/installing/" + ) + + # Check required modules + required_modules = { + "gitlab": "python-gitlab>=4.0.0", + "git": "GitPython>=3.1.0", + "rich": "rich>=13.0.0", + } + + missing_modules = [] + for module_name, package_spec in required_modules.items(): + try: + __import__(module_name) + logger.debug(f"Module {module_name} available") + except ImportError: + missing_modules.append((module_name, package_spec)) + logger.debug(f"Module {module_name} not available") + + if missing_modules: + error_msg = "Required dependencies are not available:\n" + for module_name, package_spec in missing_modules: + error_msg += f" • {module_name} (install: {package_spec})\n" + + error_msg += ( + "\nSOLUTION:\n" + "1. If using uv (recommended):\n" + " • Ensure script has proper shebang: #!/usr/bin/env -S uv run --script\n" + " • Run directly: ./gitlab/gitlab-pkg-upload.py\n" + " • uv will automatically install dependencies\n\n" + "2. Manual installation with pip:\n" + ) + + for module_name, package_spec in missing_modules: + error_msg += f" pip install '{package_spec}'\n" + + error_msg += ( + "\n3. Install all at once:\n" + " pip install python-gitlab>=4.0.0 rich>=13.0.0 GitPython>=3.1.0\n\n" + "4. Using virtual environment (recommended):\n" + " python -m venv venv\n" + " source venv/bin/activate # On Windows: venv\\Scripts\\activate\n" + " pip install python-gitlab>=4.0.0 rich>=13.0.0 GitPython>=3.1.0\n\n" + "TROUBLESHOOTING:\n" + "• Check Python version: python --version\n" + "• Check pip version: pip --version\n" + "• Update pip: pip install --upgrade pip\n" + "• For corporate networks: pip install --trusted-host pypi.org --trusted-host pypi.python.org\n\n" + "For more help: https://packaging.python.org/tutorials/installing-packages/" + ) + + raise ConfigurationError(error_msg) + + logger.debug("All required dependencies are available") + + +def validate_gitlab_token(token: str, gitlab_url: str = DEFAULT_GITLAB_URL) -> None: + """ + Validate GitLab token availability and basic validity. + + Performs basic format validation on the token including: + - Non-empty string check + - Minimum length validation (20+ characters) + - glpat- prefix token length validation (26+ characters) + + Args: + token: GitLab authentication token to validate + gitlab_url: GitLab instance URL for constructing help URLs + + Raises: + ConfigurationError: If token is empty, too short, or has invalid format, + with guidance on creating and configuring tokens. + + Examples: + Valid token: + >>> validate_gitlab_token("glpat-xxxxxxxxxxxxxxxxxxxx") + # Success - no exception raised + + Empty token: + >>> validate_gitlab_token("") + ConfigurationError: GitLab token is required... + + Short token: + >>> validate_gitlab_token("short") + ConfigurationError: GitLab token appears to be invalid (too short)... + """ + logger.debug("Validating GitLab token...") + + if not token or not isinstance(token, str): + raise ConfigurationError( + "GitLab token is required but not provided.\n\n" + "SOLUTION:\n" + "1. Create a GitLab personal access token:\n" + f" • Visit: {gitlab_url}/-/profile/personal_access_tokens\n" + " • Click 'Add new token'\n" + " • Name: 'Package Upload Token'\n" + " • Scopes: Select 'api' (required for package operations)\n" + " • Expiration: Set appropriate date\n" + " • Click 'Create personal access token'\n" + " • Copy the generated token immediately\n\n" + "2. Set the token as environment variable:\n" + " export GITLAB_TOKEN='your-token-here'\n\n" + "3. Or use command line argument:\n" + " --token your-token-here\n\n" + "4. For CI/CD pipelines:\n" + " export GITLAB_TOKEN=$CI_JOB_TOKEN\n\n" + "IMPORTANT:\n" + "• Token must have 'api' scope (not just 'read_api')\n" + "• Token must not be expired\n" + "• Keep token secure and never commit to version control\n\n" + "TROUBLESHOOTING:\n" + "• Check token format: should be 20+ characters\n" + "• Verify token hasn't expired\n" + f"• Test token manually: curl -H 'PRIVATE-TOKEN: your-token' {gitlab_url}/api/v4/user\n\n" + "For more help: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html" + ) + + # Basic token format validation + token = token.strip() + if len(token) < 20: + raise ConfigurationError( + f"GitLab token appears to be invalid (too short: {len(token)} characters).\n\n" + "SOLUTION:\n" + "1. Verify you copied the complete token:\n" + " • GitLab personal access tokens are typically 20+ characters\n" + " • Ensure no whitespace or truncation occurred\n" + " • Check for copy/paste errors\n\n" + "2. Generate a new token if needed:\n" + f" • Visit: {gitlab_url}/-/profile/personal_access_tokens\n" + " • Create new token with 'api' scope\n" + " • Copy the complete token\n\n" + "3. Test token format:\n" + " echo $GITLAB_TOKEN | wc -c # Should be 20+ characters\n\n" + "For more help: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html" + ) + + # Check for common token format issues + if token.startswith("glpat-") and len(token) < 26: + raise ConfigurationError( + f"GitLab personal access token appears incomplete.\n" + f"Token length: {len(token)} characters (expected 26+ for glpat- tokens)\n\n" + "SOLUTION:\n" + "1. Verify complete token was copied:\n" + " • Personal access tokens start with 'glpat-' and are 26+ characters\n" + " • Check for truncation during copy/paste\n" + " • Ensure no line breaks or extra characters\n\n" + "2. Generate new token if corrupted:\n" + f" • Visit: {gitlab_url}/-/profile/personal_access_tokens\n" + " • Revoke old token if compromised\n" + " • Create new token with 'api' scope\n\n" + "For more help: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html" + ) + + logger.debug("GitLab token format validation passed") + + +def validate_git_installation() -> None: + """ + Validate that Git is installed and accessible. + + Runs 'git --version' to verify Git is available in the system PATH + and functioning correctly. + + Raises: + ConfigurationError: If Git is not installed, not in PATH, command times out, + or other unexpected errors occur, with platform-specific installation + instructions and troubleshooting steps. + + Examples: + Git installed: + >>> validate_git_installation() + # Success - no exception raised + + Git not installed: + >>> validate_git_installation() + ConfigurationError: Git is not installed or not available in PATH... + """ + logger.debug("Validating Git installation...") + + try: + result = subprocess.run( + ["git", "--version"], capture_output=True, text=True, timeout=10 + ) + + if result.returncode != 0: + raise ConfigurationError( + f"Git command failed with exit code {result.returncode}.\n" + f"Error output: {result.stderr}\n\n" + "SOLUTION:\n" + "1. Install Git:\n" + " • Ubuntu/Debian: sudo apt update && sudo apt install git\n" + " • CentOS/RHEL: sudo yum install git\n" + " • macOS: brew install git (or install Xcode Command Line Tools)\n" + " • Windows: Download from https://git-scm.com/download/windows\n\n" + "2. Verify installation:\n" + " git --version\n\n" + "3. Check PATH configuration:\n" + " which git # On Unix-like systems\n" + " where git # On Windows\n\n" + "For more help: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git" + ) + + git_version = result.stdout.strip() + logger.debug(f"Git is available: {git_version}") + + except FileNotFoundError: + raise ConfigurationError( + "Git is not installed or not available in PATH.\n\n" + "SOLUTION:\n" + "1. Install Git:\n" + " • Ubuntu/Debian: sudo apt update && sudo apt install git\n" + " • CentOS/RHEL: sudo yum install git\n" + " • macOS: brew install git (or install Xcode Command Line Tools)\n" + " • Windows: Download from https://git-scm.com/download/windows\n\n" + "2. Add Git to PATH (if installed but not in PATH):\n" + " • Find Git installation directory\n" + " • Add to PATH environment variable\n" + " • Restart terminal/command prompt\n\n" + "3. Verify installation:\n" + " git --version\n\n" + "TROUBLESHOOTING:\n" + "• Check if Git is installed: ls /usr/bin/git\n" + "• Check PATH: echo $PATH\n" + "• For Windows: Check 'Program Files\\Git\\bin' is in PATH\n\n" + "For more help: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git" + ) + + except subprocess.TimeoutExpired: + raise ConfigurationError( + "Git command timed out. This may indicate system issues.\n\n" + "SOLUTION:\n" + "1. Check system resources:\n" + " • Ensure sufficient memory and CPU available\n" + " • Check for system overload\n\n" + "2. Verify Git installation:\n" + " git --version\n\n" + "3. Try running Git commands manually:\n" + " git status\n\n" + "If problem persists, consider reinstalling Git." + ) + + except Exception as e: + raise ConfigurationError( + f"Unexpected error checking Git installation: {e}\n\n" + "SOLUTION:\n" + "1. Verify Git is properly installed:\n" + " git --version\n\n" + "2. Check system permissions:\n" + " • Ensure user can execute Git commands\n" + " • Check file permissions on Git executable\n\n" + "3. Reinstall Git if necessary:\n" + " • Download from https://git-scm.com/downloads\n" + " • Follow installation instructions for your OS\n\n" + "For more help: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git" + ) + + +def validate_git_repository(working_directory: str = ".") -> None: + """ + Validate Git repository access when Git operations are needed. + + Uses GitPython to verify the specified directory is within a valid Git + repository and that the repository is accessible (can read config and remotes). + + Args: + working_directory: Directory to check for Git repository. Defaults to + current directory. Parent directories are searched for .git folder. + + Raises: + ConfigurationError: If directory is not in a Git repository, repository + is corrupted/inaccessible, or permission errors occur, with + troubleshooting guidance and repair suggestions. + + Examples: + Valid repository: + >>> validate_git_repository("/path/to/repo") + # Success - no exception raised + + Not a Git repository: + >>> validate_git_repository("/tmp") + ConfigurationError: Not a Git repository... + + Corrupted repository: + >>> validate_git_repository("/path/to/corrupted/repo") + ConfigurationError: Git repository found but not fully accessible... + """ + logger.debug(f"Validating Git repository access in: {working_directory}") + + try: + import git + + # Find Git repository (searches parent directories) + repo = git.Repo(working_directory, search_parent_directories=True) + + logger.debug(f"Git repository found at: {repo.working_dir}") + + # Test basic repository operations + try: + # Try to read repository configuration + repo.config_reader() # Just verify it's accessible + logger.debug("Git repository configuration accessible") + + # Try to read remotes + remotes = list(repo.remotes) + logger.debug(f"Git remotes accessible: {len(remotes)} remote(s)") + + except Exception as e: + raise ConfigurationError( + f"Git repository found but not fully accessible: {e}\n\n" + "SOLUTION:\n" + "1. Check repository integrity:\n" + " git fsck\n\n" + "2. Check file permissions:\n" + f" ls -la {repo.working_dir}/.git\n" + " • Ensure .git directory is readable\n" + " • Check ownership and permissions\n\n" + "3. Try repository repair:\n" + " git gc --prune=now\n" + " git repack -ad\n\n" + "4. If corrupted, consider re-cloning:\n" + " • Backup any uncommitted changes\n" + " • Clone fresh copy from remote\n\n" + "TROUBLESHOOTING:\n" + "• Check disk space: df -h\n" + "• Check file system errors: dmesg | grep -i error\n" + "• Verify Git version: git --version\n\n" + "For more help: https://git-scm.com/docs/git-fsck" + ) + + except git.InvalidGitRepositoryError: + raise ConfigurationError( + f"Directory '{working_directory}' is not inside a Git repository.\n\n" + "SOLUTION:\n" + "1. Ensure you're in a Git repository:\n" + " git status\n\n" + "2. Initialize repository if needed:\n" + " git init\n" + " git remote add origin \n\n" + "3. Use manual project specification if Git auto-detection isn't needed:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project\n\n" + "For more help: https://git-scm.com/docs/git-init" + ) + + except PermissionError as e: + raise ConfigurationError( + f"Permission denied accessing Git repository in '{working_directory}': {e}\n\n" + "SOLUTION:\n" + "1. Check directory permissions:\n" + f" ls -la {working_directory}\n" + " • Ensure directory is readable and accessible\n\n" + "2. Check .git directory permissions:\n" + f" ls -la {working_directory}/.git\n\n" + "3. Fix permissions if needed:\n" + f" chmod -R u+rw {working_directory}/.git\n\n" + "For more help: https://git-scm.com/docs/git-init" + ) + + except git.GitCommandError as e: + raise ConfigurationError( + f"Git command error in '{working_directory}': {e}\n\n" + "SOLUTION:\n" + "1. Verify Git is installed and working:\n" + " git --version\n\n" + "2. Check repository status:\n" + " git status\n\n" + "3. Check for repository corruption:\n" + " git fsck\n\n" + "For more help: https://git-scm.com/docs/git-fsck" + ) + + except OSError as e: + raise ConfigurationError( + f"OS error accessing Git repository in '{working_directory}': {e}\n\n" + "SOLUTION:\n" + "1. Verify directory exists and is accessible:\n" + f" ls -la {working_directory}\n\n" + "2. Check disk space:\n" + " df -h\n\n" + "3. Check file system health:\n" + " dmesg | grep -i error\n\n" + "For more help: https://git-scm.com/docs/git-init" + ) + + except Exception as e: + raise ConfigurationError( + f"Unexpected error validating Git repository access: {e}\n\n" + "SOLUTION:\n" + "1. Ensure you're in a Git repository:\n" + " git status\n\n" + "2. Initialize repository if needed:\n" + " git init\n" + " git remote add origin \n\n" + "3. Check directory permissions:\n" + f" ls -la {working_directory}\n" + " • Ensure directory is readable and accessible\n\n" + "4. Use manual project specification if Git auto-detection isn't needed:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project\n\n" + "For more help: https://git-scm.com/docs/git-init" + ) + + +def validate_project_specification( + project_spec: str, + spec_type: str = "auto", + gitlab_url: str = DEFAULT_GITLAB_URL, +) -> tuple[str, str]: + """ + Validate and normalize a project specification (URL or path). + + Handles both GitLab project URLs and project paths, validating format + and extracting components. + + Args: + project_spec: Project specification - either a full GitLab URL + (e.g., "https://gitlab.com/namespace/project") or a project path + (e.g., "namespace/project") + spec_type: Type of specification - "url", "path", or "auto" (default). + When "auto", attempts to detect the type based on the spec format. + gitlab_url: Base URL of the GitLab instance to use for path specs. + Defaults to DEFAULT_GITLAB_URL ("https://gitlab.com"). + Ignored when spec_type is "url" or when auto-detected as URL. + + Returns: + Tuple of (gitlab_url, project_path) where: + - gitlab_url: Base URL of the GitLab instance (e.g., "https://gitlab.com") + For path specs, returns the provided gitlab_url parameter + - project_path: Project path including namespace (e.g., "namespace/project") + + Raises: + ProjectResolutionError: If project specification is invalid, with + format examples and suggestions. + + Examples: + URL specification: + >>> validate_project_specification("https://gitlab.com/mygroup/myproject") + ('https://gitlab.com', 'mygroup/myproject') + + Path specification: + >>> validate_project_specification("mygroup/myproject", spec_type="path") + ('https://gitlab.com', 'mygroup/myproject') + + Path with custom GitLab URL: + >>> validate_project_specification("mygroup/myproject", gitlab_url="https://gitlab.example.com") + ('https://gitlab.example.com', 'mygroup/myproject') + + Auto-detect URL: + >>> validate_project_specification("https://gitlab.example.com/ns/proj") + ('https://gitlab.example.com', 'ns/proj') + + Invalid path: + >>> validate_project_specification("invalid") + ProjectResolutionError: Invalid project path format... + """ + if not project_spec or not isinstance(project_spec, str): + raise ProjectResolutionError( + "Project specification is required but not provided.\n\n" + "SOLUTION:\n" + "Provide a project URL or path:\n" + " • URL format: --project-url https://gitlab.com/namespace/project\n" + " • Path format: --project-path namespace/project\n\n" + "Examples:\n" + " • https://gitlab.com/mycompany/my-project\n" + " • mycompany/my-project\n" + " • group/subgroup/project-name" + ) + + project_spec = project_spec.strip() + + # Auto-detect spec type if needed + if spec_type == "auto": + if project_spec.startswith(("http://", "https://")): + spec_type = "url" + else: + spec_type = "path" + + if spec_type == "url": + # Use existing normalize_gitlab_url function + try: + return normalize_gitlab_url(project_spec) + except ConfigurationError as e: + raise ProjectResolutionError(str(e)) + + elif spec_type == "path": + # Validate and normalize project path + # Strip leading/trailing slashes and whitespace + path = project_spec.strip().strip("/") + + if not path: + raise ProjectResolutionError( + "Project path cannot be empty.\n\n" + "SOLUTION:\n" + "Provide a valid project path in namespace/project format:\n" + " • mycompany/my-project\n" + " • group/subgroup/project-name\n" + " • username/personal-project" + ) + + # Split path into components + path_components = path.split("/") + + # Validate path has at least namespace/project components + if len(path_components) < 2: + raise ProjectResolutionError( + f"Invalid project path format: '{project_spec}'.\n" + "Path must contain at least namespace/project.\n\n" + "SOLUTION:\n" + "Provide a valid project path:\n" + " • namespace/project (minimum required)\n" + " • group/subgroup/project (nested groups)\n\n" + "Examples:\n" + " • mycompany/my-project\n" + " • group/subgroup/project-name\n" + " • username/personal-project" + ) + + # Validate all path components are non-empty + for i, component in enumerate(path_components): + if not component: + raise ProjectResolutionError( + f"Invalid project path: '{project_spec}'.\n" + "Path contains empty component (consecutive slashes).\n\n" + "SOLUTION:\n" + "Remove consecutive slashes from the path:\n" + f" • Invalid: {project_spec}\n" + f" • Valid: {'/'.join(c for c in path_components if c)}" + ) + + # Return the provided GitLab URL for path specs + return gitlab_url, path + + else: + raise ProjectResolutionError( + f"Unknown specification type: '{spec_type}'.\n" + "Expected 'url', 'path', or 'auto'." + ) + + +def validate_configuration( + token: Optional[str] = None, + gitlab_url: str = DEFAULT_GITLAB_URL, + require_git: bool = False, + working_directory: str = ".", +) -> None: + """ + Comprehensive configuration validation for GitLab package uploads. + + Orchestrates validation of all configuration components in sequence: + 1. Dependencies (Python version, required modules) + 2. GitLab token (format and availability) + 3. Git installation (always checked, fails only if require_git=True) + 4. Git repository access (only if require_git=True) + + Args: + token: GitLab authentication token. If None, attempts to retrieve + from environment variable via get_gitlab_token(). + gitlab_url: GitLab instance URL for token validation help messages. + Defaults to "https://gitlab.com". + require_git: Whether Git operations are required. If False, Git + validation failures are logged as warnings but don't raise errors. + working_directory: Working directory for Git repository validation. + Defaults to current directory. + + Raises: + ConfigurationError: If any required validation fails. Includes + detailed error messages with resolution steps. + + Examples: + Basic validation (no Git required): + >>> validate_configuration(token="glpat-xxxx") + # Success - dependencies and token validated + + With Git requirement: + >>> validate_configuration(token="glpat-xxxx", require_git=True) + # Success - all validations passed including Git + + Missing token: + >>> validate_configuration() + ConfigurationError: No GitLab token provided... + + Git required but not installed: + >>> validate_configuration(require_git=True) + ConfigurationError: Git is not installed... + """ + logger.info("Starting configuration validation...") + + # 1. Validate dependencies + try: + validate_dependencies() + logger.info("Dependencies validation passed") + except ConfigurationError: + logger.error("Dependencies validation failed") + raise + + # 2. Validate GitLab token + try: + if token is None: + token = get_gitlab_token(None) + validate_gitlab_token(token, gitlab_url) + logger.info("GitLab token validation passed") + except ConfigurationError: + logger.error("GitLab token validation failed") + raise + + # 3. Validate Git installation (always check since it might be needed) + try: + validate_git_installation() + logger.info("Git installation validation passed") + except ConfigurationError as e: + if require_git: + logger.error("Git installation validation failed") + raise + else: + logger.warning( + "Git installation validation failed (not required for this operation)" + ) + logger.debug(f"Git validation error: {e}") + + # 4. Validate Git repository access (only if Git operations are required) + if require_git: + try: + validate_git_repository(working_directory) + logger.info("Git repository access validation passed") + except ConfigurationError: + logger.error("Git repository access validation failed") + raise + + logger.info("Configuration validation completed successfully") From 609ffed9984d4bcf8a75dfaeb010bedbfeb46650 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Fri, 9 Jan 2026 11:04:41 -0600 Subject: [PATCH 06/36] gitlab-pkg-upload: Add upload orchestration with retry handling The package upload functionality currently lacks robust error handling and duplicate detection. Network interruptions, rate limits, or server errors cause immediate failures, requiring manual intervention and restart. Additionally, re-uploading files without duplicate awareness leads to wasted bandwidth and potential registry inconsistencies. Introduce an upload orchestration module using tenacity for automatic retry with exponential backoff. The retry logic distinguishes between transient errors (timeouts, rate limits, server errors) and permanent failures (authentication, validation) to avoid futile retry attempts. Duplicate detection supports configurable policies (skip, replace, error) to handle existing files gracefully, while checksum validation ensures upload integrity before marking operations complete. Signed-off-by: Javier Tia --- src/gitlab_pkg_upload/uploader.py | 515 ++++++++++++++++++++++++++++++ 1 file changed, 515 insertions(+) create mode 100644 src/gitlab_pkg_upload/uploader.py diff --git a/src/gitlab_pkg_upload/uploader.py b/src/gitlab_pkg_upload/uploader.py new file mode 100644 index 0000000..75ea8e5 --- /dev/null +++ b/src/gitlab_pkg_upload/uploader.py @@ -0,0 +1,515 @@ +"""Upload orchestration for gitlab-pkg-upload using tenacity for retry handling.""" + +from __future__ import annotations + +import logging +import time +from pathlib import Path +from typing import Optional + +from gitlab import Gitlab +from gitlab.exceptions import GitlabError +from tenacity import ( + retry, + retry_if_exception, + stop_after_attempt, + wait_exponential, +) + +from .duplicate_detector import calculate_sha256 +from .models import ( + ChecksumValidationError, + DuplicatePolicy, + NetworkError, + RemoteFile, + UploadContext, + UploadResult, +) + +logger = logging.getLogger(__name__) + + +def is_transient_error(exception: Exception) -> bool: + """ + Determine if an exception represents a transient error that should be retried. + + Transient errors include network issues, timeouts, rate limits, and server errors. + Permanent errors include authentication failures, validation errors, and not found errors. + + Args: + exception: The exception to classify + + Returns: + True if the error is transient and should be retried, False otherwise + """ + # Check for network-related exception types + if isinstance(exception, (ConnectionError, TimeoutError)): + logger.debug(f"Transient error detected (exception type): {type(exception).__name__}") + return True + + error_msg = str(exception).lower() + + # Permanent errors - do not retry + permanent_indicators = [ + "401", + "403", + "unauthorized", + "forbidden", + "400", + "422", + "validation", + "404", + "not found", + ] + for indicator in permanent_indicators: + if indicator in error_msg: + logger.debug(f"Permanent error detected (indicator: {indicator}): {exception}") + return False + + # Transient errors - should retry + transient_indicators = [ + "connection", + "timeout", + "network", + "unreachable", + "408", + "429", + "rate limit", + "500", + "502", + "503", + "504", + "service unavailable", + "bad gateway", + "gateway timeout", + ] + for indicator in transient_indicators: + if indicator in error_msg: + logger.debug(f"Transient error detected (indicator: {indicator}): {exception}") + return True + + # GitLab-specific transient errors + if isinstance(exception, GitlabError): + # Check response code if available + response_code = getattr(exception, "response_code", None) + if response_code: + if response_code >= 500 or response_code in (408, 429): + logger.debug(f"Transient GitLab error detected (response code: {response_code})") + return True + if response_code in (401, 403, 404, 400, 422): + logger.debug(f"Permanent GitLab error detected (response code: {response_code})") + return False + + # Default to not retrying unknown errors + logger.debug(f"Unknown error type, not retrying: {exception}") + return False + + +@retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10), + retry=retry_if_exception(is_transient_error), +) +def upload_single_file(context: UploadContext, file: Path, target_filename: str) -> str: + """ + Upload a single file to the GitLab generic package registry. + + This function handles the actual upload operation with automatic retry + for transient errors using tenacity. + + Args: + context: Upload context containing GitLab client and configuration + file: Path to the local file to upload + target_filename: Target filename in the registry + + Returns: + Download URL for the uploaded file + + Raises: + GitlabError: If upload fails after all retry attempts + NetworkError: If network issues persist after retries + """ + # Handle dry-run mode + if context.config.dry_run: + logger.info(f"[DRY-RUN] Would upload: {file} -> {target_filename}") + mock_url = ( + f"{context.config.gitlab_url}/api/v4/projects/{context.project_id}" + f"/packages/generic/{context.config.package_name}/{context.config.version}/{target_filename}" + ) + return mock_url + + # Perform actual upload + start_time = time.time() + file_size_mb = file.stat().st_size / (1024 * 1024) + + logger.info(f"Uploading {target_filename} ({file_size_mb:.2f} MB)...") + logger.debug(f"Source path: {file}") + + project = context.gl.projects.get(context.project_id) + project.generic_packages.upload( + package_name=context.config.package_name, + package_version=context.config.version, + file_name=target_filename, + path=file.as_posix(), + ) + + elapsed_time = time.time() - start_time + logger.info( + f"Uploaded {target_filename} ({file_size_mb:.2f} MB) in {elapsed_time:.2f}s" + ) + + # Construct download URL + download_url = ( + f"{context.config.gitlab_url}/api/v4/projects/{context.project_id}" + f"/packages/generic/{context.config.package_name}/{context.config.version}/{target_filename}" + ) + + return download_url + + +def validate_upload( + context: UploadContext, filename: str, expected_sha256: str +) -> bool: + """ + Validate that an uploaded file has the correct checksum in the registry. + + Args: + context: Upload context containing GitLab client and configuration + filename: The filename to validate in the registry + expected_sha256: Expected SHA256 checksum of the file + + Returns: + True if validation succeeds + + Raises: + ChecksumValidationError: If checksum mismatch detected + """ + # Handle dry-run mode + if context.config.dry_run: + logger.info(f"[DRY-RUN] Would validate checksum for: {filename}") + return True + + logger.debug(f"Validating upload checksum for {filename}") + logger.debug(f"Expected SHA256: {expected_sha256}") + + project = context.gl.projects.get(context.project_id) + packages = project.packages.list( + package_name=context.config.package_name, get_all=True + ) + + # Find the target package version + target_package = next( + (p for p in packages if p.version == context.config.version), None + ) + + if not target_package: + logger.error( + f"Package {context.config.package_name} v{context.config.version} not found during validation" + ) + return False + + # Get package files + package_obj = project.packages.get(target_package.id) + package_files = package_obj.package_files.list(get_all=True) + + # Find file matching filename (handle exact match and path variations) + matching_file = None + for pkg_file in package_files: + file_name = getattr(pkg_file, "file_name", "") + if file_name == filename or file_name.endswith(f"/{filename}"): + matching_file = pkg_file + break + + if not matching_file: + logger.error(f"File {filename} not found in registry during validation") + return False + + # Extract remote checksum + remote_sha256 = getattr(matching_file, "file_sha256", None) + + if not remote_sha256: + logger.warning(f"Remote checksum not available for {filename}, skipping validation") + return True + + # Handle special case for empty files + empty_file_sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + if expected_sha256.lower() == empty_file_sha256 and remote_sha256.lower() == empty_file_sha256: + logger.debug(f"Empty file checksum validated for {filename}") + return True + + # Compare checksums (case-insensitive) + if remote_sha256.lower() == expected_sha256.lower(): + logger.info(f"Checksum validated for {filename}") + logger.debug(f"Checksum: {expected_sha256}") + return True + + # Checksum mismatch - this is an error + error_msg = ( + f"Checksum mismatch for {filename}: " + f"expected {expected_sha256}, got {remote_sha256}" + ) + logger.error(error_msg) + raise ChecksumValidationError(error_msg) + + +def handle_duplicate( + context: UploadContext, file: Path, remote: RemoteFile +) -> tuple[str, str]: + """ + Handle a detected duplicate file based on the configured policy. + + Args: + context: Upload context containing GitLab client and configuration + file: Path to the local file that is a duplicate + remote: RemoteFile information about the existing file + + Returns: + Tuple of (action_taken, url_or_proceed_flag): + - ("skipped", download_url) - File was skipped, returns existing URL + - ("replaced", "proceed_with_upload") - File was deleted, proceed with upload + - ("error", ...) - Should not reach here, raises ValueError instead + + Raises: + ValueError: If policy is ERROR + """ + logger.info( + f"Duplicate detected for {file.name}: " + f"remote file {remote.filename} (checksum: {remote.sha256_checksum})" + ) + + policy = context.config.duplicate_policy + + if policy == DuplicatePolicy.SKIP: + logger.info(f"Skipping duplicate: {file.name} (policy: SKIP)") + return ("skipped", remote.download_url) + + elif policy == DuplicatePolicy.REPLACE: + logger.info(f"Replacing duplicate: {file.name} (policy: REPLACE)") + delete_file_from_registry(context, remote.filename) + return ("replaced", "proceed_with_upload") + + elif policy == DuplicatePolicy.ERROR: + error_msg = ( + f"Duplicate file detected: {file.name} " + f"(remote: {remote.filename}, checksum: {remote.sha256_checksum}). " + f"Use --duplicate-policy=skip or --duplicate-policy=replace to handle duplicates." + ) + logger.error(error_msg) + raise ValueError(error_msg) + + # Should not reach here + raise ValueError(f"Unknown duplicate policy: {policy}") + + +def delete_file_from_registry(context: UploadContext, filename: str) -> int: + """ + Delete a file from the GitLab package registry. + + Args: + context: Upload context containing GitLab client and configuration + filename: The filename to delete from the registry + + Returns: + Number of files deleted + """ + # Handle dry-run mode + if context.config.dry_run: + logger.info(f"[DRY-RUN] Would delete: {filename} from registry") + return 0 + + logger.debug(f"Deleting {filename} from registry") + + project = context.gl.projects.get(context.project_id) + packages = project.packages.list( + package_name=context.config.package_name, get_all=True + ) + + # Find the target package version + target_package = next( + (p for p in packages if p.version == context.config.version), None + ) + + if not target_package: + logger.warning( + f"Package {context.config.package_name} v{context.config.version} not found, " + f"nothing to delete" + ) + return 0 + + # Get package files + package_obj = project.packages.get(target_package.id) + package_files = package_obj.package_files.list(get_all=True) + + # Find and delete all files matching filename + deleted_count = 0 + for pkg_file in package_files: + file_name = getattr(pkg_file, "file_name", "") + if file_name == filename: + try: + pkg_file.delete() + deleted_count += 1 + logger.info(f"Deleted file from registry: {filename} (ID: {pkg_file.id})") + except Exception as e: + logger.warning(f"Failed to delete file {filename} (ID: {pkg_file.id}): {e}") + + if deleted_count == 0: + logger.warning(f"No files named {filename} found to delete") + + return deleted_count + + +def upload_files( + context: UploadContext, files: list[tuple[Path, str]] +) -> list[UploadResult]: + """ + Upload multiple files to the GitLab generic package registry. + + This is the main orchestration function that handles duplicate detection, + upload, validation, and registration for each file. + + Args: + context: Upload context containing GitLab client and configuration + files: List of (source_path, target_filename) tuples to upload + + Returns: + List of UploadResult objects for each file + """ + results: list[UploadResult] = [] + + for source_path, target_filename in files: + logger.debug(f"Processing file: {source_path} -> {target_filename}") + + try: + # Check for session duplicate + session_duplicate = context.detector.check_session_duplicate( + source_path, target_filename + ) + + if session_duplicate: + logger.info( + f"Session duplicate detected for {target_filename}, skipping" + ) + # Construct URL from session duplicate info + existing_url = ( + f"{context.config.gitlab_url}/api/v4/projects/{context.project_id}" + f"/packages/generic/{context.config.package_name}/{context.config.version}/{target_filename}" + ) + results.append( + UploadResult( + source_path=str(source_path), + target_filename=target_filename, + success=True, + result=existing_url, + was_duplicate=True, + duplicate_action="skipped", + existing_url=existing_url, + ) + ) + continue + + # Calculate checksum for remote duplicate check + checksum = calculate_sha256(source_path) + logger.debug(f"Calculated checksum for {source_path}: {checksum}") + + # Check for remote duplicate + remote_duplicate = context.detector.check_remote_duplicate( + context.config.package_name, + context.config.version, + target_filename, + checksum, + ) + + if remote_duplicate: + try: + action, result_value = handle_duplicate( + context, source_path, remote_duplicate + ) + + if action == "skipped": + results.append( + UploadResult( + source_path=str(source_path), + target_filename=target_filename, + success=True, + result=result_value, + was_duplicate=True, + duplicate_action="skipped", + existing_url=result_value, + ) + ) + continue + # If action is "replaced", proceed with upload below + + except ValueError as e: + # ERROR policy triggered + results.append( + UploadResult( + source_path=str(source_path), + target_filename=target_filename, + success=False, + result=str(e), + was_duplicate=True, + duplicate_action="error", + existing_url=remote_duplicate.download_url, + ) + ) + if context.config.fail_fast: + logger.error(f"Fail-fast enabled, stopping after error: {e}") + return results + continue + else: + # No checksum match found, but a file with the same name may exist + # with a different checksum. Delete it if policy is REPLACE to avoid + # upload conflicts or stale artifacts. + if context.config.duplicate_policy == DuplicatePolicy.REPLACE: + deleted_count = delete_file_from_registry(context, target_filename) + if deleted_count > 0: + logger.info( + f"Deleted {deleted_count} existing file(s) named {target_filename} " + f"with different checksum (policy: REPLACE)" + ) + + # Upload the file + download_url = upload_single_file(context, source_path, target_filename) + + # Validate the upload + validate_upload(context, target_filename, checksum) + + # Register file in session + context.detector.register_file(source_path, target_filename, checksum) + + # Create success result + was_duplicate = remote_duplicate is not None + duplicate_action = "replaced" if was_duplicate else None + + results.append( + UploadResult( + source_path=str(source_path), + target_filename=target_filename, + success=True, + result=download_url, + was_duplicate=was_duplicate, + duplicate_action=duplicate_action, + existing_url=remote_duplicate.download_url if remote_duplicate else None, + ) + ) + + except Exception as e: + logger.error(f"Failed to upload {source_path} -> {target_filename}: {e}") + results.append( + UploadResult( + source_path=str(source_path), + target_filename=target_filename, + success=False, + result=str(e), + was_duplicate=False, + duplicate_action=None, + existing_url=None, + ) + ) + + if context.config.fail_fast: + logger.error(f"Fail-fast enabled, stopping after error: {e}") + return results + + return results From f887c9a53d5a59514d4a4a6fedf75fe97733245c Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Fri, 9 Jan 2026 15:28:43 -0600 Subject: [PATCH 07/36] gitlab_pkg_upload: Add output formatting module The CLI currently lacks proper handling of different output environments. When output is piped to a file or consumed by another process, rich terminal formatting with ANSI escape codes and Unicode characters corrupts the output, making it unusable for automation pipelines. Introduce a dedicated formatting module that detects terminal capabilities at runtime and adapts output accordingly. The module supports three output modes: rich console output with colors and Unicode for interactive terminals, structured JSON for machine consumption, and plain ASCII text for non-TTY environments. This separation ensures the tool integrates cleanly into CI/CD pipelines while maintaining a good user experience in interactive sessions. Signed-off-by: Javier Tia --- src/gitlab_pkg_upload/formatters.py | 599 ++++++++++++++++++++++++++++ 1 file changed, 599 insertions(+) create mode 100644 src/gitlab_pkg_upload/formatters.py diff --git a/src/gitlab_pkg_upload/formatters.py b/src/gitlab_pkg_upload/formatters.py new file mode 100644 index 0000000..ae4b210 --- /dev/null +++ b/src/gitlab_pkg_upload/formatters.py @@ -0,0 +1,599 @@ +"""Output formatting module for gitlab-pkg-upload. + +Provides terminal capability detection and multiple output formats +(rich console, JSON, plain text) for upload results and error messages. +""" + +from __future__ import annotations + +import json +import logging +import os +import sys +from typing import Any, Dict, List, Optional + +from rich.console import Console +from rich.status import Status +from rich.table import Table + +from .models import GitLabUploadError, UploadConfig, UploadResult, enhance_error_message + + +# Terminal Detection Functions + + +def detect_tty() -> bool: + """Detect if stdout is connected to a TTY terminal. + + Returns: + True if stdout is connected to a TTY terminal, False otherwise. + + Examples: + >>> detect_tty() # In a terminal + True + >>> detect_tty() # When piped to a file + False + """ + try: + if sys.stdout is None: + return False + if not hasattr(sys.stdout, "isatty"): + return False + return sys.stdout.isatty() + except Exception: + return False + + +def detect_color_support() -> bool: + """Detect if terminal supports color output based on environment variables and platform. + + Checks environment variables in order of precedence: + - NO_COLOR: if set (any value), returns False + - FORCE_COLOR: if set (any value), returns True + - COLORTERM: if set, returns True + - TERM: if contains "color" or equals "xterm-256color", returns True + + On Windows, also checks for ANSICON and WT_SESSION environment variables. + + Returns: + True if terminal supports color output, False otherwise. + + Examples: + >>> os.environ['FORCE_COLOR'] = '1' + >>> detect_color_support() + True + >>> os.environ['NO_COLOR'] = '1' + >>> detect_color_support() + False + """ + if not detect_tty(): + return False + + # NO_COLOR takes highest precedence + if os.environ.get("NO_COLOR") is not None: + return False + + # FORCE_COLOR overrides other checks + if os.environ.get("FORCE_COLOR") is not None: + return True + + # COLORTERM indicates color support + if os.environ.get("COLORTERM"): + return True + + # Check TERM variable + term = os.environ.get("TERM", "") + if "color" in term.lower() or term == "xterm-256color": + return True + + # Windows-specific checks + if sys.platform == "win32": + # Windows Terminal + if os.environ.get("WT_SESSION"): + return True + # ANSICON + if os.environ.get("ANSICON"): + return True + # ConEmu + if os.environ.get("ConEmuANSI") == "ON": + return True + + return False + + +def detect_unicode_support() -> bool: + """Detect if terminal supports Unicode characters. + + Checks: + - stdout encoding contains "utf" (case-insensitive) + - LANG or LC_ALL environment variables for UTF-8 indicators + - On Windows, checks if console encoding is UTF-8 + + Returns: + True if terminal supports Unicode, False otherwise. + + Examples: + >>> detect_unicode_support() # In a UTF-8 terminal + True + >>> detect_unicode_support() # In an ASCII-only terminal + False + """ + if not detect_tty(): + return False + + # Check stdout encoding + try: + encoding = getattr(sys.stdout, "encoding", None) + if encoding and "utf" in encoding.lower(): + return True + except Exception: + pass + + # Check locale environment variables + lang = os.environ.get("LANG", "") or os.environ.get("LC_ALL", "") + if "utf" in lang.lower() or "UTF" in lang: + return True + + return False + + +# OutputFormatter Class + + +class OutputFormatter: + """Formats and outputs upload results based on configuration. + + Encapsulates formatting logic with methods for different output modes, + respecting the --plain flag override and terminal capabilities. + + Attributes: + config: Upload configuration with output preferences. + is_tty: Whether stdout is connected to a TTY. + supports_color: Whether terminal supports color output. + supports_unicode: Whether terminal supports Unicode characters. + console: Rich Console instance for formatted output. + """ + + _logger = logging.getLogger(__name__) + + def __init__(self, config: UploadConfig) -> None: + """Initialize OutputFormatter with configuration. + + Args: + config: Upload configuration containing output preferences + (json_output, plain_output flags). + + Examples: + >>> config = UploadConfig(plain_output=True, ...) + >>> formatter = OutputFormatter(config) + >>> formatter.is_tty + False + """ + self.config = config + + # Determine terminal capabilities + if config.plain_output: + # Plain output mode forces all capabilities to False + self.is_tty = False + self.supports_color = False + self.supports_unicode = False + else: + self.is_tty = detect_tty() + self.supports_color = detect_color_support() + self.supports_unicode = detect_unicode_support() + + # Initialize Rich Console with appropriate settings + self.console = Console( + force_terminal=self.is_tty and not config.plain_output, + no_color=not self.supports_color or config.plain_output, + legacy_windows=False, + ) + + def format_output( + self, results: List[UploadResult], package_name: str, version: str + ) -> None: + """Format and output upload results based on configuration. + + Determines the appropriate output format based on config.json_output + and config.plain_output flags, then delegates to the appropriate + formatting method. + + Args: + results: List of upload results to format. + package_name: Name of the package being uploaded. + version: Version of the package. + + Examples: + >>> formatter.format_output(results, "my-package", "1.0.0") + """ + if self.config.json_output: + self._format_json_output(results, package_name, version) + elif self.config.plain_output or not self.is_tty: + self._format_plain_output(results, package_name, version) + else: + self._format_rich_output(results, package_name, version) + + def _format_rich_output( + self, results: List[UploadResult], package_name: str, version: str + ) -> None: + """Format output using rich console with colors and formatting. + + Args: + results: List of upload results to format. + package_name: Name of the package being uploaded. + version: Version of the package. + """ + # Categorize results into three lists + successful_uploads: List[UploadResult] = [] + skipped_duplicates: List[UploadResult] = [] + failed_uploads: List[UploadResult] = [] + + for result in results: + if result.success and result.duplicate_action != "skipped": + successful_uploads.append(result) + elif result.success and result.duplicate_action == "skipped": + skipped_duplicates.append(result) + else: + failed_uploads.append(result) + + # Display Upload Summary Header + self.console.print("\n[bold]Upload Summary[/bold]\n") + + # Display Successful Uploads Section + if successful_uploads: + self.console.print("[bold green]✓ Successful Uploads[/bold green]\n") + for result in successful_uploads: + self.console.print(f"[cyan]Source File:[/cyan] {result.source_path}") + self.console.print(f"[cyan]Target Filename:[/cyan] {result.target_filename}") + self.console.print(f"[cyan]Download URL:[/cyan] [blue]{result.result}[/blue]") + if result.was_duplicate and result.duplicate_action == "replaced": + self.console.print("[cyan]Action:[/cyan] [yellow]Replaced existing duplicate[/yellow]") + if result.existing_url is not None: + self.console.print(f"[cyan]Previous URL:[/cyan] [dim]{result.existing_url}[/dim]") + self.console.print() + + # Display Skipped Duplicates Section + if skipped_duplicates: + self.console.print("[bold yellow]⚠ Skipped Duplicates[/bold yellow]\n") + for result in skipped_duplicates: + self.console.print(f"[cyan]Source File:[/cyan] {result.source_path}") + self.console.print(f"[cyan]Target Filename:[/cyan] {result.target_filename}") + self.console.print(f"[cyan]Existing URL:[/cyan] [blue]{result.existing_url or result.result}[/blue]") + self.console.print(f"[cyan]Reason:[/cyan] {result.result}") + self.console.print() + + # Display Failed Uploads Section + if failed_uploads: + self.console.print("[bold red]✗ Failed Uploads[/bold red]\n") + for result in failed_uploads: + self.console.print(f"[cyan]Source File:[/cyan] {result.source_path}") + self.console.print(f"[cyan]Target Filename:[/cyan] {result.target_filename}") + self.console.print(f"[cyan]Error:[/cyan] [red]{result.result}[/red]") + if result.was_duplicate: + self.console.print(f"[cyan]Duplicate Action:[/cyan] {result.duplicate_action}") + if result.existing_url is not None: + self.console.print(f"[cyan]Existing URL:[/cyan] [blue]{result.existing_url}[/blue]") + self.console.print() + + # Calculate and Display Statistics + total_processed = len(successful_uploads) + len(skipped_duplicates) + len(failed_uploads) + replaced_count = sum(1 for r in successful_uploads if r.was_duplicate and r.duplicate_action == "replaced") + new_uploads_count = len(successful_uploads) - replaced_count + + self.console.print("\n[bold]Duplicate Detection Statistics:[/bold]") + self.console.print(f"• New uploads: {new_uploads_count}") + self.console.print(f"• Replaced duplicates: {replaced_count}") + self.console.print(f"• Skipped duplicates: {len(skipped_duplicates)}") + self.console.print(f"• Failed uploads: {len(failed_uploads)}") + self.console.print(f"• Total processed: {total_processed}") + + # Display Final Results Summary + self.console.print(f"\n[bold]Final Results:[/bold] {len(successful_uploads)} uploaded ({new_uploads_count} new, {replaced_count} replaced), {len(skipped_duplicates)} skipped duplicates, {len(failed_uploads)} failed out of {total_processed} total") + + if not failed_uploads: + self.console.print(f"\n[bold green]✓[/bold green] All files processed successfully for {package_name} v{version}: {new_uploads_count} new uploads, {replaced_count} replaced duplicates, {len(skipped_duplicates)} skipped duplicates") + + def _format_json_output( + self, results: List[UploadResult], package_name: str, version: str + ) -> None: + """Format output as JSON structure. + + JSON output is printed to stdout for machine parsing. + Any logging goes to stderr via self._logger to maintain separation. + + Args: + results: List of upload results to format. + package_name: Name of the package being uploaded. + version: Version of the package. + """ + # Categorize results into three lists + successful_uploads: List[UploadResult] = [] + skipped_duplicates: List[UploadResult] = [] + failed_uploads: List[UploadResult] = [] + + for result in results: + if result.success and result.duplicate_action != "skipped": + successful_uploads.append(result) + elif result.success and result.duplicate_action == "skipped": + skipped_duplicates.append(result) + else: + failed_uploads.append(result) + + # Build upload result objects for each category + successful_uploads_data = [] + for result in successful_uploads: + successful_uploads_data.append({ + "source_path": result.source_path, + "target_filename": result.target_filename, + "download_url": result.result, + "checksum": None, # Reserved for future use + "was_duplicate": result.was_duplicate, + "duplicate_action": result.duplicate_action, + "existing_url": result.existing_url, + "error_message": None, + }) + + skipped_duplicates_data = [] + for result in skipped_duplicates: + skipped_duplicates_data.append({ + "source_path": result.source_path, + "target_filename": result.target_filename, + "download_url": result.existing_url or result.result, + "checksum": None, + "was_duplicate": result.was_duplicate, + "duplicate_action": result.duplicate_action, + "existing_url": result.existing_url, + "error_message": None, + }) + + failed_uploads_data = [] + for result in failed_uploads: + failed_uploads_data.append({ + "source_path": result.source_path, + "target_filename": result.target_filename, + "download_url": None, + "checksum": None, + "was_duplicate": result.was_duplicate, + "duplicate_action": result.duplicate_action, + "existing_url": result.existing_url, + "error_message": result.result, + }) + + # Calculate statistics + total_processed = len(successful_uploads) + len(skipped_duplicates) + len(failed_uploads) + replaced_count = sum( + 1 for r in successful_uploads + if r.was_duplicate and r.duplicate_action == "replaced" + ) + new_uploads_count = len(successful_uploads) - replaced_count + + statistics = { + "total_processed": total_processed, + "new_uploads": new_uploads_count, + "replaced_duplicates": replaced_count, + "skipped_duplicates": len(skipped_duplicates), + "failed_uploads": len(failed_uploads), + } + + # Build JSON structure + output_data: Dict[str, Any] = { + "success": len(failed_uploads) == 0, + "exit_code": 0 if len(failed_uploads) == 0 else 1, + "package_name": package_name, + "version": version, + "successful_uploads": successful_uploads_data, + "skipped_duplicates": skipped_duplicates_data, + "failed_uploads": failed_uploads_data, + "statistics": statistics, + } + + # Add top-level error fields when failures occur + if failed_uploads: + failed_count = len(failed_uploads) + if failed_count == 1: + output_data["error"] = failed_uploads[0].result + else: + output_data["error"] = f"{failed_count} file(s) failed to upload" + output_data["error_type"] = "UploadError" + + # Output JSON to stdout (not self.console.print to avoid rich formatting) + print(json.dumps(output_data, indent=2)) + + def _format_plain_output( + self, results: List[UploadResult], package_name: str, version: str + ) -> None: + """Format output as plain text without colors or special characters. + + Plain text output is printed to stdout using ASCII-only characters. + No color codes or ANSI escape sequences are used. + + Args: + results: List of upload results to format. + package_name: Name of the package being uploaded. + version: Version of the package. + """ + # Categorize results into three lists + successful_uploads: List[UploadResult] = [] + skipped_duplicates: List[UploadResult] = [] + failed_uploads: List[UploadResult] = [] + + for result in results: + if result.success and result.duplicate_action != "skipped": + successful_uploads.append(result) + elif result.success and result.duplicate_action == "skipped": + skipped_duplicates.append(result) + else: + failed_uploads.append(result) + + # Display Upload Summary Header + print("\nUpload Summary\n") + + # Display Successful Uploads Section + if successful_uploads: + print("[OK] Successful Uploads\n") + for result in successful_uploads: + print(f"Source File: {result.source_path}") + print(f"Target Filename: {result.target_filename}") + print(f"Download URL: {result.result}") + if result.was_duplicate and result.duplicate_action == "replaced": + print("Action: Replaced existing duplicate") + if result.existing_url is not None: + print(f"Previous URL: {result.existing_url}") + print() + + # Display Skipped Duplicates Section + if skipped_duplicates: + print("[SKIP] Skipped Duplicates\n") + for result in skipped_duplicates: + print(f"Source File: {result.source_path}") + print(f"Target Filename: {result.target_filename}") + print(f"Existing URL: {result.existing_url or result.result}") + print(f"Reason: {result.result}") + print() + + # Display Failed Uploads Section + if failed_uploads: + print("[FAIL] Failed Uploads\n") + for result in failed_uploads: + print(f"Source File: {result.source_path}") + print(f"Target Filename: {result.target_filename}") + print(f"Error: {result.result}") + if result.was_duplicate: + print(f"Duplicate Action: {result.duplicate_action}") + if result.existing_url is not None: + print(f"Existing URL: {result.existing_url}") + print() + + # Calculate statistics + total_processed = len(successful_uploads) + len(skipped_duplicates) + len(failed_uploads) + replaced_count = sum( + 1 for r in successful_uploads + if r.was_duplicate and r.duplicate_action == "replaced" + ) + new_uploads_count = len(successful_uploads) - replaced_count + + # Display Statistics + print("Duplicate Detection Statistics:") + print(f"* New uploads: {new_uploads_count}") + print(f"* Replaced duplicates: {replaced_count}") + print(f"* Skipped duplicates: {len(skipped_duplicates)}") + print(f"* Failed uploads: {len(failed_uploads)}") + print(f"* Total processed: {total_processed}") + + # Display Final Results + print(f"\nFinal Results: {len(successful_uploads)} uploaded ({new_uploads_count} new, {replaced_count} replaced), {len(skipped_duplicates)} skipped duplicates, {len(failed_uploads)} failed out of {total_processed} total") + + if not failed_uploads: + print(f"\n[OK] All files processed successfully for {package_name} v{version}: {new_uploads_count} new uploads, {replaced_count} replaced duplicates, {len(skipped_duplicates)} skipped duplicates") + + def create_progress_spinner(self, message: str) -> Status: + """Create a progress spinner for long-running operations. + + Args: + message: Message to display alongside the spinner. + + Returns: + A Rich Status object that can be used as a context manager. + + Examples: + >>> with formatter.create_progress_spinner("Uploading...") as status: + ... upload_file() + """ + if self.config.plain_output or not self.is_tty: + # Return a Status that does nothing for plain output + return Status(message, console=self.console, spinner="dots") + return Status(message, console=self.console, spinner="dots") + + +# Error Formatting Function + + +def format_error( + error: Exception, context: Optional[Dict[str, Any]] = None +) -> str: + """Format error messages with context for better debugging. + + Uses enhanced error messages from models.py when context is available. + + Args: + error: The exception to format. + context: Optional context dictionary with keys like: + - 'operation': Operation that failed + - 'project_path': Project path being accessed + - 'gitlab_url': GitLab instance URL + + Returns: + Formatted error string with error type, message, and context. + + Raises: + None - this function handles all errors gracefully. + + Examples: + >>> error = ValueError("Invalid input") + >>> format_error(error) + 'ERROR: ValueError\\nInvalid input' + + >>> context = {'operation': 'upload', 'project_path': 'group/project'} + >>> format_error(error, context) + 'ERROR: ValueError\\nInvalid input\\nOperation: upload\\nProject: group/project' + """ + error_type = type(error).__name__ + + # Build error message header + lines = [f"ERROR: {error_type}"] + + # Get exit code if it's a GitLabUploadError + if isinstance(error, GitLabUploadError): + lines.append(f"Exit code: {error.exit_code}") + + # Use enhanced error message if context is provided + if context: + enhanced_message = enhance_error_message(error, context) + lines.append(enhanced_message) + else: + lines.append(str(error)) + + return "\n".join(lines) + + +# Helper Functions + + +def get_formatter(config: UploadConfig) -> OutputFormatter: + """Factory function to create an OutputFormatter instance. + + Args: + config: Upload configuration containing output preferences. + + Returns: + Configured OutputFormatter instance. + + Examples: + >>> config = UploadConfig(...) + >>> formatter = get_formatter(config) + >>> formatter.format_output(results, "pkg", "1.0.0") + """ + return OutputFormatter(config) + + +def display_progress(formatter: OutputFormatter, message: str) -> Status: + """Convenience function that wraps formatter.create_progress_spinner(). + + Provides a standalone function interface for progress display, + maintaining backward compatibility if other modules expect a function + rather than a method. + + Args: + formatter: OutputFormatter instance to use for progress display. + message: Message to display alongside the spinner. + + Returns: + A Rich Status object that can be used as a context manager. + + Examples: + >>> formatter = get_formatter(config) + >>> with display_progress(formatter, "Uploading...") as status: + ... upload_file() + ... status.update("Processing...") + """ + return formatter.create_progress_spinner(message) From 27f16fc959d670e8f5c6e34659d3fc47b58b5f11 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Fri, 9 Jan 2026 16:00:10 -0600 Subject: [PATCH 08/36] tests: Add comprehensive unit tests for formatters module The formatters module lacked test coverage, making it difficult to refactor output formatting logic or verify behavior across different terminal environments. Without tests, changes to terminal detection, color support, or output rendering could silently break user-facing output. Introduce a complete test suite covering terminal detection, rich console output, JSON output, plain text output, error formatting, and progress display. Mock Rich console and spinner dependencies to ensure tests run reliably without real terminal rendering, enabling consistent CI execution regardless of environment. Signed-off-by: Javier Tia --- pyproject.toml | 1 + tests/unit/__init__.py | 1 + tests/unit/test_formatters.py | 1612 +++++++++++++++++++++++++++++++++ 3 files changed, 1614 insertions(+) create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_formatters.py diff --git a/pyproject.toml b/pyproject.toml index 9dde100..ecfba14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,4 +78,5 @@ markers = [ "integration: Integration tests requiring GitLab API access", "fast: Fast-running tests", "slow: Slow-running tests", + "timeout: Timeout for test execution (provided by pytest-timeout)", ] diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..79a0f89 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +# Unit tests for gitlab-pkg-upload diff --git a/tests/unit/test_formatters.py b/tests/unit/test_formatters.py new file mode 100644 index 0000000..0b4fa77 --- /dev/null +++ b/tests/unit/test_formatters.py @@ -0,0 +1,1612 @@ +""" +Comprehensive unit tests for the formatters module. + +These tests validate the output formatting functionality including terminal +detection, rich console output, JSON output, plain text output, error +formatting, and progress display. + +All Rich console/spinner dependencies are mocked to prevent real terminal +rendering and ensure all output is captured via StringIO/mocks. +""" + +from __future__ import annotations + +import io +import json +import os +import re +import sys +from contextlib import contextmanager +from typing import Any, Dict, Generator, List, Optional +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from src.gitlab_pkg_upload.formatters import ( + OutputFormatter, + detect_color_support, + detect_tty, + detect_unicode_support, + display_progress, + format_error, + get_formatter, +) +from src.gitlab_pkg_upload.models import ( + DuplicatePolicy, + GitLabUploadError, + UploadConfig, + UploadResult, +) +from tests.utils.test_helpers import validate_json_result + +# Mark these as fast unit tests +pytestmark = [pytest.mark.fast, pytest.mark.unit] + + +# ============================================================================= +# Mock Console and Status Classes +# ============================================================================= + + +class MockConsole: + """Mock Console that captures output to a StringIO buffer without terminal rendering.""" + + def __init__(self, *args, **kwargs): + self._buffer = io.StringIO() + self._force_terminal = kwargs.get("force_terminal", False) + self._file = kwargs.get("file", self._buffer) + + def print(self, *args, **kwargs): + """Capture print output to buffer.""" + text = " ".join(str(arg) for arg in args) + self._file.write(text + "\n") + + def rule(self, title="", **kwargs): + """Capture rule output.""" + self._file.write(f"--- {title} ---\n") + + def status(self, message, **kwargs): + """Return a mock status context manager.""" + return MockStatus(message, console=self) + + def getvalue(self): + """Get captured output.""" + if hasattr(self._file, "getvalue"): + return self._file.getvalue() + return self._buffer.getvalue() + + +class MockStatus: + """Mock Status that doesn't perform real terminal rendering.""" + + def __init__(self, message="", console=None, **kwargs): + self._message = message + self._console = console + self._started = False + + def __enter__(self): + self._started = True + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self._started = False + return False + + def start(self): + self._started = True + + def stop(self): + self._started = False + + def update(self, message): + self._message = message + + +@pytest.fixture +def mock_rich_console(): + """Fixture that patches rich.console.Console with MockConsole.""" + with patch("rich.console.Console", MockConsole): + with patch("src.gitlab_pkg_upload.formatters.Console", MockConsole): + yield MockConsole + + +@pytest.fixture +def mock_rich_status(): + """Fixture that patches rich.status.Status with MockStatus.""" + with patch("rich.status.Status", MockStatus): + with patch("src.gitlab_pkg_upload.formatters.Status", MockStatus): + yield MockStatus + + +# ============================================================================= +# Helper Functions and Fixtures +# ============================================================================= + + +def create_upload_config( + package_name: str = "test-package", + version: str = "1.0.0", + duplicate_policy: DuplicatePolicy = DuplicatePolicy.SKIP, + retry_count: int = 3, + verbosity: str = "normal", + dry_run: bool = False, + fail_fast: bool = False, + json_output: bool = False, + plain_output: bool = False, + gitlab_url: str = "https://gitlab.com", + token: Optional[str] = "test-token", +) -> UploadConfig: + """Factory function to create UploadConfig with customizable parameters.""" + return UploadConfig( + package_name=package_name, + version=version, + duplicate_policy=duplicate_policy, + retry_count=retry_count, + verbosity=verbosity, + dry_run=dry_run, + fail_fast=fail_fast, + json_output=json_output, + plain_output=plain_output, + gitlab_url=gitlab_url, + token=token, + ) + + +def create_upload_result( + source_path: str = "/path/to/file.txt", + target_filename: str = "file.txt", + success: bool = True, + result: str = "https://gitlab.com/api/v4/projects/1/packages/generic/test/1.0.0/file.txt", + was_duplicate: bool = False, + duplicate_action: Optional[str] = None, + existing_url: Optional[str] = None, +) -> UploadResult: + """Factory function to create UploadResult with customizable parameters.""" + return UploadResult( + source_path=source_path, + target_filename=target_filename, + success=success, + result=result, + was_duplicate=was_duplicate, + duplicate_action=duplicate_action, + existing_url=existing_url, + ) + + +def assert_no_ansi_codes(text: str) -> None: + """Helper to verify string contains no ANSI escape sequences.""" + # ANSI escape code pattern + ansi_pattern = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]") + matches = ansi_pattern.findall(text) + assert not matches, f"Found ANSI escape codes in text: {matches}" + + +def assert_valid_json(text: str) -> Dict[str, Any]: + """Helper to parse and validate JSON structure.""" + try: + return json.loads(text) + except json.JSONDecodeError as e: + pytest.fail(f"Invalid JSON: {e}\nText: {text}") + + +@contextmanager +def capture_stdout() -> Generator[io.StringIO, None, None]: + """Context manager to capture stdout.""" + captured = io.StringIO() + old_stdout = sys.stdout + sys.stdout = captured + try: + yield captured + finally: + sys.stdout = old_stdout + + +@contextmanager +def capture_stderr() -> Generator[io.StringIO, None, None]: + """Context manager to capture stderr.""" + captured = io.StringIO() + old_stderr = sys.stderr + sys.stderr = captured + try: + yield captured + finally: + sys.stderr = old_stderr + + +@pytest.fixture +def mock_upload_config() -> UploadConfig: + """Fixture that returns a basic UploadConfig instance with default values.""" + return create_upload_config() + + +@pytest.fixture +def mock_upload_results() -> List[UploadResult]: + """Fixture that returns a list of mock UploadResult objects with various scenarios.""" + return [ + # Successful new upload + create_upload_result( + source_path="/path/to/file1.txt", + target_filename="file1.txt", + success=True, + result="https://gitlab.com/api/v4/projects/1/packages/generic/test/1.0.0/file1.txt", + ), + # Successful replaced duplicate + create_upload_result( + source_path="/path/to/file2.txt", + target_filename="file2.txt", + success=True, + result="https://gitlab.com/api/v4/projects/1/packages/generic/test/1.0.0/file2.txt", + was_duplicate=True, + duplicate_action="replaced", + existing_url="https://gitlab.com/old/file2.txt", + ), + # Skipped duplicate + create_upload_result( + source_path="/path/to/file3.txt", + target_filename="file3.txt", + success=True, + result="Skipped: duplicate detected", + was_duplicate=True, + duplicate_action="skipped", + existing_url="https://gitlab.com/existing/file3.txt", + ), + # Failed upload + create_upload_result( + source_path="/path/to/file4.txt", + target_filename="file4.txt", + success=False, + result="Upload failed: network error", + ), + ] + + +@pytest.fixture +def clean_env(): + """Fixture that provides a clean environment for terminal detection tests.""" + # Save original environment + original_env = os.environ.copy() + + # Remove terminal-related variables + vars_to_remove = [ + "NO_COLOR", "FORCE_COLOR", "COLORTERM", "TERM", + "WT_SESSION", "ANSICON", "ConEmuANSI", + "LANG", "LC_ALL", + ] + for var in vars_to_remove: + os.environ.pop(var, None) + + yield + + # Restore original environment + os.environ.clear() + os.environ.update(original_env) + + +# ============================================================================= +# Test Classes +# ============================================================================= + + +class TestTerminalDetection: + """Tests for terminal detection functions.""" + + @pytest.mark.timeout(60) + def test_detect_tty_when_stdout_is_tty(self): + """Test detect_tty returns True when stdout.isatty() returns True.""" + mock_stdout = Mock() + mock_stdout.isatty.return_value = True + + with patch.object(sys, "stdout", mock_stdout): + assert detect_tty() is True + + @pytest.mark.timeout(60) + def test_detect_tty_when_stdout_is_not_tty(self): + """Test detect_tty returns False when stdout.isatty() returns False.""" + mock_stdout = Mock() + mock_stdout.isatty.return_value = False + + with patch.object(sys, "stdout", mock_stdout): + assert detect_tty() is False + + @pytest.mark.timeout(60) + def test_detect_tty_when_stdout_is_none(self): + """Test detect_tty returns False when stdout is None.""" + with patch.object(sys, "stdout", None): + assert detect_tty() is False + + @pytest.mark.timeout(60) + def test_detect_tty_when_isatty_missing(self): + """Test detect_tty returns False when stdout lacks isatty attribute.""" + mock_stdout = object() # Object without isatty + + with patch.object(sys, "stdout", mock_stdout): + assert detect_tty() is False + + @pytest.mark.timeout(60) + def test_detect_tty_when_exception_raised(self): + """Test detect_tty returns False when isatty() raises exception.""" + mock_stdout = Mock() + mock_stdout.isatty.side_effect = OSError("Permission denied") + + with patch.object(sys, "stdout", mock_stdout): + assert detect_tty() is False + + @pytest.mark.timeout(60) + def test_detect_color_support_with_no_color_env(self, clean_env): + """Test detect_color_support returns False when NO_COLOR is set.""" + os.environ["NO_COLOR"] = "1" + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + assert detect_color_support() is False + + @pytest.mark.timeout(60) + def test_detect_color_support_with_force_color_env(self, clean_env): + """Test detect_color_support returns True when FORCE_COLOR is set.""" + os.environ["FORCE_COLOR"] = "1" + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + assert detect_color_support() is True + + @pytest.mark.timeout(60) + def test_detect_color_support_with_colorterm_env(self, clean_env): + """Test detect_color_support returns True when COLORTERM is set.""" + os.environ["COLORTERM"] = "truecolor" + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + assert detect_color_support() is True + + @pytest.mark.timeout(60) + def test_detect_color_support_with_term_color(self, clean_env): + """Test detect_color_support returns True when TERM contains color.""" + os.environ["TERM"] = "xterm-256color" + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + assert detect_color_support() is True + + @pytest.mark.timeout(60) + def test_detect_color_support_without_tty(self, clean_env): + """Test detect_color_support returns False when not in a TTY.""" + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=False): + assert detect_color_support() is False + + @pytest.mark.timeout(60) + def test_detect_color_support_windows_wt_session(self, clean_env): + """Test detect_color_support returns True on Windows with WT_SESSION.""" + os.environ["WT_SESSION"] = "some-session-id" + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch.object(sys, "platform", "win32"): + assert detect_color_support() is True + + @pytest.mark.timeout(60) + def test_detect_color_support_windows_ansicon(self, clean_env): + """Test detect_color_support returns True on Windows with ANSICON.""" + os.environ["ANSICON"] = "1" + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch.object(sys, "platform", "win32"): + assert detect_color_support() is True + + @pytest.mark.timeout(60) + def test_detect_color_support_precedence(self, clean_env): + """Test that NO_COLOR takes precedence over FORCE_COLOR.""" + os.environ["NO_COLOR"] = "1" + os.environ["FORCE_COLOR"] = "1" + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + assert detect_color_support() is False + + @pytest.mark.timeout(60) + def test_detect_unicode_support_with_utf8_encoding(self, clean_env): + """Test detect_unicode_support returns True with UTF-8 encoding.""" + mock_stdout = Mock() + mock_stdout.encoding = "utf-8" + mock_stdout.isatty.return_value = True + + with patch.object(sys, "stdout", mock_stdout): + assert detect_unicode_support() is True + + @pytest.mark.timeout(60) + def test_detect_unicode_support_with_lang_utf8(self, clean_env): + """Test detect_unicode_support returns True with UTF-8 LANG.""" + os.environ["LANG"] = "en_US.UTF-8" + mock_stdout = Mock() + mock_stdout.encoding = "ascii" # Non-UTF8 + mock_stdout.isatty.return_value = True + + with patch.object(sys, "stdout", mock_stdout): + assert detect_unicode_support() is True + + @pytest.mark.timeout(60) + def test_detect_unicode_support_with_lc_all_utf8(self, clean_env): + """Test detect_unicode_support returns True with UTF-8 LC_ALL.""" + os.environ["LC_ALL"] = "en_US.UTF-8" + mock_stdout = Mock() + mock_stdout.encoding = "ascii" # Non-UTF8 + mock_stdout.isatty.return_value = True + + with patch.object(sys, "stdout", mock_stdout): + assert detect_unicode_support() is True + + @pytest.mark.timeout(60) + def test_detect_unicode_support_without_tty(self, clean_env): + """Test detect_unicode_support returns False when not in a TTY.""" + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=False): + assert detect_unicode_support() is False + + @pytest.mark.timeout(60) + def test_detect_unicode_support_with_ascii_encoding(self, clean_env): + """Test detect_unicode_support returns False with ASCII encoding.""" + mock_stdout = Mock() + mock_stdout.encoding = "ascii" + mock_stdout.isatty.return_value = True + + with patch.object(sys, "stdout", mock_stdout): + assert detect_unicode_support() is False + + +class TestOutputFormatterInit: + """Tests for OutputFormatter initialization.""" + + @pytest.mark.timeout(60) + def test_init_with_plain_output_flag(self, mock_rich_console): + """Test OutputFormatter with plain_output=True disables all capabilities.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + assert formatter.is_tty is False + assert formatter.supports_color is False + assert formatter.supports_unicode is False + + @pytest.mark.timeout(60) + def test_init_without_plain_output_flag(self, mock_rich_console): + """Test OutputFormatter detects terminal capabilities when plain_output=False.""" + config = create_upload_config(plain_output=False) + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("src.gitlab_pkg_upload.formatters.detect_color_support", return_value=True): + with patch("src.gitlab_pkg_upload.formatters.detect_unicode_support", return_value=True): + formatter = OutputFormatter(config) + + assert formatter.is_tty is True + assert formatter.supports_color is True + assert formatter.supports_unicode is True + + @pytest.mark.timeout(60) + def test_init_console_configuration(self, mock_rich_console): + """Test Console is initialized with correct parameters.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + # Check that console was created (using MockConsole) + assert formatter.console is not None + assert isinstance(formatter.console, MockConsole) + + @pytest.mark.timeout(60) + def test_init_with_json_output_flag(self, mock_rich_console): + """Test OutputFormatter initializes correctly with json_output=True.""" + config = create_upload_config(json_output=True) + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + assert formatter.config.json_output is True + + @pytest.mark.timeout(60) + def test_init_stores_config(self, mock_rich_console): + """Test that OutputFormatter stores the config reference.""" + config = create_upload_config() + formatter = OutputFormatter(config) + + assert formatter.config is config + + +class TestRichOutputFormatting: + """Tests for rich console output formatting.""" + + @pytest.mark.timeout(60) + def test_format_rich_output_successful_uploads(self, mock_rich_console): + """Test rich output displays successful uploads correctly.""" + config = create_upload_config(plain_output=False) + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("src.gitlab_pkg_upload.formatters.detect_color_support", return_value=True): + with patch("src.gitlab_pkg_upload.formatters.detect_unicode_support", return_value=True): + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="https://gitlab.com/download/file.txt", + ) + ] + + # Capture the console output using MockConsole + captured = io.StringIO() + formatter.console = MockConsole(file=captured, force_terminal=True) + + formatter._format_rich_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "Successful Uploads" in output + assert "file.txt" in output + + @pytest.mark.timeout(60) + def test_format_rich_output_skipped_duplicates(self, mock_rich_console): + """Test rich output displays skipped duplicates correctly.""" + config = create_upload_config(plain_output=False) + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="Skipped: duplicate", + was_duplicate=True, + duplicate_action="skipped", + existing_url="https://gitlab.com/existing/file.txt", + ) + ] + + captured = io.StringIO() + formatter.console = MockConsole(file=captured, force_terminal=True) + + formatter._format_rich_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "Skipped Duplicates" in output + + @pytest.mark.timeout(60) + def test_format_rich_output_failed_uploads(self, mock_rich_console): + """Test rich output displays failed uploads correctly.""" + config = create_upload_config(plain_output=False) + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=False, + result="Upload failed: network error", + ) + ] + + captured = io.StringIO() + formatter.console = MockConsole(file=captured, force_terminal=True) + + formatter._format_rich_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "Failed Uploads" in output + assert "network error" in output + + @pytest.mark.timeout(60) + def test_format_rich_output_replaced_duplicates(self, mock_rich_console): + """Test rich output displays replaced duplicates correctly.""" + config = create_upload_config(plain_output=False) + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="https://gitlab.com/download/file.txt", + was_duplicate=True, + duplicate_action="replaced", + existing_url="https://gitlab.com/old/file.txt", + ) + ] + + captured = io.StringIO() + formatter.console = MockConsole(file=captured, force_terminal=True) + + formatter._format_rich_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "Replaced existing duplicate" in output + assert "Previous URL" in output + + @pytest.mark.timeout(60) + def test_format_rich_output_statistics(self, mock_upload_results, mock_rich_console): + """Test rich output displays statistics correctly.""" + config = create_upload_config(plain_output=False) + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + captured = io.StringIO() + formatter.console = MockConsole(file=captured, force_terminal=True) + + formatter._format_rich_output(mock_upload_results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "Duplicate Detection Statistics" in output + assert "New uploads:" in output + assert "Replaced duplicates:" in output + assert "Skipped duplicates:" in output + assert "Failed uploads:" in output + assert "Total processed:" in output + + @pytest.mark.timeout(60) + def test_format_rich_output_empty_results(self, mock_rich_console): + """Test rich output handles empty results list.""" + config = create_upload_config(plain_output=False) + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + captured = io.StringIO() + formatter.console = MockConsole(file=captured, force_terminal=True) + + formatter._format_rich_output([], "test-package", "1.0.0") + + output = captured.getvalue() + assert "Upload Summary" in output + # Should show 0 in statistics + assert "Total processed:" in output + # MockConsole doesn't produce ANSI codes, so no stripping needed + assert "Total processed: 0" in output + + @pytest.mark.timeout(60) + def test_format_rich_output_all_successful(self, mock_rich_console): + """Test rich output shows success message when all uploads succeed.""" + config = create_upload_config(plain_output=False) + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + results = [ + create_upload_result(success=True), + create_upload_result( + source_path="/path/to/file2.txt", + target_filename="file2.txt", + success=True, + ), + ] + + captured = io.StringIO() + formatter.console = MockConsole(file=captured, force_terminal=True) + + formatter._format_rich_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "All files processed successfully" in output + + +class TestJsonOutputFormatting: + """Tests for JSON output formatting.""" + + @pytest.mark.timeout(60) + def test_format_json_output_successful_uploads(self, mock_rich_console): + """Test JSON output structure for successful uploads.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="https://gitlab.com/download/file.txt", + ) + ] + + with capture_stdout() as captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + assert json_data["success"] is True + assert len(json_data["successful_uploads"]) == 1 + assert json_data["successful_uploads"][0]["target_filename"] == "file.txt" + + @pytest.mark.timeout(60) + def test_format_json_output_with_skipped_duplicates(self, mock_rich_console): + """Test JSON output includes skipped duplicates array.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="Skipped: duplicate", + was_duplicate=True, + duplicate_action="skipped", + existing_url="https://gitlab.com/existing/file.txt", + ) + ] + + with capture_stdout() as captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + assert len(json_data["skipped_duplicates"]) == 1 + assert json_data["skipped_duplicates"][0]["duplicate_action"] == "skipped" + assert json_data["skipped_duplicates"][0]["existing_url"] == "https://gitlab.com/existing/file.txt" + + @pytest.mark.timeout(60) + def test_format_json_output_with_failed_uploads(self, mock_rich_console): + """Test JSON output includes failed_uploads array and error fields.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=False, + result="Upload failed: network error", + ) + ] + + with capture_stdout() as captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + assert json_data["success"] is False + assert len(json_data["failed_uploads"]) == 1 + assert "error" in json_data + assert "error_type" in json_data + + @pytest.mark.timeout(60) + def test_format_json_output_statistics_accuracy(self, mock_upload_results, mock_rich_console): + """Test JSON output statistics match actual counts.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + with capture_stdout() as captured: + formatter._format_json_output(mock_upload_results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + stats = json_data["statistics"] + # mock_upload_results has: 1 new upload, 1 replaced, 1 skipped, 1 failed + assert stats["total_processed"] == 4 + assert stats["new_uploads"] == 1 + assert stats["replaced_duplicates"] == 1 + assert stats["skipped_duplicates"] == 1 + assert stats["failed_uploads"] == 1 + + @pytest.mark.timeout(60) + def test_format_json_output_duplicate_metadata(self, mock_rich_console): + """Test JSON output includes duplicate detection metadata.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="https://gitlab.com/download/file.txt", + was_duplicate=True, + duplicate_action="replaced", + existing_url="https://gitlab.com/old/file.txt", + ) + ] + + with capture_stdout() as captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + upload = json_data["successful_uploads"][0] + assert upload["was_duplicate"] is True + assert upload["duplicate_action"] == "replaced" + assert upload["existing_url"] == "https://gitlab.com/old/file.txt" + + @pytest.mark.timeout(60) + def test_format_json_output_exit_code_success(self, mock_rich_console): + """Test JSON output has exit_code=0 when no failures.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [create_upload_result(success=True)] + + with capture_stdout() as captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + assert json_data["exit_code"] == 0 + + @pytest.mark.timeout(60) + def test_format_json_output_exit_code_failure(self, mock_rich_console): + """Test JSON output has exit_code=1 when failures exist.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [create_upload_result(success=False, result="Error")] + + with capture_stdout() as captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + assert json_data["exit_code"] == 1 + + @pytest.mark.timeout(60) + def test_format_json_output_required_fields(self, mock_rich_console): + """Test JSON output contains all required fields.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [create_upload_result()] + + with capture_stdout() as captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + required_fields = [ + "success", "exit_code", "package_name", "version", + "successful_uploads", "statistics" + ] + for field in required_fields: + assert field in json_data, f"Missing required field: {field}" + + @pytest.mark.timeout(60) + def test_format_json_output_goes_to_stdout(self, mock_rich_console): + """Test JSON output is printed to stdout, not stderr.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [create_upload_result()] + + with capture_stdout() as stdout_captured: + with capture_stderr() as stderr_captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + stdout_output = stdout_captured.getvalue() + stderr_output = stderr_captured.getvalue() + + # JSON should be in stdout + assert stdout_output.strip().startswith("{") + # stderr should be empty or contain only logs + assert "success" not in stderr_output + + @pytest.mark.timeout(60) + def test_format_json_output_validation(self, mock_rich_console): + """Test JSON output can be validated with validate_json_result helper.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + ) + ] + + with capture_stdout() as captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = json.loads(output) + + # Use the helper from test_helpers + is_valid = validate_json_result( + json_data, + expected_success=True, + expected_files=["file.txt"] + ) + assert is_valid + + @pytest.mark.timeout(60) + def test_format_json_output_multiple_failures_error_message(self, mock_rich_console): + """Test JSON output error message for multiple failures.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file1.txt", + target_filename="file1.txt", + success=False, + result="Error 1", + ), + create_upload_result( + source_path="/path/to/file2.txt", + target_filename="file2.txt", + success=False, + result="Error 2", + ), + ] + + with capture_stdout() as captured: + formatter._format_json_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + assert "2 file(s) failed" in json_data["error"] + + +class TestPlainTextOutputFormatting: + """Tests for plain text output formatting.""" + + @pytest.mark.timeout(60) + def test_format_plain_output_successful_uploads(self, mock_rich_console): + """Test plain output displays successful uploads with [OK] prefix.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + ) + ] + + with capture_stdout() as captured: + formatter._format_plain_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "[OK] Successful Uploads" in output + assert "file.txt" in output + + @pytest.mark.timeout(60) + def test_format_plain_output_skipped_duplicates(self, mock_rich_console): + """Test plain output displays skipped duplicates with [SKIP] prefix.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + success=True, + result="Skipped", + was_duplicate=True, + duplicate_action="skipped", + ) + ] + + with capture_stdout() as captured: + formatter._format_plain_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "[SKIP] Skipped Duplicates" in output + + @pytest.mark.timeout(60) + def test_format_plain_output_failed_uploads(self, mock_rich_console): + """Test plain output displays failed uploads with [FAIL] prefix.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + success=False, + result="Network error", + ) + ] + + with capture_stdout() as captured: + formatter._format_plain_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "[FAIL] Failed Uploads" in output + assert "Network error" in output + + @pytest.mark.timeout(60) + def test_format_plain_output_no_color_codes(self, mock_rich_console): + """Test plain output contains no ANSI escape sequences.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result(success=True), + create_upload_result(success=False, result="Error"), + ] + + with capture_stdout() as captured: + formatter._format_plain_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert_no_ansi_codes(output) + + @pytest.mark.timeout(60) + def test_format_plain_output_no_unicode_characters(self, mock_rich_console): + """Test plain output uses only ASCII characters.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result(success=True), + create_upload_result(success=False, result="Error"), + ] + + with capture_stdout() as captured: + formatter._format_plain_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + # Check that all characters are ASCII + try: + output.encode("ascii") + except UnicodeEncodeError as e: + pytest.fail(f"Plain output contains non-ASCII characters: {e}") + + @pytest.mark.timeout(60) + def test_format_plain_output_statistics(self, mock_rich_console): + """Test plain output displays statistics with asterisk bullets.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + results = [create_upload_result(success=True)] + + with capture_stdout() as captured: + formatter._format_plain_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "Duplicate Detection Statistics:" in output + assert "* New uploads:" in output + assert "* Replaced duplicates:" in output + assert "* Skipped duplicates:" in output + assert "* Failed uploads:" in output + + @pytest.mark.timeout(60) + def test_format_plain_output_replaced_duplicates(self, mock_rich_console): + """Test plain output displays replacement action in plain text.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + success=True, + was_duplicate=True, + duplicate_action="replaced", + existing_url="https://gitlab.com/old/file.txt", + ) + ] + + with capture_stdout() as captured: + formatter._format_plain_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "Action: Replaced existing duplicate" in output + assert "Previous URL:" in output + + +class TestErrorFormatting: + """Tests for error formatting functions.""" + + @pytest.mark.timeout(60) + def test_format_error_basic(self): + """Test basic error formatting with error type and message.""" + error = ValueError("Invalid input") + result = format_error(error) + + assert "ERROR: ValueError" in result + assert "Invalid input" in result + + @pytest.mark.timeout(60) + def test_format_error_with_context(self): + """Test error formatting with context dictionary.""" + error = ValueError("Not found") + context = { + "operation": "upload", + "project_path": "group/project", + "gitlab_url": "https://gitlab.com", + } + result = format_error(error, context) + + assert "ERROR: ValueError" in result + # Context should be included via enhance_error_message + + @pytest.mark.timeout(60) + def test_format_error_gitlab_upload_error(self): + """Test error formatting includes exit code for GitLabUploadError.""" + error = GitLabUploadError("Upload failed") + result = format_error(error) + + assert "ERROR: GitLabUploadError" in result + assert "Exit code:" in result + + @pytest.mark.timeout(60) + def test_format_error_without_context(self): + """Test error formatting without context uses basic formatting.""" + error = RuntimeError("Something went wrong") + result = format_error(error) + + assert "ERROR: RuntimeError" in result + assert "Something went wrong" in result + + @pytest.mark.timeout(60) + def test_format_error_uses_enhance_error_message(self): + """Test that enhance_error_message is called when context is provided.""" + error = ValueError("404 not found") + context = { + "operation": "fetch", + "project_path": "group/project", + "gitlab_url": "https://gitlab.com", + } + + with patch("src.gitlab_pkg_upload.formatters.enhance_error_message") as mock_enhance: + mock_enhance.return_value = "Enhanced error message" + result = format_error(error, context) + + mock_enhance.assert_called_once_with(error, context) + assert "Enhanced error message" in result + + @pytest.mark.timeout(60) + def test_format_error_handles_exceptions_gracefully(self): + """Test format_error doesn't crash on unusual exceptions.""" + # Create an exception with unusual attributes + class CustomError(Exception): + pass + + error = CustomError("Custom error message") + result = format_error(error) + + assert "ERROR: CustomError" in result + assert "Custom error message" in result + + +class TestProgressDisplay: + """Tests for progress display functionality.""" + + @pytest.mark.timeout(60) + def test_create_progress_spinner_returns_status(self, mock_rich_console, mock_rich_status): + """Test create_progress_spinner returns a Status-like object.""" + config = create_upload_config() + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + spinner = formatter.create_progress_spinner("Loading...") + # MockStatus should be returned + assert spinner is not None + assert hasattr(spinner, "__enter__") # Should be a context manager + assert hasattr(spinner, "__exit__") + + @pytest.mark.timeout(60) + def test_create_progress_spinner_with_message(self, mock_rich_console, mock_rich_status): + """Test create_progress_spinner accepts custom message.""" + config = create_upload_config() + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + message = "Uploading files..." + spinner = formatter.create_progress_spinner(message) + + # The Status object should have been created with our message + assert spinner is not None + + @pytest.mark.timeout(60) + def test_create_progress_spinner_plain_output_mode(self, mock_rich_console, mock_rich_status): + """Test spinner is created but doesn't display in plain output mode.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + spinner = formatter.create_progress_spinner("Loading...") + # Should still return a Status-like object, just won't display + assert spinner is not None + + @pytest.mark.timeout(60) + def test_create_progress_spinner_non_tty(self, mock_rich_console, mock_rich_status): + """Test spinner creation when is_tty is False.""" + config = create_upload_config(plain_output=False) + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=False): + formatter = OutputFormatter(config) + + spinner = formatter.create_progress_spinner("Loading...") + assert spinner is not None + + @pytest.mark.timeout(60) + def test_create_progress_spinner_as_context_manager(self, mock_rich_console, mock_rich_status): + """Test spinner works as a context manager.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + # Use MockConsole to avoid actual output + formatter.console = MockConsole(file=io.StringIO(), force_terminal=False) + + # Should not raise an exception + with formatter.create_progress_spinner("Loading..."): + pass + + @pytest.mark.timeout(60) + def test_display_progress_function(self, mock_rich_console, mock_rich_status): + """Test standalone display_progress function delegates to formatter.""" + config = create_upload_config() + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + spinner = display_progress(formatter, "Processing...") + # Should return a Status-like object + assert spinner is not None + assert hasattr(spinner, "__enter__") + + +class TestOutputFormatSelection: + """Tests for output format selection logic.""" + + @pytest.mark.timeout(60) + def test_format_output_selects_json_when_json_output_true(self, mock_rich_console): + """Test format_output calls JSON formatter when json_output=True.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [create_upload_result()] + + with patch.object(formatter, "_format_json_output") as mock_json: + with patch.object(formatter, "_format_plain_output") as mock_plain: + with patch.object(formatter, "_format_rich_output") as mock_rich: + formatter.format_output(results, "test-package", "1.0.0") + + mock_json.assert_called_once() + mock_plain.assert_not_called() + mock_rich.assert_not_called() + + @pytest.mark.timeout(60) + def test_format_output_selects_plain_when_plain_output_true(self, mock_rich_console): + """Test format_output calls plain formatter when plain_output=True.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + results = [create_upload_result()] + + with patch.object(formatter, "_format_json_output") as mock_json: + with patch.object(formatter, "_format_plain_output") as mock_plain: + with patch.object(formatter, "_format_rich_output") as mock_rich: + formatter.format_output(results, "test-package", "1.0.0") + + mock_json.assert_not_called() + mock_plain.assert_called_once() + mock_rich.assert_not_called() + + @pytest.mark.timeout(60) + def test_format_output_selects_plain_when_not_tty(self, mock_rich_console): + """Test format_output calls plain formatter when not in TTY.""" + config = create_upload_config(plain_output=False, json_output=False) + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=False): + formatter = OutputFormatter(config) + + results = [create_upload_result()] + + with patch.object(formatter, "_format_json_output") as mock_json: + with patch.object(formatter, "_format_plain_output") as mock_plain: + with patch.object(formatter, "_format_rich_output") as mock_rich: + formatter.format_output(results, "test-package", "1.0.0") + + mock_json.assert_not_called() + mock_plain.assert_called_once() + mock_rich.assert_not_called() + + @pytest.mark.timeout(60) + def test_format_output_selects_rich_when_tty(self, mock_rich_console): + """Test format_output calls rich formatter when in TTY.""" + config = create_upload_config(plain_output=False, json_output=False) + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("src.gitlab_pkg_upload.formatters.detect_color_support", return_value=True): + formatter = OutputFormatter(config) + + results = [create_upload_result()] + + with patch.object(formatter, "_format_json_output") as mock_json: + with patch.object(formatter, "_format_plain_output") as mock_plain: + with patch.object(formatter, "_format_rich_output") as mock_rich: + formatter.format_output(results, "test-package", "1.0.0") + + mock_json.assert_not_called() + mock_plain.assert_not_called() + mock_rich.assert_called_once() + + @pytest.mark.timeout(60) + def test_format_output_json_takes_precedence(self, mock_rich_console): + """Test JSON output takes precedence over plain output.""" + config = create_upload_config(json_output=True, plain_output=True) + formatter = OutputFormatter(config) + + results = [create_upload_result()] + + with patch.object(formatter, "_format_json_output") as mock_json: + with patch.object(formatter, "_format_plain_output") as mock_plain: + formatter.format_output(results, "test-package", "1.0.0") + + mock_json.assert_called_once() + mock_plain.assert_not_called() + + +class TestOutputFormatterIntegration: + """Integration tests for OutputFormatter end-to-end workflows.""" + + @pytest.mark.timeout(60) + def test_full_workflow_rich_output(self, mock_upload_results, mock_rich_console): + """Test complete rich output workflow.""" + config = create_upload_config(plain_output=False) + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("src.gitlab_pkg_upload.formatters.detect_color_support", return_value=True): + formatter = OutputFormatter(config) + + captured = io.StringIO() + formatter.console = MockConsole(file=captured, force_terminal=True) + + formatter.format_output(mock_upload_results, "test-package", "1.0.0") + + output = captured.getvalue() + + # Verify all sections are present + assert "Upload Summary" in output + assert "Successful Uploads" in output + assert "Skipped Duplicates" in output + assert "Failed Uploads" in output + assert "Duplicate Detection Statistics" in output + + @pytest.mark.timeout(60) + def test_full_workflow_json_output(self, mock_upload_results, mock_rich_console): + """Test complete JSON output workflow.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + with capture_stdout() as captured: + formatter.format_output(mock_upload_results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + # Verify JSON structure + assert "success" in json_data + assert "exit_code" in json_data + assert "package_name" in json_data + assert json_data["package_name"] == "test-package" + assert "version" in json_data + assert json_data["version"] == "1.0.0" + + @pytest.mark.timeout(60) + def test_full_workflow_plain_output(self, mock_upload_results, mock_rich_console): + """Test complete plain text output workflow.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + with capture_stdout() as captured: + formatter.format_output(mock_upload_results, "test-package", "1.0.0") + + output = captured.getvalue() + + # Verify plain text format + assert_no_ansi_codes(output) + assert "Upload Summary" in output + assert "[OK]" in output or "[SKIP]" in output or "[FAIL]" in output + + @pytest.mark.timeout(60) + def test_formatter_factory_function(self, mock_rich_console): + """Test get_formatter factory function returns correct instance.""" + config = create_upload_config() + formatter = get_formatter(config) + + assert isinstance(formatter, OutputFormatter) + assert formatter.config is config + + @pytest.mark.timeout(60) + def test_multiple_format_calls(self, mock_rich_console): + """Test multiple format_output calls work correctly.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results1 = [create_upload_result(target_filename="file1.txt")] + results2 = [create_upload_result(target_filename="file2.txt")] + + with capture_stdout() as captured1: + formatter.format_output(results1, "package1", "1.0.0") + + json1 = assert_valid_json(captured1.getvalue()) + assert json1["package_name"] == "package1" + + with capture_stdout() as captured2: + formatter.format_output(results2, "package2", "2.0.0") + + json2 = assert_valid_json(captured2.getvalue()) + assert json2["package_name"] == "package2" + + +class TestEdgeCases: + """Tests for edge cases and boundary conditions.""" + + @pytest.mark.timeout(60) + def test_empty_results_list(self, mock_rich_console): + """Test handling of empty results list in all format methods.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + with capture_stdout() as captured: + formatter.format_output([], "test-package", "1.0.0") + + json_data = assert_valid_json(captured.getvalue()) + assert json_data["success"] is True + assert json_data["statistics"]["total_processed"] == 0 + + @pytest.mark.timeout(60) + def test_very_long_filenames(self, mock_rich_console): + """Test handling of extremely long filenames.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + long_name = "a" * 500 + ".txt" + results = [create_upload_result(target_filename=long_name)] + + with capture_stdout() as captured: + formatter.format_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + # Should not crash and should contain the filename (possibly truncated) + assert "a" in output + + @pytest.mark.timeout(60) + def test_special_characters_in_paths(self, mock_rich_console): + """Test handling of special characters in file paths.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + special_path = '/path/with spaces/and "quotes"/file.txt' + results = [create_upload_result(source_path=special_path)] + + with capture_stdout() as captured: + formatter.format_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + # JSON should properly escape special characters + assert json_data["successful_uploads"][0]["source_path"] == special_path + + @pytest.mark.timeout(60) + def test_unicode_in_error_messages(self, mock_rich_console): + """Test handling of Unicode characters in error messages.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + unicode_error = "Error: файл не найден (file not found) 文件未找到" + results = [create_upload_result(success=False, result=unicode_error)] + + with capture_stdout() as captured: + formatter.format_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + # Unicode should be preserved in JSON + assert unicode_error in json_data["failed_uploads"][0]["error_message"] + + @pytest.mark.timeout(60) + def test_large_number_of_results(self, mock_rich_console): + """Test handling of many upload results.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + # Create 100 results + results = [ + create_upload_result( + source_path=f"/path/to/file{i}.txt", + target_filename=f"file{i}.txt", + ) + for i in range(100) + ] + + with capture_stdout() as captured: + formatter.format_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + assert json_data["statistics"]["total_processed"] == 100 + assert len(json_data["successful_uploads"]) == 100 + + @pytest.mark.timeout(60) + def test_result_with_none_values(self, mock_rich_console): + """Test handling of results with None optional fields.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + was_duplicate=False, + duplicate_action=None, + existing_url=None, + ) + ] + + with capture_stdout() as captured: + formatter.format_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + upload = json_data["successful_uploads"][0] + assert upload["was_duplicate"] is False + assert upload["duplicate_action"] is None + assert upload["existing_url"] is None + + @pytest.mark.timeout(60) + def test_mixed_success_and_failure_results(self, mock_rich_console): + """Test proper categorization of mixed results.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result(success=True, target_filename="success1.txt"), + create_upload_result(success=False, target_filename="fail1.txt", result="Error 1"), + create_upload_result(success=True, target_filename="success2.txt"), + create_upload_result(success=False, target_filename="fail2.txt", result="Error 2"), + create_upload_result( + success=True, + target_filename="skipped.txt", + was_duplicate=True, + duplicate_action="skipped", + ), + ] + + with capture_stdout() as captured: + formatter.format_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + assert len(json_data["successful_uploads"]) == 2 + assert len(json_data["failed_uploads"]) == 2 + assert len(json_data["skipped_duplicates"]) == 1 + assert json_data["statistics"]["total_processed"] == 5 + + @pytest.mark.timeout(60) + def test_empty_package_name_and_version(self, mock_rich_console): + """Test handling of empty package name and version.""" + config = create_upload_config(json_output=True) + formatter = OutputFormatter(config) + + results = [create_upload_result()] + + with capture_stdout() as captured: + formatter.format_output(results, "", "") + + output = captured.getvalue() + json_data = assert_valid_json(output) + + assert json_data["package_name"] == "" + assert json_data["version"] == "" + + @pytest.mark.timeout(60) + def test_console_file_output_isolation(self, mock_rich_console): + """Test that console output doesn't interfere with stdout capture.""" + config = create_upload_config(plain_output=False) + + with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + # Redirect console to a separate buffer using MockConsole + console_buffer = io.StringIO() + formatter.console = MockConsole(file=console_buffer, force_terminal=True) + + results = [create_upload_result()] + + with capture_stdout() as stdout_buffer: + formatter._format_rich_output(results, "test-package", "1.0.0") + + # Rich output should go to console_buffer, not stdout_buffer + assert console_buffer.getvalue() # Console got output + assert not stdout_buffer.getvalue() # stdout is empty From 9050199129a81d64232b613fee8cb0a1fa278c60 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Fri, 9 Jan 2026 17:44:18 -0600 Subject: [PATCH 09/36] cli: Implement full argument parsing and validation The previous CLI module was a placeholder that delegated to an external script via runpy. This approach prevented proper package installation and made the tool unusable when installed via pip, as the standalone script path resolution failed outside the development environment. Replace the placeholder implementation with a complete argparse-based CLI that handles all argument parsing, validation, and shell completion natively. This enables standard package distribution while providing comprehensive flag validation with user-friendly error messages. The implementation follows a phased approach, with project resolution and upload orchestration marked as TODO items for subsequent work. Signed-off-by: Javier Tia --- src/gitlab_pkg_upload/cli.py | 507 ++++++++++++++++++++++++++++++- tests/test_basic_uploads.py | 10 +- tests/test_duplicate_handling.py | 20 +- tests/test_error_scenarios.py | 18 +- tests/test_integration.py | 18 +- tests/test_project_resolution.py | 12 +- tests/utils/test_helpers.py | 2 +- 7 files changed, 533 insertions(+), 54 deletions(-) diff --git a/src/gitlab_pkg_upload/cli.py b/src/gitlab_pkg_upload/cli.py index ae751bc..e1624f2 100644 --- a/src/gitlab_pkg_upload/cli.py +++ b/src/gitlab_pkg_upload/cli.py @@ -1,30 +1,509 @@ """CLI entry point for gitlab-pkg-upload. -This module provides the main() function that serves as the entry point -for the installed console script. +This module provides the command-line interface for uploading files to GitLab's +Generic Package Registry. It handles argument parsing, validation, and +orchestrates the upload workflow. + +Supported flags: + Required: + --package-name Package name in the registry + --package-version Package version + + File input (mutually exclusive): + --files List of files to upload + --directory Directory containing files to upload + + Project specification: + --project-url Full GitLab project URL + --project-path Project path (namespace/project) + --gitlab-url GitLab instance URL (default: https://gitlab.com) + --token GitLab API token (or use GITLAB_TOKEN env var) + + Duplicate handling: + --duplicate-policy How to handle duplicates: skip, replace, error + + File mapping: + --file-mapping Rename files during upload (source:target format) + + Verbosity (mutually exclusive): + --verbose Enable verbose output + --quiet Suppress non-essential output + --debug Enable debug output + + Operational: + --dry-run Preview actions without executing + --fail-fast Stop on first failure + --retry Number of retry attempts + --json-output Output results as JSON + --plain Force plain text output (no colors) + --version Display version number + +Usage examples: + # Upload a single file + gitlab-pkg-upload --package-name myapp --package-version 1.0.0 --files dist/app.tar.gz + + # Upload multiple files + gitlab-pkg-upload --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz + + # Upload from directory + gitlab-pkg-upload --package-name myapp --package-version 1.0.0 --directory dist/ + + # With file renaming + gitlab-pkg-upload --package-name myapp --package-version 1.0.0 \\ + --files local.tar.gz --file-mapping local.tar.gz:remote.tar.gz + + # Dry run with verbose output + gitlab-pkg-upload --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ + --dry-run --verbose + + # JSON output for CI/CD pipelines + gitlab-pkg-upload --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ + --json-output """ -import runpy +from __future__ import annotations + +import argparse import sys from pathlib import Path +from typing import TYPE_CHECKING + +import argcomplete + +from gitlab_pkg_upload.models import DuplicatePolicy +from gitlab_pkg_upload.validators import DEFAULT_GITLAB_URL + +if TYPE_CHECKING: + pass + + +def create_argument_parser() -> argparse.ArgumentParser: + """Create and configure the argument parser for gitlab-pkg-upload. + + Returns: + Configured ArgumentParser instance with all supported arguments. + """ + parser = argparse.ArgumentParser( + prog="gitlab-pkg-upload", + description=( + "Upload files to GitLab's Generic Package Registry.\n\n" + "This tool uploads one or more files to a GitLab project's package registry, " + "with support for duplicate detection, retry handling, and various output formats." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""\ +Examples: + # Upload a single file + %(prog)s --package-name myapp --package-version 1.0.0 --files dist/app.tar.gz + + # Upload multiple files + %(prog)s --package-name myapp --package-version 1.0.0 --files file1.bin file2.bin + + # Upload all files from a directory + %(prog)s --package-name myapp --package-version 1.0.0 --directory dist/ + + # With file renaming (source:target format) + %(prog)s --package-name myapp --package-version 1.0.0 \\ + --files local.tar.gz --file-mapping local.tar.gz:app-1.0.0.tar.gz + + # Skip duplicates (default behavior) + %(prog)s --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ + --duplicate-policy skip + + # Replace existing files + %(prog)s --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ + --duplicate-policy replace + + # Dry run with verbose output + %(prog)s --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ + --dry-run --verbose + + # JSON output for CI/CD pipelines + %(prog)s --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ + --json-output --quiet + + # Specify project explicitly + %(prog)s --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ + --project-url https://gitlab.com/mygroup/myproject + + # Use custom GitLab instance + %(prog)s --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ + --gitlab-url https://gitlab.example.com --project-path mygroup/myproject + +Environment variables: + GITLAB_TOKEN GitLab API token (alternative to --token) +""", + ) + + # Required arguments (validated in validate_flags to allow --version to work alone) + required_group = parser.add_argument_group("required arguments") + required_group.add_argument( + "--package-name", + type=str, + metavar="NAME", + help="Package name in the GitLab registry (e.g., 'myapp', 'my-library')", + ) + required_group.add_argument( + "--package-version", + type=str, + metavar="VERSION", + help="Package version (e.g., '1.0.0', '2.3.1-beta')", + ) + + # File input arguments (mutual exclusion validated in validate_flags for exit code 3) + file_input_group = parser.add_argument_group( + "file input (one required)", + "Specify files to upload using either --files or --directory", + ) + file_input_group.add_argument( + "--files", + nargs="+", + type=str, + metavar="FILE", + help="List of files to upload (e.g., --files file1.tar.gz file2.tar.gz)", + ) + file_input_group.add_argument( + "--directory", + type=str, + metavar="DIR", + help="Directory containing files to upload (uploads all top-level files)", + ) + + # Project specification arguments + project_group = parser.add_argument_group( + "project specification", + "Specify the target GitLab project (auto-detected from Git remote if not provided)", + ) + project_group.add_argument( + "--project-url", + type=str, + metavar="URL", + help="Full GitLab project URL (e.g., 'https://gitlab.com/namespace/project')", + ) + project_group.add_argument( + "--project-path", + type=str, + metavar="PATH", + help="Project path in namespace/project format (e.g., 'mygroup/myproject')", + ) + project_group.add_argument( + "--gitlab-url", + type=str, + default=DEFAULT_GITLAB_URL, + metavar="URL", + help=f"GitLab instance URL (default: {DEFAULT_GITLAB_URL})", + ) + project_group.add_argument( + "--token", + type=str, + metavar="TOKEN", + help="GitLab API token (or set GITLAB_TOKEN environment variable)", + ) + + # Duplicate handling + parser.add_argument( + "--duplicate-policy", + type=str, + choices=["skip", "replace", "error"], + default="skip", + metavar="POLICY", + help=( + "How to handle duplicate files: " + "'skip' (default) - skip uploading, " + "'replace' - delete existing and upload new, " + "'error' - fail with error" + ), + ) + + # File mapping + parser.add_argument( + "--file-mapping", + action="append", + type=str, + metavar="SOURCE:TARGET", + help=( + "Rename files during upload using source:target format. " + "Can be specified multiple times (e.g., --file-mapping local.bin:remote.bin). " + "Only valid with --files, not --directory." + ), + ) + + # Verbosity flags (mutual exclusion validated in validate_flags for exit code 3) + verbosity_group = parser.add_argument_group( + "verbosity", + "Control output verbosity (mutually exclusive)", + ) + verbosity_group.add_argument( + "--verbose", + action="store_true", + help="Enable verbose output with detailed progress information", + ) + verbosity_group.add_argument( + "--quiet", + action="store_true", + help="Suppress non-essential output (only show errors and final summary)", + ) + verbosity_group.add_argument( + "--debug", + action="store_true", + help="Enable debug output with full diagnostic information", + ) + + # Operational flags + operational_group = parser.add_argument_group("operational options") + operational_group.add_argument( + "--dry-run", + action="store_true", + help="Preview actions without executing uploads (shows what would be done)", + ) + operational_group.add_argument( + "--fail-fast", + action="store_true", + help="Stop immediately on first upload failure (default: continue with remaining files)", + ) + operational_group.add_argument( + "--retry", + type=int, + default=0, + metavar="N", + help="Number of retry attempts for failed uploads (default: 0)", + ) + + # Output format flags + output_group = parser.add_argument_group("output format") + output_group.add_argument( + "--json-output", + action="store_true", + help="Output results as JSON (useful for CI/CD pipelines and scripting)", + ) + output_group.add_argument( + "--plain", + action="store_true", + help="Force plain text output without colors or formatting", + ) + # Version flag - handled early via action="version" to bypass other requirements + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {get_version()}", + help="Display version number and exit", + ) -def main() -> None: + return parser + + +def validate_flags(args: argparse.Namespace) -> None: + """Validate flag combinations and detect conflicts. + + Checks for: + - Required arguments (--package-name and --package-version for upload runs) + - Conflicting file input (--files and --directory) + - Conflicting verbosity flags (--verbose, --quiet, --debug) + - Conflicting project specification (--project-url with --project-path) + - File input requirement (--files or --directory must be provided) + - File mapping constraint (--file-mapping only valid with --files) + + Args: + args: Parsed argument namespace from argparse. + + Raises: + SystemExit: With exit code 3 (ConfigurationError) if conflicts are detected. + """ + errors: list[str] = [] + + # Check required arguments for upload runs + if not args.package_name: + errors.append( + "--package-name is required. " + "Specify the package name in the GitLab registry." + ) + if not args.package_version: + errors.append( + "--package-version is required. " + "Specify the package version." + ) + + # Check for conflicting file input flags + if args.files and args.directory: + errors.append( + "Cannot specify both --files and --directory. " + "Use --files for explicit file list or --directory to upload all files from a directory." + ) + + # Check for conflicting verbosity flags + verbosity_flags = [] + if args.verbose: + verbosity_flags.append("--verbose") + if args.quiet: + verbosity_flags.append("--quiet") + if args.debug: + verbosity_flags.append("--debug") + if len(verbosity_flags) > 1: + errors.append( + f"Cannot specify multiple verbosity flags: {', '.join(verbosity_flags)}. " + "Choose one of --verbose, --quiet, or --debug." + ) + + # Check for conflicting project specification + if args.project_url and args.project_path: + errors.append( + "Cannot specify both --project-url and --project-path. " + "Use --project-url for full URLs or --project-path with --gitlab-url." + ) + + # Check that file input is provided + if not args.files and not args.directory: + errors.append( + "Either --files or --directory must be provided. " + "Use --files for explicit file list or --directory to upload all files from a directory." + ) + + # Check that file-mapping is only used with --files + if args.file_mapping and args.directory: + errors.append( + "--file-mapping can only be used with --files, not with --directory. " + "File mappings require explicit file specification." + ) + + # Check retry value is non-negative + if args.retry < 0: + errors.append( + f"--retry must be a non-negative integer, got {args.retry}." + ) + + # Report all errors + if errors: + for error in errors: + print(f"Error: {error}", file=sys.stderr) + print( + "\nUse --help for usage information.", + file=sys.stderr, + ) + sys.exit(3) # ConfigurationError exit code + + +def get_version() -> str: + """Get the package version from pyproject.toml. + + Returns: + Version string from pyproject.toml, or 'unknown' if not found. + """ + try: + # Try to find pyproject.toml relative to this module + module_path = Path(__file__).parent + # Check in package location (installed) + pyproject_paths = [ + module_path.parent.parent / "pyproject.toml", # Development layout + module_path / "pyproject.toml", # Alternate location + ] + + for pyproject_path in pyproject_paths: + if pyproject_path.exists(): + content = pyproject_path.read_text() + # Simple parsing - look for version = "x.y.z" + for line in content.splitlines(): + line = line.strip() + if line.startswith("version") and "=" in line: + # Extract version value + _, _, value = line.partition("=") + value = value.strip().strip('"').strip("'") + return value + + # Fallback: try importlib.metadata (for installed packages) + try: + from importlib.metadata import version as get_pkg_version + + return get_pkg_version("gitlab-pkg-upload") + except Exception: + pass + + return "unknown" + except Exception: + return "unknown" + + +def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace: + """Parse and validate command-line arguments. + + Creates the argument parser, integrates shell completion with argcomplete, + parses arguments, and validates flag combinations. + + Args: + argv: Command-line arguments to parse. If None, uses sys.argv[1:]. + + Returns: + Validated argument namespace. + + Raises: + SystemExit: If argument parsing fails or flag conflicts are detected. + """ + parser = create_argument_parser() + + # Enable shell completion via argcomplete + argcomplete.autocomplete(parser) + + # Parse arguments + args = parser.parse_args(argv) + + # Validate flag combinations + validate_flags(args) + + # Convert duplicate_policy string to enum + args.duplicate_policy = DuplicatePolicy(args.duplicate_policy) + + return args + + +def main(argv: list[str] | None = None) -> None: """Main entry point for the gitlab-pkg-upload CLI. - This function runs the standalone script as the main module. - It handles the case where the script is installed as a package. + Parses command-line arguments, validates configuration, and orchestrates + the upload workflow. + + Args: + argv: Command-line arguments. If None, uses sys.argv[1:]. """ - # Locate the standalone script relative to this module - repo_root = Path(__file__).parent.parent.parent - script_path = repo_root / "gitlab-pkg-upload.py" + # Parse arguments + # Note: --version flag is handled automatically by argparse via action="version" + args = parse_arguments(argv) + + # TODO: Phase 2 - Project resolution + # - Auto-detect from Git remote if no project specified + # - Resolve project ID from URL or path + # - Validate project accessibility + + # TODO: Phase 3 - Context building + # - Build UploadConfig from parsed arguments + # - Create GitLab client connection + # - Initialize DuplicateDetector + # - Build UploadContext - if not script_path.exists(): - print(f"Error: Could not find gitlab-pkg-upload.py at {script_path}", file=sys.stderr) - sys.exit(1) + # TODO: Phase 4 - Upload orchestration + # - Collect files to upload + # - Execute uploads with retry handling + # - Format and display results - # Run the script as __main__ - runpy.run_path(str(script_path), run_name="__main__") + # Placeholder: print parsed configuration (for development/testing) + if args.debug: + print("Parsed arguments:", file=sys.stderr) + print(f" package_name: {args.package_name}", file=sys.stderr) + print(f" package_version: {args.package_version}", file=sys.stderr) + print(f" files: {args.files}", file=sys.stderr) + print(f" directory: {args.directory}", file=sys.stderr) + print(f" project_url: {args.project_url}", file=sys.stderr) + print(f" project_path: {args.project_path}", file=sys.stderr) + print(f" gitlab_url: {args.gitlab_url}", file=sys.stderr) + print(f" duplicate_policy: {args.duplicate_policy}", file=sys.stderr) + print(f" file_mapping: {args.file_mapping}", file=sys.stderr) + print(f" verbose: {args.verbose}", file=sys.stderr) + print(f" quiet: {args.quiet}", file=sys.stderr) + print(f" debug: {args.debug}", file=sys.stderr) + print(f" dry_run: {args.dry_run}", file=sys.stderr) + print(f" fail_fast: {args.fail_fast}", file=sys.stderr) + print(f" retry: {args.retry}", file=sys.stderr) + print(f" json_output: {args.json_output}", file=sys.stderr) + print(f" plain: {args.plain}", file=sys.stderr) if __name__ == "__main__": diff --git a/tests/test_basic_uploads.py b/tests/test_basic_uploads.py index ebbd176..0fcf7d6 100644 --- a/tests/test_basic_uploads.py +++ b/tests/test_basic_uploads.py @@ -69,7 +69,7 @@ def test_single_file_upload(self, gitlab_client, artifact_manager, project_path) str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--files", str(test_file.path), @@ -148,7 +148,7 @@ def test_multiple_file_upload(self, gitlab_client, artifact_manager, project_pat str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--files", ] @@ -235,7 +235,7 @@ def test_directory_upload(self, gitlab_client, artifact_manager, project_path): str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--directory", str(directory_path), @@ -324,7 +324,7 @@ def test_file_mapping_upload(self, gitlab_client, artifact_manager, project_path str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--files", str(test_files[0].path), @@ -428,7 +428,7 @@ def test_large_file_upload(gitlab_client, artifact_manager, project_path): str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--files", str(test_file.path), diff --git a/tests/test_duplicate_handling.py b/tests/test_duplicate_handling.py index 7b9b085..219473c 100644 --- a/tests/test_duplicate_handling.py +++ b/tests/test_duplicate_handling.py @@ -210,7 +210,7 @@ def test_skip_duplicate_policy(self, gitlab_client, artifact_manager, project_pa str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--files", str(test_file.path), @@ -266,7 +266,7 @@ def test_skip_duplicate_policy(self, gitlab_client, artifact_manager, project_pa str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--files", str(test_file.path), @@ -348,7 +348,7 @@ def test_replace_duplicate_policy( str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--files", str(first_test_file.path), @@ -400,7 +400,7 @@ def test_replace_duplicate_policy( str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--files", str(second_test_file.path), @@ -498,7 +498,7 @@ def test_error_duplicate_policy( str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--files", str(test_file.path), @@ -543,7 +543,7 @@ def test_error_duplicate_policy( str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--files", str(test_file.path), @@ -631,7 +631,7 @@ def test_multiple_file_skip_duplicates(gitlab_client, artifact_manager, project_ str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--files", ] @@ -671,7 +671,7 @@ def test_multiple_file_skip_duplicates(gitlab_client, artifact_manager, project_ str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--files", ] @@ -754,7 +754,7 @@ def test_mixed_duplicate_and_new_files(gitlab_client, artifact_manager, project_ str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--files", ] @@ -803,7 +803,7 @@ def test_mixed_duplicate_and_new_files(gitlab_client, artifact_manager, project_ str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--files", ] diff --git a/tests/test_error_scenarios.py b/tests/test_error_scenarios.py index a33e600..4cd9cfd 100644 --- a/tests/test_error_scenarios.py +++ b/tests/test_error_scenarios.py @@ -75,7 +75,7 @@ def test_network_failure_simulation( str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--gitlab-url", "https://invalid-gitlab-url.example.com", @@ -185,7 +185,7 @@ def test_authentication_error(self, gitlab_client, artifact_manager, project_pat str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--token", invalid_token, @@ -290,7 +290,7 @@ def test_error_message_validation( str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--token", _get_gitlab_token(), @@ -386,7 +386,7 @@ def test_error_message_validation( str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), "--package-name", package_name, - "--version", + "--package-version", "1.0.1", "--project-path", invalid_project_path, @@ -507,7 +507,7 @@ def test_failure_continuation_behavior( str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--token", _get_gitlab_token(), @@ -588,7 +588,7 @@ def test_failure_continuation_behavior( str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), "--package-name", package_name, - "--version", + "--package-version", "1.0.1", "--duplicate-policy", "invalid-policy", # Invalid policy @@ -676,7 +676,7 @@ def test_non_ascii_filename_rejection( str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--token", _get_gitlab_token(), @@ -800,7 +800,7 @@ def test_non_ascii_filename_in_directory(gitlab_client, artifact_manager, projec str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--token", _get_gitlab_token(), @@ -910,7 +910,7 @@ def test_mixed_ascii_non_ascii_filenames(gitlab_client, artifact_manager, projec str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--token", _get_gitlab_token(), diff --git a/tests/test_integration.py b/tests/test_integration.py index c0feb9c..5b28031 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -57,7 +57,7 @@ def test_comprehensive_upload_validation(gitlab_client, artifact_manager, projec str(executor.script_path), "--package-name", single_package, - "--version", + "--package-version", "1.0.0", "--files", str(single_file.path), @@ -111,7 +111,7 @@ def test_comprehensive_upload_validation(gitlab_client, artifact_manager, projec str(executor.script_path), "--package-name", multi_package, - "--version", + "--package-version", "1.0.0", "--files", ] @@ -162,7 +162,7 @@ def test_comprehensive_upload_validation(gitlab_client, artifact_manager, projec str(executor.script_path), "--package-name", dir_package, - "--version", + "--package-version", "1.0.0", "--directory", str(directory_path), @@ -550,7 +550,7 @@ def test_end_to_end_workflow_validation(gitlab_client, artifact_manager, project str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--files", ] @@ -639,7 +639,7 @@ def test_parallel_execution_safety(gitlab_client, artifact_manager, project_path str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--files", str(test_file.path), @@ -735,7 +735,7 @@ def test_comprehensive_cleanup_verification( str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--files", str(test_files[i].path), @@ -839,7 +839,7 @@ def test_multi_scenario_workflow_validation( str(executor.script_path), "--package-name", package_name_1, - "--version", + "--package-version", "1.0.0", "--files", str(test_file_1.path), @@ -891,7 +891,7 @@ def test_multi_scenario_workflow_validation( str(executor.script_path), "--package-name", package_name_2, - "--version", + "--package-version", "1.0.0", "--directory", str(directory_path), @@ -940,7 +940,7 @@ def test_multi_scenario_workflow_validation( str(executor.script_path), "--package-name", package_name_3, - "--version", + "--package-version", "1.0.0", "--files", ] diff --git a/tests/test_project_resolution.py b/tests/test_project_resolution.py index 497f50c..9193963 100644 --- a/tests/test_project_resolution.py +++ b/tests/test_project_resolution.py @@ -68,7 +68,7 @@ def test_git_auto_detection(self, gitlab_client, artifact_manager, project_path) str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--json-output", "--files", @@ -168,7 +168,7 @@ def test_manual_project_url_specification( str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--project-url", project_url, @@ -263,7 +263,7 @@ def test_manual_project_url_specification( str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--project-url", project_url, @@ -344,7 +344,7 @@ def test_manual_project_path_specification( str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--project-path", project_path, @@ -430,7 +430,7 @@ def test_invalid_project_path_error_handling( str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--project-path", invalid_project_path, @@ -562,7 +562,7 @@ def test_invalid_project_url_error_handling( str(executor.script_path), "--package-name", package_name, - "--version", + "--package-version", "1.0.0", "--project-url", invalid_project_url, diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py index a02625a..48382b6 100644 --- a/tests/utils/test_helpers.py +++ b/tests/utils/test_helpers.py @@ -269,7 +269,7 @@ def build_command(self, use_json_output: bool = False, **kwargs) -> List[str]: if "package_name" in kwargs: command.extend(["--package-name", kwargs["package_name"]]) if "version" in kwargs: - command.extend(["--version", kwargs["version"]]) + command.extend(["--package-version", kwargs["version"]]) if "files" in kwargs: if isinstance(kwargs["files"], list): command.extend(["--files"] + kwargs["files"]) From f67930e1cca1a8f473054af5c3a3e94576652068 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Fri, 9 Jan 2026 18:14:51 -0600 Subject: [PATCH 10/36] gitlab_pkg_upload/cli: Add GitLab project auto-detection and resolution The CLI previously required manual project specification for every invocation, forcing users to provide full project URLs or paths even when running from within a Git repository that already contains this information in its remotes. Introduce GitAutoDetector to discover Git repositories and extract GitLab project information from configured remotes, prioritizing the origin remote when multiple GitLab remotes exist. Add ProjectResolver to validate project paths against the GitLab API and resolve them to project IDs. The auto-detection gracefully skips known non-GitLab hosts like GitHub and Bitbucket, falling back to manual specification with actionable error messages when detection fails. Signed-off-by: Javier Tia --- src/gitlab_pkg_upload/cli.py | 576 ++++++++++++++++++++++++++++++++++- 1 file changed, 566 insertions(+), 10 deletions(-) diff --git a/src/gitlab_pkg_upload/cli.py b/src/gitlab_pkg_upload/cli.py index e1624f2..df195ce 100644 --- a/src/gitlab_pkg_upload/cli.py +++ b/src/gitlab_pkg_upload/cli.py @@ -19,6 +19,12 @@ --gitlab-url GitLab instance URL (default: https://gitlab.com) --token GitLab API token (or use GITLAB_TOKEN env var) + Project resolution (auto-detected or manual): + Auto-detection: Searches for .git directory and extracts GitLab project + from git remotes (prioritizes 'origin' remote) + --project-url: Full GitLab project URL (e.g., https://gitlab.com/ns/proj) + --project-path: Project path with --gitlab-url (e.g., namespace/project) + Duplicate handling: --duplicate-policy How to handle duplicates: skip, replace, error @@ -64,18 +70,38 @@ from __future__ import annotations import argparse +import logging import sys from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional import argcomplete - -from gitlab_pkg_upload.models import DuplicatePolicy -from gitlab_pkg_upload.validators import DEFAULT_GITLAB_URL +import git +from gitlab import Gitlab +from gitlab.exceptions import GitlabAuthenticationError, GitlabGetError + +from gitlab_pkg_upload.models import ( + AuthenticationError, + ConfigurationError, + DuplicatePolicy, + GitRemoteInfo, + ProjectInfo, + ProjectResolutionError, + enhance_error_message, +) +from gitlab_pkg_upload.validators import ( + DEFAULT_GITLAB_URL, + get_gitlab_token, + normalize_gitlab_url, + parse_git_url, +) if TYPE_CHECKING: pass +# Module-level logger +logger = logging.getLogger(__name__) + def create_argument_parser() -> argparse.ArgumentParser: """Create and configure the argument parser for gitlab-pkg-upload. @@ -455,6 +481,495 @@ def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace: return args +class GitAutoDetector: + """Auto-detect GitLab project from Git repository. + + This class handles Git repository discovery and remote parsing to + automatically detect GitLab project information from the current + working directory. + + Attributes: + working_directory: Directory to search for Git repository. + """ + + def __init__(self, working_directory: str = ".") -> None: + """Initialize GitAutoDetector. + + Args: + working_directory: Directory to search for Git repository. + Parent directories are also searched. + """ + self.working_directory = working_directory + + def find_git_repository(self) -> Optional[git.Repo]: + """Find Git repository in working directory or parent directories. + + Returns: + Git repository object if found, None if no repository exists. + + Raises: + ProjectResolutionError: If repository access fails due to + permissions, corruption, or other errors. + """ + try: + repo = git.Repo(self.working_directory, search_parent_directories=True) + logger.debug(f"Found Git repository at: {repo.working_dir}") + return repo + except git.InvalidGitRepositoryError: + logger.debug(f"No Git repository found in {self.working_directory}") + return None + except PermissionError as e: + raise ProjectResolutionError( + f"Permission denied accessing Git repository in '{self.working_directory}': {e}\n\n" + "SOLUTION:\n" + "1. Check directory permissions:\n" + f" ls -la {self.working_directory}\n\n" + "2. Use manual project specification:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project" + ) + except git.GitCommandError as e: + raise ProjectResolutionError( + f"Git command error in '{self.working_directory}': {e}\n\n" + "SOLUTION:\n" + "1. Check repository status:\n" + " git status\n\n" + "2. Use manual project specification:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project" + ) + except OSError as e: + raise ProjectResolutionError( + f"OS error accessing Git repository in '{self.working_directory}': {e}\n\n" + "SOLUTION:\n" + "1. Verify directory exists:\n" + f" ls -la {self.working_directory}\n\n" + "2. Use manual project specification:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project" + ) + + def _looks_like_gitlab_url(self, url: str) -> bool: + """Check if URL appears to be a GitLab instance. + + Args: + url: URL to check. + + Returns: + True if URL contains GitLab-related keywords, False otherwise. + """ + url_lower = url.lower() + gitlab_indicators = ["gitlab.com", "gitlab.", ".gitlab.", "git.lab"] + return any(indicator in url_lower for indicator in gitlab_indicators) + + def _is_known_non_gitlab_host(self, host: str) -> bool: + """Check if host is a known non-GitLab service. + + Args: + host: Hostname to check (e.g., 'github.com', 'bitbucket.org'). + + Returns: + True if host is a known non-GitLab service, False otherwise. + """ + host_lower = host.lower() + # Known non-GitLab Git hosting services + non_gitlab_hosts = [ + "github.com", + "github.", + ".github.", + "bitbucket.org", + "bitbucket.", + ".bitbucket.", + "codeberg.org", + "codeberg.", + "sr.ht", + "sourcehut.", + "gitea.com", + "gitea.", + "gogs.", + "azure.com", + "dev.azure.com", + "visualstudio.com", + ] + return any(indicator in host_lower for indicator in non_gitlab_hosts) + + def parse_git_url(self, remote_url: str) -> Optional[tuple[str, str]]: + """Parse a Git remote URL to extract GitLab instance URL and project path. + + Args: + remote_url: Git remote URL (HTTPS or SSH format). + + Returns: + Tuple of (gitlab_url, project_path) if successful, None if URL + is not a GitLab URL or cannot be parsed. + + Raises: + ProjectResolutionError: If URL looks like GitLab but format is unrecognized. + """ + try: + gitlab_url, project_path = parse_git_url(remote_url) + + # Check if this is a known non-GitLab host (GitHub, Bitbucket, etc.) + if self._is_known_non_gitlab_host(gitlab_url): + logger.debug(f"Ignoring known non-GitLab host: {gitlab_url}") + return None + + # Validate this is a GitLab instance + if self._looks_like_gitlab_url(gitlab_url): + logger.debug(f"Parsed GitLab URL: {gitlab_url}, project: {project_path}") + return gitlab_url, project_path + + # URL parsed but doesn't look like GitLab - return it anyway + # (could be self-hosted GitLab without 'gitlab' in hostname) + logger.debug( + f"URL parsed but doesn't contain 'gitlab': {gitlab_url}, project: {project_path}" + ) + return gitlab_url, project_path + + except Exception as e: + # Check if URL looks like it should be GitLab + if self._looks_like_gitlab_url(remote_url): + raise ProjectResolutionError( + f"URL appears to be GitLab but format is unrecognized: '{remote_url}'\n\n" + f"Parse error: {e}\n\n" + "SOLUTION:\n" + "Supported Git URL formats:\n" + " • HTTPS: https://gitlab.com/namespace/project.git\n" + " • SSH: git@gitlab.com:namespace/project.git\n\n" + "Use manual project specification:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project" + ) + # Not GitLab URL - return None + logger.debug(f"Could not parse URL as GitLab: {remote_url}") + return None + + def get_gitlab_remotes(self, repo: git.Repo) -> list[GitRemoteInfo]: + """Extract GitLab remotes from repository. + + Iterates through repository remotes, identifies GitLab instances, + and prioritizes 'origin' remote when multiple GitLab remotes exist. + + Args: + repo: Git repository to extract remotes from. + + Returns: + List of GitRemoteInfo objects for GitLab remotes, sorted with + 'origin' first if present. + + Raises: + ProjectResolutionError: If no remotes found or no GitLab remotes detected. + """ + remotes = list(repo.remotes) + + if not remotes: + raise ProjectResolutionError( + "No Git remotes configured in repository.\n\n" + "SOLUTION:\n" + "1. Add a Git remote:\n" + " git remote add origin https://gitlab.com/namespace/project.git\n\n" + "2. Or use manual project specification:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project" + ) + + gitlab_remotes: list[GitRemoteInfo] = [] + all_remote_urls: list[str] = [] + + for remote in remotes: + # Get all URLs for this remote + urls = list(remote.urls) + all_remote_urls.extend(urls) + + for url in urls: + parsed = self.parse_git_url(url) + if parsed: + gitlab_url, project_path = parsed + gitlab_remotes.append( + GitRemoteInfo( + name=remote.name, + url=url, + gitlab_url=gitlab_url, + project_path=project_path, + ) + ) + logger.debug(f"Found GitLab remote '{remote.name}': {project_path}") + break # Only use first valid URL per remote + + if not gitlab_remotes: + remote_list = "\n".join(f" • {url}" for url in all_remote_urls) + raise ProjectResolutionError( + f"No GitLab remotes found in repository.\n\n" + f"Found remotes:\n{remote_list}\n\n" + "SOLUTION:\n" + "1. Add a GitLab remote:\n" + " git remote add origin https://gitlab.com/namespace/project.git\n\n" + "2. Or use manual project specification:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project\n\n" + "Supported GitLab URL formats:\n" + " • HTTPS: https://gitlab.com/namespace/project.git\n" + " • SSH: git@gitlab.com:namespace/project.git" + ) + + # Prioritize 'origin' remote + gitlab_remotes.sort(key=lambda r: (0 if r.name == "origin" else 1, r.name)) + + if len(gitlab_remotes) > 1: + logger.info( + f"Multiple GitLab remotes found, using '{gitlab_remotes[0].name}': " + f"{gitlab_remotes[0].project_path}" + ) + + return gitlab_remotes + + +class ProjectResolver: + """Resolve GitLab project ID from project path. + + This class handles GitLab API interactions to resolve project paths + to project IDs and validate project access. + + Attributes: + gl: Authenticated GitLab client. + project_cache: Cache of resolved project IDs. + """ + + def __init__(self, gitlab_client: Gitlab) -> None: + """Initialize ProjectResolver. + + Args: + gitlab_client: Authenticated GitLab client instance. + """ + self.gl = gitlab_client + self.project_cache: dict[str, int] = {} + + def parse_project_url(self, url: str) -> ProjectInfo: + """Parse a GitLab project URL into components. + + Args: + url: Full GitLab project URL. + + Returns: + ProjectInfo object with parsed components. + + Raises: + ProjectResolutionError: If URL format is invalid. + """ + try: + gitlab_url, project_path = normalize_gitlab_url(url) + except Exception as e: + raise ProjectResolutionError( + f"Invalid GitLab project URL: '{url}'\n\n" + f"Error: {e}\n\n" + "SOLUTION:\n" + "Expected URL format: https://gitlab.com/namespace/project\n\n" + "Examples:\n" + " • https://gitlab.com/mycompany/my-project\n" + " • https://gitlab.example.com/group/subgroup/project" + ) + + # Split project_path into namespace and project_name + path_parts = project_path.split("/") + namespace = "/".join(path_parts[:-1]) + project_name = path_parts[-1] + + return ProjectInfo( + gitlab_url=gitlab_url, + namespace=namespace, + project_name=project_name, + project_path=project_path, + original_url=url, + ) + + def resolve_project_id(self, gitlab_url: str, project_path: str) -> int: + """Resolve project path to project ID via GitLab API. + + Uses caching to avoid redundant API calls for the same project. + + Args: + gitlab_url: GitLab instance URL. + project_path: Project path (namespace/project). + + Returns: + Project ID. + + Raises: + ProjectResolutionError: If project cannot be found or accessed. + """ + cache_key = f"{gitlab_url}/{project_path}" + + # Check cache first + if cache_key in self.project_cache: + logger.debug(f"Using cached project ID for {project_path}") + return self.project_cache[cache_key] + + context = { + "project_path": project_path, + "gitlab_url": gitlab_url, + "operation": "project resolution", + } + + try: + logger.debug(f"Resolving project ID for: {project_path}") + project = self.gl.projects.get(project_path) + project_id = project.id + + # Cache the result + self.project_cache[cache_key] = project_id + logger.info(f"Resolved project '{project_path}' to ID {project_id}") + + return project_id + + except GitlabGetError as e: + error_msg = enhance_error_message(e, context) + raise ProjectResolutionError(error_msg) + except GitlabAuthenticationError as e: + error_msg = enhance_error_message(e, context) + raise ProjectResolutionError(error_msg) + except Exception as e: + error_msg = enhance_error_message(e, context) + raise ProjectResolutionError(error_msg) + + def validate_project_access(self, project_id: int) -> bool: + """Validate that the project is accessible. + + Args: + project_id: Project ID to validate. + + Returns: + True if project is accessible, False otherwise. + """ + try: + project = self.gl.projects.get(project_id) + # Check if we can access basic project attributes + _ = project.name + _ = project.path_with_namespace + logger.debug(f"Project access validated: {project.path_with_namespace}") + return True + except Exception as e: + logger.warning(f"Project access validation failed for ID {project_id}: {e}") + return False + + +def auto_detect_project() -> tuple[str, str]: + """Auto-detect GitLab project from git repository. + + Searches for a Git repository in the current directory and parent + directories, then extracts GitLab project information from git remotes. + + Returns: + Tuple of (gitlab_url, project_path). + + Raises: + ProjectResolutionError: If auto-detection fails. + """ + detector = GitAutoDetector() + + # Find git repository + repo = detector.find_git_repository() + if repo is None: + raise ProjectResolutionError( + "No Git repository found in current directory or parent directories.\n\n" + "SOLUTION:\n" + "1. Ensure you're in a Git repository:\n" + " git status\n\n" + "2. Initialize a repository if needed:\n" + " git init\n" + " git remote add origin https://gitlab.com/namespace/project.git\n\n" + "3. Or use manual project specification:\n" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project" + ) + + # Get GitLab remotes + gitlab_remotes = detector.get_gitlab_remotes(repo) + + # Select first (prioritized) remote + selected_remote = gitlab_remotes[0] + gitlab_url = selected_remote.gitlab_url + project_path = selected_remote.project_path + + logger.info(f"Auto-detected project: {project_path} from {gitlab_url}") + + return gitlab_url, project_path + + +def resolve_project_manually( + project_url: str | None, + project_path: str | None, + gitlab_url: str, +) -> tuple[str, str]: + """Resolve project from manual specification. + + Args: + project_url: Full GitLab project URL (mutually exclusive with project_path). + project_path: Project path in namespace/project format. + gitlab_url: GitLab instance URL (used with project_path). + + Returns: + Tuple of (gitlab_url, project_path). + + Raises: + ProjectResolutionError: If project specification is invalid. + """ + if project_url: + # Parse full project URL + try: + resolved_gitlab_url, resolved_project_path = normalize_gitlab_url(project_url) + logger.info(f"Using project URL: {project_url}") + return resolved_gitlab_url, resolved_project_path + except Exception as e: + raise ProjectResolutionError( + f"Invalid project URL: '{project_url}'\n\n" + f"Error: {e}\n\n" + "SOLUTION:\n" + "Expected URL format: https://gitlab.com/namespace/project\n\n" + "Examples:\n" + " • https://gitlab.com/mycompany/my-project\n" + " • https://gitlab.example.com/group/subgroup/project" + ) + + elif project_path: + # Validate project path format + path = project_path.strip().strip("/") + + if "/" not in path: + raise ProjectResolutionError( + f"Invalid project path format: '{project_path}'\n\n" + "Project path must contain at least namespace/project.\n\n" + "SOLUTION:\n" + "Examples of valid project paths:\n" + " • mycompany/my-project\n" + " • group/subgroup/project-name\n" + " • username/personal-project" + ) + + # Validate path components + path_parts = path.split("/") + if len(path_parts) < 2 or not all(path_parts[:2]): + raise ProjectResolutionError( + f"Invalid project path: '{project_path}'\n\n" + "Path must contain at least namespace and project name.\n\n" + "SOLUTION:\n" + "Examples of valid project paths:\n" + " • mycompany/my-project\n" + " • group/subgroup/project-name" + ) + + logger.info(f"Using project path: {path} at {gitlab_url}") + return gitlab_url, path + + else: + raise ProjectResolutionError( + "No project specification provided.\n\n" + "SOLUTION:\n" + "Use one of the following:\n" + " • --project-url https://gitlab.com/namespace/project\n" + " • --project-path namespace/project --gitlab-url https://gitlab.com" + ) + + def main(argv: list[str] | None = None) -> None: """Main entry point for the gitlab-pkg-upload CLI. @@ -468,14 +983,54 @@ def main(argv: list[str] | None = None) -> None: # Note: --version flag is handled automatically by argparse via action="version" args = parse_arguments(argv) - # TODO: Phase 2 - Project resolution - # - Auto-detect from Git remote if no project specified - # - Resolve project ID from URL or path - # - Validate project accessibility + # Project resolution + try: + if args.project_url or args.project_path: + # Manual specification (Flow 8) + gitlab_url, project_path = resolve_project_manually( + project_url=args.project_url, + project_path=args.project_path, + gitlab_url=args.gitlab_url, + ) + else: + # Auto-detection (Flow 7) + gitlab_url, project_path = auto_detect_project() + + # Authenticate with GitLab + token = get_gitlab_token(args.token) + gl = Gitlab(gitlab_url, private_token=token) + gl.auth() + + # Resolve project ID + resolver = ProjectResolver(gl) + project_id = resolver.resolve_project_id(gitlab_url, project_path) + + # Validate access + if not resolver.validate_project_access(project_id): + raise ProjectResolutionError( + f"Cannot access project {project_path}. " + f"Verify you have appropriate permissions." + ) + + # Log success + if args.verbose or args.debug: + print(f"Successfully resolved project: {project_path} (ID: {project_id})") + + except ProjectResolutionError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(4) + except AuthenticationError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(2) + except ConfigurationError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(3) + except Exception as e: + print(f"Unexpected error during project resolution: {e}", file=sys.stderr) + sys.exit(1) # TODO: Phase 3 - Context building # - Build UploadConfig from parsed arguments - # - Create GitLab client connection # - Initialize DuplicateDetector # - Build UploadContext @@ -493,7 +1048,8 @@ def main(argv: list[str] | None = None) -> None: print(f" directory: {args.directory}", file=sys.stderr) print(f" project_url: {args.project_url}", file=sys.stderr) print(f" project_path: {args.project_path}", file=sys.stderr) - print(f" gitlab_url: {args.gitlab_url}", file=sys.stderr) + print(f" gitlab_url: {gitlab_url}", file=sys.stderr) + print(f" project_id: {project_id}", file=sys.stderr) print(f" duplicate_policy: {args.duplicate_policy}", file=sys.stderr) print(f" file_mapping: {args.file_mapping}", file=sys.stderr) print(f" verbose: {args.verbose}", file=sys.stderr) From 9fe5f6ef882e0ee3b3b6b4ae082e7cf8311c718d Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Fri, 9 Jan 2026 19:29:20 -0600 Subject: [PATCH 11/36] gitlab-pkg-upload: Add context building phase with structured logging The CLI module uses ad-hoc print statements for logging and lacks a unified upload context, making it difficult to control output verbosity and pass configuration state to downstream upload components. Introduce RichHandler-based logging that respects verbosity flags and routes output to stderr when JSON mode is enabled, preserving stdout for machine-readable output. Add UploadContextBuilder to construct a fully validated UploadContext containing the GitLab client, configuration, and duplicate detector, which subsequent upload phases will consume. Signed-off-by: Javier Tia --- src/gitlab_pkg_upload/cli.py | 229 ++++++++++++++++++++++++++++++----- 1 file changed, 198 insertions(+), 31 deletions(-) diff --git a/src/gitlab_pkg_upload/cli.py b/src/gitlab_pkg_upload/cli.py index df195ce..2d4389b 100644 --- a/src/gitlab_pkg_upload/cli.py +++ b/src/gitlab_pkg_upload/cli.py @@ -79,7 +79,10 @@ import git from gitlab import Gitlab from gitlab.exceptions import GitlabAuthenticationError, GitlabGetError +from rich.console import Console +from rich.logging import RichHandler +from gitlab_pkg_upload.duplicate_detector import DuplicateDetector from gitlab_pkg_upload.models import ( AuthenticationError, ConfigurationError, @@ -87,6 +90,8 @@ GitRemoteInfo, ProjectInfo, ProjectResolutionError, + UploadConfig, + UploadContext, enhance_error_message, ) from gitlab_pkg_upload.validators import ( @@ -103,6 +108,70 @@ logger = logging.getLogger(__name__) +def determine_verbosity(args: argparse.Namespace) -> str: + """Determine verbosity level from parsed arguments. + + Checks verbosity flags in priority order: debug > verbose > quiet > normal. + + Args: + args: Parsed argument namespace from argparse. + + Returns: + One of: 'debug', 'verbose', 'quiet', or 'normal'. + """ + if args.debug: + return "debug" + elif args.verbose: + return "verbose" + elif args.quiet: + return "quiet" + else: + return "normal" + + +def setup_logging(args: argparse.Namespace) -> None: + """Configure logging based on verbosity flags. + + Sets up Python's root logger with RichHandler for enhanced console output. + When --json-output is enabled, logs go to stderr to keep stdout clean for JSON. + + Args: + args: Parsed argument namespace containing verbosity flags. + """ + verbosity = determine_verbosity(args) + + # Determine log level based on verbosity + log_levels = { + "debug": logging.DEBUG, + "verbose": logging.INFO, + "quiet": logging.WARNING, + "normal": logging.INFO, + } + level = log_levels.get(verbosity, logging.INFO) + + # Use stderr when json_output is enabled to keep stdout clean for JSON + stream = sys.stderr if args.json_output else sys.stdout + + # Configure RichHandler with appropriate settings + rich_handler = RichHandler( + console=Console(file=stream), + show_time=True, + show_path=False, + markup=True, + rich_tracebacks=True, + ) + + # Configure root logger + logging.basicConfig( + level=level, + format="%(message)s", + handlers=[rich_handler], + force=True, # Reconfigure if already configured + ) + + logger.debug(f"Logging configured: level={verbosity}, stream={'stderr' if args.json_output else 'stdout'}") + + def create_argument_parser() -> argparse.ArgumentParser: """Create and configure the argument parser for gitlab-pkg-upload. @@ -852,6 +921,94 @@ def validate_project_access(self, project_id: int) -> bool: return False +class UploadContextBuilder: + """Builder for safely initializing upload context with all required components. + + This class follows the builder pattern to create an UploadContext with + all dependencies properly initialized and validated. + """ + + def __init__(self) -> None: + """Initialize UploadContextBuilder.""" + pass + + def build( + self, + args: argparse.Namespace, + gl: Gitlab, + project_id: int, + project_path: str, + gitlab_url: str, + token: str, + ) -> UploadContext: + """Build an UploadContext from parsed arguments and resolved project info. + + Args: + args: Parsed argument namespace from argparse. + gl: Authenticated GitLab client. + project_id: Resolved GitLab project ID. + project_path: Resolved project path (namespace/project). + gitlab_url: GitLab instance URL. + token: Resolved GitLab API token (from CLI or environment). + + Returns: + Fully initialized UploadContext ready for upload operations. + + Raises: + ConfigurationError: If context building fails due to configuration issues. + """ + try: + # Determine verbosity level + verbosity = determine_verbosity(args) + + # Create UploadConfig from parsed arguments + config = UploadConfig( + package_name=args.package_name, + version=args.package_version, + duplicate_policy=args.duplicate_policy, + retry_count=args.retry, + verbosity=verbosity, + dry_run=args.dry_run, + fail_fast=args.fail_fast, + json_output=args.json_output, + plain_output=args.plain, + gitlab_url=gitlab_url, + token=token, # Resolved token (from CLI or environment) + ) + + logger.debug(f"Created UploadConfig: package={config.package_name}, version={config.version}") + + # Initialize DuplicateDetector + detector = DuplicateDetector(gl, project_id) + logger.debug(f"Initialized DuplicateDetector for project ID {project_id}") + + # Create and return UploadContext + context = UploadContext( + gl=gl, + config=config, + detector=detector, + project_id=project_id, + project_path=project_path, + ) + + logger.info(f"Built upload context for {project_path} (ID: {project_id})") + logger.debug( + f"Context details: package={config.package_name}, version={config.version}, " + f"duplicate_policy={config.duplicate_policy.value}, dry_run={config.dry_run}" + ) + + return context + + except Exception as e: + raise ConfigurationError( + f"Failed to build upload context: {e}\n\n" + "SOLUTION:\n" + " • Verify all required arguments are provided\n" + " • Check that project ID is valid\n" + " • Ensure GitLab client is properly authenticated" + ) + + def auto_detect_project() -> tuple[str, str]: """Auto-detect GitLab project from git repository. @@ -983,6 +1140,9 @@ def main(argv: list[str] | None = None) -> None: # Note: --version flag is handled automatically by argparse via action="version" args = parse_arguments(argv) + # Configure logging based on verbosity flags + setup_logging(args) + # Project resolution try: if args.project_url or args.project_path: @@ -1013,53 +1173,60 @@ def main(argv: list[str] | None = None) -> None: ) # Log success - if args.verbose or args.debug: - print(f"Successfully resolved project: {project_path} (ID: {project_id})") + logger.info(f"Successfully resolved project: {project_path} (ID: {project_id})") + + # Phase 3 - Context building + builder = UploadContextBuilder() + context = builder.build( + args=args, + gl=gl, + project_id=project_id, + project_path=project_path, + gitlab_url=gitlab_url, + token=token, + ) + logger.debug(f"Upload context built successfully for {project_path}") except ProjectResolutionError as e: - print(f"Error: {e}", file=sys.stderr) + logger.error(f"Project resolution failed: {e}") sys.exit(4) except AuthenticationError as e: - print(f"Error: {e}", file=sys.stderr) + logger.error(f"Authentication failed: {e}") sys.exit(2) except ConfigurationError as e: - print(f"Error: {e}", file=sys.stderr) + logger.error(f"Configuration error: {e}") sys.exit(3) except Exception as e: - print(f"Unexpected error during project resolution: {e}", file=sys.stderr) + logger.error(f"Unexpected error during project resolution: {e}") sys.exit(1) - # TODO: Phase 3 - Context building - # - Build UploadConfig from parsed arguments - # - Initialize DuplicateDetector - # - Build UploadContext - # TODO: Phase 4 - Upload orchestration # - Collect files to upload # - Execute uploads with retry handling # - Format and display results - # Placeholder: print parsed configuration (for development/testing) + # Debug output: print parsed configuration (for development/testing) if args.debug: - print("Parsed arguments:", file=sys.stderr) - print(f" package_name: {args.package_name}", file=sys.stderr) - print(f" package_version: {args.package_version}", file=sys.stderr) - print(f" files: {args.files}", file=sys.stderr) - print(f" directory: {args.directory}", file=sys.stderr) - print(f" project_url: {args.project_url}", file=sys.stderr) - print(f" project_path: {args.project_path}", file=sys.stderr) - print(f" gitlab_url: {gitlab_url}", file=sys.stderr) - print(f" project_id: {project_id}", file=sys.stderr) - print(f" duplicate_policy: {args.duplicate_policy}", file=sys.stderr) - print(f" file_mapping: {args.file_mapping}", file=sys.stderr) - print(f" verbose: {args.verbose}", file=sys.stderr) - print(f" quiet: {args.quiet}", file=sys.stderr) - print(f" debug: {args.debug}", file=sys.stderr) - print(f" dry_run: {args.dry_run}", file=sys.stderr) - print(f" fail_fast: {args.fail_fast}", file=sys.stderr) - print(f" retry: {args.retry}", file=sys.stderr) - print(f" json_output: {args.json_output}", file=sys.stderr) - print(f" plain: {args.plain}", file=sys.stderr) + logger.debug("Parsed arguments:") + logger.debug(f" package_name: {args.package_name}") + logger.debug(f" package_version: {args.package_version}") + logger.debug(f" files: {args.files}") + logger.debug(f" directory: {args.directory}") + logger.debug(f" project_url: {args.project_url}") + logger.debug(f" project_path: {args.project_path}") + logger.debug(f" gitlab_url: {gitlab_url}") + logger.debug(f" project_id: {project_id}") + logger.debug(f" duplicate_policy: {args.duplicate_policy}") + logger.debug(f" file_mapping: {args.file_mapping}") + logger.debug(f" verbose: {args.verbose}") + logger.debug(f" quiet: {args.quiet}") + logger.debug(f" debug: {args.debug}") + logger.debug(f" dry_run: {args.dry_run}") + logger.debug(f" fail_fast: {args.fail_fast}") + logger.debug(f" retry: {args.retry}") + logger.debug(f" json_output: {args.json_output}") + logger.debug(f" plain: {args.plain}") + logger.debug(f" context: {context}") if __name__ == "__main__": From a3845152aeb514e89b637a0e64549b471e914e7f Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Fri, 9 Jan 2026 20:06:58 -0600 Subject: [PATCH 12/36] gitlab_pkg_upload/cli: Implement upload orchestration phase The CLI previously stopped after resolving the GitLab project context, leaving file collection, upload execution, and result formatting as unimplemented TODO items. Users could not actually upload packages despite the command accepting all necessary arguments. Complete the main function by wiring together file collection, upload execution via the uploader module, and result formatting. Exit codes now consistently derive from exception classes where available, with a centralized mapping table for standard Python exceptions. This ensures predictable exit behavior for scripting and CI integration while keeping error handling logic maintainable. Signed-off-by: Javier Tia --- src/gitlab_pkg_upload/cli.py | 135 +++++++++++++++++++++++++++-------- 1 file changed, 104 insertions(+), 31 deletions(-) diff --git a/src/gitlab_pkg_upload/cli.py b/src/gitlab_pkg_upload/cli.py index 2d4389b..7075167 100644 --- a/src/gitlab_pkg_upload/cli.py +++ b/src/gitlab_pkg_upload/cli.py @@ -87,6 +87,7 @@ AuthenticationError, ConfigurationError, DuplicatePolicy, + GitLabUploadError, GitRemoteInfo, ProjectInfo, ProjectResolutionError, @@ -104,9 +105,25 @@ if TYPE_CHECKING: pass +from gitlab_pkg_upload.formatters import OutputFormatter +from gitlab_pkg_upload.uploader import upload_files +from gitlab_pkg_upload.validators import collect_files + # Module-level logger logger = logging.getLogger(__name__) +# Exception exit code mapping for standard Python exceptions +# Custom exceptions (GitLabUploadError subclasses) use their exit_code attribute. +# Standard Python exceptions use this mapping table. +# Unknown exceptions default to exit code 1. +EXCEPTION_EXIT_CODE_MAP: dict[type, int] = { + FileNotFoundError: 5, # File validation failure + PermissionError: 5, # File validation failure + ValueError: 3, # Configuration error + ConnectionError: 6, # Network error + TimeoutError: 6, # Network error +} + def determine_verbosity(args: argparse.Namespace) -> str: """Determine verbosity level from parsed arguments. @@ -1187,46 +1204,102 @@ def main(argv: list[str] | None = None) -> None: ) logger.debug(f"Upload context built successfully for {project_path}") - except ProjectResolutionError as e: - logger.error(f"Project resolution failed: {e}") - sys.exit(4) except AuthenticationError as e: logger.error(f"Authentication failed: {e}") - sys.exit(2) + sys.exit(e.exit_code) except ConfigurationError as e: logger.error(f"Configuration error: {e}") - sys.exit(3) + sys.exit(e.exit_code) + except ProjectResolutionError as e: + logger.error(f"Project resolution failed: {e}") + sys.exit(e.exit_code) + except GitLabUploadError as e: + logger.error(f"GitLab error: {e}") + sys.exit(e.exit_code) + except FileNotFoundError as e: + logger.error(f"File not found: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(FileNotFoundError, 1)) + except PermissionError as e: + logger.error(f"Permission denied: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(PermissionError, 1)) + except ValueError as e: + logger.error(f"Value error: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(ValueError, 1)) + except ConnectionError as e: + logger.error(f"Connection error: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(ConnectionError, 1)) + except TimeoutError as e: + logger.error(f"Timeout error: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(TimeoutError, 1)) except Exception as e: logger.error(f"Unexpected error during project resolution: {e}") sys.exit(1) - # TODO: Phase 4 - Upload orchestration - # - Collect files to upload - # - Execute uploads with retry handling - # - Format and display results + # Phase 4 - Upload orchestration + try: + # Step 1: Collect files to upload + files_to_upload, file_errors = collect_files( + files=args.files, + directory=args.directory, + file_mappings=args.file_mapping, + ) - # Debug output: print parsed configuration (for development/testing) - if args.debug: - logger.debug("Parsed arguments:") - logger.debug(f" package_name: {args.package_name}") - logger.debug(f" package_version: {args.package_version}") - logger.debug(f" files: {args.files}") - logger.debug(f" directory: {args.directory}") - logger.debug(f" project_url: {args.project_url}") - logger.debug(f" project_path: {args.project_path}") - logger.debug(f" gitlab_url: {gitlab_url}") - logger.debug(f" project_id: {project_id}") - logger.debug(f" duplicate_policy: {args.duplicate_policy}") - logger.debug(f" file_mapping: {args.file_mapping}") - logger.debug(f" verbose: {args.verbose}") - logger.debug(f" quiet: {args.quiet}") - logger.debug(f" debug: {args.debug}") - logger.debug(f" dry_run: {args.dry_run}") - logger.debug(f" fail_fast: {args.fail_fast}") - logger.debug(f" retry: {args.retry}") - logger.debug(f" json_output: {args.json_output}") - logger.debug(f" plain: {args.plain}") - logger.debug(f" context: {context}") + # Handle file collection errors + if file_errors: + for error in file_errors: + logger.error( + f"File validation error for {error['source_path']}: {error['error_message']}" + ) + if args.fail_fast: + logger.error("Fail-fast enabled, stopping due to file validation errors") + sys.exit(5) + + # Check if we have any valid files to upload + if not files_to_upload: + logger.error("No valid files to upload") + sys.exit(5) + + logger.info(f"Collected {len(files_to_upload)} files to upload") + + # Step 2: Execute uploads + results = upload_files(context, files_to_upload) + + # Step 3: Format and display results + formatter = OutputFormatter(context.config) + formatter.format_output( + results, + context.config.package_name, + context.config.version, + ) + + # Step 4: Determine exit code based on results + failed_count = sum(1 for r in results if not r.success) + if failed_count > 0: + sys.exit(1) + else: + sys.exit(0) + + except FileNotFoundError as e: + logger.error(f"File not found: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(FileNotFoundError, 1)) + except PermissionError as e: + logger.error(f"Permission denied: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(PermissionError, 1)) + except ValueError as e: + logger.error(f"Value error: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(ValueError, 1)) + except ConnectionError as e: + logger.error(f"Connection error: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(ConnectionError, 1)) + except TimeoutError as e: + logger.error(f"Timeout error: {e}") + sys.exit(EXCEPTION_EXIT_CODE_MAP.get(TimeoutError, 1)) + except GitLabUploadError as e: + logger.error(f"Upload error: {e}") + sys.exit(e.exit_code) + except Exception as e: + logger.error(f"Unexpected error during upload: {e}") + sys.exit(1) if __name__ == "__main__": From 0f144077e45a273aa67c72f7c4585c44ab3710b9 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 06:59:30 -0600 Subject: [PATCH 13/36] tests: Add comprehensive unit tests for models and validators The gitlab-pkg-upload package lacks unit test coverage for its core data structures and validation logic. Without tests, regressions in model behavior, exception handling, or input validation could go undetected, making the codebase fragile as it evolves. Introduce a complete unit test suite covering the models module (dataclasses, enums, exception hierarchy, and error enhancement functions) and the validators module (file validation, Git URL parsing, token handling, and configuration validation). All tests are isolated using mocks for external dependencies like filesystem, subprocess, and GitPython, ensuring fast and reliable execution without requiring actual GitLab API access or specific system configurations. Signed-off-by: Javier Tia --- tests/unit/test_models.py | 933 ++++++++++++++++++++++++++++ tests/unit/test_validators.py | 1094 +++++++++++++++++++++++++++++++++ 2 files changed, 2027 insertions(+) create mode 100644 tests/unit/test_models.py create mode 100644 tests/unit/test_validators.py diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py new file mode 100644 index 0000000..fb9c66a --- /dev/null +++ b/tests/unit/test_models.py @@ -0,0 +1,933 @@ +""" +Comprehensive unit tests for the models module. + +These tests validate the data models, enums, exceptions, and error enhancement +functions used throughout the gitlab-pkg-upload package. + +All tests are isolated and do not require external dependencies like GitLab API +or filesystem access. +""" + +from __future__ import annotations + +from typing import Optional +from unittest.mock import MagicMock, Mock + +import pytest + +from gitlab_pkg_upload.models import ( + # Dataclasses + FileFingerprint, + GitRemoteInfo, + ProjectInfo, + ProjectResolutionResult, + RemoteFile, + UploadConfig, + UploadContext, + UploadResult, + # Enums + DuplicatePolicy, + # Exceptions + AuthenticationError, + ChecksumValidationError, + ConfigurationError, + FileValidationError, + GitLabUploadError, + NetworkError, + ProjectResolutionError, + # Error enhancement functions + enhance_error_message, + handle_authentication_error, + handle_network_connectivity_error, + handle_permission_error, + handle_project_not_found_error, +) + +# Mark these as fast unit tests +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def sample_file_fingerprint() -> FileFingerprint: + """Create a sample FileFingerprint for testing.""" + return FileFingerprint( + source_path="/path/to/file.txt", + target_filename="file.txt", + sha256_checksum="a" * 64, + file_size=1024, + timestamp=1704067200.0, # 2024-01-01 00:00:00 UTC + ) + + +@pytest.fixture +def sample_remote_file() -> RemoteFile: + """Create a sample RemoteFile for testing.""" + return RemoteFile( + file_id=12345, + filename="package.tar.gz", + sha256_checksum="b" * 64, + file_size=2048, + download_url="https://gitlab.com/api/v4/projects/1/packages/generic/pkg/1.0.0/package.tar.gz", + package_name="my-package", + version="1.0.0", + ) + + +@pytest.fixture +def sample_upload_result() -> UploadResult: + """Create a sample UploadResult for testing.""" + return UploadResult( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="https://gitlab.com/api/v4/projects/1/packages/generic/pkg/1.0.0/file.txt", + ) + + +@pytest.fixture +def sample_project_info() -> ProjectInfo: + """Create a sample ProjectInfo for testing.""" + return ProjectInfo( + gitlab_url="https://gitlab.com", + namespace="mygroup", + project_name="myproject", + project_path="mygroup/myproject", + original_url="https://gitlab.com/mygroup/myproject", + ) + + +@pytest.fixture +def sample_upload_config() -> UploadConfig: + """Create a sample UploadConfig for testing.""" + return UploadConfig( + package_name="test-package", + version="1.0.0", + duplicate_policy=DuplicatePolicy.SKIP, + retry_count=3, + verbosity="normal", + dry_run=False, + fail_fast=False, + json_output=False, + plain_output=False, + gitlab_url="https://gitlab.com", + token="glpat-xxxxxxxxxxxxxxxxxxxx", + ) + + +@pytest.fixture +def mock_gitlab_client() -> MagicMock: + """Create a mock Gitlab client for testing UploadContext.""" + mock_gl = MagicMock() + mock_gl.url = "https://gitlab.com" + return mock_gl + + +@pytest.fixture +def mock_duplicate_detector() -> MagicMock: + """Create a mock DuplicateDetector for testing UploadContext.""" + mock_detector = MagicMock() + mock_detector.policy = DuplicatePolicy.SKIP + return mock_detector + + +# ============================================================================= +# Test Classes +# ============================================================================= + + +class TestDuplicatePolicy: + """Tests for DuplicatePolicy enum.""" + + @pytest.mark.timeout(60) + def test_duplicate_policy_skip_value(self): + """Test DuplicatePolicy.SKIP has correct value.""" + assert DuplicatePolicy.SKIP.value == "skip" + + @pytest.mark.timeout(60) + def test_duplicate_policy_replace_value(self): + """Test DuplicatePolicy.REPLACE has correct value.""" + assert DuplicatePolicy.REPLACE.value == "replace" + + @pytest.mark.timeout(60) + def test_duplicate_policy_error_value(self): + """Test DuplicatePolicy.ERROR has correct value.""" + assert DuplicatePolicy.ERROR.value == "error" + + @pytest.mark.timeout(60) + def test_duplicate_policy_enum_members(self): + """Test all expected enum members exist.""" + members = list(DuplicatePolicy) + assert len(members) == 3 + assert DuplicatePolicy.SKIP in members + assert DuplicatePolicy.REPLACE in members + assert DuplicatePolicy.ERROR in members + + @pytest.mark.timeout(60) + def test_duplicate_policy_from_string(self): + """Test creating DuplicatePolicy from string value.""" + assert DuplicatePolicy("skip") == DuplicatePolicy.SKIP + assert DuplicatePolicy("replace") == DuplicatePolicy.REPLACE + assert DuplicatePolicy("error") == DuplicatePolicy.ERROR + + @pytest.mark.timeout(60) + def test_duplicate_policy_invalid_value(self): + """Test that invalid value raises ValueError.""" + with pytest.raises(ValueError): + DuplicatePolicy("invalid") + + @pytest.mark.timeout(60) + def test_duplicate_policy_string_representation(self): + """Test string representation of enum.""" + assert str(DuplicatePolicy.SKIP) == "DuplicatePolicy.SKIP" + assert DuplicatePolicy.SKIP.name == "SKIP" + + +class TestFileFingerprint: + """Tests for FileFingerprint dataclass.""" + + @pytest.mark.timeout(60) + def test_file_fingerprint_creation(self, sample_file_fingerprint: FileFingerprint): + """Test FileFingerprint can be created with all required fields.""" + assert sample_file_fingerprint.source_path == "/path/to/file.txt" + assert sample_file_fingerprint.target_filename == "file.txt" + assert sample_file_fingerprint.sha256_checksum == "a" * 64 + assert sample_file_fingerprint.file_size == 1024 + assert sample_file_fingerprint.timestamp == 1704067200.0 + + @pytest.mark.timeout(60) + def test_file_fingerprint_equality(self): + """Test FileFingerprint equality comparison.""" + fp1 = FileFingerprint( + source_path="/path/to/file.txt", + target_filename="file.txt", + sha256_checksum="a" * 64, + file_size=1024, + timestamp=1704067200.0, + ) + fp2 = FileFingerprint( + source_path="/path/to/file.txt", + target_filename="file.txt", + sha256_checksum="a" * 64, + file_size=1024, + timestamp=1704067200.0, + ) + assert fp1 == fp2 + + @pytest.mark.timeout(60) + def test_file_fingerprint_inequality(self): + """Test FileFingerprint inequality when fields differ.""" + fp1 = FileFingerprint( + source_path="/path/to/file.txt", + target_filename="file.txt", + sha256_checksum="a" * 64, + file_size=1024, + timestamp=1704067200.0, + ) + fp2 = FileFingerprint( + source_path="/path/to/other.txt", + target_filename="other.txt", + sha256_checksum="b" * 64, + file_size=2048, + timestamp=1704067200.0, + ) + assert fp1 != fp2 + + +class TestRemoteFile: + """Tests for RemoteFile dataclass.""" + + @pytest.mark.timeout(60) + def test_remote_file_creation(self, sample_remote_file: RemoteFile): + """Test RemoteFile can be created with all fields.""" + assert sample_remote_file.file_id == 12345 + assert sample_remote_file.filename == "package.tar.gz" + assert sample_remote_file.sha256_checksum == "b" * 64 + assert sample_remote_file.file_size == 2048 + assert "package.tar.gz" in sample_remote_file.download_url + assert sample_remote_file.package_name == "my-package" + assert sample_remote_file.version == "1.0.0" + + @pytest.mark.timeout(60) + def test_remote_file_optional_checksum(self): + """Test RemoteFile with None checksum (optional field).""" + remote_file = RemoteFile( + file_id=12345, + filename="package.tar.gz", + sha256_checksum=None, + file_size=2048, + download_url="https://gitlab.com/download/file", + package_name="my-package", + version="1.0.0", + ) + assert remote_file.sha256_checksum is None + + @pytest.mark.timeout(60) + def test_remote_file_equality(self): + """Test RemoteFile equality comparison.""" + rf1 = RemoteFile( + file_id=1, + filename="file.txt", + sha256_checksum="abc", + file_size=100, + download_url="https://example.com/file", + package_name="pkg", + version="1.0", + ) + rf2 = RemoteFile( + file_id=1, + filename="file.txt", + sha256_checksum="abc", + file_size=100, + download_url="https://example.com/file", + package_name="pkg", + version="1.0", + ) + assert rf1 == rf2 + + +class TestUploadResult: + """Tests for UploadResult dataclass.""" + + @pytest.mark.timeout(60) + def test_upload_result_creation(self, sample_upload_result: UploadResult): + """Test UploadResult can be created with required fields.""" + assert sample_upload_result.source_path == "/path/to/file.txt" + assert sample_upload_result.target_filename == "file.txt" + assert sample_upload_result.success is True + assert "file.txt" in sample_upload_result.result + + @pytest.mark.timeout(60) + def test_upload_result_default_values(self): + """Test UploadResult has correct default values for optional fields.""" + result = UploadResult( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="https://example.com/file.txt", + ) + assert result.was_duplicate is False + assert result.duplicate_action is None + assert result.existing_url is None + + @pytest.mark.timeout(60) + def test_upload_result_with_duplicate_info(self): + """Test UploadResult with duplicate detection information.""" + result = UploadResult( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="Skipped: duplicate detected", + was_duplicate=True, + duplicate_action="skipped", + existing_url="https://example.com/existing/file.txt", + ) + assert result.was_duplicate is True + assert result.duplicate_action == "skipped" + assert result.existing_url == "https://example.com/existing/file.txt" + + @pytest.mark.timeout(60) + def test_upload_result_failed(self): + """Test UploadResult for failed upload.""" + result = UploadResult( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=False, + result="Upload failed: network error", + ) + assert result.success is False + assert "network error" in result.result + + @pytest.mark.timeout(60) + def test_upload_result_replaced_duplicate(self): + """Test UploadResult for replaced duplicate.""" + result = UploadResult( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=True, + result="https://example.com/new/file.txt", + was_duplicate=True, + duplicate_action="replaced", + existing_url="https://example.com/old/file.txt", + ) + assert result.duplicate_action == "replaced" + + +class TestProjectInfo: + """Tests for ProjectInfo dataclass.""" + + @pytest.mark.timeout(60) + def test_project_info_creation(self, sample_project_info: ProjectInfo): + """Test ProjectInfo can be created with all fields.""" + assert sample_project_info.gitlab_url == "https://gitlab.com" + assert sample_project_info.namespace == "mygroup" + assert sample_project_info.project_name == "myproject" + assert sample_project_info.project_path == "mygroup/myproject" + assert sample_project_info.original_url == "https://gitlab.com/mygroup/myproject" + + @pytest.mark.timeout(60) + def test_project_info_nested_namespace(self): + """Test ProjectInfo with nested namespace (subgroups).""" + project_info = ProjectInfo( + gitlab_url="https://gitlab.com", + namespace="group/subgroup", + project_name="myproject", + project_path="group/subgroup/myproject", + original_url="https://gitlab.com/group/subgroup/myproject", + ) + assert project_info.namespace == "group/subgroup" + assert "subgroup" in project_info.project_path + + +class TestProjectResolutionResult: + """Tests for ProjectResolutionResult dataclass.""" + + @pytest.mark.timeout(60) + def test_project_resolution_result_success(self, sample_project_info: ProjectInfo): + """Test ProjectResolutionResult for successful resolution.""" + result = ProjectResolutionResult( + success=True, + project_id=12345, + error_message=None, + project_info=sample_project_info, + gitlab_url="https://gitlab.com", + ) + assert result.success is True + assert result.project_id == 12345 + assert result.error_message is None + assert result.project_info is not None + + @pytest.mark.timeout(60) + def test_project_resolution_result_failure(self): + """Test ProjectResolutionResult for failed resolution.""" + result = ProjectResolutionResult( + success=False, + project_id=None, + error_message="Project not found: mygroup/nonexistent", + project_info=None, + gitlab_url="https://gitlab.com", + ) + assert result.success is False + assert result.project_id is None + assert "not found" in result.error_message + assert result.project_info is None + + +class TestGitRemoteInfo: + """Tests for GitRemoteInfo dataclass.""" + + @pytest.mark.timeout(60) + def test_git_remote_info_creation(self): + """Test GitRemoteInfo can be created with all fields.""" + remote_info = GitRemoteInfo( + name="origin", + url="git@gitlab.com:mygroup/myproject.git", + gitlab_url="https://gitlab.com", + project_path="mygroup/myproject", + ) + assert remote_info.name == "origin" + assert remote_info.url == "git@gitlab.com:mygroup/myproject.git" + assert remote_info.gitlab_url == "https://gitlab.com" + assert remote_info.project_path == "mygroup/myproject" + + @pytest.mark.timeout(60) + def test_git_remote_info_https_url(self): + """Test GitRemoteInfo with HTTPS URL.""" + remote_info = GitRemoteInfo( + name="upstream", + url="https://gitlab.com/mygroup/myproject.git", + gitlab_url="https://gitlab.com", + project_path="mygroup/myproject", + ) + assert remote_info.name == "upstream" + assert "https://" in remote_info.url + + +class TestUploadConfig: + """Tests for UploadConfig dataclass.""" + + @pytest.mark.timeout(60) + def test_upload_config_creation(self, sample_upload_config: UploadConfig): + """Test UploadConfig can be created with all fields.""" + assert sample_upload_config.package_name == "test-package" + assert sample_upload_config.version == "1.0.0" + assert sample_upload_config.duplicate_policy == DuplicatePolicy.SKIP + assert sample_upload_config.retry_count == 3 + assert sample_upload_config.verbosity == "normal" + assert sample_upload_config.dry_run is False + assert sample_upload_config.fail_fast is False + assert sample_upload_config.json_output is False + assert sample_upload_config.plain_output is False + assert sample_upload_config.gitlab_url == "https://gitlab.com" + assert sample_upload_config.token is not None + + @pytest.mark.timeout(60) + def test_upload_config_with_none_token(self): + """Test UploadConfig with None token (optional field).""" + config = UploadConfig( + package_name="test-package", + version="1.0.0", + duplicate_policy=DuplicatePolicy.SKIP, + retry_count=3, + verbosity="normal", + dry_run=False, + fail_fast=False, + json_output=False, + plain_output=False, + gitlab_url="https://gitlab.com", + token=None, + ) + assert config.token is None + + @pytest.mark.timeout(60) + def test_upload_config_verbosity_options(self): + """Test UploadConfig with different verbosity options.""" + for verbosity in ["quiet", "normal", "verbose", "debug"]: + config = UploadConfig( + package_name="test", + version="1.0.0", + duplicate_policy=DuplicatePolicy.SKIP, + retry_count=3, + verbosity=verbosity, + dry_run=False, + fail_fast=False, + json_output=False, + plain_output=False, + gitlab_url="https://gitlab.com", + token=None, + ) + assert config.verbosity == verbosity + + @pytest.mark.timeout(60) + def test_upload_config_dry_run_enabled(self): + """Test UploadConfig with dry_run enabled.""" + config = UploadConfig( + package_name="test", + version="1.0.0", + duplicate_policy=DuplicatePolicy.SKIP, + retry_count=3, + verbosity="normal", + dry_run=True, + fail_fast=False, + json_output=False, + plain_output=False, + gitlab_url="https://gitlab.com", + token=None, + ) + assert config.dry_run is True + + +class TestUploadContext: + """Tests for UploadContext dataclass.""" + + @pytest.mark.timeout(60) + def test_upload_context_creation( + self, + mock_gitlab_client: MagicMock, + mock_duplicate_detector: MagicMock, + sample_upload_config: UploadConfig, + ): + """Test UploadContext can be created with all fields.""" + context = UploadContext( + gl=mock_gitlab_client, + config=sample_upload_config, + detector=mock_duplicate_detector, + project_id=12345, + project_path="mygroup/myproject", + ) + assert context.gl is mock_gitlab_client + assert context.config is sample_upload_config + assert context.detector is mock_duplicate_detector + assert context.project_id == 12345 + assert context.project_path == "mygroup/myproject" + + +class TestExceptionHierarchy: + """Tests for exception hierarchy and exit codes.""" + + @pytest.mark.timeout(60) + def test_gitlab_upload_error_base(self): + """Test GitLabUploadError base exception.""" + error = GitLabUploadError("Base error message") + assert str(error) == "Base error message" + assert error.exit_code == 1 + + @pytest.mark.timeout(60) + def test_authentication_error(self): + """Test AuthenticationError exception.""" + error = AuthenticationError("Authentication failed") + assert str(error) == "Authentication failed" + assert error.exit_code == 2 + assert isinstance(error, GitLabUploadError) + + @pytest.mark.timeout(60) + def test_configuration_error(self): + """Test ConfigurationError exception.""" + error = ConfigurationError("Invalid configuration") + assert str(error) == "Invalid configuration" + assert error.exit_code == 3 + assert isinstance(error, GitLabUploadError) + + @pytest.mark.timeout(60) + def test_project_resolution_error(self): + """Test ProjectResolutionError exception.""" + error = ProjectResolutionError("Project not found") + assert str(error) == "Project not found" + assert error.exit_code == 4 + assert isinstance(error, GitLabUploadError) + + @pytest.mark.timeout(60) + def test_file_validation_error(self): + """Test FileValidationError exception.""" + error = FileValidationError("File not readable") + assert str(error) == "File not readable" + assert error.exit_code == 5 + assert isinstance(error, GitLabUploadError) + + @pytest.mark.timeout(60) + def test_network_error(self): + """Test NetworkError exception.""" + error = NetworkError("Connection refused") + assert str(error) == "Connection refused" + assert error.exit_code == 6 + assert isinstance(error, GitLabUploadError) + + @pytest.mark.timeout(60) + def test_checksum_validation_error(self): + """Test ChecksumValidationError exception.""" + error = ChecksumValidationError("Checksum mismatch") + assert str(error) == "Checksum mismatch" + assert error.exit_code == 7 + assert isinstance(error, GitLabUploadError) + + @pytest.mark.timeout(60) + def test_exception_inheritance(self): + """Test all custom exceptions inherit from GitLabUploadError.""" + exceptions = [ + AuthenticationError, + ConfigurationError, + ProjectResolutionError, + FileValidationError, + NetworkError, + ChecksumValidationError, + ] + for exc_class in exceptions: + assert issubclass(exc_class, GitLabUploadError) + assert issubclass(exc_class, Exception) + + @pytest.mark.timeout(60) + def test_exception_can_be_caught_as_base(self): + """Test custom exceptions can be caught as GitLabUploadError.""" + try: + raise AuthenticationError("Test error") + except GitLabUploadError as e: + assert e.exit_code == 2 + assert str(e) == "Test error" + + @pytest.mark.timeout(60) + def test_exit_codes_are_unique(self): + """Test all exception classes have unique exit codes.""" + exceptions = [ + GitLabUploadError, + AuthenticationError, + ConfigurationError, + ProjectResolutionError, + FileValidationError, + NetworkError, + ChecksumValidationError, + ] + exit_codes = [exc.exit_code for exc in exceptions] + assert len(exit_codes) == len(set(exit_codes)) + + +class TestHandleProjectNotFoundError: + """Tests for handle_project_not_found_error function.""" + + @pytest.mark.timeout(60) + def test_basic_error_message(self): + """Test basic error message generation.""" + result = handle_project_not_found_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + original_error="404 Project Not Found", + ) + assert "mygroup/myproject" in result + assert "https://gitlab.com" in result + assert "404 Project Not Found" in result + + @pytest.mark.timeout(60) + def test_includes_suggestions(self): + """Test error message includes helpful suggestions.""" + result = handle_project_not_found_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + original_error="Not found", + ) + assert "Please check the following" in result + assert "Project path format is correct" in result + assert "namespace/project-name" in result + + @pytest.mark.timeout(60) + def test_includes_examples(self): + """Test error message includes example project paths.""" + result = handle_project_not_found_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + original_error="Not found", + ) + assert "Examples of valid project paths" in result + assert "mycompany/my-project" in result + assert "group/subgroup/project-name" in result + + @pytest.mark.timeout(60) + def test_includes_verification_url(self): + """Test error message includes URL to verify project.""" + result = handle_project_not_found_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + original_error="Not found", + ) + assert "You can verify the project exists by visiting" in result + assert "https://gitlab.com/mygroup/myproject" in result + + +class TestHandleAuthenticationError: + """Tests for handle_authentication_error function.""" + + @pytest.mark.timeout(60) + def test_basic_error_message(self): + """Test basic authentication error message.""" + result = handle_authentication_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + original_error="401 Unauthorized", + ) + assert "Authentication failed" in result + assert "mygroup/myproject" in result + assert "https://gitlab.com" in result + + @pytest.mark.timeout(60) + def test_includes_token_guidance(self): + """Test error message includes token configuration guidance.""" + result = handle_authentication_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + original_error="Unauthorized", + ) + assert "GitLab token" in result + assert "GITLAB_TOKEN" in result + assert "--token" in result + + @pytest.mark.timeout(60) + def test_includes_token_creation_steps(self): + """Test error message includes steps to create new token.""" + result = handle_authentication_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + original_error="Unauthorized", + ) + assert "To create a new token" in result + assert "personal_access_tokens" in result + assert "api" in result or "read_api" in result + + +class TestHandlePermissionError: + """Tests for handle_permission_error function.""" + + @pytest.mark.timeout(60) + def test_basic_error_message(self): + """Test basic permission error message.""" + result = handle_permission_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + operation="upload", + original_error="403 Forbidden", + ) + assert "Permission denied" in result + assert "upload" in result + assert "mygroup/myproject" in result + + @pytest.mark.timeout(60) + def test_includes_required_permissions(self): + """Test error message includes required permission levels.""" + result = handle_permission_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + operation="upload", + original_error="Forbidden", + ) + assert "Required permissions" in result + assert "Developer" in result or "Reporter" in result + + @pytest.mark.timeout(60) + def test_includes_project_members_link(self): + """Test error message includes link to project members page.""" + result = handle_permission_error( + project_path="mygroup/myproject", + gitlab_url="https://gitlab.com", + operation="read packages", + original_error="Forbidden", + ) + assert "project_members" in result + + +class TestHandleNetworkConnectivityError: + """Tests for handle_network_connectivity_error function.""" + + @pytest.mark.timeout(60) + def test_basic_error_message(self): + """Test basic network connectivity error message.""" + result = handle_network_connectivity_error( + gitlab_url="https://gitlab.com", + original_error="Connection refused", + ) + assert "Network connectivity issue" in result + assert "https://gitlab.com" in result + assert "Connection refused" in result + + @pytest.mark.timeout(60) + def test_includes_troubleshooting_steps(self): + """Test error message includes troubleshooting steps.""" + result = handle_network_connectivity_error( + gitlab_url="https://gitlab.com", + original_error="Timeout", + ) + assert "Troubleshooting steps" in result + assert "curl" in result + assert "nslookup" in result + + @pytest.mark.timeout(60) + def test_includes_corporate_network_hints(self): + """Test error message includes hints for corporate networks.""" + result = handle_network_connectivity_error( + gitlab_url="https://gitlab.example.com", + original_error="Connection timeout", + ) + assert "corporate network" in result or "proxy" in result + + +class TestEnhanceErrorMessage: + """Tests for enhance_error_message function.""" + + @pytest.mark.timeout(60) + def test_404_error_detection(self): + """Test 404 error is detected and enhanced.""" + error = Exception("404 Not Found") + context = { + "project_path": "mygroup/myproject", + "gitlab_url": "https://gitlab.com", + "operation": "fetch", + } + result = enhance_error_message(error, context) + assert "not found" in result.lower() or "Project" in result + + @pytest.mark.timeout(60) + def test_401_error_detection(self): + """Test 401 error is detected and enhanced.""" + error = Exception("401 Unauthorized") + context = { + "project_path": "mygroup/myproject", + "gitlab_url": "https://gitlab.com", + "operation": "upload", + } + result = enhance_error_message(error, context) + assert "Authentication" in result or "token" in result.lower() + + @pytest.mark.timeout(60) + def test_403_permission_error_detection(self): + """Test 403 permission error is detected and enhanced.""" + error = Exception("403 Forbidden: Permission denied") + context = { + "project_path": "mygroup/myproject", + "gitlab_url": "https://gitlab.com", + "operation": "upload", + } + result = enhance_error_message(error, context) + assert "Permission" in result or "permission" in result + + @pytest.mark.timeout(60) + def test_connection_error_detection(self): + """Test connection error is detected and enhanced.""" + error = Exception("Connection refused by server") + context = { + "project_path": "mygroup/myproject", + "gitlab_url": "https://gitlab.com", + "operation": "connect", + } + result = enhance_error_message(error, context) + assert "Network" in result or "connection" in result.lower() + + @pytest.mark.timeout(60) + def test_timeout_error_detection(self): + """Test timeout error is detected and enhanced.""" + error = Exception("Request timeout after 30 seconds") + context = { + "project_path": "mygroup/myproject", + "gitlab_url": "https://gitlab.com", + "operation": "upload", + } + result = enhance_error_message(error, context) + assert "Network" in result or "timeout" in result.lower() + + @pytest.mark.timeout(60) + def test_rate_limit_error_detection(self): + """Test rate limit error is detected and enhanced.""" + error = Exception("429 Too Many Requests - Rate limit exceeded") + context = { + "project_path": "mygroup/myproject", + "gitlab_url": "https://gitlab.com", + "operation": "upload", + } + result = enhance_error_message(error, context) + assert "rate limit" in result.lower() or "Rate" in result + + @pytest.mark.timeout(60) + def test_generic_error_enhancement(self): + """Test generic error gets basic enhancement.""" + error = Exception("Something unexpected happened") + context = { + "project_path": "mygroup/myproject", + "gitlab_url": "https://gitlab.com", + "operation": "process", + } + result = enhance_error_message(error, context) + assert "mygroup/myproject" in result + assert "https://gitlab.com" in result + assert "Something unexpected happened" in result + + @pytest.mark.timeout(60) + def test_missing_context_keys_use_defaults(self): + """Test missing context keys use default values.""" + error = Exception("Error") + context = {} + result = enhance_error_message(error, context) + assert "unknown" in result + + @pytest.mark.timeout(60) + def test_dns_error_detection(self): + """Test DNS resolution error is detected.""" + error = Exception("Failed to resolve hostname: DNS error") + context = { + "project_path": "mygroup/myproject", + "gitlab_url": "https://gitlab.example.com", + "operation": "connect", + } + result = enhance_error_message(error, context) + assert "Network" in result or "DNS" in result or "resolve" in result.lower() + + @pytest.mark.timeout(60) + def test_case_insensitive_error_detection(self): + """Test error detection is case-insensitive.""" + error = Exception("NOT FOUND") + context = { + "project_path": "mygroup/myproject", + "gitlab_url": "https://gitlab.com", + "operation": "fetch", + } + result = enhance_error_message(error, context) + # Should detect as not found error + assert len(result) > len("NOT FOUND") diff --git a/tests/unit/test_validators.py b/tests/unit/test_validators.py new file mode 100644 index 0000000..b472458 --- /dev/null +++ b/tests/unit/test_validators.py @@ -0,0 +1,1094 @@ +""" +Comprehensive unit tests for the validators module. + +These tests validate file validation, Git URL parsing, configuration validation, +token handling, and dependency checking functions. All external dependencies +(filesystem, subprocess, GitPython) are mocked to ensure test isolation. +""" + +from __future__ import annotations + +import hashlib +import subprocess +import sys +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, Mock, mock_open, patch + +import pytest + +from gitlab_pkg_upload.models import ( + ConfigurationError, + FileValidationError, + ProjectResolutionError, +) +from gitlab_pkg_upload.validators import ( + DEFAULT_GITLAB_URL, + calculate_sha256, + collect_files, + get_gitlab_token, + normalize_gitlab_url, + parse_file_mapping, + parse_git_url, + validate_configuration, + validate_dependencies, + validate_file_exists, + validate_filename, + validate_git_installation, + validate_git_repository, + validate_gitlab_token, + validate_project_specification, +) + +# Mark these as fast unit tests +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def mock_path_exists(): + """Create a mock Path that exists.""" + mock_path = MagicMock(spec=Path) + mock_path.exists.return_value = True + mock_path.is_file.return_value = True + mock_path.is_dir.return_value = False + mock_path.name = "test_file.txt" + mock_path.__str__ = lambda self: "/path/to/test_file.txt" + return mock_path + + +@pytest.fixture +def mock_path_not_exists(): + """Create a mock Path that does not exist.""" + mock_path = MagicMock(spec=Path) + mock_path.exists.return_value = False + mock_path.is_file.return_value = False + mock_path.__str__ = lambda self: "/path/to/nonexistent.txt" + return mock_path + + +@pytest.fixture +def mock_path_directory(): + """Create a mock Path that is a directory.""" + mock_path = MagicMock(spec=Path) + mock_path.exists.return_value = True + mock_path.is_file.return_value = False + mock_path.is_dir.return_value = True + mock_path.__str__ = lambda self: "/path/to/directory" + return mock_path + + +@pytest.fixture +def mock_git_repo(): + """Create a mock Git repository.""" + mock_repo = MagicMock() + mock_repo.working_dir = "/path/to/repo" + mock_repo.config_reader.return_value = MagicMock() + mock_repo.remotes = [MagicMock(name="origin")] + return mock_repo + + +# ============================================================================= +# Test Classes +# ============================================================================= + + +class TestFilenameValidation: + """Tests for validate_filename function.""" + + @pytest.mark.timeout(60) + def test_valid_ascii_filename(self): + """Test valid ASCII filename passes validation.""" + # Should not raise + validate_filename("package.tar.gz") + validate_filename("my-file_v1.0.bin") + validate_filename("subdir/file.txt") + validate_filename("a.b.c.d") + + @pytest.mark.timeout(60) + def test_valid_filename_with_numbers(self): + """Test filename with numbers passes validation.""" + validate_filename("file123.txt") + validate_filename("v1.2.3.tar.gz") + validate_filename("2024-01-01-backup.zip") + + @pytest.mark.timeout(60) + def test_valid_filename_with_hyphens_underscores(self): + """Test filename with hyphens and underscores passes validation.""" + validate_filename("my-file.txt") + validate_filename("my_file.txt") + validate_filename("my-file_v1.0.txt") + + @pytest.mark.timeout(60) + def test_valid_filename_with_slashes(self): + """Test filename with forward slashes (directory paths) passes validation.""" + validate_filename("subdir/file.txt") + validate_filename("a/b/c/file.txt") + validate_filename("deeply/nested/path/file.bin") + + @pytest.mark.timeout(60) + def test_non_ascii_filename_rejected(self): + """Test non-ASCII characters in filename are rejected.""" + with pytest.raises(FileValidationError) as exc_info: + validate_filename("café.tar.gz") + assert "non-ASCII" in str(exc_info.value) + assert "café.tar.gz" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_chinese_characters_rejected(self): + """Test Chinese characters in filename are rejected.""" + with pytest.raises(FileValidationError) as exc_info: + validate_filename("文件.bin") + assert "non-ASCII" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_emoji_rejected(self): + """Test emoji in filename are rejected.""" + with pytest.raises(FileValidationError) as exc_info: + validate_filename("file📦.txt") + assert "non-ASCII" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_special_characters_rejected(self): + """Test special characters are rejected.""" + special_chars = ["@", "#", "$", "%", " ", "!", "&", "(", ")", "+", "="] + for char in special_chars: + with pytest.raises(FileValidationError) as exc_info: + validate_filename(f"file{char}name.txt") + assert "special characters" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_space_in_filename_rejected(self): + """Test space in filename is rejected.""" + with pytest.raises(FileValidationError) as exc_info: + validate_filename("file name.txt") + assert "special characters" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_error_message_includes_allowed_characters(self): + """Test error message includes list of allowed characters.""" + with pytest.raises(FileValidationError) as exc_info: + validate_filename("bad@file.txt") + error_msg = str(exc_info.value) + assert "letters" in error_msg or "a-z" in error_msg + assert "digits" in error_msg or "0-9" in error_msg + + +class TestFileExistsValidation: + """Tests for validate_file_exists function.""" + + @pytest.mark.timeout(60) + def test_existing_file_passes(self, tmp_path): + """Test existing readable file passes validation.""" + test_file = tmp_path / "test.txt" + test_file.write_text("test content") + + # Should not raise + validate_file_exists(test_file) + + @pytest.mark.timeout(60) + def test_nonexistent_file_raises(self): + """Test non-existent file raises FileValidationError.""" + nonexistent = Path("/path/to/definitely/not/existing/file.txt") + with pytest.raises(FileValidationError) as exc_info: + validate_file_exists(nonexistent) + assert "not found" in str(exc_info.value).lower() + + @pytest.mark.timeout(60) + def test_directory_path_raises(self, tmp_path): + """Test directory path raises FileValidationError.""" + with pytest.raises(FileValidationError) as exc_info: + validate_file_exists(tmp_path) + assert "not a file" in str(exc_info.value).lower() + + @pytest.mark.timeout(60) + def test_unreadable_file_raises(self, tmp_path): + """Test unreadable file raises FileValidationError.""" + test_file = tmp_path / "unreadable.txt" + test_file.write_text("content") + + # Mock the open function to raise PermissionError + with patch("builtins.open", side_effect=PermissionError("Permission denied")): + with pytest.raises(FileValidationError) as exc_info: + validate_file_exists(test_file) + assert "not readable" in str(exc_info.value).lower() + + +class TestSHA256Calculation: + """Tests for calculate_sha256 function.""" + + @pytest.mark.timeout(60) + def test_calculate_sha256_basic(self, tmp_path): + """Test SHA256 calculation for a basic file.""" + test_file = tmp_path / "test.txt" + content = b"Hello, World!" + test_file.write_bytes(content) + + expected = hashlib.sha256(content).hexdigest() + result = calculate_sha256(test_file) + + assert result == expected + assert len(result) == 64 # SHA256 hex digest is 64 characters + + @pytest.mark.timeout(60) + def test_calculate_sha256_empty_file(self, tmp_path): + """Test SHA256 calculation for empty file.""" + test_file = tmp_path / "empty.txt" + test_file.write_bytes(b"") + + expected = hashlib.sha256(b"").hexdigest() + result = calculate_sha256(test_file) + + assert result == expected + + @pytest.mark.timeout(60) + def test_calculate_sha256_binary_file(self, tmp_path): + """Test SHA256 calculation for binary file.""" + test_file = tmp_path / "binary.bin" + content = bytes(range(256)) + test_file.write_bytes(content) + + expected = hashlib.sha256(content).hexdigest() + result = calculate_sha256(test_file) + + assert result == expected + + @pytest.mark.timeout(60) + def test_calculate_sha256_large_file(self, tmp_path): + """Test SHA256 calculation handles larger files correctly.""" + test_file = tmp_path / "large.bin" + # Create a file larger than the 8192 byte chunk size + content = b"x" * 50000 + test_file.write_bytes(content) + + expected = hashlib.sha256(content).hexdigest() + result = calculate_sha256(test_file) + + assert result == expected + + @pytest.mark.timeout(60) + def test_calculate_sha256_read_error(self): + """Test SHA256 calculation raises on read error.""" + with patch("builtins.open", side_effect=IOError("Read error")): + with pytest.raises(FileValidationError) as exc_info: + calculate_sha256(Path("/some/file.txt")) + assert "Failed to read file" in str(exc_info.value) + + +class TestFileMappingParsing: + """Tests for parse_file_mapping function.""" + + @pytest.mark.timeout(60) + def test_valid_mapping(self): + """Test parsing valid file mapping.""" + mappings = ["local.bin:remote.bin"] + files = ["path/to/local.bin"] + + result = parse_file_mapping(mappings, files) + + assert result == {"local.bin": "remote.bin"} + + @pytest.mark.timeout(60) + def test_multiple_mappings(self): + """Test parsing multiple file mappings.""" + mappings = ["file1.txt:renamed1.txt", "file2.bin:renamed2.bin"] + files = ["path/to/file1.txt", "path/to/file2.bin"] + + result = parse_file_mapping(mappings, files) + + assert result == { + "file1.txt": "renamed1.txt", + "file2.bin": "renamed2.bin", + } + + @pytest.mark.timeout(60) + def test_empty_mappings(self): + """Test parsing empty mappings list returns empty dict.""" + result = parse_file_mapping([], ["file.txt"]) + assert result == {} + + @pytest.mark.timeout(60) + def test_invalid_mapping_no_colon(self): + """Test invalid mapping without colon raises error.""" + with pytest.raises(ConfigurationError) as exc_info: + parse_file_mapping(["invalid_mapping"], ["file.txt"]) + assert "Invalid file mapping format" in str(exc_info.value) + assert "local.bin:remote.bin" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_invalid_mapping_multiple_colons(self): + """Test invalid mapping with multiple colons raises error.""" + with pytest.raises(ConfigurationError) as exc_info: + parse_file_mapping(["a:b:c"], ["a"]) + assert "Invalid file mapping format" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_mapping_file_not_in_list(self): + """Test mapping referencing non-existent file raises error.""" + with pytest.raises(ConfigurationError) as exc_info: + parse_file_mapping(["missing.bin:remote.bin"], ["other.bin"]) + assert "not in the files list" in str(exc_info.value) + + +class TestFileCollection: + """Tests for collect_files function.""" + + @pytest.mark.timeout(60) + def test_files_mode_basic(self, tmp_path): + """Test collecting files in files mode.""" + # Create test files + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + file1.write_text("content1") + file2.write_text("content2") + + files_to_upload, errors = collect_files( + files=[str(file1), str(file2)] + ) + + assert len(files_to_upload) == 2 + assert len(errors) == 0 + + @pytest.mark.timeout(60) + def test_files_mode_with_mapping(self, tmp_path): + """Test collecting files with file mappings.""" + file1 = tmp_path / "local.txt" + file1.write_text("content") + + files_to_upload, errors = collect_files( + files=[str(file1)], + file_mappings={"local.txt": "remote.txt"}, + ) + + assert len(files_to_upload) == 1 + assert files_to_upload[0][1] == "remote.txt" + + @pytest.mark.timeout(60) + def test_directory_mode_basic(self, tmp_path): + """Test collecting files from directory.""" + # Create test files in directory + (tmp_path / "file1.txt").write_text("content1") + (tmp_path / "file2.txt").write_text("content2") + + files_to_upload, errors = collect_files(directory=str(tmp_path)) + + assert len(files_to_upload) == 2 + assert len(errors) == 0 + + @pytest.mark.timeout(60) + def test_directory_mode_ignores_subdirectories(self, tmp_path): + """Test directory mode only collects top-level files.""" + (tmp_path / "file.txt").write_text("content") + subdir = tmp_path / "subdir" + subdir.mkdir() + (subdir / "nested.txt").write_text("nested content") + + files_to_upload, errors = collect_files(directory=str(tmp_path)) + + # Should only find the top-level file, not the nested one + assert len(files_to_upload) == 1 + assert files_to_upload[0][1] == "file.txt" + + @pytest.mark.timeout(60) + def test_mutually_exclusive_inputs_error(self, tmp_path): + """Test error when both files and directory are provided.""" + with pytest.raises(ConfigurationError) as exc_info: + collect_files( + files=["file.txt"], + directory=str(tmp_path), + ) + assert "mutually exclusive" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_missing_inputs_error(self): + """Test error when neither files nor directory is provided.""" + with pytest.raises(ConfigurationError) as exc_info: + collect_files() + assert "must be provided" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_nonexistent_file_collected_as_error(self, tmp_path): + """Test non-existent file is collected as error, not raised.""" + existing = tmp_path / "exists.txt" + existing.write_text("content") + + files_to_upload, errors = collect_files( + files=[str(existing), "/nonexistent/file.txt"] + ) + + assert len(files_to_upload) == 1 + assert len(errors) == 1 + assert "FileValidationError" in errors[0]["error_type"] + + @pytest.mark.timeout(60) + def test_invalid_filename_collected_as_error(self, tmp_path): + """Test file with invalid filename is collected as error.""" + # Create file with valid local name, but map to invalid remote name + valid_file = tmp_path / "valid.txt" + valid_file.write_text("content") + + files_to_upload, errors = collect_files( + files=[str(valid_file)], + file_mappings={"valid.txt": "invalid file.txt"}, # Space is invalid + ) + + assert len(files_to_upload) == 0 + assert len(errors) == 1 + + @pytest.mark.timeout(60) + def test_duplicate_target_filenames_error(self, tmp_path): + """Test duplicate target filenames raise error.""" + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + file1.write_text("content1") + file2.write_text("content2") + + with pytest.raises(ConfigurationError) as exc_info: + collect_files( + files=[str(file1), str(file2)], + file_mappings={ + "file1.txt": "same.txt", + "file2.txt": "same.txt", + }, + ) + assert "Duplicate target filenames" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_nonexistent_directory_error(self): + """Test error for non-existent directory.""" + with pytest.raises(ConfigurationError) as exc_info: + collect_files(directory="/nonexistent/directory") + assert "Directory not found" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_file_path_as_directory_error(self, tmp_path): + """Test error when file path is provided as directory.""" + file_path = tmp_path / "file.txt" + file_path.write_text("content") + + with pytest.raises(ConfigurationError) as exc_info: + collect_files(directory=str(file_path)) + assert "not a directory" in str(exc_info.value).lower() + + @pytest.mark.timeout(60) + def test_file_mappings_as_list(self, tmp_path): + """Test file_mappings can be provided as list of strings.""" + file1 = tmp_path / "local.txt" + file1.write_text("content") + + files_to_upload, errors = collect_files( + files=[str(file1)], + file_mappings=["local.txt:remote.txt"], + ) + + assert len(files_to_upload) == 1 + assert files_to_upload[0][1] == "remote.txt" + + @pytest.mark.timeout(60) + def test_invalid_file_mappings_type_error(self, tmp_path): + """Test error for invalid file_mappings type.""" + file1 = tmp_path / "file.txt" + file1.write_text("content") + + with pytest.raises(ConfigurationError) as exc_info: + collect_files( + files=[str(file1)], + file_mappings=123, # Invalid type + ) + assert "must be a dict or list" in str(exc_info.value) + + +class TestGitUrlParsing: + """Tests for parse_git_url function.""" + + @pytest.mark.timeout(60) + def test_https_url_basic(self): + """Test parsing basic HTTPS Git URL.""" + gitlab_url, project_path = parse_git_url( + "https://gitlab.com/namespace/project.git" + ) + assert gitlab_url == "https://gitlab.com" + assert project_path == "namespace/project" + + @pytest.mark.timeout(60) + def test_https_url_without_git_suffix(self): + """Test parsing HTTPS URL without .git suffix.""" + gitlab_url, project_path = parse_git_url( + "https://gitlab.com/namespace/project" + ) + assert gitlab_url == "https://gitlab.com" + assert project_path == "namespace/project" + + @pytest.mark.timeout(60) + def test_ssh_url_basic(self): + """Test parsing basic SSH Git URL.""" + gitlab_url, project_path = parse_git_url( + "git@gitlab.com:namespace/project.git" + ) + assert gitlab_url == "https://gitlab.com" + assert project_path == "namespace/project" + + @pytest.mark.timeout(60) + def test_ssh_url_without_git_suffix(self): + """Test parsing SSH URL without .git suffix.""" + gitlab_url, project_path = parse_git_url( + "git@gitlab.com:namespace/project" + ) + assert gitlab_url == "https://gitlab.com" + assert project_path == "namespace/project" + + @pytest.mark.timeout(60) + def test_nested_namespace(self): + """Test parsing URL with nested namespace (subgroups).""" + gitlab_url, project_path = parse_git_url( + "https://gitlab.com/group/subgroup/project.git" + ) + assert gitlab_url == "https://gitlab.com" + assert project_path == "group/subgroup/project" + + @pytest.mark.timeout(60) + def test_self_hosted_gitlab(self): + """Test parsing URL for self-hosted GitLab instance.""" + gitlab_url, project_path = parse_git_url( + "https://gitlab.example.com/namespace/project.git" + ) + assert gitlab_url == "https://gitlab.example.com" + assert project_path == "namespace/project" + + @pytest.mark.timeout(60) + def test_ssh_self_hosted(self): + """Test parsing SSH URL for self-hosted GitLab.""" + gitlab_url, project_path = parse_git_url( + "git@gitlab.example.com:namespace/project.git" + ) + assert gitlab_url == "https://gitlab.example.com" + assert project_path == "namespace/project" + + @pytest.mark.timeout(60) + def test_empty_url_error(self): + """Test error for empty URL.""" + with pytest.raises(ConfigurationError) as exc_info: + parse_git_url("") + assert "non-empty string" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_none_url_error(self): + """Test error for None URL.""" + with pytest.raises(ConfigurationError) as exc_info: + parse_git_url(None) + assert "non-empty string" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_invalid_ssh_url_no_colon(self): + """Test error for SSH URL without colon.""" + with pytest.raises(ConfigurationError) as exc_info: + parse_git_url("git@gitlab.com/namespace/project.git") + assert "Invalid SSH Git URL" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_invalid_https_scheme(self): + """Test error for non-HTTPS scheme.""" + with pytest.raises(ConfigurationError) as exc_info: + parse_git_url("http://gitlab.com/namespace/project.git") + assert "Invalid Git URL scheme" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_missing_project_path(self): + """Test error for URL missing project path.""" + with pytest.raises(ConfigurationError) as exc_info: + parse_git_url("https://gitlab.com/namespace") + assert "Path must contain at least namespace/project" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_url_with_trailing_whitespace(self): + """Test URL with whitespace is trimmed.""" + gitlab_url, project_path = parse_git_url( + " https://gitlab.com/namespace/project.git " + ) + assert gitlab_url == "https://gitlab.com" + assert project_path == "namespace/project" + + +class TestNormalizeGitlabUrl: + """Tests for normalize_gitlab_url function.""" + + @pytest.mark.timeout(60) + def test_basic_url(self): + """Test normalizing basic GitLab URL.""" + gitlab_url, project_path = normalize_gitlab_url( + "https://gitlab.com/namespace/project" + ) + assert gitlab_url == "https://gitlab.com" + assert project_path == "namespace/project" + + @pytest.mark.timeout(60) + def test_url_with_trailing_slash(self): + """Test URL with trailing slash is normalized.""" + gitlab_url, project_path = normalize_gitlab_url( + "https://gitlab.com/namespace/project/" + ) + assert gitlab_url == "https://gitlab.com" + assert project_path == "namespace/project" + + @pytest.mark.timeout(60) + def test_http_url(self): + """Test HTTP URL is accepted.""" + gitlab_url, project_path = normalize_gitlab_url( + "http://gitlab.example.com/namespace/project" + ) + assert gitlab_url == "http://gitlab.example.com" + assert project_path == "namespace/project" + + @pytest.mark.timeout(60) + def test_empty_url_error(self): + """Test error for empty URL.""" + with pytest.raises(ConfigurationError) as exc_info: + normalize_gitlab_url("") + assert "non-empty string" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_invalid_scheme_error(self): + """Test error for invalid scheme.""" + with pytest.raises(ConfigurationError) as exc_info: + normalize_gitlab_url("ftp://gitlab.com/namespace/project") + assert "Invalid GitLab URL scheme" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_missing_path_error(self): + """Test error for URL missing path.""" + with pytest.raises(ConfigurationError) as exc_info: + normalize_gitlab_url("https://gitlab.com") + assert "missing project path" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_incomplete_path_error(self): + """Test error for URL with incomplete path.""" + with pytest.raises(ConfigurationError) as exc_info: + normalize_gitlab_url("https://gitlab.com/namespace") + assert "Path must contain at least namespace/project" in str(exc_info.value) + + +class TestTokenHandling: + """Tests for get_gitlab_token function.""" + + @pytest.mark.timeout(60) + def test_cli_token_takes_precedence(self, monkeypatch): + """Test CLI token takes precedence over environment variable.""" + monkeypatch.setenv("GITLAB_TOKEN", "env-token") + + result = get_gitlab_token("cli-token") + + assert result == "cli-token" + + @pytest.mark.timeout(60) + def test_environment_token_used(self, monkeypatch): + """Test environment variable token is used when CLI token is None.""" + monkeypatch.setenv("GITLAB_TOKEN", "env-token") + + result = get_gitlab_token(None) + + assert result == "env-token" + + @pytest.mark.timeout(60) + def test_missing_token_error(self, monkeypatch): + """Test error when no token is available.""" + monkeypatch.delenv("GITLAB_TOKEN", raising=False) + + with pytest.raises(ConfigurationError) as exc_info: + get_gitlab_token(None) + assert "No GitLab token provided" in str(exc_info.value) + assert "GITLAB_TOKEN" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_empty_cli_token_falls_through(self, monkeypatch): + """Test empty CLI token falls through to environment.""" + monkeypatch.setenv("GITLAB_TOKEN", "env-token") + + result = get_gitlab_token("") + + assert result == "env-token" + + +class TestTokenValidation: + """Tests for validate_gitlab_token function.""" + + @pytest.mark.timeout(60) + def test_valid_token(self): + """Test valid token passes validation.""" + # Should not raise + validate_gitlab_token("glpat-xxxxxxxxxxxxxxxxxxxx") + validate_gitlab_token("x" * 20) + + @pytest.mark.timeout(60) + def test_empty_token_error(self): + """Test empty token raises error.""" + with pytest.raises(ConfigurationError) as exc_info: + validate_gitlab_token("") + assert "token is required" in str(exc_info.value).lower() + + @pytest.mark.timeout(60) + def test_none_token_error(self): + """Test None token raises error.""" + with pytest.raises(ConfigurationError) as exc_info: + validate_gitlab_token(None) + assert "token is required" in str(exc_info.value).lower() + + @pytest.mark.timeout(60) + def test_short_token_error(self): + """Test token too short raises error.""" + with pytest.raises(ConfigurationError) as exc_info: + validate_gitlab_token("short") + assert "too short" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_incomplete_glpat_token_error(self): + """Test incomplete glpat- token raises error.""" + # glpat- tokens should be 26+ characters, use one that's 20-25 chars + # This is caught by the glpat- specific check, not the general short check + with pytest.raises(ConfigurationError) as exc_info: + validate_gitlab_token("glpat-12345678901234") # 20 chars total + assert "incomplete" in str(exc_info.value).lower() + + @pytest.mark.timeout(60) + def test_token_with_whitespace_trimmed(self): + """Test token with whitespace is trimmed before validation.""" + # Should not raise - whitespace is stripped + validate_gitlab_token(" " + "x" * 20 + " ") + + @pytest.mark.timeout(60) + def test_error_includes_help_url(self): + """Test error message includes help URL.""" + with pytest.raises(ConfigurationError) as exc_info: + validate_gitlab_token("") + assert "personal_access_tokens" in str(exc_info.value) + + +class TestDependencyValidation: + """Tests for validate_dependencies function.""" + + @pytest.mark.timeout(60) + def test_dependencies_available(self): + """Test validation passes when all dependencies are available.""" + with patch.object(sys, "version_info", (3, 11, 0)): + with patch("builtins.__import__") as mock_import: + mock_import.return_value = MagicMock() + # Should not raise + validate_dependencies() + + @pytest.mark.timeout(60) + def test_python_version_too_low(self): + """Test error when Python version is too low.""" + with patch.object(sys, "version_info", (3, 10, 0)): + with patch.object(sys, "version", "3.10.0"): + with pytest.raises(ConfigurationError) as exc_info: + validate_dependencies() + assert "Python 3.11" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_missing_module_error(self): + """Test error when required module is missing.""" + def mock_import(name, *args, **kwargs): + if name == "gitlab": + raise ImportError("No module named 'gitlab'") + return MagicMock() + + with patch.object(sys, "version_info", (3, 11, 0)): + with patch("builtins.__import__", side_effect=mock_import): + with pytest.raises(ConfigurationError) as exc_info: + validate_dependencies() + assert "gitlab" in str(exc_info.value) + assert "python-gitlab" in str(exc_info.value) + + +class TestGitInstallationValidation: + """Tests for validate_git_installation function.""" + + @pytest.mark.timeout(60) + def test_git_installed(self): + """Test validation passes when Git is installed.""" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "git version 2.40.0" + + with patch("subprocess.run", return_value=mock_result): + # Should not raise + validate_git_installation() + + @pytest.mark.timeout(60) + def test_git_not_installed(self): + """Test error when Git is not installed.""" + with patch("subprocess.run", side_effect=FileNotFoundError()): + with pytest.raises(ConfigurationError) as exc_info: + validate_git_installation() + assert "Git is not installed" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_git_command_failed(self): + """Test error when Git command fails.""" + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stderr = "git: command not found" + + with patch("subprocess.run", return_value=mock_result): + with pytest.raises(ConfigurationError) as exc_info: + validate_git_installation() + assert "Git command failed" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_git_command_timeout(self): + """Test error when Git command times out.""" + with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("git", 10)): + with pytest.raises(ConfigurationError) as exc_info: + validate_git_installation() + assert "timed out" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_error_includes_installation_instructions(self): + """Test error includes platform-specific installation instructions.""" + with patch("subprocess.run", side_effect=FileNotFoundError()): + with pytest.raises(ConfigurationError) as exc_info: + validate_git_installation() + error_msg = str(exc_info.value) + assert "apt" in error_msg or "brew" in error_msg or "Windows" in error_msg + + +class TestGitRepositoryValidation: + """Tests for validate_git_repository function.""" + + @pytest.mark.timeout(60) + def test_valid_repository(self, mock_git_repo): + """Test validation passes for valid Git repository.""" + with patch("git.Repo", return_value=mock_git_repo): + # Should not raise + validate_git_repository(".") + + @pytest.mark.timeout(60) + def test_not_a_git_repository(self): + """Test error when directory is not a Git repository.""" + import git + + with patch("git.Repo", side_effect=git.InvalidGitRepositoryError()): + with pytest.raises(ConfigurationError) as exc_info: + validate_git_repository("/tmp") + assert "not inside a Git repository" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_permission_denied(self): + """Test error when permission is denied.""" + with patch("git.Repo", side_effect=PermissionError("Access denied")): + with pytest.raises(ConfigurationError) as exc_info: + validate_git_repository("/protected/repo") + assert "Permission denied" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_repository_not_accessible(self, mock_git_repo): + """Test error when repository is not fully accessible.""" + mock_git_repo.config_reader.side_effect = Exception("Config not readable") + + with patch("git.Repo", return_value=mock_git_repo): + with pytest.raises(ConfigurationError) as exc_info: + validate_git_repository(".") + assert "not fully accessible" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_error_includes_repair_guidance(self): + """Test error includes repository repair guidance.""" + import git + + with patch("git.Repo", side_effect=git.InvalidGitRepositoryError()): + with pytest.raises(ConfigurationError) as exc_info: + validate_git_repository("/tmp") + error_msg = str(exc_info.value) + assert "git init" in error_msg or "git status" in error_msg + + +class TestProjectSpecValidation: + """Tests for validate_project_specification function.""" + + @pytest.mark.timeout(60) + def test_url_spec_auto_detected(self): + """Test URL specification is auto-detected.""" + gitlab_url, project_path = validate_project_specification( + "https://gitlab.com/mygroup/myproject" + ) + assert gitlab_url == "https://gitlab.com" + assert project_path == "mygroup/myproject" + + @pytest.mark.timeout(60) + def test_path_spec_auto_detected(self): + """Test path specification is auto-detected.""" + gitlab_url, project_path = validate_project_specification( + "mygroup/myproject" + ) + assert gitlab_url == DEFAULT_GITLAB_URL + assert project_path == "mygroup/myproject" + + @pytest.mark.timeout(60) + def test_url_spec_explicit(self): + """Test explicit URL specification type.""" + gitlab_url, project_path = validate_project_specification( + "https://gitlab.example.com/ns/proj", + spec_type="url", + ) + assert gitlab_url == "https://gitlab.example.com" + assert project_path == "ns/proj" + + @pytest.mark.timeout(60) + def test_path_spec_explicit(self): + """Test explicit path specification type.""" + gitlab_url, project_path = validate_project_specification( + "mygroup/myproject", + spec_type="path", + ) + assert project_path == "mygroup/myproject" + + @pytest.mark.timeout(60) + def test_path_spec_with_custom_gitlab_url(self): + """Test path specification with custom GitLab URL.""" + gitlab_url, project_path = validate_project_specification( + "mygroup/myproject", + spec_type="path", + gitlab_url="https://gitlab.example.com", + ) + assert gitlab_url == "https://gitlab.example.com" + assert project_path == "mygroup/myproject" + + @pytest.mark.timeout(60) + def test_empty_spec_error(self): + """Test error for empty specification.""" + with pytest.raises(ProjectResolutionError) as exc_info: + validate_project_specification("") + assert "required" in str(exc_info.value).lower() + + @pytest.mark.timeout(60) + def test_none_spec_error(self): + """Test error for None specification.""" + with pytest.raises(ProjectResolutionError) as exc_info: + validate_project_specification(None) + assert "required" in str(exc_info.value).lower() + + @pytest.mark.timeout(60) + def test_invalid_path_format(self): + """Test error for invalid path format (missing namespace).""" + with pytest.raises(ProjectResolutionError) as exc_info: + validate_project_specification("myproject", spec_type="path") + assert "namespace/project" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_path_with_consecutive_slashes(self): + """Test error for path with consecutive slashes.""" + with pytest.raises(ProjectResolutionError) as exc_info: + validate_project_specification("group//project", spec_type="path") + assert "empty component" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_nested_namespace_path(self): + """Test nested namespace path is accepted.""" + gitlab_url, project_path = validate_project_specification( + "group/subgroup/project", + spec_type="path", + ) + assert project_path == "group/subgroup/project" + + @pytest.mark.timeout(60) + def test_unknown_spec_type_error(self): + """Test error for unknown specification type.""" + with pytest.raises(ProjectResolutionError) as exc_info: + validate_project_specification( + "mygroup/myproject", + spec_type="unknown", + ) + assert "Unknown specification type" in str(exc_info.value) + + +class TestConfigurationValidation: + """Tests for validate_configuration orchestration function.""" + + @pytest.mark.timeout(60) + def test_successful_validation(self, monkeypatch): + """Test successful configuration validation.""" + monkeypatch.setenv("GITLAB_TOKEN", "x" * 26) + + with patch("gitlab_pkg_upload.validators.validate_dependencies"): + with patch("gitlab_pkg_upload.validators.validate_git_installation"): + # Should not raise + validate_configuration(token="x" * 26, require_git=False) + + @pytest.mark.timeout(60) + def test_validation_with_require_git(self, monkeypatch): + """Test validation with Git requirement.""" + monkeypatch.setenv("GITLAB_TOKEN", "x" * 26) + + with patch("gitlab_pkg_upload.validators.validate_dependencies"): + with patch("gitlab_pkg_upload.validators.validate_git_installation"): + with patch("gitlab_pkg_upload.validators.validate_git_repository"): + # Should not raise + validate_configuration( + token="x" * 26, + require_git=True, + ) + + @pytest.mark.timeout(60) + def test_dependencies_failure_propagates(self): + """Test dependencies validation failure propagates.""" + with patch( + "gitlab_pkg_upload.validators.validate_dependencies", + side_effect=ConfigurationError("Missing dependency"), + ): + with pytest.raises(ConfigurationError) as exc_info: + validate_configuration(token="x" * 26) + assert "Missing dependency" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_token_validation_failure_propagates(self): + """Test token validation failure propagates.""" + with patch("gitlab_pkg_upload.validators.validate_dependencies"): + with pytest.raises(ConfigurationError): + validate_configuration(token="short") + + @pytest.mark.timeout(60) + def test_git_failure_ignored_when_not_required(self, monkeypatch): + """Test Git validation failure is ignored when not required.""" + monkeypatch.setenv("GITLAB_TOKEN", "x" * 26) + + with patch("gitlab_pkg_upload.validators.validate_dependencies"): + with patch( + "gitlab_pkg_upload.validators.validate_git_installation", + side_effect=ConfigurationError("Git not found"), + ): + # Should not raise - Git is not required + validate_configuration( + token="x" * 26, + require_git=False, + ) + + @pytest.mark.timeout(60) + def test_git_failure_propagates_when_required(self, monkeypatch): + """Test Git validation failure propagates when required.""" + monkeypatch.setenv("GITLAB_TOKEN", "x" * 26) + + with patch("gitlab_pkg_upload.validators.validate_dependencies"): + with patch( + "gitlab_pkg_upload.validators.validate_git_installation", + side_effect=ConfigurationError("Git not found"), + ): + with pytest.raises(ConfigurationError) as exc_info: + validate_configuration( + token="x" * 26, + require_git=True, + ) + assert "Git not found" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_token_from_environment(self, monkeypatch): + """Test token is retrieved from environment when not provided.""" + monkeypatch.setenv("GITLAB_TOKEN", "x" * 26) + + with patch("gitlab_pkg_upload.validators.validate_dependencies"): + with patch("gitlab_pkg_upload.validators.validate_git_installation"): + # Should not raise - token from environment + validate_configuration(token=None, require_git=False) From 3fc546aafe84664da596700a9acc7d0f0ebe3a09 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 07:08:41 -0600 Subject: [PATCH 14/36] gitlab_pkg_upload: Add comprehensive duplicate detector unit tests The duplicate detection module lacked test coverage, making it risky to refactor or extend its functionality. Without tests, regressions in session-level tracking or remote GitLab API interactions could go unnoticed until runtime failures occur in production uploads. Add a complete unit test suite covering the DuplicateDetector class and its helper functions. The tests validate SHA256 calculation edge cases, retry logic behavior, session registry management, and remote duplicate detection via mocked GitLab API responses. All external dependencies are mocked to ensure test isolation and fast execution. Signed-off-by: Javier Tia --- tests/unit/test_duplicate_detector.py | 934 ++++++++++++++++++++++++++ 1 file changed, 934 insertions(+) create mode 100644 tests/unit/test_duplicate_detector.py diff --git a/tests/unit/test_duplicate_detector.py b/tests/unit/test_duplicate_detector.py new file mode 100644 index 0000000..4ccc78e --- /dev/null +++ b/tests/unit/test_duplicate_detector.py @@ -0,0 +1,934 @@ +""" +Comprehensive unit tests for the duplicate_detector module. + +These tests validate the DuplicateDetector class and related helper functions +for session-level and remote duplicate detection. All external dependencies +(GitLab API, filesystem, network) are mocked to ensure test isolation. +""" + +from __future__ import annotations + +import hashlib +import time +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from gitlab_pkg_upload.duplicate_detector import ( + DuplicateDetector, + calculate_sha256, + handle_network_error_with_retry, +) +from gitlab_pkg_upload.models import FileFingerprint, RemoteFile + +# Mark these as fast unit tests +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def mock_gitlab_client() -> MagicMock: + """Create a mock GitLab client for testing.""" + mock_gl = MagicMock() + mock_gl.url = "https://gitlab.com" + mock_gl.api_url = "https://gitlab.com/api/v4" + return mock_gl + + +@pytest.fixture +def mock_project() -> MagicMock: + """Create a mock GitLab project for testing.""" + mock_proj = MagicMock() + mock_proj.id = 12345 + mock_proj.packages = MagicMock() + mock_proj.generic_packages = MagicMock() + return mock_proj + + +@pytest.fixture +def sample_file_path(tmp_path) -> Path: + """Create a sample file for testing.""" + file_path = tmp_path / "test_file.txt" + file_path.write_text("test content for hashing") + return file_path + + +@pytest.fixture +def sample_checksum() -> str: + """Return a valid SHA256 hex string (64 characters).""" + return "a" * 64 + + +@pytest.fixture +def mock_package_file() -> MagicMock: + """Create a mock package file with checksum.""" + mock_file = MagicMock() + mock_file.id = 1001 + mock_file.file_name = "test.bin" + mock_file.file_sha256 = "a" * 64 + mock_file.size = 1024 + return mock_file + + +# ============================================================================= +# Test Classes +# ============================================================================= + + +class TestCalculateSHA256: + """Test the calculate_sha256 helper function.""" + + @pytest.mark.timeout(60) + def test_calculate_sha256_text_file(self, tmp_path): + """Test SHA256 calculation for a text file.""" + test_file = tmp_path / "test.txt" + content = b"Hello, World!" + test_file.write_bytes(content) + + expected = hashlib.sha256(content).hexdigest() + result = calculate_sha256(test_file) + + assert result == expected + assert len(result) == 64 + + @pytest.mark.timeout(60) + def test_calculate_sha256_binary_file(self, tmp_path): + """Test SHA256 calculation for a binary file.""" + test_file = tmp_path / "binary.bin" + content = bytes(range(256)) + test_file.write_bytes(content) + + expected = hashlib.sha256(content).hexdigest() + result = calculate_sha256(test_file) + + assert result == expected + + @pytest.mark.timeout(60) + def test_calculate_sha256_empty_file(self, tmp_path): + """Test SHA256 calculation for an empty file returns expected empty file SHA256.""" + test_file = tmp_path / "empty.txt" + test_file.write_bytes(b"") + + expected = hashlib.sha256(b"").hexdigest() + result = calculate_sha256(test_file) + + assert result == expected + assert result == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + + @pytest.mark.timeout(60) + def test_calculate_sha256_large_file(self, tmp_path): + """Test SHA256 calculation reads file in chunks (8192 bytes).""" + test_file = tmp_path / "large.bin" + # Create a file larger than 8192 bytes to test chunked reading + content = b"x" * 50000 + test_file.write_bytes(content) + + expected = hashlib.sha256(content).hexdigest() + result = calculate_sha256(test_file) + + assert result == expected + + @pytest.mark.timeout(60) + def test_calculate_sha256_file_not_found(self): + """Test FileNotFoundError is raised for missing file.""" + nonexistent = Path("/nonexistent/path/to/file.txt") + + with pytest.raises(FileNotFoundError): + calculate_sha256(nonexistent) + + @pytest.mark.timeout(60) + def test_calculate_sha256_permission_error(self, tmp_path): + """Test PermissionError is raised for unreadable file.""" + test_file = tmp_path / "unreadable.txt" + test_file.write_bytes(b"content") + + with patch("builtins.open", side_effect=PermissionError("Permission denied")): + with pytest.raises(PermissionError): + calculate_sha256(test_file) + + +class TestHandleNetworkErrorWithRetry: + """Test the retry logic helper function.""" + + @pytest.mark.timeout(60) + def test_retry_success_first_attempt(self): + """Test operation succeeds immediately, no retries needed.""" + mock_operation = MagicMock(return_value="success") + + with patch("time.sleep"): + result = handle_network_error_with_retry( + operation_name="test op", + operation_func=mock_operation, + ) + + assert result == "success" + mock_operation.assert_called_once() + + @pytest.mark.timeout(60) + def test_retry_success_after_failures(self): + """Test operation fails twice then succeeds, verify retry count.""" + call_count = 0 + + def operation_with_failures(): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise ConnectionError("Network error") + return "success" + + with patch("time.sleep"): + result = handle_network_error_with_retry( + operation_name="test op", + operation_func=operation_with_failures, + max_retries=3, + ) + + assert result == "success" + assert call_count == 3 + + @pytest.mark.timeout(60) + def test_retry_exhausted_raises_exception(self): + """Test all retries fail, verify last exception is raised.""" + mock_operation = MagicMock(side_effect=ConnectionError("Persistent failure")) + + with patch("time.sleep"): + with pytest.raises(ConnectionError) as exc_info: + handle_network_error_with_retry( + operation_name="test op", + operation_func=mock_operation, + max_retries=2, + ) + + assert "Persistent failure" in str(exc_info.value) + assert mock_operation.call_count == 3 # Initial + 2 retries + + @pytest.mark.timeout(60) + def test_retry_with_custom_delays(self): + """Test custom retry delay list is respected.""" + mock_operation = MagicMock(side_effect=ConnectionError("Error")) + custom_delays = [5, 10, 15] + + with patch("time.sleep") as mock_sleep: + with pytest.raises(ConnectionError): + handle_network_error_with_retry( + operation_name="test op", + operation_func=mock_operation, + max_retries=3, + retry_delays=custom_delays, + ) + + # Verify sleep was called with custom delays + sleep_calls = [call[0][0] for call in mock_sleep.call_args_list] + assert sleep_calls == [5, 10, 15] + + @pytest.mark.timeout(60) + def test_retry_logs_attempts(self, caplog): + """Test logging calls for each retry attempt.""" + mock_operation = MagicMock(side_effect=ConnectionError("Error")) + + with patch("time.sleep"): + with pytest.raises(ConnectionError): + handle_network_error_with_retry( + operation_name="test operation", + operation_func=mock_operation, + max_retries=2, + ) + + # Check that retry attempts were logged + assert "test operation failed" in caplog.text + assert "attempt" in caplog.text.lower() + + @pytest.mark.timeout(60) + def test_retry_default_delays(self): + """Test default delays [1, 2, 4] are used when not specified.""" + mock_operation = MagicMock(side_effect=ConnectionError("Error")) + + with patch("time.sleep") as mock_sleep: + with pytest.raises(ConnectionError): + handle_network_error_with_retry( + operation_name="test op", + operation_func=mock_operation, + max_retries=3, + ) + + sleep_calls = [call[0][0] for call in mock_sleep.call_args_list] + assert sleep_calls == [1, 2, 4] + + +class TestDuplicateDetectorInit: + """Test DuplicateDetector initialization.""" + + @pytest.mark.timeout(60) + def test_init_with_valid_params(self, mock_gitlab_client): + """Test detector creation with mock client and project ID.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + assert detector.gl is mock_gitlab_client + assert detector.project_id == 12345 + assert isinstance(detector.session_registry, dict) + + @pytest.mark.timeout(60) + def test_init_session_registry_empty(self, mock_gitlab_client): + """Test session_registry starts as empty dict.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + assert detector.session_registry == {} + assert len(detector.session_registry) == 0 + + @pytest.mark.timeout(60) + def test_init_stores_gitlab_client(self, mock_gitlab_client): + """Test GitLab client is stored correctly.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + assert detector.gl is mock_gitlab_client + + @pytest.mark.timeout(60) + def test_init_stores_project_id(self, mock_gitlab_client): + """Test project ID is stored correctly.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=99999) + + assert detector.project_id == 99999 + + +class TestCheckSessionDuplicate: + """Test session-level duplicate detection.""" + + @pytest.mark.timeout(60) + def test_no_session_duplicate_when_empty(self, mock_gitlab_client, sample_file_path): + """Test empty registry returns None.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_session_duplicate(sample_file_path, "target.txt") + + assert result is None + + @pytest.mark.timeout(60) + def test_session_duplicate_same_checksum(self, mock_gitlab_client, tmp_path): + """Test file with same target name and checksum returns FileFingerprint.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + # Create two files with identical content + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + content = b"identical content" + file1.write_bytes(content) + file2.write_bytes(content) + + checksum = hashlib.sha256(content).hexdigest() + + # Register the first file + detector.register_file(file1, "target.txt", checksum) + + # Check for duplicate with second file + result = detector.check_session_duplicate(file2, "target.txt") + + assert result is not None + assert isinstance(result, FileFingerprint) + assert result.target_filename == "target.txt" + assert result.sha256_checksum == checksum + + @pytest.mark.timeout(60) + def test_session_duplicate_different_checksum(self, mock_gitlab_client, tmp_path, caplog): + """Test same target name but different checksum returns None, logs warning.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + # Create two files with different content + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + file1.write_bytes(b"content version 1") + file2.write_bytes(b"content version 2") + + checksum1 = hashlib.sha256(b"content version 1").hexdigest() + + # Register the first file + detector.register_file(file1, "target.txt", checksum1) + + # Check for duplicate with second file (different content) + result = detector.check_session_duplicate(file2, "target.txt") + + assert result is None + assert "different content" in caplog.text.lower() + + @pytest.mark.timeout(60) + def test_session_duplicate_different_source_path(self, mock_gitlab_client, tmp_path): + """Test same target name and checksum but different source path still detected as duplicate.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + # Create two files with identical content in different locations + subdir = tmp_path / "subdir" + subdir.mkdir() + + file1 = tmp_path / "file1.txt" + file2 = subdir / "file2.txt" + content = b"same content" + file1.write_bytes(content) + file2.write_bytes(content) + + checksum = hashlib.sha256(content).hexdigest() + + # Register the first file + detector.register_file(file1, "target.txt", checksum) + + # Check for duplicate with second file from different location + result = detector.check_session_duplicate(file2, "target.txt") + + assert result is not None + assert result.sha256_checksum == checksum + + @pytest.mark.timeout(60) + def test_session_duplicate_checksum_calculation(self, mock_gitlab_client, tmp_path): + """Test calculate_sha256 is called with correct file path.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + content = b"content" + file1.write_bytes(content) + file2.write_bytes(content) + + checksum = hashlib.sha256(content).hexdigest() + detector.register_file(file1, "target.txt", checksum) + + with patch( + "gitlab_pkg_upload.duplicate_detector.calculate_sha256", + return_value=checksum, + ) as mock_calc: + detector.check_session_duplicate(file2, "target.txt") + mock_calc.assert_called_once_with(file2) + + @pytest.mark.timeout(60) + def test_session_duplicate_logging(self, mock_gitlab_client, tmp_path, caplog): + """Test appropriate log messages for duplicate detection.""" + import logging + + caplog.set_level(logging.DEBUG) + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + content = b"content" + file1.write_bytes(content) + file2.write_bytes(content) + + checksum = hashlib.sha256(content).hexdigest() + detector.register_file(file1, "target.txt", checksum) + + detector.check_session_duplicate(file2, "target.txt") + + assert "Session duplicate detected" in caplog.text + + +class TestCheckRemoteDuplicate: + """Test remote duplicate detection with GitLab API.""" + + @pytest.mark.timeout(60) + def test_no_remote_duplicate_package_not_found( + self, mock_gitlab_client, mock_project + ): + """Test packages.list returns empty, verify None returned.""" + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [] + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is None + + @pytest.mark.timeout(60) + def test_no_remote_duplicate_version_not_found( + self, mock_gitlab_client, mock_project + ): + """Test package exists but version doesn't match, verify None.""" + mock_package = MagicMock() + mock_package.version = "2.0.0" # Different version + mock_package.id = 1 + + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is None + + @pytest.mark.timeout(60) + def test_no_remote_duplicate_filename_not_found( + self, mock_gitlab_client, mock_project, mock_package_file + ): + """Test package and version exist but filename doesn't match, verify None.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_package_file.file_name = "other.bin" # Different filename + mock_package_obj.package_files.list.return_value = [mock_package_file] + + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is None + + @pytest.mark.timeout(60) + def test_remote_duplicate_checksum_match( + self, mock_gitlab_client, mock_project, mock_package_file + ): + """Test package file with matching checksum, verify RemoteFile returned.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_package_file.file_name = "test.bin" + mock_package_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_package_file] + + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is not None + assert isinstance(result, RemoteFile) + assert result.filename == "test.bin" + assert result.sha256_checksum == "a" * 64 + + @pytest.mark.timeout(60) + def test_remote_duplicate_checksum_mismatch( + self, mock_gitlab_client, mock_project, mock_package_file + ): + """Test filename matches but checksum differs, verify None returned.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_package_file.file_name = "test.bin" + mock_package_file.file_sha256 = "b" * 64 # Different checksum + mock_package_obj.package_files.list.return_value = [mock_package_file] + + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is None + + @pytest.mark.timeout(60) + def test_remote_duplicate_no_checksum_available( + self, mock_gitlab_client, mock_project, caplog + ): + """Test remote file has no checksum attribute, verify None returned with warning.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock() + mock_file.file_name = "test.bin" + # No file_sha256 attribute - use spec to control what attrs exist + del mock_file.file_sha256 + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is None + assert "checksum not available" in caplog.text.lower() + + @pytest.mark.timeout(60) + def test_remote_duplicate_case_insensitive_checksum( + self, mock_gitlab_client, mock_project, mock_package_file + ): + """Test checksum comparison is case-insensitive.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_package_file.file_name = "test.bin" + mock_package_file.file_sha256 = "AABBCC" + "d" * 58 # Upper case + mock_package_obj.package_files.list.return_value = [mock_package_file] + + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="aabbcc" + "d" * 58, # Lower case + ) + + assert result is not None + assert isinstance(result, RemoteFile) + + @pytest.mark.timeout(60) + def test_remote_duplicate_constructs_download_url( + self, mock_gitlab_client, mock_project, mock_package_file + ): + """Test RemoteFile has correctly formatted download URL.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_package_file.file_name = "test.bin" + mock_package_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_package_file] + + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is not None + assert "test-pkg" in result.download_url + assert "1.0.0" in result.download_url + assert "test.bin" in result.download_url + assert "12345" in result.download_url + + @pytest.mark.timeout(60) + def test_remote_duplicate_retry_on_network_error(self, mock_gitlab_client): + """Test network error triggers retry logic.""" + call_count = 0 + + def mock_get(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise ConnectionError("Network error") + mock_proj = MagicMock() + mock_proj.packages.list.return_value = [] + return mock_proj + + mock_gitlab_client.projects.get.side_effect = mock_get + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + with patch("time.sleep"): + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is None + assert call_count == 3 + + @pytest.mark.timeout(60) + def test_remote_duplicate_returns_none_on_persistent_error( + self, mock_gitlab_client, caplog + ): + """Test all retries fail, verify None returned (not exception).""" + mock_gitlab_client.projects.get.side_effect = ConnectionError("Persistent error") + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + with patch("time.sleep"): + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is None + assert "Proceeding without duplicate detection" in caplog.text + + @pytest.mark.timeout(60) + def test_remote_duplicate_multiple_files_same_name( + self, mock_gitlab_client, mock_project + ): + """Test multiple files with same name, verify correct one matched by checksum.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + # Create multiple files with same name but different checksums + mock_file1 = MagicMock() + mock_file1.file_name = "test.bin" + mock_file1.file_sha256 = "b" * 64 + mock_file1.id = 1001 + mock_file1.size = 1024 + + mock_file2 = MagicMock() + mock_file2.file_name = "test.bin" + mock_file2.file_sha256 = "a" * 64 # This one matches + mock_file2.id = 1002 + mock_file2.size = 2048 + + mock_package_obj = MagicMock() + mock_package_obj.package_files.list.return_value = [mock_file1, mock_file2] + + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="test.bin", + checksum="a" * 64, + ) + + assert result is not None + assert result.sha256_checksum == "a" * 64 + assert result.file_id == 1002 + + +class TestRegisterFile: + """Test file registration in session.""" + + @pytest.mark.timeout(60) + def test_register_file_creates_fingerprint(self, mock_gitlab_client, tmp_path): + """Test register file creates FileFingerprint with correct attributes.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + test_file = tmp_path / "test.txt" + test_file.write_bytes(b"content") + checksum = "a" * 64 + + detector.register_file(test_file, "target.txt", checksum) + + fingerprint = detector.session_registry.get("target.txt") + assert fingerprint is not None + assert isinstance(fingerprint, FileFingerprint) + assert fingerprint.target_filename == "target.txt" + assert fingerprint.sha256_checksum == checksum + + @pytest.mark.timeout(60) + def test_register_file_adds_to_registry(self, mock_gitlab_client, tmp_path): + """Test file added to session_registry with target_filename as key.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + test_file = tmp_path / "test.txt" + test_file.write_bytes(b"content") + + detector.register_file(test_file, "target.txt", "a" * 64) + + assert "target.txt" in detector.session_registry + assert len(detector.session_registry) == 1 + + @pytest.mark.timeout(60) + def test_register_file_overwrites_existing(self, mock_gitlab_client, tmp_path): + """Test register same target_filename twice, verify second overwrites first.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + file1.write_bytes(b"content1") + file2.write_bytes(b"content2") + + detector.register_file(file1, "target.txt", "a" * 64) + detector.register_file(file2, "target.txt", "b" * 64) + + assert len(detector.session_registry) == 1 + assert detector.session_registry["target.txt"].sha256_checksum == "b" * 64 + + @pytest.mark.timeout(60) + def test_register_file_uses_file_stats(self, mock_gitlab_client, tmp_path): + """Test Path.stat() is used to extract file size correctly.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + test_file = tmp_path / "test.txt" + content = b"test content for size verification" + test_file.write_bytes(content) + + detector.register_file(test_file, "target.txt", "a" * 64) + + fingerprint = detector.session_registry["target.txt"] + assert fingerprint.file_size == len(content) + + @pytest.mark.timeout(60) + def test_register_file_uses_current_timestamp(self, mock_gitlab_client, tmp_path): + """Test time.time() is used to record timestamp.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + test_file = tmp_path / "test.txt" + test_file.write_bytes(b"content") + + mock_time = 1704067200.0 # Fixed timestamp + + with patch("gitlab_pkg_upload.duplicate_detector.time.time", return_value=mock_time): + detector.register_file(test_file, "target.txt", "a" * 64) + + fingerprint = detector.session_registry["target.txt"] + assert fingerprint.timestamp == mock_time + + @pytest.mark.timeout(60) + def test_register_file_logging(self, mock_gitlab_client, tmp_path, caplog): + """Test registration is logged with checksum.""" + import logging + + caplog.set_level(logging.DEBUG) + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + test_file = tmp_path / "test.txt" + test_file.write_bytes(b"content") + checksum = "abcd1234" + "e" * 56 + + detector.register_file(test_file, "target.txt", checksum) + + assert "Registered file in session" in caplog.text + assert "target.txt" in caplog.text + assert "abcd1234" in caplog.text + + +class TestDuplicateDetectorIntegration: + """Integration tests for complete workflows.""" + + @pytest.mark.timeout(60) + def test_workflow_register_then_check_session(self, mock_gitlab_client, tmp_path): + """Test register file, then check for session duplicate, verify found.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + # Create two files with same content + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + content = b"identical content" + file1.write_bytes(content) + file2.write_bytes(content) + + checksum = hashlib.sha256(content).hexdigest() + + # Register first file + detector.register_file(file1, "target.txt", checksum) + + # Check second file for session duplicate + result = detector.check_session_duplicate(file2, "target.txt") + + assert result is not None + assert result.sha256_checksum == checksum + + @pytest.mark.timeout(60) + def test_workflow_check_remote_then_register( + self, mock_gitlab_client, mock_project, tmp_path + ): + """Test check remote (not found), register, check session (found).""" + mock_gitlab_client.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [] + + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + file1 = tmp_path / "file1.txt" + file2 = tmp_path / "file2.txt" + content = b"content" + file1.write_bytes(content) + file2.write_bytes(content) + + checksum = hashlib.sha256(content).hexdigest() + + # Check remote - not found + remote_result = detector.check_remote_duplicate( + package_name="test-pkg", + version="1.0.0", + filename="target.txt", + checksum=checksum, + ) + assert remote_result is None + + # Register file + detector.register_file(file1, "target.txt", checksum) + + # Check session - found + session_result = detector.check_session_duplicate(file2, "target.txt") + assert session_result is not None + + @pytest.mark.timeout(60) + def test_workflow_multiple_files_different_names(self, mock_gitlab_client, tmp_path): + """Test register multiple files with different names, verify all tracked.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + files = [] + for i in range(5): + f = tmp_path / f"file{i}.txt" + f.write_bytes(f"content {i}".encode()) + files.append(f) + detector.register_file(f, f"target{i}.txt", f"{'a' * 63}{i}") + + assert len(detector.session_registry) == 5 + for i in range(5): + assert f"target{i}.txt" in detector.session_registry + + @pytest.mark.timeout(60) + def test_workflow_session_registry_size(self, mock_gitlab_client, tmp_path): + """Test register N files, verify session_registry has N entries.""" + detector = DuplicateDetector(mock_gitlab_client, project_id=12345) + + n = 10 + for i in range(n): + f = tmp_path / f"file{i}.txt" + f.write_bytes(f"content {i}".encode()) + detector.register_file(f, f"target{i}.txt", f"{'a' * 63}{i}") + + assert len(detector.session_registry) == n From 3f481012b0b442bbd127dd86dbe8d85647597fc6 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 07:09:45 -0600 Subject: [PATCH 15/36] gitlab_pkg_upload: Add comprehensive unit tests for uploader module The uploader module previously lacked test coverage, making it difficult to verify correctness of upload orchestration, retry behavior, duplicate detection, and error handling. Without tests, regressions could go unnoticed during refactoring or feature additions. Introduce a complete unit test suite covering all public functions in the uploader module. The tests validate transient error classification for retry logic, single file upload with dry-run support, checksum validation including case-insensitive comparison and path variations, duplicate handling across all policy modes (skip, replace, error), file deletion from the registry with proper error recovery, and the main upload orchestration including fail-fast behavior. All external dependencies are mocked to ensure test isolation and fast execution. Signed-off-by: Javier Tia --- tests/unit/test_uploader.py | 1345 +++++++++++++++++++++++++++++++++++ 1 file changed, 1345 insertions(+) create mode 100644 tests/unit/test_uploader.py diff --git a/tests/unit/test_uploader.py b/tests/unit/test_uploader.py new file mode 100644 index 0000000..33780d3 --- /dev/null +++ b/tests/unit/test_uploader.py @@ -0,0 +1,1345 @@ +""" +Comprehensive unit tests for the uploader module. + +These tests validate upload orchestration including retry logic, duplicate handling, +checksum validation, and file deletion. All external dependencies (GitLab API, +filesystem, network) are mocked to ensure test isolation. +""" + +from __future__ import annotations + +import time +from pathlib import Path +from unittest.mock import MagicMock, Mock, call, patch + +import pytest +from gitlab.exceptions import GitlabError + +from gitlab_pkg_upload.models import ( + ChecksumValidationError, + DuplicatePolicy, + RemoteFile, + UploadConfig, + UploadContext, + UploadResult, +) +from gitlab_pkg_upload.uploader import ( + delete_file_from_registry, + handle_duplicate, + is_transient_error, + upload_files, + upload_single_file, + validate_upload, +) + +# Mark these as fast unit tests +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def mock_upload_config() -> UploadConfig: + """Create a sample UploadConfig for testing.""" + return UploadConfig( + package_name="test-package", + version="1.0.0", + duplicate_policy=DuplicatePolicy.SKIP, + retry_count=3, + verbosity="normal", + dry_run=False, + fail_fast=False, + json_output=False, + plain_output=False, + gitlab_url="https://gitlab.com", + token="glpat-xxxxxxxxxxxxxxxxxxxx", + ) + + +@pytest.fixture +def mock_upload_config_dry_run() -> UploadConfig: + """Create a sample UploadConfig with dry_run enabled.""" + return UploadConfig( + package_name="test-package", + version="1.0.0", + duplicate_policy=DuplicatePolicy.SKIP, + retry_count=3, + verbosity="normal", + dry_run=True, + fail_fast=False, + json_output=False, + plain_output=False, + gitlab_url="https://gitlab.com", + token="glpat-xxxxxxxxxxxxxxxxxxxx", + ) + + +@pytest.fixture +def mock_gitlab_client() -> MagicMock: + """Create a mock GitLab client for testing.""" + mock_gl = MagicMock() + mock_gl.url = "https://gitlab.com" + mock_gl.api_url = "https://gitlab.com/api/v4" + return mock_gl + + +@pytest.fixture +def mock_project() -> MagicMock: + """Create a mock GitLab project for testing.""" + mock_proj = MagicMock() + mock_proj.id = 12345 + mock_proj.packages = MagicMock() + mock_proj.generic_packages = MagicMock() + return mock_proj + + +@pytest.fixture +def mock_duplicate_detector() -> MagicMock: + """Create a mock DuplicateDetector for testing.""" + mock_detector = MagicMock() + mock_detector.check_session_duplicate.return_value = None + mock_detector.check_remote_duplicate.return_value = None + mock_detector.register_file.return_value = None + return mock_detector + + +@pytest.fixture +def mock_upload_context( + mock_gitlab_client, mock_upload_config, mock_duplicate_detector +) -> UploadContext: + """Create a sample UploadContext for testing.""" + return UploadContext( + gl=mock_gitlab_client, + config=mock_upload_config, + detector=mock_duplicate_detector, + project_id=12345, + project_path="mygroup/myproject", + ) + + +@pytest.fixture +def mock_file_path(tmp_path) -> Path: + """Create a mock file for testing.""" + test_file = tmp_path / "test.bin" + test_file.write_bytes(b"test content for upload") + return test_file + + +@pytest.fixture +def sample_remote_file() -> RemoteFile: + """Create a sample RemoteFile for testing duplicate handling.""" + return RemoteFile( + file_id=12345, + filename="test.bin", + sha256_checksum="a" * 64, + file_size=1024, + download_url="https://gitlab.com/api/v4/projects/12345/packages/generic/test-package/1.0.0/test.bin", + package_name="test-package", + version="1.0.0", + ) + + +# ============================================================================= +# Test Classes +# ============================================================================= + + +class TestIsTransientError: + """Test error classification for retry logic.""" + + @pytest.mark.timeout(60) + def test_connection_error_is_transient(self): + """Test ConnectionError returns True.""" + error = ConnectionError("Connection refused") + assert is_transient_error(error) is True + + @pytest.mark.timeout(60) + def test_timeout_error_is_transient(self): + """Test TimeoutError returns True.""" + error = TimeoutError("Connection timed out") + assert is_transient_error(error) is True + + @pytest.mark.timeout(60) + def test_500_error_is_transient(self): + """Test exception with '500' in message returns True.""" + error = Exception("500 Internal Server Error") + assert is_transient_error(error) is True + + @pytest.mark.timeout(60) + def test_502_bad_gateway_is_transient(self): + """Test exception with '502' or 'bad gateway' returns True.""" + assert is_transient_error(Exception("502 Bad Gateway")) is True + assert is_transient_error(Exception("bad gateway error")) is True + + @pytest.mark.timeout(60) + def test_503_service_unavailable_is_transient(self): + """Test exception with '503' or 'service unavailable' returns True.""" + assert is_transient_error(Exception("503 Service Unavailable")) is True + assert is_transient_error(Exception("service unavailable")) is True + + @pytest.mark.timeout(60) + def test_429_rate_limit_is_transient(self): + """Test exception with '429' or 'rate limit' returns True.""" + assert is_transient_error(Exception("429 Too Many Requests")) is True + assert is_transient_error(Exception("rate limit exceeded")) is True + + @pytest.mark.timeout(60) + def test_401_unauthorized_is_permanent(self): + """Test exception with '401' returns False.""" + error = Exception("401 Unauthorized") + assert is_transient_error(error) is False + + @pytest.mark.timeout(60) + def test_403_forbidden_is_permanent(self): + """Test exception with '403' returns False.""" + error = Exception("403 Forbidden") + assert is_transient_error(error) is False + + @pytest.mark.timeout(60) + def test_404_not_found_is_permanent(self): + """Test exception with '404' returns False.""" + error = Exception("404 Not Found") + assert is_transient_error(error) is False + + @pytest.mark.timeout(60) + def test_400_bad_request_is_permanent(self): + """Test exception with '400' returns False.""" + error = Exception("400 Bad Request") + assert is_transient_error(error) is False + + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_500(self): + """Test GitlabError with response_code=500 returns True.""" + error = GitlabError("Server error") + error.response_code = 500 + assert is_transient_error(error) is True + + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_401(self): + """Test GitlabError with response_code=401 returns False.""" + error = GitlabError("Unauthorized") + error.response_code = 401 + assert is_transient_error(error) is False + + @pytest.mark.timeout(60) + def test_unknown_error_is_permanent(self): + """Test generic Exception returns False (default behavior).""" + error = Exception("Something unknown happened") + assert is_transient_error(error) is False + + +class TestUploadSingleFile: + """Test single file upload with retry decorator.""" + + @pytest.mark.timeout(60) + def test_upload_success(self, mock_upload_context, mock_file_path, mock_project): + """Test successful upload returns download URL.""" + mock_upload_context.gl.projects.get.return_value = mock_project + + result = upload_single_file(mock_upload_context, mock_file_path, "target.bin") + + assert "target.bin" in result + assert "test-package" in result + assert "1.0.0" in result + + @pytest.mark.timeout(60) + def test_upload_dry_run_mode( + self, mock_gitlab_client, mock_upload_config_dry_run, mock_duplicate_detector, mock_file_path + ): + """Test dry_run=True returns mock URL without actual upload.""" + context = UploadContext( + gl=mock_gitlab_client, + config=mock_upload_config_dry_run, + detector=mock_duplicate_detector, + project_id=12345, + project_path="mygroup/myproject", + ) + + result = upload_single_file(context, mock_file_path, "target.bin") + + assert "target.bin" in result + mock_gitlab_client.projects.get.assert_not_called() + + @pytest.mark.timeout(60) + def test_upload_calls_generic_packages_upload( + self, mock_upload_context, mock_file_path, mock_project + ): + """Test project.generic_packages.upload called with correct params.""" + mock_upload_context.gl.projects.get.return_value = mock_project + + upload_single_file(mock_upload_context, mock_file_path, "target.bin") + + mock_project.generic_packages.upload.assert_called_once() + call_kwargs = mock_project.generic_packages.upload.call_args[1] + assert call_kwargs["package_name"] == "test-package" + assert call_kwargs["package_version"] == "1.0.0" + assert call_kwargs["file_name"] == "target.bin" + + @pytest.mark.timeout(60) + def test_upload_logs_file_size_and_time( + self, mock_upload_context, mock_file_path, mock_project, caplog + ): + """Test logging includes file size in MB and elapsed time.""" + import logging + + caplog.set_level(logging.DEBUG) + + mock_upload_context.gl.projects.get.return_value = mock_project + + upload_single_file(mock_upload_context, mock_file_path, "target.bin") + + assert "MB" in caplog.text + assert "target.bin" in caplog.text + + @pytest.mark.timeout(60) + def test_upload_constructs_correct_download_url( + self, mock_upload_context, mock_file_path, mock_project + ): + """Test returned URL matches expected format.""" + mock_upload_context.gl.projects.get.return_value = mock_project + + result = upload_single_file(mock_upload_context, mock_file_path, "target.bin") + + expected_base = "https://gitlab.com/api/v4/projects/12345/packages/generic" + assert result.startswith(expected_base) + assert "test-package" in result + assert "1.0.0" in result + assert "target.bin" in result + + @pytest.mark.timeout(60) + def test_upload_file_size_calculation( + self, mock_upload_context, tmp_path, mock_project, caplog + ): + """Test Path.stat() with specific size is logged correctly.""" + import logging + + caplog.set_level(logging.DEBUG) + + # Create a file with known size + test_file = tmp_path / "sized.bin" + content = b"x" * (1024 * 1024) # 1 MB + test_file.write_bytes(content) + + mock_upload_context.gl.projects.get.return_value = mock_project + + upload_single_file(mock_upload_context, test_file, "sized.bin") + + assert "1.00 MB" in caplog.text or "1.0" in caplog.text + + +class TestValidateUpload: + """Test checksum validation after upload.""" + + @pytest.mark.timeout(60) + def test_validate_success_checksum_match(self, mock_upload_context, mock_project): + """Test package file with matching checksum returns True.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock() + mock_file.file_name = "target.bin" + mock_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = validate_upload(mock_upload_context, "target.bin", "a" * 64) + + assert result is True + + @pytest.mark.timeout(60) + def test_validate_dry_run_mode( + self, mock_gitlab_client, mock_upload_config_dry_run, mock_duplicate_detector + ): + """Test dry_run=True returns True without API calls.""" + context = UploadContext( + gl=mock_gitlab_client, + config=mock_upload_config_dry_run, + detector=mock_duplicate_detector, + project_id=12345, + project_path="mygroup/myproject", + ) + + result = validate_upload(context, "target.bin", "a" * 64) + + assert result is True + mock_gitlab_client.projects.get.assert_not_called() + + @pytest.mark.timeout(60) + def test_validate_checksum_mismatch_raises_error( + self, mock_upload_context, mock_project + ): + """Test mismatched checksum raises ChecksumValidationError.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock() + mock_file.file_name = "target.bin" + mock_file.file_sha256 = "b" * 64 # Different checksum + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with pytest.raises(ChecksumValidationError) as exc_info: + validate_upload(mock_upload_context, "target.bin", "a" * 64) + + assert "mismatch" in str(exc_info.value).lower() + + @pytest.mark.timeout(60) + def test_validate_package_not_found(self, mock_upload_context, mock_project): + """Test packages.list returns empty, verify False returned.""" + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [] + + result = validate_upload(mock_upload_context, "target.bin", "a" * 64) + + assert result is False + + @pytest.mark.timeout(60) + def test_validate_file_not_found_in_package( + self, mock_upload_context, mock_project + ): + """Test package exists but file not in package_files, verify False.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock() + mock_file.file_name = "other.bin" # Different filename + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = validate_upload(mock_upload_context, "target.bin", "a" * 64) + + assert result is False + + @pytest.mark.timeout(60) + def test_validate_no_remote_checksum_available( + self, mock_upload_context, mock_project, caplog + ): + """Test remote file has no file_sha256 attribute, verify True (skip validation).""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock(spec=[]) # No file_sha256 attribute + mock_file.file_name = "target.bin" + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = validate_upload(mock_upload_context, "target.bin", "a" * 64) + + assert result is True + assert "skipping validation" in caplog.text.lower() + + @pytest.mark.timeout(60) + def test_validate_empty_file_checksum(self, mock_upload_context, mock_project): + """Test special case for empty file SHA256.""" + empty_sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock() + mock_file.file_name = "target.bin" + mock_file.file_sha256 = empty_sha256 + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = validate_upload(mock_upload_context, "target.bin", empty_sha256) + + assert result is True + + @pytest.mark.timeout(60) + def test_validate_case_insensitive_checksum(self, mock_upload_context, mock_project): + """Test checksum comparison is case-insensitive.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock() + mock_file.file_name = "target.bin" + mock_file.file_sha256 = "AABBCC" + "d" * 58 # Uppercase + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = validate_upload( + mock_upload_context, "target.bin", "aabbcc" + "d" * 58 # Lowercase + ) + + assert result is True + + @pytest.mark.timeout(60) + def test_validate_filename_with_path_variations( + self, mock_upload_context, mock_project + ): + """Test filename matching handles path variations (exact match and endswith).""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock() + mock_file.file_name = "subdir/target.bin" + mock_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = validate_upload(mock_upload_context, "target.bin", "a" * 64) + + assert result is True + + +class TestHandleDuplicate: + """Test duplicate handling based on policy.""" + + @pytest.mark.timeout(60) + def test_handle_duplicate_skip_policy( + self, mock_upload_context, mock_file_path, sample_remote_file + ): + """Test Policy=SKIP returns ('skipped', download_url).""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.SKIP + + action, result = handle_duplicate( + mock_upload_context, mock_file_path, sample_remote_file + ) + + assert action == "skipped" + assert result == sample_remote_file.download_url + + @pytest.mark.timeout(60) + def test_handle_duplicate_replace_policy( + self, mock_upload_context, mock_file_path, sample_remote_file, mock_project + ): + """Test Policy=REPLACE calls delete and returns ('replaced', 'proceed_with_upload').""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.REPLACE + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [] # Simplified for this test + + action, result = handle_duplicate( + mock_upload_context, mock_file_path, sample_remote_file + ) + + assert action == "replaced" + assert result == "proceed_with_upload" + + @pytest.mark.timeout(60) + def test_handle_duplicate_error_policy( + self, mock_upload_context, mock_file_path, sample_remote_file + ): + """Test Policy=ERROR raises ValueError with helpful message.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.ERROR + + with pytest.raises(ValueError) as exc_info: + handle_duplicate(mock_upload_context, mock_file_path, sample_remote_file) + + assert "Duplicate file detected" in str(exc_info.value) + assert "--duplicate-policy" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_handle_duplicate_skip_returns_existing_url( + self, mock_upload_context, mock_file_path, sample_remote_file + ): + """Test existing RemoteFile.download_url is returned for SKIP policy.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.SKIP + sample_remote_file.download_url = "https://custom-url.com/file.bin" + + action, result = handle_duplicate( + mock_upload_context, mock_file_path, sample_remote_file + ) + + assert result == "https://custom-url.com/file.bin" + + @pytest.mark.timeout(60) + def test_handle_duplicate_replace_calls_delete( + self, mock_upload_context, mock_file_path, sample_remote_file, mock_project + ): + """Test delete_file_from_registry is called with correct filename.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.REPLACE + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [] + + with patch( + "gitlab_pkg_upload.uploader.delete_file_from_registry" + ) as mock_delete: + handle_duplicate(mock_upload_context, mock_file_path, sample_remote_file) + mock_delete.assert_called_once_with( + mock_upload_context, sample_remote_file.filename + ) + + @pytest.mark.timeout(60) + def test_handle_duplicate_logging( + self, mock_upload_context, mock_file_path, sample_remote_file, caplog + ): + """Test appropriate log messages for each policy.""" + import logging + + caplog.set_level(logging.DEBUG) + + mock_upload_context.config.duplicate_policy = DuplicatePolicy.SKIP + + handle_duplicate(mock_upload_context, mock_file_path, sample_remote_file) + + assert "Duplicate detected" in caplog.text + assert "SKIP" in caplog.text + + +class TestDeleteFileFromRegistry: + """Test file deletion from GitLab registry.""" + + @pytest.mark.timeout(60) + def test_delete_success(self, mock_upload_context, mock_project): + """Test package file delete() called and count returned.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock() + mock_file.file_name = "target.bin" + mock_file.id = 1001 + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = delete_file_from_registry(mock_upload_context, "target.bin") + + assert result == 1 + mock_file.delete.assert_called_once() + + @pytest.mark.timeout(60) + def test_delete_dry_run_mode( + self, mock_gitlab_client, mock_upload_config_dry_run, mock_duplicate_detector + ): + """Test dry_run=True returns 0 without deletion.""" + context = UploadContext( + gl=mock_gitlab_client, + config=mock_upload_config_dry_run, + detector=mock_duplicate_detector, + project_id=12345, + project_path="mygroup/myproject", + ) + + result = delete_file_from_registry(context, "target.bin") + + assert result == 0 + mock_gitlab_client.projects.get.assert_not_called() + + @pytest.mark.timeout(60) + def test_delete_package_not_found( + self, mock_upload_context, mock_project, caplog + ): + """Test packages.list returns empty, verify 0 returned with warning.""" + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [] + + result = delete_file_from_registry(mock_upload_context, "target.bin") + + assert result == 0 + assert "not found" in caplog.text.lower() + + @pytest.mark.timeout(60) + def test_delete_file_not_found(self, mock_upload_context, mock_project, caplog): + """Test package exists but filename not found, verify 0 returned.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file = MagicMock() + mock_file.file_name = "other.bin" # Different filename + mock_package_obj.package_files.list.return_value = [mock_file] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = delete_file_from_registry(mock_upload_context, "target.bin") + + assert result == 0 + assert "No files named" in caplog.text + + @pytest.mark.timeout(60) + def test_delete_multiple_files_same_name(self, mock_upload_context, mock_project): + """Test multiple files with same name, verify all deleted.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file1 = MagicMock() + mock_file1.file_name = "target.bin" + mock_file1.id = 1001 + mock_file2 = MagicMock() + mock_file2.file_name = "target.bin" + mock_file2.id = 1002 + mock_file3 = MagicMock() + mock_file3.file_name = "target.bin" + mock_file3.id = 1003 + mock_package_obj.package_files.list.return_value = [ + mock_file1, + mock_file2, + mock_file3, + ] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = delete_file_from_registry(mock_upload_context, "target.bin") + + assert result == 3 + mock_file1.delete.assert_called_once() + mock_file2.delete.assert_called_once() + mock_file3.delete.assert_called_once() + + @pytest.mark.timeout(60) + def test_delete_handles_deletion_error( + self, mock_upload_context, mock_project, caplog + ): + """Test delete() raises exception, verify logged and continues.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + mock_file1 = MagicMock() + mock_file1.file_name = "target.bin" + mock_file1.id = 1001 + mock_file1.delete.side_effect = Exception("Delete failed") + mock_file2 = MagicMock() + mock_file2.file_name = "target.bin" + mock_file2.id = 1002 + mock_package_obj.package_files.list.return_value = [mock_file1, mock_file2] + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = delete_file_from_registry(mock_upload_context, "target.bin") + + assert result == 1 # Only second file deleted successfully + assert "Failed to delete" in caplog.text + + @pytest.mark.timeout(60) + def test_delete_returns_correct_count(self, mock_upload_context, mock_project): + """Test delete 3 files, verify returns 3.""" + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + + mock_package_obj = MagicMock() + files = [] + for i in range(3): + mock_file = MagicMock() + mock_file.file_name = "target.bin" + mock_file.id = 1000 + i + files.append(mock_file) + mock_package_obj.package_files.list.return_value = files + + mock_upload_context.gl.projects.get.return_value = mock_project + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + result = delete_file_from_registry(mock_upload_context, "target.bin") + + assert result == 3 + + +class TestUploadFiles: + """Test main orchestration function.""" + + @pytest.mark.timeout(60) + def test_upload_files_single_file_success( + self, mock_upload_context, mock_file_path, mock_project + ): + """Test upload one file, verify UploadResult with success=True.""" + mock_upload_context.gl.projects.get.return_value = mock_project + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + mock_package_obj = MagicMock() + mock_pkg_file = MagicMock() + mock_pkg_file.file_name = "target.bin" + mock_pkg_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_pkg_file] + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with patch( + "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files(mock_upload_context, [(mock_file_path, "target.bin")]) + + assert len(results) == 1 + assert results[0].success is True + assert results[0].target_filename == "target.bin" + + @pytest.mark.timeout(60) + def test_upload_files_multiple_files_success( + self, mock_upload_context, tmp_path, mock_project + ): + """Test upload multiple files, verify all succeed.""" + file1 = tmp_path / "file1.bin" + file2 = tmp_path / "file2.bin" + file1.write_bytes(b"content1") + file2.write_bytes(b"content2") + + mock_upload_context.gl.projects.get.return_value = mock_project + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + mock_package_obj = MagicMock() + mock_package_obj.package_files.list.return_value = [] + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with patch( + "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + ): + with patch( + "gitlab_pkg_upload.uploader.validate_upload", return_value=True + ): + results = upload_files( + mock_upload_context, + [(file1, "target1.bin"), (file2, "target2.bin")], + ) + + assert len(results) == 2 + assert all(r.success for r in results) + + @pytest.mark.timeout(60) + def test_upload_files_session_duplicate_skipped( + self, mock_upload_context, mock_file_path, mock_project + ): + """Test session duplicate detected, verify skipped without upload.""" + session_fingerprint = MagicMock() + session_fingerprint.sha256_checksum = "a" * 64 + mock_upload_context.detector.check_session_duplicate.return_value = ( + session_fingerprint + ) + + results = upload_files(mock_upload_context, [(mock_file_path, "target.bin")]) + + assert len(results) == 1 + assert results[0].success is True + assert results[0].was_duplicate is True + assert results[0].duplicate_action == "skipped" + mock_upload_context.gl.projects.get.assert_not_called() + + @pytest.mark.timeout(60) + def test_upload_files_remote_duplicate_skip_policy( + self, mock_upload_context, mock_file_path, sample_remote_file + ): + """Test remote duplicate with SKIP policy, verify skipped.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.SKIP + mock_upload_context.detector.check_remote_duplicate.return_value = ( + sample_remote_file + ) + + with patch( + "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files( + mock_upload_context, [(mock_file_path, "target.bin")] + ) + + assert len(results) == 1 + assert results[0].success is True + assert results[0].was_duplicate is True + assert results[0].duplicate_action == "skipped" + + @pytest.mark.timeout(60) + def test_upload_files_remote_duplicate_replace_policy( + self, mock_upload_context, mock_file_path, sample_remote_file, mock_project + ): + """Test remote duplicate with REPLACE policy, verify deleted then uploaded.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.REPLACE + mock_upload_context.detector.check_remote_duplicate.return_value = ( + sample_remote_file + ) + mock_upload_context.gl.projects.get.return_value = mock_project + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + mock_package_obj = MagicMock() + mock_pkg_file = MagicMock() + mock_pkg_file.file_name = "target.bin" + mock_pkg_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_pkg_file] + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with patch( + "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files( + mock_upload_context, [(mock_file_path, "target.bin")] + ) + + assert len(results) == 1 + assert results[0].success is True + assert results[0].was_duplicate is True + assert results[0].duplicate_action == "replaced" + + @pytest.mark.timeout(60) + def test_upload_files_remote_duplicate_error_policy( + self, mock_upload_context, mock_file_path, sample_remote_file + ): + """Test remote duplicate with ERROR policy, verify UploadResult with success=False.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.ERROR + mock_upload_context.detector.check_remote_duplicate.return_value = ( + sample_remote_file + ) + + with patch( + "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files( + mock_upload_context, [(mock_file_path, "target.bin")] + ) + + assert len(results) == 1 + assert results[0].success is False + assert results[0].was_duplicate is True + assert results[0].duplicate_action == "error" + + @pytest.mark.timeout(60) + def test_upload_files_fail_fast_enabled( + self, mock_upload_context, tmp_path, mock_project + ): + """Test first file fails with fail_fast=True, verify second file not attempted.""" + mock_upload_context.config.fail_fast = True + + file1 = tmp_path / "file1.bin" + file2 = tmp_path / "file2.bin" + file1.write_bytes(b"content1") + file2.write_bytes(b"content2") + + mock_upload_context.gl.projects.get.side_effect = Exception("Upload failed") + + with patch( + "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files( + mock_upload_context, + [(file1, "target1.bin"), (file2, "target2.bin")], + ) + + assert len(results) == 1 + assert results[0].success is False + + @pytest.mark.timeout(60) + def test_upload_files_fail_fast_disabled( + self, mock_upload_context, tmp_path, mock_project + ): + """Test first file fails with fail_fast=False, verify second file attempted.""" + mock_upload_context.config.fail_fast = False + + file1 = tmp_path / "file1.bin" + file2 = tmp_path / "file2.bin" + file1.write_bytes(b"content1") + file2.write_bytes(b"content2") + + call_count = 0 + + def mock_get_project(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise Exception("First upload failed") + return mock_project + + mock_upload_context.gl.projects.get.side_effect = mock_get_project + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + mock_package_obj = MagicMock() + mock_pkg_file = MagicMock() + mock_pkg_file.file_name = "target2.bin" + mock_pkg_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_pkg_file] + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with patch( + "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files( + mock_upload_context, + [(file1, "target1.bin"), (file2, "target2.bin")], + ) + + assert len(results) == 2 + assert results[0].success is False + assert results[1].success is True + + @pytest.mark.timeout(60) + def test_upload_files_checksum_calculation( + self, mock_upload_context, mock_file_path, mock_project + ): + """Test calculate_sha256 called for each file.""" + mock_upload_context.gl.projects.get.return_value = mock_project + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + mock_package_obj = MagicMock() + mock_pkg_file = MagicMock() + mock_pkg_file.file_name = "target.bin" + mock_pkg_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_pkg_file] + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with patch( + "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + ) as mock_sha: + upload_files(mock_upload_context, [(mock_file_path, "target.bin")]) + mock_sha.assert_called_once_with(mock_file_path) + + @pytest.mark.timeout(60) + def test_upload_files_validation_called( + self, mock_upload_context, mock_file_path, mock_project + ): + """Test validate_upload called after each successful upload.""" + mock_upload_context.gl.projects.get.return_value = mock_project + + with patch( + "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + ): + with patch( + "gitlab_pkg_upload.uploader.validate_upload", return_value=True + ) as mock_validate: + upload_files(mock_upload_context, [(mock_file_path, "target.bin")]) + mock_validate.assert_called_once() + + @pytest.mark.timeout(60) + def test_upload_files_registration_called( + self, mock_upload_context, mock_file_path, mock_project + ): + """Test detector.register_file called after validation.""" + mock_upload_context.gl.projects.get.return_value = mock_project + + with patch( + "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + ): + with patch( + "gitlab_pkg_upload.uploader.validate_upload", return_value=True + ): + upload_files(mock_upload_context, [(mock_file_path, "target.bin")]) + + mock_upload_context.detector.register_file.assert_called_once() + + @pytest.mark.timeout(60) + def test_upload_files_upload_exception_handled( + self, mock_upload_context, mock_file_path + ): + """Test upload raises exception, verify UploadResult with success=False.""" + mock_upload_context.gl.projects.get.side_effect = Exception("Network error") + + with patch( + "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files( + mock_upload_context, [(mock_file_path, "target.bin")] + ) + + assert len(results) == 1 + assert results[0].success is False + # Error may be wrapped in RetryError from tenacity + assert results[0].result != "" + + @pytest.mark.timeout(60) + def test_upload_files_replace_policy_deletes_different_checksum( + self, mock_upload_context, mock_file_path, mock_project + ): + """Test no remote duplicate but file exists with different checksum, verify deleted.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.REPLACE + mock_upload_context.detector.check_remote_duplicate.return_value = None + mock_upload_context.gl.projects.get.return_value = mock_project + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + mock_package_obj = MagicMock() + mock_pkg_file = MagicMock() + mock_pkg_file.file_name = "target.bin" + mock_pkg_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_pkg_file] + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with patch( + "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + ): + with patch( + "gitlab_pkg_upload.uploader.delete_file_from_registry", return_value=1 + ) as mock_delete: + upload_files(mock_upload_context, [(mock_file_path, "target.bin")]) + mock_delete.assert_called() + + @pytest.mark.timeout(60) + def test_upload_files_result_includes_duplicate_metadata( + self, mock_upload_context, mock_file_path, sample_remote_file + ): + """Test UploadResult includes was_duplicate, duplicate_action, existing_url.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.SKIP + mock_upload_context.detector.check_remote_duplicate.return_value = ( + sample_remote_file + ) + + with patch( + "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files( + mock_upload_context, [(mock_file_path, "target.bin")] + ) + + assert results[0].was_duplicate is True + assert results[0].duplicate_action == "skipped" + assert results[0].existing_url == sample_remote_file.download_url + + @pytest.mark.timeout(60) + def test_upload_files_constructs_session_duplicate_url( + self, mock_upload_context, mock_file_path + ): + """Test session duplicate detected, verify URL constructed correctly.""" + session_fingerprint = MagicMock() + session_fingerprint.sha256_checksum = "a" * 64 + mock_upload_context.detector.check_session_duplicate.return_value = ( + session_fingerprint + ) + + results = upload_files(mock_upload_context, [(mock_file_path, "target.bin")]) + + assert results[0].success is True + assert "test-package" in results[0].existing_url + assert "1.0.0" in results[0].existing_url + assert "target.bin" in results[0].existing_url + + +class TestUploadFilesIntegration: + """Integration tests for complete upload workflows.""" + + @pytest.mark.timeout(60) + def test_workflow_no_duplicates_all_succeed( + self, mock_upload_context, tmp_path, mock_project + ): + """Test upload 3 files with no duplicates, verify all succeed.""" + files = [] + for i in range(3): + f = tmp_path / f"file{i}.bin" + f.write_bytes(f"content {i}".encode()) + files.append((f, f"target{i}.bin")) + + mock_upload_context.gl.projects.get.return_value = mock_project + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + mock_package_obj = MagicMock() + mock_package_obj.package_files.list.return_value = [] + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with patch( + "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + ): + with patch( + "gitlab_pkg_upload.uploader.validate_upload", return_value=True + ): + results = upload_files(mock_upload_context, files) + + assert len(results) == 3 + assert all(r.success for r in results) + assert all(not r.was_duplicate for r in results) + + @pytest.mark.timeout(60) + def test_workflow_mixed_success_and_failure( + self, mock_upload_context, tmp_path, mock_project + ): + """Test some files succeed, some fail, verify correct results.""" + mock_upload_context.config.fail_fast = False + + file1 = tmp_path / "file1.bin" + file2 = tmp_path / "file2.bin" + file3 = tmp_path / "file3.bin" + file1.write_bytes(b"content1") + file2.write_bytes(b"content2") + file3.write_bytes(b"content3") + + # Track calls to determine when to fail + # upload_single_file calls projects.get once per file + # validate_upload calls projects.get once per file + # So for 3 files: file1 upload, file1 validate, file2 upload (fail), ... + upload_call_count = 0 + + def mock_upload_side_effect(context, file, target): + nonlocal upload_call_count + upload_call_count += 1 + if upload_call_count == 2: # Second file fails during upload + raise Exception("Upload failed") + return f"https://gitlab.com/download/{target}" + + mock_upload_context.gl.projects.get.return_value = mock_project + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + mock_package_obj = MagicMock() + mock_pkg_file = MagicMock() + mock_pkg_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_pkg_file] + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with patch( + "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + ): + with patch( + "gitlab_pkg_upload.uploader.upload_single_file", + side_effect=mock_upload_side_effect, + ): + results = upload_files( + mock_upload_context, + [(file1, "t1.bin"), (file2, "t2.bin"), (file3, "t3.bin")], + ) + + assert len(results) == 3 + assert results[0].success is True + assert results[1].success is False + assert results[2].success is True + + @pytest.mark.timeout(60) + def test_workflow_all_duplicates_skip_policy( + self, mock_upload_context, tmp_path, sample_remote_file + ): + """Test all files are duplicates with SKIP policy, verify all skipped.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.SKIP + + files = [] + for i in range(3): + f = tmp_path / f"file{i}.bin" + f.write_bytes(f"content {i}".encode()) + files.append((f, f"target{i}.bin")) + + mock_upload_context.detector.check_remote_duplicate.return_value = ( + sample_remote_file + ) + + with patch( + "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files(mock_upload_context, files) + + assert len(results) == 3 + assert all(r.success for r in results) + assert all(r.was_duplicate for r in results) + assert all(r.duplicate_action == "skipped" for r in results) + + @pytest.mark.timeout(60) + def test_workflow_duplicate_then_new_file( + self, mock_upload_context, tmp_path, sample_remote_file, mock_project + ): + """Test first file is duplicate (skipped), second is new (uploaded).""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.SKIP + + file1 = tmp_path / "file1.bin" + file2 = tmp_path / "file2.bin" + file1.write_bytes(b"content1") + file2.write_bytes(b"content2") + + # First file is duplicate, second is not + mock_upload_context.detector.check_remote_duplicate.side_effect = [ + sample_remote_file, + None, + ] + + mock_upload_context.gl.projects.get.return_value = mock_project + + mock_package = MagicMock() + mock_package.version = "1.0.0" + mock_package.id = 1 + mock_package_obj = MagicMock() + mock_pkg_file = MagicMock() + mock_pkg_file.file_name = "target2.bin" + mock_pkg_file.file_sha256 = "a" * 64 + mock_package_obj.package_files.list.return_value = [mock_pkg_file] + mock_project.packages.list.return_value = [mock_package] + mock_project.packages.get.return_value = mock_package_obj + + with patch( + "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files( + mock_upload_context, + [(file1, "target1.bin"), (file2, "target2.bin")], + ) + + assert len(results) == 2 + assert results[0].success is True + assert results[0].was_duplicate is True + assert results[0].duplicate_action == "skipped" + assert results[1].success is True + assert results[1].was_duplicate is False + + @pytest.mark.timeout(60) + def test_workflow_session_duplicate_prevents_remote_check( + self, mock_upload_context, tmp_path + ): + """Test session duplicate found, verify remote check not called.""" + file1 = tmp_path / "file1.bin" + file2 = tmp_path / "file2.bin" + file1.write_bytes(b"content") + file2.write_bytes(b"content") + + session_fingerprint = MagicMock() + session_fingerprint.sha256_checksum = "a" * 64 + mock_upload_context.detector.check_session_duplicate.return_value = ( + session_fingerprint + ) + + results = upload_files(mock_upload_context, [(file1, "target.bin")]) + + assert len(results) == 1 + assert results[0].success is True + assert results[0].was_duplicate is True + mock_upload_context.detector.check_remote_duplicate.assert_not_called() From a6e8f64c70919f69b84ca29f212635bfd9b309a3 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 07:14:52 -0600 Subject: [PATCH 16/36] tests: Add comprehensive unit tests for CLI module The CLI module lacked unit test coverage, making it difficult to verify argument parsing, flag validation, Git repository auto-detection, and the main orchestration flow. Without tests, refactoring or extending CLI functionality risks introducing regressions that would only surface during manual testing or production use. Introduce a complete test suite covering all CLI components including verbosity determination, logging setup, argument parsing, flag conflict detection, GitAutoDetector for repository discovery, ProjectResolver for GitLab project resolution, UploadContextBuilder for context creation, and the main function error handling paths. Each test class isolates a specific component with mocked dependencies to ensure fast, deterministic execution. Signed-off-by: Javier Tia --- tests/unit/test_cli.py | 1736 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1736 insertions(+) create mode 100644 tests/unit/test_cli.py diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..60e4105 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,1736 @@ +""" +Comprehensive unit tests for the CLI module. + +Tests cover argument parsing, flag validation, project resolution, +Git auto-detection, context building, and main orchestration. +All tests are isolated with mocked dependencies. + +Test Structure: + - TestDetermineVerbosity: Tests for verbosity flag priority + - TestSetupLogging: Tests for logging configuration + - TestCreateArgumentParser: Tests for argument parser creation + - TestValidateFlags: Tests for flag validation and conflict detection + - TestGitAutoDetector: Tests for Git repository auto-detection + - TestProjectResolver: Tests for GitLab project resolution + - TestUploadContextBuilder: Tests for context building + - TestHelperFunctions: Tests for utility functions + - TestParseArguments: Tests for argument parsing with shell completion + - TestMainFunction: Tests for main orchestration flow + - TestExceptionExitCodeMapping: Tests for exit code mapping + - TestEdgeCases: Tests for edge cases and error scenarios + +Running Tests: + # Run all CLI tests + pytest tests/unit/test_cli.py -v + + # Run specific test class + pytest tests/unit/test_cli.py::TestDetermineVerbosity -v + + # Run tests with coverage + pytest tests/unit/test_cli.py --cov=gitlab_pkg_upload.cli --cov-report=term-missing +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch, call + +import pytest +import git +from gitlab import Gitlab +from gitlab.exceptions import GitlabAuthenticationError, GitlabGetError + +from gitlab_pkg_upload.cli import ( + # Constants + EXCEPTION_EXIT_CODE_MAP, + # Functions + determine_verbosity, + setup_logging, + create_argument_parser, + validate_flags, + get_version, + parse_arguments, + auto_detect_project, + resolve_project_manually, + main, + # Classes + GitAutoDetector, + ProjectResolver, + UploadContextBuilder, +) +from gitlab_pkg_upload.models import ( + DuplicatePolicy, + GitRemoteInfo, + ProjectInfo, + UploadConfig, + UploadContext, + AuthenticationError, + ConfigurationError, + ProjectResolutionError, + FileValidationError, + NetworkError, +) + +# Mark all tests as fast unit tests +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def mock_args(): + """Create mock argument namespace with default values.""" + args = argparse.Namespace() + args.package_name = "test-package" + args.package_version = "1.0.0" + args.files = ["file1.txt"] + args.directory = None + args.file_mapping = None + args.project_url = None + args.project_path = None + args.gitlab_url = "https://gitlab.com" + args.token = None + args.duplicate_policy = "skip" + args.retry = 0 + args.verbose = False + args.quiet = False + args.debug = False + args.dry_run = False + args.fail_fast = False + args.json_output = False + args.plain = False + return args + + +@pytest.fixture +def mock_gitlab_client(): + """Create mock GitLab client.""" + mock_gl = MagicMock() + mock_gl.url = "https://gitlab.com" + mock_gl.api_url = "https://gitlab.com/api/v4" + mock_gl.auth = MagicMock() + mock_gl.user = MagicMock(username="testuser", name="Test User") + mock_gl.projects = MagicMock() + return mock_gl + + +@pytest.fixture +def mock_git_repo(): + """Create mock Git repository.""" + mock_repo = MagicMock(spec=git.Repo) + mock_repo.working_dir = "/path/to/repo" + return mock_repo + + +@pytest.fixture +def mock_git_remote(): + """Create mock Git remote.""" + mock_remote = MagicMock() + mock_remote.name = "origin" + mock_remote.urls = iter(["git@gitlab.com:mygroup/myproject.git"]) + return mock_remote + + +# ============================================================================= +# Test Classes +# ============================================================================= + + +class TestDetermineVerbosity: + """Tests for determine_verbosity function.""" + + @pytest.mark.timeout(60) + def test_debug_flag_highest_priority(self, mock_args): + """Test debug flag takes highest priority.""" + mock_args.debug = True + mock_args.verbose = True + mock_args.quiet = True + assert determine_verbosity(mock_args) == "debug" + + @pytest.mark.timeout(60) + def test_verbose_flag_second_priority(self, mock_args): + """Test verbose flag takes second priority.""" + mock_args.debug = False + mock_args.verbose = True + mock_args.quiet = True + assert determine_verbosity(mock_args) == "verbose" + + @pytest.mark.timeout(60) + def test_quiet_flag_third_priority(self, mock_args): + """Test quiet flag takes third priority.""" + mock_args.debug = False + mock_args.verbose = False + mock_args.quiet = True + assert determine_verbosity(mock_args) == "quiet" + + @pytest.mark.timeout(60) + def test_normal_default(self, mock_args): + """Test normal is default when no flags set.""" + mock_args.debug = False + mock_args.verbose = False + mock_args.quiet = False + assert determine_verbosity(mock_args) == "normal" + + +class TestSetupLogging: + """Tests for setup_logging function.""" + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.logging.basicConfig') + @patch('gitlab_pkg_upload.cli.RichHandler') + @patch('gitlab_pkg_upload.cli.Console') + def test_logging_setup_normal(self, mock_console, mock_rich_handler, mock_basic_config, mock_args): + """Test logging setup with normal verbosity.""" + setup_logging(mock_args) + mock_basic_config.assert_called_once() + call_kwargs = mock_basic_config.call_args[1] + assert call_kwargs['level'] == logging.INFO + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.logging.basicConfig') + @patch('gitlab_pkg_upload.cli.RichHandler') + @patch('gitlab_pkg_upload.cli.Console') + def test_logging_setup_debug(self, mock_console, mock_rich_handler, mock_basic_config, mock_args): + """Test logging setup with debug verbosity.""" + mock_args.debug = True + setup_logging(mock_args) + call_kwargs = mock_basic_config.call_args[1] + assert call_kwargs['level'] == logging.DEBUG + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.logging.basicConfig') + @patch('gitlab_pkg_upload.cli.RichHandler') + @patch('gitlab_pkg_upload.cli.Console') + def test_logging_setup_quiet(self, mock_console, mock_rich_handler, mock_basic_config, mock_args): + """Test logging setup with quiet verbosity.""" + mock_args.quiet = True + setup_logging(mock_args) + call_kwargs = mock_basic_config.call_args[1] + assert call_kwargs['level'] == logging.WARNING + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.logging.basicConfig') + @patch('gitlab_pkg_upload.cli.RichHandler') + @patch('gitlab_pkg_upload.cli.Console') + def test_logging_setup_verbose(self, mock_console, mock_rich_handler, mock_basic_config, mock_args): + """Test logging setup with verbose verbosity.""" + mock_args.verbose = True + setup_logging(mock_args) + call_kwargs = mock_basic_config.call_args[1] + assert call_kwargs['level'] == logging.INFO + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.logging.basicConfig') + @patch('gitlab_pkg_upload.cli.RichHandler') + @patch('gitlab_pkg_upload.cli.Console') + def test_logging_uses_stderr_for_json_output(self, mock_console, mock_rich_handler, mock_basic_config, mock_args): + """Test logging uses stderr when json_output is enabled.""" + mock_args.json_output = True + setup_logging(mock_args) + mock_console.assert_called_once() + call_kwargs = mock_console.call_args[1] + assert call_kwargs['file'] == sys.stderr + + +class TestCreateArgumentParser: + """Tests for create_argument_parser function.""" + + @pytest.mark.timeout(60) + def test_parser_creation(self): + """Test argument parser is created successfully.""" + parser = create_argument_parser() + assert isinstance(parser, argparse.ArgumentParser) + assert parser.prog == "gitlab-pkg-upload" + + @pytest.mark.timeout(60) + def test_parser_has_required_argument_groups(self): + """Test parser has expected argument groups and options.""" + parser = create_argument_parser() + # Parse with no args - parser itself won't fail, but validate_flags will + args = parser.parse_args([]) + # Verify that required argument attributes exist (even if None) + assert hasattr(args, 'package_name') + assert hasattr(args, 'package_version') + assert hasattr(args, 'files') + assert hasattr(args, 'directory') + + @pytest.mark.timeout(60) + def test_parser_accepts_valid_arguments(self): + """Test parser accepts valid argument combinations.""" + parser = create_argument_parser() + args = parser.parse_args([ + '--package-name', 'test', + '--package-version', '1.0.0', + '--files', 'file.txt' + ]) + assert args.package_name == 'test' + assert args.package_version == '1.0.0' + assert args.files == ['file.txt'] + + @pytest.mark.timeout(60) + def test_parser_duplicate_policy_choices(self): + """Test duplicate policy accepts valid choices.""" + parser = create_argument_parser() + for policy in ['skip', 'replace', 'error']: + args = parser.parse_args([ + '--package-name', 'test', + '--package-version', '1.0.0', + '--files', 'file.txt', + '--duplicate-policy', policy + ]) + assert args.duplicate_policy == policy + + @pytest.mark.timeout(60) + def test_parser_invalid_duplicate_policy(self): + """Test invalid duplicate policy is rejected.""" + parser = create_argument_parser() + with pytest.raises(SystemExit): + parser.parse_args([ + '--package-name', 'test', + '--package-version', '1.0.0', + '--files', 'file.txt', + '--duplicate-policy', 'invalid' + ]) + + @pytest.mark.timeout(60) + def test_parser_multiple_files(self): + """Test parser accepts multiple files.""" + parser = create_argument_parser() + args = parser.parse_args([ + '--package-name', 'test', + '--package-version', '1.0.0', + '--files', 'file1.txt', 'file2.txt', 'file3.txt' + ]) + assert args.files == ['file1.txt', 'file2.txt', 'file3.txt'] + + @pytest.mark.timeout(60) + def test_parser_default_values(self): + """Test parser has correct default values.""" + parser = create_argument_parser() + args = parser.parse_args([ + '--package-name', 'test', + '--package-version', '1.0.0', + '--files', 'file.txt' + ]) + assert args.duplicate_policy == 'skip' + assert args.retry == 0 + assert args.verbose is False + assert args.quiet is False + assert args.debug is False + assert args.dry_run is False + assert args.fail_fast is False + assert args.json_output is False + assert args.plain is False + + @pytest.mark.timeout(60) + def test_parser_directory_option(self): + """Test parser accepts directory option.""" + parser = create_argument_parser() + args = parser.parse_args([ + '--package-name', 'test', + '--package-version', '1.0.0', + '--directory', '/path/to/dir' + ]) + assert args.directory == '/path/to/dir' + assert args.files is None + + @pytest.mark.timeout(60) + def test_parser_file_mapping_option(self): + """Test parser accepts file mapping options.""" + parser = create_argument_parser() + args = parser.parse_args([ + '--package-name', 'test', + '--package-version', '1.0.0', + '--files', 'file.txt', + '--file-mapping', 'file.txt:renamed.txt', + '--file-mapping', 'other.bin:new.bin' + ]) + assert args.file_mapping == ['file.txt:renamed.txt', 'other.bin:new.bin'] + + +class TestValidateFlags: + """Tests for validate_flags function.""" + + @pytest.mark.timeout(60) + def test_missing_package_name_raises_error(self, mock_args): + """Test missing package name raises SystemExit.""" + mock_args.package_name = None + with pytest.raises(SystemExit) as exc_info: + validate_flags(mock_args) + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + def test_missing_package_version_raises_error(self, mock_args): + """Test missing package version raises SystemExit.""" + mock_args.package_version = None + with pytest.raises(SystemExit) as exc_info: + validate_flags(mock_args) + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + def test_both_files_and_directory_raises_error(self, mock_args): + """Test specifying both --files and --directory raises error.""" + mock_args.files = ["file.txt"] + mock_args.directory = "/path/to/dir" + with pytest.raises(SystemExit) as exc_info: + validate_flags(mock_args) + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + def test_multiple_verbosity_flags_raises_error(self, mock_args): + """Test multiple verbosity flags raises error.""" + mock_args.verbose = True + mock_args.quiet = True + with pytest.raises(SystemExit) as exc_info: + validate_flags(mock_args) + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + def test_both_project_url_and_path_raises_error(self, mock_args): + """Test specifying both project URL and path raises error.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = "group/project" + with pytest.raises(SystemExit) as exc_info: + validate_flags(mock_args) + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + def test_file_mapping_with_directory_raises_error(self, mock_args): + """Test file mapping with directory raises error.""" + mock_args.files = None + mock_args.directory = "/path/to/dir" + mock_args.file_mapping = ["source:target"] + with pytest.raises(SystemExit) as exc_info: + validate_flags(mock_args) + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + def test_negative_retry_raises_error(self, mock_args): + """Test negative retry count raises error.""" + mock_args.retry = -1 + with pytest.raises(SystemExit) as exc_info: + validate_flags(mock_args) + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + def test_valid_flags_pass_validation(self, mock_args): + """Test valid flag combination passes validation.""" + # Should not raise + validate_flags(mock_args) + + @pytest.mark.timeout(60) + def test_no_file_input_raises_error(self, mock_args): + """Test no file input raises error.""" + mock_args.files = None + mock_args.directory = None + with pytest.raises(SystemExit) as exc_info: + validate_flags(mock_args) + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + def test_all_verbosity_flags_raises_error(self, mock_args): + """Test all three verbosity flags raises error.""" + mock_args.verbose = True + mock_args.quiet = True + mock_args.debug = True + with pytest.raises(SystemExit) as exc_info: + validate_flags(mock_args) + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + def test_verbose_and_debug_raises_error(self, mock_args): + """Test verbose and debug flags raises error.""" + mock_args.verbose = True + mock_args.debug = True + with pytest.raises(SystemExit) as exc_info: + validate_flags(mock_args) + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + def test_zero_retry_is_valid(self, mock_args): + """Test zero retry count is valid.""" + mock_args.retry = 0 + # Should not raise + validate_flags(mock_args) + + @pytest.mark.timeout(60) + def test_positive_retry_is_valid(self, mock_args): + """Test positive retry count is valid.""" + mock_args.retry = 5 + # Should not raise + validate_flags(mock_args) + + +class TestGitAutoDetector: + """Tests for GitAutoDetector class.""" + + @pytest.mark.timeout(60) + def test_initialization(self): + """Test GitAutoDetector initialization.""" + detector = GitAutoDetector() + assert detector.working_directory == "." + + @pytest.mark.timeout(60) + def test_initialization_with_custom_directory(self): + """Test GitAutoDetector with custom directory.""" + detector = GitAutoDetector("/custom/path") + assert detector.working_directory == "/custom/path" + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.git.Repo') + def test_find_git_repository_success(self, mock_repo_class): + """Test finding Git repository successfully.""" + mock_repo = MagicMock() + mock_repo.working_dir = "/path/to/repo" + mock_repo_class.return_value = mock_repo + + detector = GitAutoDetector() + repo = detector.find_git_repository() + + assert repo is mock_repo + mock_repo_class.assert_called_once_with(".", search_parent_directories=True) + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.git.Repo') + def test_find_git_repository_not_found(self, mock_repo_class): + """Test Git repository not found returns None.""" + mock_repo_class.side_effect = git.InvalidGitRepositoryError() + + detector = GitAutoDetector() + repo = detector.find_git_repository() + + assert repo is None + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.git.Repo') + def test_find_git_repository_permission_error(self, mock_repo_class): + """Test Git repository permission error raises ProjectResolutionError.""" + mock_repo_class.side_effect = PermissionError("Access denied") + + detector = GitAutoDetector() + with pytest.raises(ProjectResolutionError) as exc_info: + detector.find_git_repository() + assert "Permission denied" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.git.Repo') + def test_find_git_repository_git_command_error(self, mock_repo_class): + """Test Git command error raises ProjectResolutionError.""" + mock_repo_class.side_effect = git.GitCommandError("git status", 128, stderr="fatal: error") + + detector = GitAutoDetector() + with pytest.raises(ProjectResolutionError) as exc_info: + detector.find_git_repository() + assert "Git command error" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.git.Repo') + def test_find_git_repository_os_error(self, mock_repo_class): + """Test OS error raises ProjectResolutionError.""" + mock_repo_class.side_effect = OSError("Disk error") + + detector = GitAutoDetector() + with pytest.raises(ProjectResolutionError) as exc_info: + detector.find_git_repository() + assert "OS error" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_looks_like_gitlab_url(self): + """Test GitLab URL detection.""" + detector = GitAutoDetector() + assert detector._looks_like_gitlab_url("https://gitlab.com/project") + assert detector._looks_like_gitlab_url("https://my.gitlab.io/project") + assert detector._looks_like_gitlab_url("https://gitlab.example.com/project") + assert detector._looks_like_gitlab_url("https://git.lab.company.com/project") + assert not detector._looks_like_gitlab_url("https://github.com/project") + assert not detector._looks_like_gitlab_url("https://example.com/project") + + @pytest.mark.timeout(60) + def test_is_known_non_gitlab_host(self): + """Test known non-GitLab host detection.""" + detector = GitAutoDetector() + assert detector._is_known_non_gitlab_host("github.com") + assert detector._is_known_non_gitlab_host("bitbucket.org") + assert detector._is_known_non_gitlab_host("codeberg.org") + assert detector._is_known_non_gitlab_host("dev.azure.com") + assert not detector._is_known_non_gitlab_host("gitlab.com") + assert not detector._is_known_non_gitlab_host("gitlab.example.com") + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.parse_git_url') + def test_parse_git_url_success(self, mock_parse): + """Test parsing Git URL successfully.""" + mock_parse.return_value = ("https://gitlab.com", "group/project") + + detector = GitAutoDetector() + result = detector.parse_git_url("git@gitlab.com:group/project.git") + + assert result == ("https://gitlab.com", "group/project") + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.parse_git_url') + def test_parse_git_url_non_gitlab(self, mock_parse): + """Test parsing non-GitLab URL returns None.""" + mock_parse.return_value = ("https://github.com", "group/project") + + detector = GitAutoDetector() + result = detector.parse_git_url("git@github.com:group/project.git") + + assert result is None + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.parse_git_url') + def test_parse_git_url_unknown_host(self, mock_parse): + """Test parsing URL from unknown host still returns it.""" + mock_parse.return_value = ("https://git.example.com", "group/project") + + detector = GitAutoDetector() + result = detector.parse_git_url("git@git.example.com:group/project.git") + + # Unknown hosts are returned (could be self-hosted GitLab) + assert result == ("https://git.example.com", "group/project") + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.parse_git_url') + def test_parse_git_url_gitlab_like_error(self, mock_parse): + """Test parsing GitLab-like URL that fails raises error.""" + mock_parse.side_effect = Exception("Parse error") + + detector = GitAutoDetector() + with pytest.raises(ProjectResolutionError) as exc_info: + detector.parse_git_url("https://gitlab.com/invalid") + assert "format is unrecognized" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.parse_git_url') + def test_parse_git_url_non_gitlab_error_returns_none(self, mock_parse): + """Test parsing non-GitLab URL that fails returns None.""" + mock_parse.side_effect = Exception("Parse error") + + detector = GitAutoDetector() + result = detector.parse_git_url("https://example.com/something") + + assert result is None + + @pytest.mark.timeout(60) + def test_get_gitlab_remotes_success(self, mock_git_repo): + """Test extracting GitLab remotes successfully.""" + mock_remote = MagicMock() + mock_remote.name = "origin" + mock_remote.urls = iter(["git@gitlab.com:group/project.git"]) + mock_git_repo.remotes = [mock_remote] + + detector = GitAutoDetector() + with patch.object(detector, 'parse_git_url', return_value=("https://gitlab.com", "group/project")): + remotes = detector.get_gitlab_remotes(mock_git_repo) + + assert len(remotes) == 1 + assert remotes[0].name == "origin" + assert remotes[0].gitlab_url == "https://gitlab.com" + assert remotes[0].project_path == "group/project" + + @pytest.mark.timeout(60) + def test_get_gitlab_remotes_no_remotes(self, mock_git_repo): + """Test no remotes raises ProjectResolutionError.""" + mock_git_repo.remotes = [] + + detector = GitAutoDetector() + with pytest.raises(ProjectResolutionError) as exc_info: + detector.get_gitlab_remotes(mock_git_repo) + assert "No Git remotes configured" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_get_gitlab_remotes_prioritizes_origin(self, mock_git_repo): + """Test origin remote is prioritized.""" + mock_remote1 = MagicMock() + mock_remote1.name = "upstream" + mock_remote1.urls = iter(["git@gitlab.com:group/project1.git"]) + + mock_remote2 = MagicMock() + mock_remote2.name = "origin" + mock_remote2.urls = iter(["git@gitlab.com:group/project2.git"]) + + mock_git_repo.remotes = [mock_remote1, mock_remote2] + + detector = GitAutoDetector() + with patch.object(detector, 'parse_git_url', side_effect=[ + ("https://gitlab.com", "group/project1"), + ("https://gitlab.com", "group/project2") + ]): + remotes = detector.get_gitlab_remotes(mock_git_repo) + + assert remotes[0].name == "origin" + assert remotes[0].project_path == "group/project2" + + @pytest.mark.timeout(60) + def test_get_gitlab_remotes_no_gitlab_remotes(self, mock_git_repo): + """Test no GitLab remotes raises ProjectResolutionError.""" + mock_remote = MagicMock() + mock_remote.name = "origin" + mock_remote.urls = iter(["git@github.com:group/project.git"]) + mock_git_repo.remotes = [mock_remote] + + detector = GitAutoDetector() + with patch.object(detector, 'parse_git_url', return_value=None): + with pytest.raises(ProjectResolutionError) as exc_info: + detector.get_gitlab_remotes(mock_git_repo) + assert "No GitLab remotes found" in str(exc_info.value) + + +class TestProjectResolver: + """Tests for ProjectResolver class.""" + + @pytest.mark.timeout(60) + def test_initialization(self, mock_gitlab_client): + """Test ProjectResolver initialization.""" + resolver = ProjectResolver(mock_gitlab_client) + assert resolver.gl is mock_gitlab_client + assert isinstance(resolver.project_cache, dict) + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.normalize_gitlab_url') + def test_parse_project_url_success(self, mock_normalize, mock_gitlab_client): + """Test parsing project URL successfully.""" + mock_normalize.return_value = ("https://gitlab.com", "group/project") + + resolver = ProjectResolver(mock_gitlab_client) + result = resolver.parse_project_url("https://gitlab.com/group/project") + + assert isinstance(result, ProjectInfo) + assert result.gitlab_url == "https://gitlab.com" + assert result.project_path == "group/project" + assert result.namespace == "group" + assert result.project_name == "project" + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.normalize_gitlab_url') + def test_parse_project_url_nested_namespace(self, mock_normalize, mock_gitlab_client): + """Test parsing project URL with nested namespace.""" + mock_normalize.return_value = ("https://gitlab.com", "group/subgroup/project") + + resolver = ProjectResolver(mock_gitlab_client) + result = resolver.parse_project_url("https://gitlab.com/group/subgroup/project") + + assert result.namespace == "group/subgroup" + assert result.project_name == "project" + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.normalize_gitlab_url') + def test_parse_project_url_invalid(self, mock_normalize, mock_gitlab_client): + """Test parsing invalid project URL raises error.""" + mock_normalize.side_effect = Exception("Invalid URL") + + resolver = ProjectResolver(mock_gitlab_client) + with pytest.raises(ProjectResolutionError) as exc_info: + resolver.parse_project_url("invalid-url") + assert "Invalid GitLab project URL" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_resolve_project_id_success(self, mock_gitlab_client): + """Test resolving project ID successfully.""" + mock_project = MagicMock() + mock_project.id = 12345 + mock_gitlab_client.projects.get.return_value = mock_project + + resolver = ProjectResolver(mock_gitlab_client) + project_id = resolver.resolve_project_id("https://gitlab.com", "group/project") + + assert project_id == 12345 + mock_gitlab_client.projects.get.assert_called_once_with("group/project") + + @pytest.mark.timeout(60) + def test_resolve_project_id_cached(self, mock_gitlab_client): + """Test project ID resolution uses cache.""" + mock_project = MagicMock() + mock_project.id = 12345 + mock_gitlab_client.projects.get.return_value = mock_project + + resolver = ProjectResolver(mock_gitlab_client) + # First call + project_id1 = resolver.resolve_project_id("https://gitlab.com", "group/project") + # Second call should use cache + project_id2 = resolver.resolve_project_id("https://gitlab.com", "group/project") + + assert project_id1 == project_id2 + # Should only call API once + assert mock_gitlab_client.projects.get.call_count == 1 + + @pytest.mark.timeout(60) + def test_resolve_project_id_not_found(self, mock_gitlab_client): + """Test project not found raises ProjectResolutionError.""" + mock_gitlab_client.projects.get.side_effect = GitlabGetError("404 Not Found") + + resolver = ProjectResolver(mock_gitlab_client) + with pytest.raises(ProjectResolutionError): + resolver.resolve_project_id("https://gitlab.com", "group/nonexistent") + + @pytest.mark.timeout(60) + def test_resolve_project_id_auth_error(self, mock_gitlab_client): + """Test authentication error raises ProjectResolutionError.""" + mock_gitlab_client.projects.get.side_effect = GitlabAuthenticationError("401 Unauthorized") + + resolver = ProjectResolver(mock_gitlab_client) + with pytest.raises(ProjectResolutionError): + resolver.resolve_project_id("https://gitlab.com", "group/project") + + @pytest.mark.timeout(60) + def test_validate_project_access_success(self, mock_gitlab_client): + """Test validating project access successfully.""" + mock_project = MagicMock() + mock_project.name = "Test Project" + mock_project.path_with_namespace = "group/project" + mock_gitlab_client.projects.get.return_value = mock_project + + resolver = ProjectResolver(mock_gitlab_client) + result = resolver.validate_project_access(12345) + + assert result is True + + @pytest.mark.timeout(60) + def test_validate_project_access_failure(self, mock_gitlab_client): + """Test validating project access failure.""" + mock_gitlab_client.projects.get.side_effect = Exception("Access denied") + + resolver = ProjectResolver(mock_gitlab_client) + result = resolver.validate_project_access(12345) + + assert result is False + + +class TestUploadContextBuilder: + """Tests for UploadContextBuilder class.""" + + @pytest.mark.timeout(60) + def test_initialization(self): + """Test UploadContextBuilder initialization.""" + builder = UploadContextBuilder() + assert builder is not None + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.DuplicateDetector') + def test_build_context_success(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test building upload context successfully.""" + mock_detector = MagicMock() + mock_detector_class.return_value = mock_detector + mock_args.duplicate_policy = DuplicatePolicy.SKIP + + builder = UploadContextBuilder() + context = builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + + assert isinstance(context, UploadContext) + assert context.gl is mock_gitlab_client + assert context.project_id == 12345 + assert context.project_path == "group/project" + assert isinstance(context.config, UploadConfig) + assert context.config.package_name == "test-package" + assert context.config.version == "1.0.0" + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.DuplicateDetector') + def test_build_context_with_verbosity(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test context building respects verbosity settings.""" + mock_detector = MagicMock() + mock_detector_class.return_value = mock_detector + mock_args.verbose = True + mock_args.duplicate_policy = DuplicatePolicy.SKIP + + builder = UploadContextBuilder() + context = builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + + assert context.config.verbosity == "verbose" + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.DuplicateDetector') + def test_build_context_with_dry_run(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test context building with dry run enabled.""" + mock_detector = MagicMock() + mock_detector_class.return_value = mock_detector + mock_args.dry_run = True + mock_args.duplicate_policy = DuplicatePolicy.SKIP + + builder = UploadContextBuilder() + context = builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + + assert context.config.dry_run is True + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.DuplicateDetector') + def test_build_context_with_debug(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test context building with debug verbosity.""" + mock_detector = MagicMock() + mock_detector_class.return_value = mock_detector + mock_args.debug = True + mock_args.duplicate_policy = DuplicatePolicy.SKIP + + builder = UploadContextBuilder() + context = builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + + assert context.config.verbosity == "debug" + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.DuplicateDetector') + def test_build_context_with_replace_policy(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test context building with replace duplicate policy.""" + mock_detector = MagicMock() + mock_detector_class.return_value = mock_detector + mock_args.duplicate_policy = DuplicatePolicy.REPLACE + + builder = UploadContextBuilder() + context = builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + + assert context.config.duplicate_policy == DuplicatePolicy.REPLACE + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.DuplicateDetector') + def test_build_context_error_handling(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test context building raises ConfigurationError on failure.""" + mock_detector_class.side_effect = Exception("Detector init failed") + mock_args.duplicate_policy = DuplicatePolicy.SKIP + + builder = UploadContextBuilder() + with pytest.raises(ConfigurationError) as exc_info: + builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + assert "Failed to build upload context" in str(exc_info.value) + + +class TestHelperFunctions: + """Tests for helper functions.""" + + @pytest.mark.timeout(60) + def test_get_version_returns_string(self): + """Test get_version returns a string.""" + version = get_version() + assert isinstance(version, str) + # Version should not be empty + assert len(version) > 0 + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.GitAutoDetector') + def test_auto_detect_project_success(self, mock_detector_class): + """Test auto-detecting project successfully.""" + mock_detector = MagicMock() + mock_repo = MagicMock() + mock_remote = GitRemoteInfo( + name="origin", + url="git@gitlab.com:group/project.git", + gitlab_url="https://gitlab.com", + project_path="group/project" + ) + mock_detector.find_git_repository.return_value = mock_repo + mock_detector.get_gitlab_remotes.return_value = [mock_remote] + mock_detector_class.return_value = mock_detector + + gitlab_url, project_path = auto_detect_project() + + assert gitlab_url == "https://gitlab.com" + assert project_path == "group/project" + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.GitAutoDetector') + def test_auto_detect_project_no_repo(self, mock_detector_class): + """Test auto-detect fails when no Git repository found.""" + mock_detector = MagicMock() + mock_detector.find_git_repository.return_value = None + mock_detector_class.return_value = mock_detector + + with pytest.raises(ProjectResolutionError) as exc_info: + auto_detect_project() + assert "No Git repository found" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.normalize_gitlab_url') + def test_resolve_project_manually_with_url(self, mock_normalize): + """Test manual project resolution with URL.""" + mock_normalize.return_value = ("https://gitlab.com", "group/project") + + gitlab_url, project_path = resolve_project_manually( + project_url="https://gitlab.com/group/project", + project_path=None, + gitlab_url="https://gitlab.com" + ) + + assert gitlab_url == "https://gitlab.com" + assert project_path == "group/project" + + @pytest.mark.timeout(60) + def test_resolve_project_manually_with_path(self): + """Test manual project resolution with path.""" + gitlab_url, project_path = resolve_project_manually( + project_url=None, + project_path="group/project", + gitlab_url="https://gitlab.com" + ) + + assert gitlab_url == "https://gitlab.com" + assert project_path == "group/project" + + @pytest.mark.timeout(60) + def test_resolve_project_manually_with_nested_path(self): + """Test manual project resolution with nested path.""" + gitlab_url, project_path = resolve_project_manually( + project_url=None, + project_path="group/subgroup/project", + gitlab_url="https://gitlab.example.com" + ) + + assert gitlab_url == "https://gitlab.example.com" + assert project_path == "group/subgroup/project" + + @pytest.mark.timeout(60) + def test_resolve_project_manually_invalid_path(self): + """Test manual resolution with invalid path format.""" + with pytest.raises(ProjectResolutionError) as exc_info: + resolve_project_manually( + project_url=None, + project_path="invalid", # Missing namespace + gitlab_url="https://gitlab.com" + ) + assert "Invalid project path format" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_resolve_project_manually_no_specification(self): + """Test manual resolution with no specification raises error.""" + with pytest.raises(ProjectResolutionError) as exc_info: + resolve_project_manually( + project_url=None, + project_path=None, + gitlab_url="https://gitlab.com" + ) + assert "No project specification provided" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.normalize_gitlab_url') + def test_resolve_project_manually_url_parse_error(self, mock_normalize): + """Test manual resolution with URL parse error.""" + mock_normalize.side_effect = Exception("Invalid URL format") + + with pytest.raises(ProjectResolutionError) as exc_info: + resolve_project_manually( + project_url="not-a-valid-url", + project_path=None, + gitlab_url="https://gitlab.com" + ) + assert "Invalid project URL" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_resolve_project_manually_strips_slashes(self): + """Test manual resolution strips leading/trailing slashes from path.""" + gitlab_url, project_path = resolve_project_manually( + project_url=None, + project_path="/group/project/", + gitlab_url="https://gitlab.com" + ) + + assert project_path == "group/project" + + @pytest.mark.timeout(60) + def test_resolve_project_manually_empty_path_parts(self): + """Test manual resolution with empty path parts raises error.""" + with pytest.raises(ProjectResolutionError) as exc_info: + resolve_project_manually( + project_url=None, + project_path="/project", # Empty namespace + gitlab_url="https://gitlab.com" + ) + assert "Invalid project path" in str(exc_info.value) + + +class TestParseArguments: + """Tests for parse_arguments function.""" + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.argcomplete.autocomplete') + @patch('gitlab_pkg_upload.cli.validate_flags') + def test_parse_arguments_success(self, mock_validate, mock_autocomplete): + """Test parsing arguments successfully.""" + result = parse_arguments([ + '--package-name', 'test', + '--package-version', '1.0.0', + '--files', 'file.txt' + ]) + + assert result.package_name == "test" + assert result.package_version == "1.0.0" + mock_autocomplete.assert_called_once() + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.argcomplete.autocomplete') + @patch('gitlab_pkg_upload.cli.validate_flags') + def test_parse_arguments_converts_duplicate_policy(self, mock_validate, mock_autocomplete): + """Test duplicate policy is converted to enum.""" + result = parse_arguments([ + '--package-name', 'test', + '--package-version', '1.0.0', + '--files', 'file.txt', + '--duplicate-policy', 'replace' + ]) + + assert result.duplicate_policy == DuplicatePolicy.REPLACE + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.argcomplete.autocomplete') + @patch('gitlab_pkg_upload.cli.validate_flags') + def test_parse_arguments_all_policies(self, mock_validate, mock_autocomplete): + """Test all duplicate policies are converted correctly.""" + for policy_str, policy_enum in [ + ('skip', DuplicatePolicy.SKIP), + ('replace', DuplicatePolicy.REPLACE), + ('error', DuplicatePolicy.ERROR) + ]: + result = parse_arguments([ + '--package-name', 'test', + '--package-version', '1.0.0', + '--files', 'file.txt', + '--duplicate-policy', policy_str + ]) + assert result.duplicate_policy == policy_enum + + +class TestMainFunction: + """Tests for main function orchestration.""" + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.parse_arguments') + @patch('gitlab_pkg_upload.cli.setup_logging') + @patch('gitlab_pkg_upload.cli.auto_detect_project') + def test_main_authentication_error(self, mock_auto_detect, mock_setup_logging, + mock_parse_args, mock_args): + """Test main function handles authentication error.""" + mock_parse_args.return_value = mock_args + mock_auto_detect.side_effect = AuthenticationError("Auth failed") + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 2 + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.parse_arguments') + @patch('gitlab_pkg_upload.cli.setup_logging') + @patch('gitlab_pkg_upload.cli.auto_detect_project') + def test_main_configuration_error(self, mock_auto_detect, mock_setup_logging, + mock_parse_args, mock_args): + """Test main function handles configuration error.""" + mock_parse_args.return_value = mock_args + mock_auto_detect.side_effect = ConfigurationError("Config error") + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.parse_arguments') + @patch('gitlab_pkg_upload.cli.setup_logging') + @patch('gitlab_pkg_upload.cli.auto_detect_project') + def test_main_project_resolution_error(self, mock_auto_detect, mock_setup_logging, + mock_parse_args, mock_args): + """Test main function handles project resolution error.""" + mock_parse_args.return_value = mock_args + mock_auto_detect.side_effect = ProjectResolutionError("Project not found") + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 4 + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.parse_arguments') + @patch('gitlab_pkg_upload.cli.setup_logging') + @patch('gitlab_pkg_upload.cli.auto_detect_project') + def test_main_file_not_found_error(self, mock_auto_detect, mock_setup_logging, + mock_parse_args, mock_args): + """Test main function handles FileNotFoundError.""" + mock_parse_args.return_value = mock_args + mock_auto_detect.side_effect = FileNotFoundError("File missing") + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 5 + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.parse_arguments') + @patch('gitlab_pkg_upload.cli.setup_logging') + @patch('gitlab_pkg_upload.cli.auto_detect_project') + def test_main_permission_error(self, mock_auto_detect, mock_setup_logging, + mock_parse_args, mock_args): + """Test main function handles PermissionError.""" + mock_parse_args.return_value = mock_args + mock_auto_detect.side_effect = PermissionError("Access denied") + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 5 + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.parse_arguments') + @patch('gitlab_pkg_upload.cli.setup_logging') + @patch('gitlab_pkg_upload.cli.auto_detect_project') + def test_main_value_error(self, mock_auto_detect, mock_setup_logging, + mock_parse_args, mock_args): + """Test main function handles ValueError.""" + mock_parse_args.return_value = mock_args + mock_auto_detect.side_effect = ValueError("Invalid value") + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.parse_arguments') + @patch('gitlab_pkg_upload.cli.setup_logging') + @patch('gitlab_pkg_upload.cli.auto_detect_project') + def test_main_connection_error(self, mock_auto_detect, mock_setup_logging, + mock_parse_args, mock_args): + """Test main function handles ConnectionError.""" + mock_parse_args.return_value = mock_args + mock_auto_detect.side_effect = ConnectionError("Network error") + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 6 + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.parse_arguments') + @patch('gitlab_pkg_upload.cli.setup_logging') + @patch('gitlab_pkg_upload.cli.auto_detect_project') + def test_main_timeout_error(self, mock_auto_detect, mock_setup_logging, + mock_parse_args, mock_args): + """Test main function handles TimeoutError.""" + mock_parse_args.return_value = mock_args + mock_auto_detect.side_effect = TimeoutError("Timed out") + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 6 + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.parse_arguments') + @patch('gitlab_pkg_upload.cli.setup_logging') + @patch('gitlab_pkg_upload.cli.auto_detect_project') + def test_main_unexpected_error(self, mock_auto_detect, mock_setup_logging, + mock_parse_args, mock_args): + """Test main function handles unexpected errors.""" + mock_parse_args.return_value = mock_args + mock_auto_detect.side_effect = RuntimeError("Unexpected error") + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 1 + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.parse_arguments') + @patch('gitlab_pkg_upload.cli.setup_logging') + @patch('gitlab_pkg_upload.cli.resolve_project_manually') + @patch('gitlab_pkg_upload.cli.get_gitlab_token') + @patch('gitlab_pkg_upload.cli.Gitlab') + @patch('gitlab_pkg_upload.cli.ProjectResolver') + @patch('gitlab_pkg_upload.cli.UploadContextBuilder') + @patch('gitlab_pkg_upload.cli.collect_files') + @patch('gitlab_pkg_upload.cli.upload_files') + @patch('gitlab_pkg_upload.cli.OutputFormatter') + def test_main_success_flow(self, mock_formatter_class, mock_upload, + mock_collect, mock_builder_class, + mock_resolver_class, mock_gitlab_class, + mock_get_token, mock_resolve_manual, + mock_setup_logging, mock_parse_args, mock_args): + """Test main function success flow.""" + # Setup args for manual project specification + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.duplicate_policy = DuplicatePolicy.SKIP + mock_parse_args.return_value = mock_args + mock_resolve_manual.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gitlab_class.return_value = mock_gl + + mock_resolver = MagicMock() + mock_resolver.resolve_project_id.return_value = 12345 + mock_resolver.validate_project_access.return_value = True + mock_resolver_class.return_value = mock_resolver + + mock_builder = MagicMock() + mock_context = MagicMock() + mock_context.config = MagicMock() + mock_context.config.package_name = "test-package" + mock_context.config.version = "1.0.0" + mock_builder.build.return_value = mock_context + mock_builder_class.return_value = mock_builder + + mock_collect.return_value = ([MagicMock()], []) + mock_result = MagicMock() + mock_result.success = True + mock_upload.return_value = [mock_result] + + mock_formatter = MagicMock() + mock_formatter_class.return_value = mock_formatter + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 0 + mock_setup_logging.assert_called_once() + mock_gl.auth.assert_called_once() + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.parse_arguments') + @patch('gitlab_pkg_upload.cli.setup_logging') + @patch('gitlab_pkg_upload.cli.resolve_project_manually') + @patch('gitlab_pkg_upload.cli.get_gitlab_token') + @patch('gitlab_pkg_upload.cli.Gitlab') + @patch('gitlab_pkg_upload.cli.ProjectResolver') + def test_main_project_access_validation_fails(self, mock_resolver_class, + mock_gitlab_class, mock_get_token, + mock_resolve_manual, mock_setup_logging, + mock_parse_args, mock_args): + """Test main function when project access validation fails.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.duplicate_policy = DuplicatePolicy.SKIP + mock_parse_args.return_value = mock_args + mock_resolve_manual.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gitlab_class.return_value = mock_gl + + mock_resolver = MagicMock() + mock_resolver.resolve_project_id.return_value = 12345 + mock_resolver.validate_project_access.return_value = False + mock_resolver_class.return_value = mock_resolver + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 4 + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.parse_arguments') + @patch('gitlab_pkg_upload.cli.setup_logging') + @patch('gitlab_pkg_upload.cli.resolve_project_manually') + @patch('gitlab_pkg_upload.cli.get_gitlab_token') + @patch('gitlab_pkg_upload.cli.Gitlab') + @patch('gitlab_pkg_upload.cli.ProjectResolver') + @patch('gitlab_pkg_upload.cli.UploadContextBuilder') + @patch('gitlab_pkg_upload.cli.collect_files') + def test_main_no_valid_files(self, mock_collect, mock_builder_class, + mock_resolver_class, mock_gitlab_class, + mock_get_token, mock_resolve_manual, + mock_setup_logging, mock_parse_args, mock_args): + """Test main function with no valid files to upload.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.duplicate_policy = DuplicatePolicy.SKIP + mock_parse_args.return_value = mock_args + mock_resolve_manual.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gitlab_class.return_value = mock_gl + + mock_resolver = MagicMock() + mock_resolver.resolve_project_id.return_value = 12345 + mock_resolver.validate_project_access.return_value = True + mock_resolver_class.return_value = mock_resolver + + mock_builder = MagicMock() + mock_context = MagicMock() + mock_builder.build.return_value = mock_context + mock_builder_class.return_value = mock_builder + + # No valid files + mock_collect.return_value = ([], []) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 5 + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.parse_arguments') + @patch('gitlab_pkg_upload.cli.setup_logging') + @patch('gitlab_pkg_upload.cli.resolve_project_manually') + @patch('gitlab_pkg_upload.cli.get_gitlab_token') + @patch('gitlab_pkg_upload.cli.Gitlab') + @patch('gitlab_pkg_upload.cli.ProjectResolver') + @patch('gitlab_pkg_upload.cli.UploadContextBuilder') + @patch('gitlab_pkg_upload.cli.collect_files') + @patch('gitlab_pkg_upload.cli.upload_files') + @patch('gitlab_pkg_upload.cli.OutputFormatter') + def test_main_partial_upload_failure(self, mock_formatter_class, mock_upload, + mock_collect, mock_builder_class, + mock_resolver_class, mock_gitlab_class, + mock_get_token, mock_resolve_manual, + mock_setup_logging, mock_parse_args, mock_args): + """Test main function with partial upload failures.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.duplicate_policy = DuplicatePolicy.SKIP + mock_parse_args.return_value = mock_args + mock_resolve_manual.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gitlab_class.return_value = mock_gl + + mock_resolver = MagicMock() + mock_resolver.resolve_project_id.return_value = 12345 + mock_resolver.validate_project_access.return_value = True + mock_resolver_class.return_value = mock_resolver + + mock_builder = MagicMock() + mock_context = MagicMock() + mock_context.config = MagicMock() + mock_context.config.package_name = "test-package" + mock_context.config.version = "1.0.0" + mock_builder.build.return_value = mock_context + mock_builder_class.return_value = mock_builder + + mock_collect.return_value = ([MagicMock(), MagicMock()], []) + # One success, one failure + mock_result1 = MagicMock() + mock_result1.success = True + mock_result2 = MagicMock() + mock_result2.success = False + mock_upload.return_value = [mock_result1, mock_result2] + + mock_formatter = MagicMock() + mock_formatter_class.return_value = mock_formatter + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 1 + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.parse_arguments') + @patch('gitlab_pkg_upload.cli.setup_logging') + @patch('gitlab_pkg_upload.cli.resolve_project_manually') + @patch('gitlab_pkg_upload.cli.get_gitlab_token') + @patch('gitlab_pkg_upload.cli.Gitlab') + @patch('gitlab_pkg_upload.cli.ProjectResolver') + @patch('gitlab_pkg_upload.cli.UploadContextBuilder') + @patch('gitlab_pkg_upload.cli.collect_files') + def test_main_file_errors_with_fail_fast(self, mock_collect, mock_builder_class, + mock_resolver_class, mock_gitlab_class, + mock_get_token, mock_resolve_manual, + mock_setup_logging, mock_parse_args, mock_args): + """Test main function with file errors and fail_fast enabled.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.duplicate_policy = DuplicatePolicy.SKIP + mock_args.fail_fast = True + mock_parse_args.return_value = mock_args + mock_resolve_manual.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gitlab_class.return_value = mock_gl + + mock_resolver = MagicMock() + mock_resolver.resolve_project_id.return_value = 12345 + mock_resolver.validate_project_access.return_value = True + mock_resolver_class.return_value = mock_resolver + + mock_builder = MagicMock() + mock_context = MagicMock() + mock_builder.build.return_value = mock_context + mock_builder_class.return_value = mock_builder + + # Files with errors + mock_collect.return_value = ( + [MagicMock()], + [{'source_path': 'bad.txt', 'error_message': 'Invalid file'}] + ) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 5 + + +class TestExceptionExitCodeMapping: + """Tests for exception exit code mapping.""" + + @pytest.mark.timeout(60) + def test_exception_exit_code_map_structure(self): + """Test EXCEPTION_EXIT_CODE_MAP has expected structure.""" + assert isinstance(EXCEPTION_EXIT_CODE_MAP, dict) + assert FileNotFoundError in EXCEPTION_EXIT_CODE_MAP + assert PermissionError in EXCEPTION_EXIT_CODE_MAP + assert ValueError in EXCEPTION_EXIT_CODE_MAP + assert ConnectionError in EXCEPTION_EXIT_CODE_MAP + assert TimeoutError in EXCEPTION_EXIT_CODE_MAP + + @pytest.mark.timeout(60) + def test_exception_exit_codes_are_integers(self): + """Test all exit codes are integers.""" + for exc_type, exit_code in EXCEPTION_EXIT_CODE_MAP.items(): + assert isinstance(exit_code, int) + assert exit_code > 0 + + @pytest.mark.timeout(60) + def test_file_not_found_exit_code(self): + """Test FileNotFoundError maps to exit code 5.""" + assert EXCEPTION_EXIT_CODE_MAP[FileNotFoundError] == 5 + + @pytest.mark.timeout(60) + def test_permission_error_exit_code(self): + """Test PermissionError maps to exit code 5.""" + assert EXCEPTION_EXIT_CODE_MAP[PermissionError] == 5 + + @pytest.mark.timeout(60) + def test_value_error_exit_code(self): + """Test ValueError maps to exit code 3.""" + assert EXCEPTION_EXIT_CODE_MAP[ValueError] == 3 + + @pytest.mark.timeout(60) + def test_connection_error_exit_code(self): + """Test ConnectionError maps to exit code 6.""" + assert EXCEPTION_EXIT_CODE_MAP[ConnectionError] == 6 + + @pytest.mark.timeout(60) + def test_timeout_error_exit_code(self): + """Test TimeoutError maps to exit code 6.""" + assert EXCEPTION_EXIT_CODE_MAP[TimeoutError] == 6 + + +class TestEdgeCases: + """Tests for edge cases and error scenarios.""" + + @pytest.mark.timeout(60) + def test_git_auto_detector_with_empty_remotes(self, mock_git_repo): + """Test GitAutoDetector with repository that has no remotes.""" + mock_git_repo.remotes = [] + + detector = GitAutoDetector() + with pytest.raises(ProjectResolutionError) as exc_info: + detector.get_gitlab_remotes(mock_git_repo) + assert "No Git remotes configured" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_project_resolver_with_nested_subgroups(self, mock_gitlab_client): + """Test ProjectResolver with deeply nested subgroups.""" + mock_project = MagicMock() + mock_project.id = 12345 + mock_gitlab_client.projects.get.return_value = mock_project + + resolver = ProjectResolver(mock_gitlab_client) + project_id = resolver.resolve_project_id( + "https://gitlab.com", + "group/subgroup1/subgroup2/project" + ) + + assert project_id == 12345 + + @pytest.mark.timeout(60) + def test_git_auto_detector_multiple_urls_per_remote(self, mock_git_repo): + """Test GitAutoDetector handles multiple URLs per remote.""" + mock_remote = MagicMock() + mock_remote.name = "origin" + # Multiple URLs - should use first valid one + mock_remote.urls = iter([ + "git@gitlab.com:group/project.git", + "https://gitlab.com/group/project.git" + ]) + mock_git_repo.remotes = [mock_remote] + + detector = GitAutoDetector() + with patch.object(detector, 'parse_git_url', return_value=("https://gitlab.com", "group/project")): + remotes = detector.get_gitlab_remotes(mock_git_repo) + + # Should only have one remote info (uses first valid URL) + assert len(remotes) == 1 + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.normalize_gitlab_url') + def test_project_resolver_deeply_nested_namespace(self, mock_normalize, mock_gitlab_client): + """Test parsing URL with deeply nested namespace.""" + mock_normalize.return_value = ( + "https://gitlab.com", + "org/team/sub1/sub2/project" + ) + + resolver = ProjectResolver(mock_gitlab_client) + result = resolver.parse_project_url( + "https://gitlab.com/org/team/sub1/sub2/project" + ) + + assert result.namespace == "org/team/sub1/sub2" + assert result.project_name == "project" + + @pytest.mark.timeout(60) + def test_validate_flags_multiple_errors_all_reported(self, mock_args, capsys): + """Test that multiple validation errors are all reported.""" + mock_args.package_name = None + mock_args.package_version = None + mock_args.files = None + mock_args.directory = None + + with pytest.raises(SystemExit) as exc_info: + validate_flags(mock_args) + + assert exc_info.value.code == 3 + captured = capsys.readouterr() + # Should report all errors + assert "--package-name" in captured.err + assert "--package-version" in captured.err + + @pytest.mark.timeout(60) + def test_determine_verbosity_only_debug(self, mock_args): + """Test verbosity with only debug flag set.""" + mock_args.debug = True + mock_args.verbose = False + mock_args.quiet = False + assert determine_verbosity(mock_args) == "debug" + + @pytest.mark.timeout(60) + def test_determine_verbosity_only_verbose(self, mock_args): + """Test verbosity with only verbose flag set.""" + mock_args.debug = False + mock_args.verbose = True + mock_args.quiet = False + assert determine_verbosity(mock_args) == "verbose" + + @pytest.mark.timeout(60) + def test_determine_verbosity_only_quiet(self, mock_args): + """Test verbosity with only quiet flag set.""" + mock_args.debug = False + mock_args.verbose = False + mock_args.quiet = True + assert determine_verbosity(mock_args) == "quiet" + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.DuplicateDetector') + def test_build_context_with_json_output(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test context building with JSON output enabled.""" + mock_detector = MagicMock() + mock_detector_class.return_value = mock_detector + mock_args.json_output = True + mock_args.duplicate_policy = DuplicatePolicy.SKIP + + builder = UploadContextBuilder() + context = builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + + assert context.config.json_output is True + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.DuplicateDetector') + def test_build_context_with_plain_output(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test context building with plain output enabled.""" + mock_detector = MagicMock() + mock_detector_class.return_value = mock_detector + mock_args.plain = True + mock_args.duplicate_policy = DuplicatePolicy.SKIP + + builder = UploadContextBuilder() + context = builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + + assert context.config.plain_output is True + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.DuplicateDetector') + def test_build_context_with_fail_fast(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test context building with fail_fast enabled.""" + mock_detector = MagicMock() + mock_detector_class.return_value = mock_detector + mock_args.fail_fast = True + mock_args.duplicate_policy = DuplicatePolicy.SKIP + + builder = UploadContextBuilder() + context = builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + + assert context.config.fail_fast is True + + @pytest.mark.timeout(60) + @patch('gitlab_pkg_upload.cli.DuplicateDetector') + def test_build_context_with_retry_count(self, mock_detector_class, mock_args, mock_gitlab_client): + """Test context building with custom retry count.""" + mock_detector = MagicMock() + mock_detector_class.return_value = mock_detector + mock_args.retry = 5 + mock_args.duplicate_policy = DuplicatePolicy.SKIP + + builder = UploadContextBuilder() + context = builder.build( + args=mock_args, + gl=mock_gitlab_client, + project_id=12345, + project_path="group/project", + gitlab_url="https://gitlab.com", + token="test-token" + ) + + assert context.config.retry_count == 5 From a264997407ef41d881ae569276510f5a9498390a Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 07:35:09 -0600 Subject: [PATCH 17/36] tests: Add integration test suite using direct module invocation Currently, integration testing for the GitLab package upload CLI relies on subprocess execution, which introduces overhead, complicates test isolation, and makes debugging harder. Tests spawn separate processes for each CLI invocation, capture output via pipes, and parse results from stdout/stderr. This approach is slower and provides less control over the execution environment. Introduce a comprehensive integration test suite that calls the CLI main() function directly instead of using subprocess. The new test infrastructure captures stdout/stderr via context managers, handles SystemExit exceptions to extract exit codes, and provides structured test helpers for common operations. This approach improves test speed, simplifies debugging, and enables better isolation while maintaining full coverage of CLI functionality including single/multiple file uploads, directory uploads, duplicate handling policies, project resolution, and error scenarios. Signed-off-by: Javier Tia --- tests/integration/__init__.py | 33 + tests/integration/conftest.py | 78 ++ tests/integration/test_duplicate_handling.py | 628 +++++++++++++ tests/integration/test_end_to_end.py | 868 ++++++++++++++++++ tests/integration/test_error_scenarios.py | 646 +++++++++++++ tests/integration/test_helpers_module.py | 600 ++++++++++++ .../integration/test_multiple_files_upload.py | 419 +++++++++ tests/integration/test_project_resolution.py | 480 ++++++++++ tests/integration/test_single_file_upload.py | 270 ++++++ 9 files changed, 4022 insertions(+) create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_duplicate_handling.py create mode 100644 tests/integration/test_end_to_end.py create mode 100644 tests/integration/test_error_scenarios.py create mode 100644 tests/integration/test_helpers_module.py create mode 100644 tests/integration/test_multiple_files_upload.py create mode 100644 tests/integration/test_project_resolution.py create mode 100644 tests/integration/test_single_file_upload.py diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..88b6b79 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1,33 @@ +""" +Integration tests package for GitLab package upload CLI. + +This package contains integration tests that use direct module invocation +instead of subprocess execution. Tests call the CLI main() function directly +to improve test isolation and reduce overhead. + +Test Modules: + - test_helpers_module: ModuleExecutor and helper utilities + - test_single_file_upload: Single file upload tests + - test_multiple_files_upload: Multiple files and directory upload tests + - test_duplicate_handling: Duplicate handling policy tests + - test_project_resolution: Project resolution tests + - test_error_scenarios: Error handling tests + - test_end_to_end: Comprehensive end-to-end tests + +Usage: + Run all integration tests: + pytest tests/integration/ -v + + Run specific test file: + pytest tests/integration/test_single_file_upload.py -v + + Run with parallel execution: + pytest tests/integration/ -n auto + +Test Markers: + - integration: All integration tests + - api: Tests requiring GitLab API access + - slow: Tests that take longer to run + - fast: Tests that run quickly + - cleanup: Tests that verify cleanup functionality +""" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..9d6fbef --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,78 @@ +""" +Pytest configuration for integration tests using direct module invocation. + +This module provides fixtures and configuration specific to the integration +tests that call the CLI main() function directly. + +Fixtures from parent conftest.py are automatically inherited: + - gitlab_client: GitLab test client for API verification + - artifact_manager: Test artifact management + - project_path: GitLab project path + +Usage: + Fixtures are automatically available to all tests in this package. + Import additional utilities from test_helpers_module as needed. +""" + +import logging + +import pytest + + +def pytest_configure(config): + """Configure pytest for integration tests.""" + # Register custom markers + config.addinivalue_line( + "markers", + "module_integration: Integration tests using direct module invocation", + ) + config.addinivalue_line( + "markers", + "api: Tests requiring GitLab API access", + ) + config.addinivalue_line( + "markers", + "cleanup: Tests that verify cleanup functionality", + ) + config.addinivalue_line( + "markers", + "fast: Tests that run quickly", + ) + + +@pytest.fixture(autouse=True) +def setup_integration_logging(caplog): + """ + Configure logging for integration tests. + + This fixture sets up appropriate logging levels for integration tests + to capture relevant debug information while avoiding excessive output. + """ + # Set logging level to capture warnings and errors + caplog.set_level(logging.WARNING) + + # Set specific loggers to appropriate levels + logging.getLogger("gitlab_pkg_upload").setLevel(logging.WARNING) + logging.getLogger("urllib3").setLevel(logging.WARNING) + + yield + + # Cleanup logging after test + logging.getLogger("gitlab_pkg_upload").setLevel(logging.WARNING) + + +@pytest.fixture +def module_executor(): + """ + Provide a ModuleExecutor instance for tests. + + This fixture creates a fresh ModuleExecutor for each test that requests it. + Most tests will create their own executor, but this fixture is available + for tests that need a shared or pre-configured executor. + + Returns: + ModuleExecutor instance + """ + from .test_helpers_module import ModuleExecutor + + return ModuleExecutor() diff --git a/tests/integration/test_duplicate_handling.py b/tests/integration/test_duplicate_handling.py new file mode 100644 index 0000000..c5426db --- /dev/null +++ b/tests/integration/test_duplicate_handling.py @@ -0,0 +1,628 @@ +""" +Duplicate handling policy integration tests using direct module invocation. + +This module tests skip, replace, and error duplicate policies by calling +the CLI main() function directly. +""" + +import os +import time + +import pytest + +from .test_helpers_module import ( + ModuleExecutor, + validate_json_result, +) + +# Test markers for categorization +pytestmark = [ + pytest.mark.integration, # These are integration tests + pytest.mark.api, # These require GitLab API access + pytest.mark.slow, # These tests take longer due to multiple uploads and API calls +] + + +def _get_gitlab_token(): + """Get GitLab token from environment with proper error handling.""" + token = os.environ.get("GITLAB_TOKEN") + if not token: + pytest.skip("GITLAB_TOKEN environment variable not set") + return token + + +def _validate_upload_consistency( + gitlab_client, + package_name: str, + version: str, + filename: str, + expected_checksum: str, +) -> bool: + """ + Validate upload results using the same logic as the upload script. + + Args: + gitlab_client: GitLab test client + package_name: Name of the uploaded package + version: Package version + filename: Name of the uploaded file + expected_checksum: Expected SHA256 checksum + + Returns: + True if validation succeeds using upload script logic, False otherwise + """ + try: + # Step 1: Verify file exists in registry (same as upload script verification) + if not gitlab_client.verify_upload( + package_name, version, filename, expected_checksum + ): + return False + + # Step 2: Verify download URL is accessible (same as upload script URL generation) + download_url = gitlab_client.get_download_url(package_name, version, filename) + if not download_url: + return False + + # Step 3: Verify downloaded content matches expected checksum + if not gitlab_client.download_and_verify( + package_name, version, filename, expected_checksum + ): + return False + + return True + + except Exception: + return False + + +class TestDuplicateHandling: + """ + Test class for duplicate handling policies using direct module invocation. + """ + + @pytest.mark.timeout(180) + def test_skip_duplicate_policy(self, gitlab_client, artifact_manager, project_path): + """ + Test skip duplicate policy - should skip uploading existing files. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test file + test_file = artifact_manager.create_test_file( + filename="duplicate-skip-module.txt", size_bytes=2048, content_pattern="text" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("skip-duplicate-module", "1.0.0") + + executor = ModuleExecutor() + + # First upload - should succeed as new file + first_argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) + + first_upload_result = executor.execute_upload( + argv=first_argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate first upload succeeded + assert first_upload_result.json_data is not None + assert validate_json_result( + first_upload_result.json_data, + expected_success=True, + expected_files=[str(test_file.path)], + ) + assert first_upload_result.json_data["success"] is True + assert first_upload_result.json_data["statistics"]["new_uploads"] == 1 + assert first_upload_result.json_data["statistics"]["skipped_duplicates"] == 0 + assert first_upload_result.success, ( + f"First upload failed: {first_upload_result.error_message}" + ) + assert first_upload_result.exit_code == 0 + + first_validation = _validate_upload_consistency( + gitlab_client, + package_name, + "1.0.0", + test_file.path.name, + test_file.checksum, + ) + assert first_validation, "First upload validation failed" + + # Wait to ensure first upload is processed + time.sleep(2) + + # Second upload - should skip duplicate + second_upload_result = executor.execute_upload( + argv=first_argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate second upload succeeded (skip behavior) + assert second_upload_result.success, ( + f"Second upload failed: {second_upload_result.error_message}" + ) + assert second_upload_result.exit_code == 0 + + registry_verification = _validate_upload_consistency( + gitlab_client, + package_name, + "1.0.0", + test_file.path.name, + test_file.checksum, + ) + assert registry_verification, "Registry verification failed after skip duplicate test" + + assert second_upload_result.json_data is not None + assert ( + second_upload_result.json_data["statistics"]["skipped_duplicates"] >= 1 + ), "Expected at least one skipped duplicate" + assert len(second_upload_result.json_data["skipped_duplicates"]) >= 1, ( + "Expected files in skipped_duplicates list" + ) + skipped = second_upload_result.json_data["skipped_duplicates"][0] + assert skipped["was_duplicate"] is True + assert skipped["duplicate_action"] == "skipped" + assert skipped["target_filename"] == test_file.path.name + + @pytest.mark.timeout(180) + def test_replace_duplicate_policy( + self, gitlab_client, artifact_manager, project_path + ): + """ + Test replace duplicate policy - should replace existing duplicate files. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create first test file + first_test_file = artifact_manager.create_test_file( + filename="duplicate-replace-module.txt", + size_bytes=1024, + content_pattern="text", + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("replace-duplicate-module", "1.0.0") + + executor = ModuleExecutor() + + # First upload - should succeed + first_argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(first_test_file.path)], + project_path=project_path, + duplicate_policy="replace", + json_output=True, + ) + + first_upload_result = executor.execute_upload( + argv=first_argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate first upload succeeded + assert first_upload_result.json_data is not None + assert validate_json_result( + first_upload_result.json_data, + expected_success=True, + expected_files=[str(first_test_file.path)], + ) + assert first_upload_result.json_data["success"] is True + assert first_upload_result.json_data["statistics"]["new_uploads"] == 1 + assert first_upload_result.success + + # Wait a moment to ensure the first upload is processed + time.sleep(2) + + # Create second test file with same name but different content + second_test_file = artifact_manager.create_test_file( + filename="duplicate-replace-module.txt", + size_bytes=2048, # Different size + content_pattern="json", # Different content pattern + ) + + # Second upload with same filename but different content - should replace + second_argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(second_test_file.path)], + project_path=project_path, + duplicate_policy="replace", + json_output=True, + ) + + second_upload_result = executor.execute_upload( + argv=second_argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Verify that both uploads succeeded + assert first_upload_result.success, ( + f"First upload failed: {first_upload_result.error_message}" + ) + assert second_upload_result.success, ( + f"Second upload failed: {second_upload_result.error_message}" + ) + + # GitLab API verification - file should exist with the second file's checksum + api_verification = _validate_upload_consistency( + gitlab_client, + package_name, + "1.0.0", + second_test_file.path.name, + second_test_file.checksum, + ) + assert api_verification, "GitLab API verification failed - file was not replaced" + + # Additional check: download and verify content matches second file + download_verification = gitlab_client.download_and_verify( + package_name=package_name, + version="1.0.0", + filename=second_test_file.path.name, + expected_checksum=second_test_file.checksum, + ) + assert download_verification, ( + "Download verification failed - file content does not match second file" + ) + + assert second_upload_result.json_data is not None + assert second_upload_result.json_data["success"] is True + assert ( + second_upload_result.json_data["statistics"]["replaced_duplicates"] >= 1 + ), "Expected at least one replaced duplicate" + replaced_upload = next( + ( + u + for u in second_upload_result.json_data["successful_uploads"] + if u["was_duplicate"] and u["duplicate_action"] == "replaced" + ), + None, + ) + assert replaced_upload is not None, "Expected replaced upload in successful_uploads" + assert replaced_upload["target_filename"] == second_test_file.path.name + + @pytest.mark.timeout(180) + def test_error_duplicate_policy( + self, gitlab_client, artifact_manager, project_path + ): + """ + Test error duplicate policy - should fail when duplicate files are detected. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test file + test_file = artifact_manager.create_test_file( + filename="duplicate-error-module.txt", size_bytes=1536, content_pattern="text" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("error-duplicate-module", "1.0.0") + + executor = ModuleExecutor() + + # First upload - should succeed + first_argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + duplicate_policy="error", + json_output=True, + ) + + first_upload_result = executor.execute_upload( + argv=first_argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate first upload succeeded + assert first_upload_result.json_data is not None + assert validate_json_result( + first_upload_result.json_data, + expected_success=True, + expected_files=[str(test_file.path)], + ) + assert first_upload_result.json_data["success"] is True + assert first_upload_result.json_data["statistics"]["new_uploads"] == 1 + assert first_upload_result.success + assert first_upload_result.exit_code == 0 + + # Wait a moment to ensure the first upload is processed + time.sleep(2) + + # Second upload with same file - should fail due to error policy + second_upload_result = executor.execute_upload( + argv=first_argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + expected_exit_code=1, # Expect failure + use_json_output=True, + ) + + # For error policy, we expect the second upload to fail + assert not second_upload_result.success, ( + "Second upload should have failed with error policy but succeeded" + ) + assert second_upload_result.exit_code != 0, ( + "Second upload should have returned non-zero exit code" + ) + + assert second_upload_result.json_data is not None, ( + "JSON output should be present even on failure" + ) + assert second_upload_result.json_data["success"] is False + assert second_upload_result.json_data["exit_code"] == 1 + assert len(second_upload_result.json_data["failed_uploads"]) > 0, ( + "Expected failed uploads" + ) + failed = second_upload_result.json_data["failed_uploads"][0] + assert "duplicate" in failed.get("error_message", "").lower(), ( + "Error message should mention duplicate" + ) + + # GitLab API verification - original file should still exist + api_verification = _validate_upload_consistency( + gitlab_client, + package_name, + "1.0.0", + test_file.path.name, + test_file.checksum, + ) + assert api_verification, "Original file should still exist" + + +@pytest.mark.slow +@pytest.mark.timeout(180) +def test_multiple_file_skip_duplicates(gitlab_client, artifact_manager, project_path): + """ + Test skip duplicate policy with multiple files where some are duplicates. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test files + test_files = [ + artifact_manager.create_test_file("multi-skip-module-1.txt", 1024, "text"), + artifact_manager.create_test_file("multi-skip-module-2.json", 2048, "json"), + artifact_manager.create_test_file("multi-skip-module-3.bin", 512, "binary"), + ] + + # Create unique package name + package_name = gitlab_client.create_test_package("multi-skip-duplicate-module", "1.0.0") + + executor = ModuleExecutor() + file_paths = [str(f.path) for f in test_files] + + # First upload - all files should succeed + first_argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=file_paths, + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) + + first_upload_result = executor.execute_upload( + argv=first_argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate first upload succeeded + assert first_upload_result.json_data is not None + assert validate_json_result( + first_upload_result.json_data, + expected_success=True, + expected_files=file_paths, + ) + assert first_upload_result.json_data["success"] is True + assert first_upload_result.json_data["statistics"]["new_uploads"] == 3 + assert first_upload_result.json_data["statistics"]["skipped_duplicates"] == 0 + assert first_upload_result.success + + # Wait for processing + time.sleep(3) + + # Second upload with same files - should skip all duplicates + second_upload_result = executor.execute_upload( + argv=first_argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate second upload succeeded (skip behavior) + assert second_upload_result.success, ( + f"Second upload failed: {second_upload_result.error_message}" + ) + + # Verify all files still exist in registry + registry_failures = [] + for test_file in test_files: + registry_verification = _validate_upload_consistency( + gitlab_client, + package_name, + "1.0.0", + test_file.path.name, + test_file.checksum, + ) + if not registry_verification: + registry_failures.append(test_file.path.name) + + assert not registry_failures, ( + f"Registry verification failed for files: {', '.join(registry_failures)}" + ) + + # Verify skip behavior for multiple files + assert second_upload_result.json_data is not None + assert second_upload_result.json_data["statistics"]["skipped_duplicates"] == 3, ( + "All 3 files should be skipped as duplicates" + ) + assert len(second_upload_result.json_data["skipped_duplicates"]) == 3 + skipped_filenames = [ + s["target_filename"] + for s in second_upload_result.json_data["skipped_duplicates"] + ] + for test_file in test_files: + assert test_file.path.name in skipped_filenames + + +@pytest.mark.timeout(180) +def test_mixed_duplicate_and_new_files(gitlab_client, artifact_manager, project_path): + """ + Test skip duplicate policy with a mix of duplicate and new files. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create initial test files + initial_files = [ + artifact_manager.create_test_file("mixed-module-1.txt", 1024, "text"), + artifact_manager.create_test_file("mixed-module-2.json", 2048, "json"), + ] + + # Create unique package name + package_name = gitlab_client.create_test_package("mixed-duplicate-module", "1.0.0") + + executor = ModuleExecutor() + initial_file_paths = [str(f.path) for f in initial_files] + + # First upload - initial files + first_argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=initial_file_paths, + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) + + first_upload_result = executor.execute_upload( + argv=first_argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate first upload succeeded + assert first_upload_result.json_data is not None + assert validate_json_result( + first_upload_result.json_data, + expected_success=True, + expected_files=initial_file_paths, + ) + assert first_upload_result.json_data["success"] is True + assert first_upload_result.json_data["statistics"]["new_uploads"] == 2 + assert first_upload_result.json_data["statistics"]["skipped_duplicates"] == 0 + assert first_upload_result.success + + # Wait for processing + time.sleep(2) + + # Create additional new files + new_files = [ + artifact_manager.create_test_file("mixed-module-3.bin", 512, "binary"), + artifact_manager.create_test_file("mixed-module-4.xml", 1536, "text"), + ] + + # Second upload with mix of duplicate and new files + all_files = initial_files + new_files + all_file_paths = [str(f.path) for f in all_files] + + second_argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=all_file_paths, + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) + + second_upload_result = executor.execute_upload( + argv=second_argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate second upload succeeded + assert second_upload_result.success, ( + f"Second upload failed: {second_upload_result.error_message}" + ) + + # Verify all files exist in registry + registry_failures = [] + for test_file in all_files: + registry_verification = _validate_upload_consistency( + gitlab_client, + package_name, + "1.0.0", + test_file.path.name, + test_file.checksum, + ) + if not registry_verification: + registry_failures.append(test_file.path.name) + + assert not registry_failures, ( + f"Registry verification failed for files: {', '.join(registry_failures)}" + ) + + # Verify mixed behavior (skip duplicates, upload new files) + assert second_upload_result.json_data is not None + assert second_upload_result.json_data["statistics"]["skipped_duplicates"] == 2, ( + "Initial 2 files should be skipped" + ) + assert second_upload_result.json_data["statistics"]["new_uploads"] == 2, ( + "New 2 files should be uploaded" + ) + skipped_filenames = [ + s["target_filename"] + for s in second_upload_result.json_data["skipped_duplicates"] + ] + new_filenames = [ + u["target_filename"] + for u in second_upload_result.json_data["successful_uploads"] + if not u["was_duplicate"] + ] + for initial_file in initial_files: + assert initial_file.path.name in skipped_filenames + for new_file in new_files: + assert new_file.path.name in new_filenames diff --git a/tests/integration/test_end_to_end.py b/tests/integration/test_end_to_end.py new file mode 100644 index 0000000..13e4bcc --- /dev/null +++ b/tests/integration/test_end_to_end.py @@ -0,0 +1,868 @@ +""" +End-to-end integration tests using direct module invocation. + +This module contains comprehensive integration tests that validate complete +workflows, error handling, coverage verification, and parallel execution +safety by calling the CLI main() function directly. +""" + +import os +import secrets +from pathlib import Path + +import pytest + +from .test_helpers_module import ( + ModuleExecutor, + validate_json_result, +) + +# Test markers for categorization +pytestmark = [ + pytest.mark.integration, # These are comprehensive integration tests + pytest.mark.api, # These require GitLab API access + pytest.mark.slow, # These tests are the slowest (comprehensive scenarios) +] + + +def _get_gitlab_token(): + """Get GitLab token from environment with proper error handling.""" + token = os.environ.get("GITLAB_TOKEN") + if not token: + pytest.skip("GITLAB_TOKEN environment variable not set") + return token + + +@pytest.mark.timeout(600) +def test_comprehensive_upload_validation(gitlab_client, artifact_manager, project_path): + """ + Test comprehensive upload validation covering all major scenarios. + + This test validates single file, multiple files, and directory uploads + in a single comprehensive test. + """ + executor = ModuleExecutor() + + # Create a variety of test files and scenarios + single_file = artifact_manager.create_test_file( + "comprehensive-single-module.txt", 1024, "text" + ) + + multiple_files = [ + artifact_manager.create_test_file("comp-multi-module-1.json", 2048, "json"), + artifact_manager.create_test_file("comp-multi-module-2.bin", 4096, "binary"), + artifact_manager.create_test_file("comp-multi-module-3.csv", 1536, "text"), + ] + + directory_files = artifact_manager.create_test_directory("comp-directory-module", 3) + directory_path = artifact_manager.base_dir / "comp-directory-module" + + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create unique package names for each scenario + single_package = gitlab_client.create_test_package("comp-single-module", "1.0.0") + multi_package = gitlab_client.create_test_package("comp-multi-module", "1.0.0") + dir_package = gitlab_client.create_test_package("comp-dir-module", "1.0.0") + + # Test 1: Single file upload + single_argv = executor.build_argv( + package_name=single_package, + version="1.0.0", + files=[str(single_file.path)], + project_path=project_path, + json_output=True, + ) + + single_result = executor.execute_upload( + argv=single_argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + assert single_result.success, ( + f"Single file upload failed: {single_result.error_message}" + ) + + # Validate JSON output + assert single_result.json_data is not None, "JSON output not available" + assert validate_json_result( + single_result.json_data, + expected_success=True, + expected_files=[str(single_file.path)], + ), "JSON validation failed for single file upload" + assert single_result.json_data["success"] is True + assert single_result.json_data["package_name"] == single_package + assert single_result.json_data["version"] == "1.0.0" + assert single_result.json_data["statistics"]["new_uploads"] == 1 + assert single_result.json_data["statistics"]["failed_uploads"] == 0 + assert len(single_result.json_data["successful_uploads"]) == 1 + + # Verify uploaded filename appears in successful_uploads + uploaded_filenames = [ + upload["target_filename"] + for upload in single_result.json_data["successful_uploads"] + ] + assert single_file.path.name in uploaded_filenames + + # Validate single file upload via GitLab API + assert gitlab_client.verify_upload( + single_package, "1.0.0", single_file.path.name, single_file.checksum + ), "Single file upload validation failed" + + # Test 2: Multiple files upload + multi_file_paths = [str(f.path) for f in multiple_files] + multi_argv = executor.build_argv( + package_name=multi_package, + version="1.0.0", + files=multi_file_paths, + project_path=project_path, + json_output=True, + ) + + multi_result = executor.execute_upload( + argv=multi_argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + assert multi_result.success, ( + f"Multiple files upload failed: {multi_result.error_message}" + ) + + # Validate JSON output + assert multi_result.json_data is not None, "JSON output not available" + assert validate_json_result( + multi_result.json_data, expected_success=True, expected_files=multi_file_paths + ), "JSON validation failed for multiple files upload" + assert multi_result.json_data["success"] is True + assert multi_result.json_data["package_name"] == multi_package + assert multi_result.json_data["version"] == "1.0.0" + assert multi_result.json_data["statistics"]["new_uploads"] == 3 + assert multi_result.json_data["statistics"]["failed_uploads"] == 0 + assert len(multi_result.json_data["successful_uploads"]) == 3 + + # Verify uploaded filenames appear in successful_uploads + uploaded_filenames = [ + upload["target_filename"] + for upload in multi_result.json_data["successful_uploads"] + ] + for test_file in multiple_files: + assert test_file.path.name in uploaded_filenames + + # Validate multiple files upload via GitLab API + for test_file in multiple_files: + assert gitlab_client.verify_upload( + multi_package, "1.0.0", test_file.path.name, test_file.checksum + ), f"Multiple files validation failed for {test_file.path.name}" + + # Test 3: Directory upload + dir_argv = executor.build_argv( + package_name=dir_package, + version="1.0.0", + directory=str(directory_path), + project_path=project_path, + json_output=True, + ) + + dir_result = executor.execute_upload( + argv=dir_argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + assert dir_result.success, f"Directory upload failed: {dir_result.error_message}" + + # Validate JSON output + assert dir_result.json_data is not None, "JSON output not available" + assert validate_json_result( + dir_result.json_data, + expected_success=True, + expected_files=[str(f.path) for f in directory_files], + ), "JSON validation failed for directory upload" + assert dir_result.json_data["success"] is True + assert dir_result.json_data["package_name"] == dir_package + assert dir_result.json_data["version"] == "1.0.0" + assert dir_result.json_data["statistics"]["new_uploads"] == len(directory_files) + assert dir_result.json_data["statistics"]["failed_uploads"] == 0 + assert len(dir_result.json_data["successful_uploads"]) == len(directory_files) + + # Verify uploaded filenames appear in successful_uploads + uploaded_filenames = [ + upload["target_filename"] + for upload in dir_result.json_data["successful_uploads"] + ] + for test_file in directory_files: + assert test_file.path.name in uploaded_filenames + + # Validate directory upload via GitLab API + for test_file in directory_files: + assert gitlab_client.verify_upload( + dir_package, "1.0.0", test_file.path.name, test_file.checksum + ), f"Directory upload validation failed for {test_file.path.name}" + + # Final registry verification for all scenarios + all_test_cases = [ + (single_package, "1.0.0", [single_file]), + (multi_package, "1.0.0", multiple_files), + (dir_package, "1.0.0", directory_files), + ] + + for package_name, version, test_files in all_test_cases: + for test_file in test_files: + assert gitlab_client.verify_upload( + package_name, version, test_file.path.name, test_file.checksum + ), ( + f"Registry verification failed for {test_file.path.name} in {package_name}" + ) + + total_files = len([single_file]) + len(multiple_files) + len(directory_files) + print(f"All {total_files} files across 3 scenarios verified successfully") + + +@pytest.mark.integration +@pytest.mark.slow +@pytest.mark.timeout(600) +def test_error_scenario_validation(gitlab_client, artifact_manager, project_path): + """ + Test comprehensive error scenario validation. + + This test validates that various error scenarios are handled correctly + and produce appropriate error messages and exit codes. + """ + executor = ModuleExecutor() + gitlab_client.set_project(project_path) + + test_results = [] + + # Error scenario 1: Invalid file path + argv = executor.build_argv( + package_name="error-test-module", + version="1.0.0", + files=["/nonexistent/invalid/file.txt"], + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) + + result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + expected_exit_code=1, + use_json_output=True, + ) + + test_results.append(("invalid_file_path", result)) + + # Validate error handling with JSON + assert not result.success or result.exit_code != 0, ( + "Invalid file path should have caused failure" + ) + if result.json_data is not None: + assert result.json_data["success"] is False + assert result.json_data["exit_code"] == 1 + assert "error" in result.json_data + + # Error scenario 2: Invalid project path + test_artifact = artifact_manager.create_test_file("valid-module.txt", 512, "text") + + argv = executor.build_argv( + package_name="error-test-module", + version="1.0.0", + files=[str(test_artifact.path)], + project_path="nonexistent/invalid-project-12345", + duplicate_policy="skip", + json_output=True, + ) + + result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + expected_exit_code=1, + use_json_output=True, + ) + + test_results.append(("invalid_project_path", result)) + + # Validate error handling with JSON + assert not result.success or result.exit_code != 0, ( + "Invalid project path should have caused failure" + ) + if result.json_data is not None: + assert result.json_data["success"] is False + assert result.json_data["exit_code"] == 1 + assert "error" in result.json_data + + # Error scenario 3: Invalid GitLab URL + test_artifact2 = artifact_manager.create_test_file("valid2-module.txt", 512, "text") + + argv = executor.build_argv( + package_name="error-test-module", + version="1.0.0", + files=[str(test_artifact2.path)], + project_path=project_path, + gitlab_url="https://invalid-gitlab-instance-12345.com", + duplicate_policy="skip", + json_output=True, + ) + + result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + expected_exit_code=1, + use_json_output=True, + ) + + test_results.append(("invalid_gitlab_url", result)) + + # Validate error handling with JSON + assert not result.success or result.exit_code != 0, ( + "Invalid GitLab URL should have caused failure" + ) + if result.json_data is not None: + assert result.json_data["success"] is False + assert result.json_data["exit_code"] == 1 + assert "error" in result.json_data + + # Error scenario 4: Missing required arguments + with pytest.raises((ValueError, TypeError)): + executor.build_argv( + package_name="", # Empty package name should cause error + version="1.0.0", + files=["dummy.txt"], + project_path=project_path, + ) + + # Validate that all error scenarios produced appropriate responses + for scenario_name, result in test_results: + assert result.exit_code != 0, ( + f"Scenario {scenario_name} exit code should be non-zero" + ) + + +@pytest.mark.integration +@pytest.mark.timeout(60) +def test_coverage_verification(): + """ + Test coverage verification to ensure all required functionality is tested. + + This test verifies that the test suite covers all required functionality + by checking that all major test modules exist. + """ + # Define required test coverage areas + required_coverage = { + "single_file_upload": False, + "multiple_file_upload": False, + "directory_upload": False, + "file_mapping": False, + "duplicate_handling_skip": False, + "duplicate_handling_replace": False, + "duplicate_handling_error": False, + "git_auto_detection": False, + "manual_project_url": False, + "manual_project_path": False, + "error_handling": False, + "network_failure": False, + "authentication_error": False, + "comprehensive_validation": False, + "error_scenario_validation": False, + } + + # Check that test modules exist + test_modules = [ + "test_single_file_upload.py", + "test_multiple_files_upload.py", + "test_duplicate_handling.py", + "test_project_resolution.py", + "test_error_scenarios.py", + "test_end_to_end.py", + ] + + tests_dir = Path(__file__).parent + existing_modules = [] + + for module in test_modules: + module_path = tests_dir / module + if module_path.exists(): + existing_modules.append(module) + + # Mark coverage areas as covered based on module existence + if module == "test_single_file_upload.py": + required_coverage["single_file_upload"] = True + elif module == "test_multiple_files_upload.py": + required_coverage["multiple_file_upload"] = True + required_coverage["directory_upload"] = True + required_coverage["file_mapping"] = True + elif module == "test_duplicate_handling.py": + required_coverage["duplicate_handling_skip"] = True + required_coverage["duplicate_handling_replace"] = True + required_coverage["duplicate_handling_error"] = True + elif module == "test_project_resolution.py": + required_coverage["git_auto_detection"] = True + required_coverage["manual_project_url"] = True + required_coverage["manual_project_path"] = True + elif module == "test_error_scenarios.py": + required_coverage["error_handling"] = True + required_coverage["network_failure"] = True + required_coverage["authentication_error"] = True + elif module == "test_end_to_end.py": + required_coverage["comprehensive_validation"] = True + required_coverage["error_scenario_validation"] = True + + # Calculate coverage statistics + total_areas = len(required_coverage) + covered_areas = sum(1 for covered in required_coverage.values() if covered) + coverage_percentage = (covered_areas / total_areas) * 100 + + # Identify missing coverage + missing_coverage = [ + area for area, covered in required_coverage.items() if not covered + ] + + # Determine success criteria + critical_areas = [ + "single_file_upload", + "multiple_file_upload", + "directory_upload", + "duplicate_handling_skip", + "error_handling", + ] + + critical_covered = all( + required_coverage.get(area, False) for area in critical_areas + ) + sufficient_coverage = coverage_percentage >= 80.0 + + print( + f"Test coverage: {covered_areas}/{total_areas} areas ({coverage_percentage:.1f}%)" + ) + print(f"Existing test modules: {existing_modules}") + + if missing_coverage: + print(f"Missing coverage: {missing_coverage}") + + # Assert coverage requirements + assert critical_covered, ( + f"Missing critical coverage areas: {[area for area in critical_areas if not required_coverage.get(area, False)]}" + ) + assert sufficient_coverage, ( + f"Insufficient coverage: {coverage_percentage:.1f}% (need 80%)" + ) + + +@pytest.mark.integration +@pytest.mark.slow +@pytest.mark.timeout(600) +def test_end_to_end_workflow_validation(gitlab_client, artifact_manager, project_path): + """ + Test end-to-end workflow validation with comprehensive cleanup verification. + + This test validates the complete workflow from file creation through upload + to cleanup, ensuring that all components work together correctly. + """ + executor = ModuleExecutor() + gitlab_client.set_project(project_path) + + # Create test artifacts + test_files = [ + artifact_manager.create_test_file("workflow-module-1.txt", 1024, "text"), + artifact_manager.create_test_file("workflow-module-2.json", 2048, "json"), + artifact_manager.create_test_file("workflow-module-3.bin", 512, "binary"), + ] + + # Create unique package for this workflow test + package_name = gitlab_client.create_test_package("workflow-validation-module", "1.0.0") + + # Execute upload + file_paths = [str(f.path) for f in test_files] + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=file_paths, + project_path=project_path, + json_output=True, + ) + + result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + assert result.success, f"End-to-end upload failed: {result.error_message}" + + # Validate JSON output + assert result.json_data is not None, "JSON output not available" + assert validate_json_result( + result.json_data, expected_success=True, expected_files=file_paths + ), "JSON validation failed for end-to-end workflow" + assert result.json_data["success"] is True + assert result.json_data["package_name"] == package_name + assert result.json_data["statistics"]["new_uploads"] == 3 + assert len(result.json_data["successful_uploads"]) == 3 + + # Verify all test file names appear in successful_uploads + uploaded_filenames = [ + upload["target_filename"] for upload in result.json_data["successful_uploads"] + ] + for test_file in test_files: + assert test_file.path.name in uploaded_filenames + + # Verify all files were uploaded correctly via GitLab API + for test_file in test_files: + assert gitlab_client.verify_upload( + package_name, "1.0.0", test_file.path.name, test_file.checksum + ), f"End-to-end verification failed for {test_file.path.name}" + + # Test cleanup verification - this will be handled by fixtures + for test_file in test_files: + assert test_file.path.exists(), ( + f"Test artifact {test_file.path} should exist before cleanup" + ) + + print( + f"End-to-end workflow validation completed successfully for package {package_name}" + ) + + +@pytest.mark.integration +@pytest.mark.timeout(600) +def test_parallel_execution_safety(gitlab_client, artifact_manager, project_path): + """ + Test that integration tests can run safely in parallel without conflicts. + + This test validates that the test infrastructure properly isolates tests + when running in parallel using pytest-xdist. + """ + executor = ModuleExecutor() + gitlab_client.set_project(project_path) + + # Create unique test artifacts with process-specific naming + process_id = os.getpid() + random_suffix = secrets.token_hex(4) + unique_prefix = f"parallel-module-{process_id}-{random_suffix}" + + test_file = artifact_manager.create_test_file( + f"{unique_prefix}-test.txt", 1024, "text" + ) + package_name = gitlab_client.create_test_package( + f"parallel-test-module-{random_suffix}", "1.0.0" + ) + + # Execute upload with unique identifiers + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + json_output=True, + ) + + result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + assert result.success, f"Parallel execution test failed: {result.error_message}" + + # Validate JSON output + assert result.json_data is not None, "JSON output not available" + assert validate_json_result( + result.json_data, expected_success=True, expected_files=[str(test_file.path)] + ), "JSON validation failed for parallel execution" + assert result.json_data["success"] is True + assert result.json_data["statistics"]["new_uploads"] == 1 + assert len(result.json_data["successful_uploads"]) == 1 + + # Verify uploaded filename appears in successful_uploads + uploaded_filenames = [ + upload["target_filename"] for upload in result.json_data["successful_uploads"] + ] + assert test_file.path.name in uploaded_filenames + + # Verify upload via GitLab API + assert gitlab_client.verify_upload( + package_name, "1.0.0", test_file.path.name, test_file.checksum + ), "Parallel execution upload verification failed" + + print(f"Parallel execution safety test completed for process {process_id}") + + +@pytest.mark.integration +@pytest.mark.cleanup +@pytest.mark.timeout(900) +def test_comprehensive_cleanup_verification( + gitlab_client, artifact_manager, project_path +): + """ + Test comprehensive cleanup verification to ensure all test artifacts are properly cleaned up. + + This test validates that the pytest fixture cleanup mechanisms work correctly + and that no test artifacts are left behind after test execution. + """ + executor = ModuleExecutor() + gitlab_client.set_project(project_path) + + # Track initial state + initial_artifacts = len(artifact_manager.artifacts) + initial_packages = len(gitlab_client.created_packages) + + # Create test artifacts that should be cleaned up + test_files = [] + for i in range(3): + test_file = artifact_manager.create_test_file( + f"cleanup-module-{i}.txt", 1024, "text" + ) + test_files.append(test_file) + + # Create test packages that should be cleaned up + package_names = [] + for i in range(2): + package_name = gitlab_client.create_test_package(f"cleanup-module-{i}", "1.0.0") + package_names.append(package_name) + + # Verify artifacts were created + assert len(artifact_manager.artifacts) == initial_artifacts + 3, ( + "Test artifacts were not created properly" + ) + assert len(gitlab_client.created_packages) == initial_packages + 2, ( + "Test packages were not tracked properly" + ) + + # Verify files exist on disk + for test_file in test_files: + assert test_file.path.exists(), f"Test file {test_file.path} should exist" + + # Perform some uploads to create actual GitLab packages + for i, package_name in enumerate(package_names): + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_files[i].path)], + project_path=project_path, + json_output=True, + ) + + result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + assert result.success, ( + f"Upload failed for cleanup test package {package_name}: {result.error_message}" + ) + + # Validate JSON output + assert result.json_data is not None, "JSON output not available" + assert validate_json_result( + result.json_data, + expected_success=True, + expected_files=[str(test_files[i].path)], + ), f"JSON validation failed for cleanup test package {package_name}" + assert result.json_data["success"] is True + assert result.json_data["statistics"]["new_uploads"] == 1 + assert len(result.json_data["successful_uploads"]) == 1 + + # Verify upload was successful via GitLab API + assert gitlab_client.verify_upload( + package_name, "1.0.0", test_files[i].path.name, test_files[i].checksum + ), f"Upload verification failed for cleanup test package {package_name}" + + # Test manual cleanup to verify it works + artifact_successful, artifact_failed = artifact_manager.cleanup_artifacts( + force=True + ) + assert artifact_failed == 0, f"Artifact cleanup failed for {artifact_failed} items" + assert artifact_successful >= 3, ( + f"Expected at least 3 artifacts cleaned up, got {artifact_successful}" + ) + + package_successful, package_failed = gitlab_client.cleanup_test_packages(force=True) + assert package_failed == 0, f"Package cleanup failed for {package_failed} items" + assert package_successful >= 2, ( + f"Expected at least 2 packages cleaned up, got {package_successful}" + ) + + # Verify cleanup was effective + for test_file in test_files: + assert not test_file.path.exists(), ( + f"Test file {test_file.path} should have been cleaned up" + ) + + assert len(artifact_manager.artifacts) == 0, ( + "Artifact manager should have no tracked artifacts after cleanup" + ) + assert len(gitlab_client.created_packages) == 0, ( + "GitLab client should have no tracked packages after cleanup" + ) + + print("Comprehensive cleanup verification completed successfully") + + +@pytest.mark.integration +@pytest.mark.slow +@pytest.mark.timeout(600) +def test_multi_scenario_workflow_validation( + gitlab_client, artifact_manager, project_path +): + """ + Test multi-scenario workflow validation combining different upload types and policies. + + This test validates complex workflows that combine multiple upload scenarios, + duplicate policies, and error conditions. + """ + executor = ModuleExecutor() + gitlab_client.set_project(project_path) + + # Scenario 1: Upload with skip duplicate policy + test_file_1 = artifact_manager.create_test_file( + "multi-scenario-module-1.txt", 1024, "text" + ) + package_name_1 = gitlab_client.create_test_package("multi-scenario-skip-module", "1.0.0") + + # First upload + argv_1 = executor.build_argv( + package_name=package_name_1, + version="1.0.0", + files=[str(test_file_1.path)], + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) + + result_1 = executor.execute_upload( + argv=argv_1, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + assert result_1.success, f"First upload failed: {result_1.error_message}" + + # Validate JSON output for first upload + assert result_1.json_data is not None, "JSON output not available" + assert validate_json_result( + result_1.json_data, + expected_success=True, + expected_files=[str(test_file_1.path)], + ), "JSON validation failed for first upload" + assert result_1.json_data["success"] is True + assert result_1.json_data["statistics"]["new_uploads"] == 1 + + # Second upload (should skip duplicate) + result_1_dup = executor.execute_upload( + argv=argv_1, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + assert result_1_dup.success, ( + f"Duplicate upload with skip policy failed: {result_1_dup.error_message}" + ) + + # Validate JSON output for duplicate upload + assert result_1_dup.json_data is not None, "JSON output not available" + assert result_1_dup.json_data["success"] is True + assert result_1_dup.json_data["statistics"]["skipped_duplicates"] == 1 + + # Scenario 2: Directory upload with replace policy + directory_files = artifact_manager.create_test_directory("multi-scenario-dir-module", 2) + directory_path = artifact_manager.base_dir / "multi-scenario-dir-module" + package_name_2 = gitlab_client.create_test_package( + "multi-scenario-replace-module", "1.0.0" + ) + + argv_2 = executor.build_argv( + package_name=package_name_2, + version="1.0.0", + directory=str(directory_path), + project_path=project_path, + duplicate_policy="replace", + json_output=True, + ) + + result_2 = executor.execute_upload( + argv=argv_2, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + assert result_2.success, f"Directory upload failed: {result_2.error_message}" + + # Validate JSON output for directory upload + assert result_2.json_data is not None, "JSON output not available" + assert validate_json_result( + result_2.json_data, + expected_success=True, + expected_files=[str(f.path) for f in directory_files], + ), "JSON validation failed for directory upload" + assert result_2.json_data["success"] is True + assert result_2.json_data["statistics"]["new_uploads"] == 2 + + # Verify directory files in successful_uploads + uploaded_filenames = [ + upload["target_filename"] for upload in result_2.json_data["successful_uploads"] + ] + for test_file in directory_files: + assert test_file.path.name in uploaded_filenames + + # Scenario 3: Multiple files with error handling + multiple_files = [ + artifact_manager.create_test_file("multi-scenario-module-3a.json", 2048, "json"), + artifact_manager.create_test_file("multi-scenario-module-3b.bin", 1024, "binary"), + ] + package_name_3 = gitlab_client.create_test_package("multi-scenario-multi-module", "1.0.0") + + file_paths = [str(f.path) for f in multiple_files] + argv_3 = executor.build_argv( + package_name=package_name_3, + version="1.0.0", + files=file_paths, + project_path=project_path, + duplicate_policy="error", + json_output=True, + ) + + result_3 = executor.execute_upload( + argv=argv_3, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + assert result_3.success, f"Multiple files upload failed: {result_3.error_message}" + + # Validate JSON output for multiple files upload + assert result_3.json_data is not None, "JSON output not available" + assert validate_json_result( + result_3.json_data, expected_success=True, expected_files=file_paths + ), "JSON validation failed for multiple files upload" + assert result_3.json_data["success"] is True + assert result_3.json_data["statistics"]["new_uploads"] == 2 + + # Verify multiple files in successful_uploads + uploaded_filenames = [ + upload["target_filename"] for upload in result_3.json_data["successful_uploads"] + ] + for test_file in multiple_files: + assert test_file.path.name in uploaded_filenames + + # Verify all uploads + assert gitlab_client.verify_upload( + package_name_1, "1.0.0", test_file_1.path.name, test_file_1.checksum + ), "Multi-scenario validation failed for single file upload" + + for test_file in directory_files: + assert gitlab_client.verify_upload( + package_name_2, "1.0.0", test_file.path.name, test_file.checksum + ), f"Multi-scenario validation failed for directory file {test_file.path.name}" + + for test_file in multiple_files: + assert gitlab_client.verify_upload( + package_name_3, "1.0.0", test_file.path.name, test_file.checksum + ), f"Multi-scenario validation failed for multiple file {test_file.path.name}" + + total_files = 1 + len(directory_files) + len(multiple_files) + print( + f"Multi-scenario workflow validation completed successfully for {total_files} files across 3 scenarios" + ) diff --git a/tests/integration/test_error_scenarios.py b/tests/integration/test_error_scenarios.py new file mode 100644 index 0000000..cb95fc2 --- /dev/null +++ b/tests/integration/test_error_scenarios.py @@ -0,0 +1,646 @@ +""" +Error scenario integration tests using direct module invocation. + +This module tests network failures, authentication errors, error message +validation, failure continuation behavior, and non-ASCII filename rejection +by calling the CLI main() function directly. +""" + +import os + +import pytest + +from .test_helpers_module import ( + ModuleExecutor, +) + +# Test markers for categorization +pytestmark = [ + pytest.mark.integration, # These are integration tests + pytest.mark.api, # These require GitLab API access + pytest.mark.slow, # These tests simulate failures and take longer +] + + +def _get_gitlab_token(): + """Get GitLab token from environment with proper error handling.""" + token = os.environ.get("GITLAB_TOKEN") + if not token: + pytest.skip("GITLAB_TOKEN environment variable not set") + return token + + +class TestErrorScenarios: + """ + Test class for error scenario handling using direct module invocation. + """ + + @pytest.mark.timeout(90) + def test_network_failure_simulation( + self, gitlab_client, artifact_manager, project_path + ): + """ + Test network failure simulation with invalid GitLab URL. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test file + test_file = artifact_manager.create_test_file( + "network-test-module.txt", size_bytes=1024, content_pattern="text" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("network-failure-module", "1.0.0") + + executor = ModuleExecutor() + + # Build argv with invalid GitLab URL to simulate network failure + argv = [ + "--package-name", package_name, + "--package-version", "1.0.0", + "--gitlab-url", "https://invalid-gitlab-url.example.com", + "--project-path", project_path, + "--token", _get_gitlab_token(), + "--files", str(test_file.path), + "--json-output", + ] + + # Execute upload (should fail due to network issues) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": ""}, # Clear GITLAB_TOKEN to force use of --token argument + expected_exit_code=1, + use_json_output=True, + ) + + # Verify that it failed as expected + assert upload_result.exit_code == 1, ( + f"Expected exit code 1, got {upload_result.exit_code}" + ) + + # Validate JSON error fields if available + if upload_result.json_data is not None: + assert upload_result.json_data.get("success") is False + assert upload_result.json_data.get("exit_code") == 1 + assert "error" in upload_result.json_data + assert "error_type" in upload_result.json_data + + # Check for network-related keywords in error message + error_msg = upload_result.json_data["error"].lower() + network_keywords = [ + "network", + "connection", + "timeout", + "failed to connect", + "resolve", + "dns", + ] + network_error_found = any( + keyword in error_msg for keyword in network_keywords + ) + assert network_error_found, ( + f"Expected network error keywords in JSON error: {upload_result.json_data['error']}" + ) + else: + # Fallback to stderr/stdout checking for early errors + error_output = upload_result.stdout + upload_result.stderr + network_error_patterns = [ + "network", + "connection", + "timeout", + "failed to connect", + ] + network_error_found = any( + pattern in error_output.lower() for pattern in network_error_patterns + ) + assert network_error_found, ( + f"Expected network error patterns in output: {error_output}" + ) + + @pytest.mark.timeout(90) + def test_authentication_error(self, gitlab_client, artifact_manager, project_path): + """ + Test authentication error handling with invalid token. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test file + test_file = artifact_manager.create_test_file( + filename="auth-error-module.txt", size_bytes=1024, content_pattern="text" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("auth-error-module", "1.0.0") + + executor = ModuleExecutor() + + # Build argv with invalid token + invalid_token = "invalid-token-that-should-fail-authentication" + argv = [ + "--package-name", package_name, + "--package-version", "1.0.0", + "--project-path", project_path, + "--token", invalid_token, + "--files", str(test_file.path), + "--json-output", + ] + + # Execute upload (should fail due to authentication issues) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": ""}, # Clear GITLAB_TOKEN to force use of --token argument + expected_exit_code=1, + use_json_output=True, + ) + + # Validate that the upload failed as expected + assert upload_result.exit_code != 0, ( + f"Expected upload to fail but got exit code: {upload_result.exit_code}" + ) + + # Validate JSON error fields if available + if upload_result.json_data is not None: + assert upload_result.json_data.get("success") is False + assert upload_result.json_data.get("exit_code") != 0 + assert "error" in upload_result.json_data + assert "error_type" in upload_result.json_data + + # Check for authentication-related keywords in error message + error_msg = upload_result.json_data["error"].lower() + auth_keywords = [ + "authentication", + "token", + "unauthorized", + "401", + "403", + "access denied", + ] + auth_error_found = any(keyword in error_msg for keyword in auth_keywords) + assert auth_error_found, ( + f"Expected authentication error keywords in JSON error: {upload_result.json_data['error']}" + ) + else: + # Fallback to stderr/stdout checking for early errors + error_output = upload_result.stdout + upload_result.stderr + auth_error_indicators = [ + "authentication", + "token", + "unauthorized", + "401", + "403", + "access denied", + ] + auth_error_present = any( + indicator in error_output.lower() for indicator in auth_error_indicators + ) + assert auth_error_present, ( + f"Expected authentication error patterns in output: {error_output}" + ) + + @pytest.mark.timeout(90) + def test_error_message_validation( + self, gitlab_client, artifact_manager, project_path + ): + """ + Test error message validation for various error scenarios. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + executor = ModuleExecutor() + + # Test scenario 1: Non-existent file + nonexistent_file = str(artifact_manager.base_dir / "nonexistent-module-file.txt") + package_name = gitlab_client.create_test_package("error-msg-module", "1.0.0") + + # Build argv with non-existent file + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[nonexistent_file], + project_path=project_path, + json_output=True, + ) + + # Execute upload (should fail due to missing file) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + expected_exit_code=1, + use_json_output=True, + ) + + # Validate error message quality + assert upload_result.exit_code != 0, ( + f"Expected upload to fail but got exit code: {upload_result.exit_code}" + ) + + # Validate JSON error fields if available + if upload_result.json_data is not None: + assert upload_result.json_data.get("success") is False + assert "error" in upload_result.json_data + + # Check for file-related keywords in error message + error_msg = upload_result.json_data["error"].lower() + file_keywords = ["file", "not found", "does not exist", "missing"] + file_error_found = any(keyword in error_msg for keyword in file_keywords) + assert file_error_found, ( + f"Expected file error keywords in JSON error: {upload_result.json_data['error']}" + ) + else: + # Fallback to stderr/stdout checking for early errors + error_output = upload_result.stdout + upload_result.stderr + file_error_patterns = [ + "file", + "not found", + "does not exist", + "missing", + ] + file_error_found = any( + pattern in error_output.lower() for pattern in file_error_patterns + ) + assert file_error_found, ( + f"Expected file error patterns in output: {error_output}" + ) + + # Test scenario 2: Invalid project path + test_file = artifact_manager.create_test_file( + filename="error-msg-test2-module.txt", size_bytes=512, content_pattern="text" + ) + + invalid_project_path = "invalid/nonexistent-project" + + # Build argv with invalid project path + argv2 = executor.build_argv( + package_name=package_name, + version="1.0.1", + files=[str(test_file.path)], + project_path=invalid_project_path, + json_output=True, + ) + + # Execute upload (should fail due to invalid project) + upload_result2 = executor.execute_upload( + argv=argv2, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + expected_exit_code=1, + use_json_output=True, + ) + + # Validate second error scenario + assert upload_result2.exit_code != 0, ( + f"Expected upload to fail but got exit code: {upload_result2.exit_code}" + ) + + # Validate JSON error fields if available + if upload_result2.json_data is not None: + assert upload_result2.json_data.get("success") is False + assert "error" in upload_result2.json_data + + # Check for project-related keywords in error message + error_msg2 = upload_result2.json_data["error"].lower() + project_keywords = ["project", "404", "not found", "access", "invalid"] + project_error_found = any( + keyword in error_msg2 for keyword in project_keywords + ) + assert project_error_found, ( + f"Expected project error keywords in JSON error: {upload_result2.json_data['error']}" + ) + + @pytest.mark.timeout(90) + def test_failure_continuation_behavior( + self, gitlab_client, artifact_manager, project_path + ): + """ + Test that the system continues processing after individual failures. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create a mix of valid and invalid files for testing continuation behavior + valid_file = artifact_manager.create_test_file( + filename="valid-continuation-module.txt", + size_bytes=1024, + content_pattern="text", + ) + + nonexistent_file = str( + artifact_manager.base_dir / "nonexistent-continuation-module.txt" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package( + "failure-continuation-module", "1.0.0" + ) + + executor = ModuleExecutor() + + # Test multiple file upload with one invalid file + argv = [ + "--package-name", package_name, + "--package-version", "1.0.0", + "--project-path", project_path, + "--files", str(valid_file.path), nonexistent_file, + "--json-output", + ] + + # Execute upload (should fail due to invalid file) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + expected_exit_code=1, + use_json_output=True, + ) + + # Validate failure continuation behavior + assert upload_result.exit_code != 0, ( + f"Expected upload to fail but got exit code: {upload_result.exit_code}" + ) + + # Validate JSON error fields if available + if upload_result.json_data is not None: + assert upload_result.json_data.get("success") is False + assert "failed_uploads" in upload_result.json_data + + # Check that the problematic file is mentioned in failed_uploads + failed_uploads = upload_result.json_data.get("failed_uploads", []) + file_mentioned = any( + "nonexistent-continuation-module.txt" in str(item).lower() + or "nonexistent" in str(item).lower() + for item in failed_uploads + ) + assert file_mentioned, ( + f"Expected problematic file in failed_uploads: {failed_uploads}" + ) + else: + # Fallback to stderr/stdout checking for early errors + error_output = upload_result.stdout + upload_result.stderr + error_mentions_file = ( + "nonexistent-continuation-module.txt" in error_output + or "nonexistent" in error_output.lower() + ) + assert error_mentions_file, ( + f"Expected error to mention the problematic file: {error_output}" + ) + + @pytest.mark.timeout(90) + def test_non_ascii_filename_rejection( + self, gitlab_client, artifact_manager, project_path + ): + """ + Test that non-ASCII filenames are properly rejected with detailed error messages. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test file with non-ASCII filename + test_file = artifact_manager.create_test_file( + filename="unicode-名前-module.txt", size_bytes=1024, content_pattern="text" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("non-ascii-module", "1.0.0") + + executor = ModuleExecutor() + + # Build argv + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + json_output=True, + ) + + # Execute upload (should fail due to non-ASCII filename) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + expected_exit_code=1, + use_json_output=True, + ) + + # Verify that it failed as expected + assert upload_result.exit_code == 1, ( + f"Expected exit code 1, got {upload_result.exit_code}" + ) + + # Validate JSON error fields if available + if upload_result.json_data is not None: + assert upload_result.json_data.get("success") is False + assert upload_result.json_data.get("exit_code") == 1 + assert "error" in upload_result.json_data + + # Check for ASCII-related keywords in error message + error_msg = upload_result.json_data["error"].lower() + ascii_keywords = ["ascii", "non-ascii", "character"] + ascii_error_found = any(keyword in error_msg for keyword in ascii_keywords) + assert ascii_error_found, ( + f"Expected ASCII-related error keywords in JSON error: {upload_result.json_data['error']}" + ) + + # Check that error message mentions the problematic filename + filename_mentioned = "名前" in upload_result.json_data["error"] + assert filename_mentioned, ( + f"Expected error to mention the problematic filename: {upload_result.json_data['error']}" + ) + else: + # Fallback to stderr/stdout checking for early errors + error_output = upload_result.stdout + upload_result.stderr + ascii_error_patterns = ["ascii", "non-ascii", "character"] + ascii_error_found = any( + pattern in error_output.lower() for pattern in ascii_error_patterns + ) + assert ascii_error_found, ( + f"Expected ASCII-related error patterns in output: {error_output}" + ) + + +@pytest.mark.slow +@pytest.mark.timeout(90) +def test_non_ascii_filename_in_directory(gitlab_client, artifact_manager, project_path): + """ + Test that non-ASCII filenames in directories are properly rejected. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create a temporary directory + test_dir = artifact_manager.base_dir / "non-ascii-dir-module" + test_dir.mkdir(parents=True, exist_ok=True) + + # Create a file with non-ASCII filename directly + non_ascii_filename = "unicode-测试文件-module.txt" + non_ascii_file_path = test_dir / non_ascii_filename + non_ascii_file_path.write_text("Test content with non-ASCII filename") + + # Create unique package name + package_name = gitlab_client.create_test_package("non-ascii-dir-module", "1.0.0") + + executor = ModuleExecutor() + + # Build argv to upload directory with non-ASCII filename + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + directory=str(test_dir), + project_path=project_path, + json_output=True, + ) + + # Execute upload (should fail due to non-ASCII filename) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + expected_exit_code=1, + use_json_output=True, + ) + + # Verify that it failed as expected + assert upload_result.exit_code == 1, ( + f"Expected exit code 1, got {upload_result.exit_code}" + ) + + # Validate JSON error fields if available + if upload_result.json_data is not None: + assert upload_result.json_data.get("success") is False + assert "error" in upload_result.json_data + + # Check that error message mentions the specific non-ASCII filename + error_msg = upload_result.json_data["error"] + filename_mentioned = ( + non_ascii_filename in error_msg + or "测试文件" in error_msg + or "unicode-" in error_msg.lower() + ) + assert filename_mentioned, ( + f"Expected error to mention the specific non-ASCII filename: {error_msg}" + ) + + # Check for ASCII-related keywords + ascii_keywords = ["ascii", "non-ascii", "character"] + ascii_error_found = any( + keyword in error_msg.lower() for keyword in ascii_keywords + ) + assert ascii_error_found, ( + f"Expected ASCII-related error keywords in JSON error: {error_msg}" + ) + + +@pytest.mark.timeout(120) +def test_mixed_ascii_non_ascii_filenames(gitlab_client, artifact_manager, project_path): + """ + Test that mixed ASCII and non-ASCII filenames are handled correctly. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create multiple test files: some with ASCII names, some with non-ASCII names + ascii_file1 = artifact_manager.create_test_file( + filename="ascii-module-1.txt", size_bytes=512, content_pattern="text" + ) + ascii_file2 = artifact_manager.create_test_file( + filename="ascii-module-2.txt", size_bytes=512, content_pattern="text" + ) + + # Create files with non-ASCII names + test_dir = artifact_manager.base_dir / "mixed-module-test" + test_dir.mkdir(parents=True, exist_ok=True) + + non_ascii_file1 = test_dir / "unicode-名前-module.txt" + non_ascii_file1.write_text("Non-ASCII content 1") + + non_ascii_file2 = test_dir / "unicode-测试-module.txt" + non_ascii_file2.write_text("Non-ASCII content 2") + + # Create unique package name + package_name = gitlab_client.create_test_package("mixed-ascii-module", "1.0.0") + + executor = ModuleExecutor() + + # Build argv to upload all files together + argv = [ + "--package-name", package_name, + "--package-version", "1.0.0", + "--project-path", project_path, + "--files", + str(ascii_file1.path), + str(ascii_file2.path), + str(non_ascii_file1), + str(non_ascii_file2), + "--json-output", + ] + + # Execute upload (should fail due to non-ASCII filenames) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + expected_exit_code=1, + use_json_output=True, + ) + + # Verify that it failed as expected + assert upload_result.exit_code == 1, ( + f"Expected exit code 1, got {upload_result.exit_code}" + ) + + # Validate JSON error fields if available + if upload_result.json_data is not None: + assert upload_result.json_data.get("success") is False + assert "error" in upload_result.json_data + + error_msg = upload_result.json_data["error"] + + # Check that error identifies non-ASCII filenames + non_ascii_mentioned = ( + "名前" in error_msg + or "测试" in error_msg + or "unicode-" in error_msg.lower() + ) + assert non_ascii_mentioned, ( + f"Expected error to identify non-ASCII filenames: {error_msg}" + ) + else: + # Fallback to stderr/stdout checking + error_output = upload_result.stdout + upload_result.stderr + non_ascii_mentioned = "名前" in error_output or "测试" in error_output + assert non_ascii_mentioned, ( + f"Expected error to identify non-ASCII filenames: {error_output}" + ) diff --git a/tests/integration/test_helpers_module.py b/tests/integration/test_helpers_module.py new file mode 100644 index 0000000..43fe1d6 --- /dev/null +++ b/tests/integration/test_helpers_module.py @@ -0,0 +1,600 @@ +""" +Test helper utilities for module-based test execution. + +This module provides utilities for calling the CLI main() function directly +instead of using subprocess execution. It captures stdout/stderr, handles +SystemExit exceptions for exit codes, and parses JSON output. +""" + +from __future__ import annotations + +import io +import json +import os +import sys +import threading +import time +from contextlib import redirect_stderr, redirect_stdout +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Dict, List, Optional + +if TYPE_CHECKING: + pass + +# Import the main function from CLI module +from gitlab_pkg_upload.cli import main + + +@dataclass +class UploadResult: + """ + Represents the result of an upload execution via module invocation. + + Args: + success: Whether the execution was successful + exit_code: Exit code from sys.exit() call + stdout: Captured standard output + stderr: Captured standard error + duration: Execution duration in seconds + uploaded_files: List of uploaded file names + upload_urls: List of upload URLs + error_message: Optional error message + json_data: Parsed JSON output when --json-output is used + Contains structured data with fields: + - success: bool + - exit_code: int + - package_name: str + - version: str + - successful_uploads: list of dicts with source_path, target_filename, download_url, was_duplicate, duplicate_action + - skipped_duplicates: list of dicts (same structure as successful_uploads) + - failed_uploads: list of dicts with source_path, target_filename, error_message + - statistics: dict with total_processed, new_uploads, replaced_duplicates, skipped_duplicates, failed_uploads + - error: str (on failure) + - error_type: str (on failure) + """ + + success: bool + exit_code: int + stdout: str + stderr: str + duration: float + uploaded_files: List[str] + upload_urls: List[str] + error_message: Optional[str] = None + json_data: Optional[Dict] = None + + def __post_init__(self): + if self.uploaded_files is None: + self.uploaded_files = [] + if self.upload_urls is None: + self.upload_urls = [] + + +class ModuleExecutor: + """ + Handles execution of the gitlab-pkg-upload CLI via direct module invocation. + + This class calls the main() function from the CLI module directly instead + of spawning a subprocess. It captures stdout/stderr via context managers + and handles SystemExit exceptions to capture exit codes. + """ + + # Thread lock for stdout/stderr capture to ensure thread safety + _capture_lock = threading.Lock() + + def __init__(self): + """Initialize module executor.""" + pass + + def execute_upload( + self, + argv: List[str], + env_vars: Optional[Dict[str, str]] = None, + expected_exit_code: int = 0, + use_json_output: bool = False, + timeout: int = 120, + ) -> UploadResult: + """ + Execute upload by calling the main() function directly. + + Args: + argv: Command-line arguments to pass to main() (without script path) + env_vars: Optional environment variables to set during execution + expected_exit_code: Expected exit code for success determination + use_json_output: Whether JSON output mode is enabled + timeout: Execution timeout in seconds (not enforced for direct calls) + + Returns: + UploadResult with execution details + + Example: + executor = ModuleExecutor() + result = executor.execute_upload( + argv=["--package-name", "test", "--package-version", "1.0.0", + "--files", "file.txt", "--json-output"], + env_vars={"GITLAB_TOKEN": "token"}, + use_json_output=True + ) + if result.json_data: + print(result.json_data["success"]) + """ + start_time = time.time() + + # Prepare environment variables + original_env = {} + if env_vars: + for key, value in env_vars.items(): + original_env[key] = os.environ.get(key) + if value is not None: + os.environ[key] = value + elif key in os.environ: + del os.environ[key] + + stdout_capture = io.StringIO() + stderr_capture = io.StringIO() + exit_code = 0 + error_message = None + + try: + # Use lock to ensure thread-safe capture + with self._capture_lock: + with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): + try: + main(argv) + exit_code = 0 # If main() returns normally, exit code is 0 + except SystemExit as e: + # Capture exit code from sys.exit() + exit_code = e.code if isinstance(e.code, int) else 1 + + except Exception as e: + duration = time.time() - start_time + error_message = f"Execution failed with exception: {e}" + return UploadResult( + success=False, + exit_code=-1, + stdout=stdout_capture.getvalue(), + stderr=stderr_capture.getvalue(), + duration=duration, + error_message=error_message, + uploaded_files=[], + upload_urls=[], + json_data=None, + ) + + finally: + # Restore original environment + for key, original_value in original_env.items(): + if original_value is not None: + os.environ[key] = original_value + elif key in os.environ: + del os.environ[key] + + duration = time.time() - start_time + stdout_content = stdout_capture.getvalue() + stderr_content = stderr_capture.getvalue() + + # Parse JSON output if enabled + json_data = None + if use_json_output: + json_data = self._parse_json_output(stdout_content) + + # Extract uploaded files and URLs + if json_data is not None: + uploaded_files, upload_urls = self._extract_data_from_json(json_data) + else: + uploaded_files = self._extract_uploaded_files(stdout_content) + upload_urls = self._extract_upload_urls(stdout_content) + + # Determine success + if json_data is not None: + # Use JSON data for success determination + success = ( + json_data.get("success", False) + and exit_code == expected_exit_code + and json_data.get("exit_code", -1) == expected_exit_code + ) + else: + # Use exit code for success determination + success = exit_code == expected_exit_code + + if not success: + if json_data is not None and "error" in json_data: + error_message = ( + f"{json_data.get('error_type', 'Error')}: " + f"{json_data.get('error', 'Unknown error')}" + ) + elif exit_code != expected_exit_code: + error_message = ( + f"Unexpected exit code: {exit_code} " + f"(expected {expected_exit_code})" + ) + + if stderr_content: + if error_message: + error_message += f". Stderr: {stderr_content}" + else: + error_message = f"Stderr: {stderr_content}" + + return UploadResult( + success=success, + exit_code=exit_code, + stdout=stdout_content, + stderr=stderr_content, + duration=duration, + error_message=error_message, + uploaded_files=uploaded_files, + upload_urls=upload_urls, + json_data=json_data, + ) + + def build_argv( + self, + package_name: str, + version: str, + files: Optional[List[str]] = None, + directory: Optional[str] = None, + project_path: Optional[str] = None, + project_url: Optional[str] = None, + gitlab_url: Optional[str] = None, + duplicate_policy: str = "skip", + file_mapping: Optional[List[str]] = None, + json_output: bool = False, + dry_run: bool = False, + fail_fast: bool = False, + verbose: bool = False, + quiet: bool = False, + debug: bool = False, + retry: int = 0, + ) -> List[str]: + """ + Build command line arguments for main() function. + + Args: + package_name: Name of the package + version: Package version + files: List of file paths to upload + directory: Directory containing files to upload + project_path: GitLab project path (namespace/project) + project_url: Full GitLab project URL + gitlab_url: GitLab instance URL + duplicate_policy: Policy for handling duplicates (skip, replace, error) + file_mapping: List of file mappings in source:target format + json_output: Enable JSON output mode + dry_run: Enable dry run mode + fail_fast: Enable fail fast mode + verbose: Enable verbose output + quiet: Enable quiet output + debug: Enable debug output + retry: Number of retry attempts + + Returns: + List of command line arguments for main() + + Raises: + ValueError: If required parameters are missing or invalid + """ + # Validate required parameters + if not package_name or not package_name.strip(): + raise ValueError("package_name is required and cannot be empty") + + if not version or not version.strip(): + raise ValueError("version is required and cannot be empty") + + if not files and not directory: + raise ValueError("Either files or directory must be provided") + + argv = [] + + # Required arguments + argv.extend(["--package-name", package_name]) + argv.extend(["--package-version", version]) + + # File input + if files: + argv.append("--files") + if isinstance(files, list): + argv.extend(files) + else: + argv.append(files) + elif directory: + argv.extend(["--directory", directory]) + + # Project specification + if project_url: + argv.extend(["--project-url", project_url]) + elif project_path: + argv.extend(["--project-path", project_path]) + if gitlab_url: + argv.extend(["--gitlab-url", gitlab_url]) + + # Duplicate policy + if duplicate_policy: + argv.extend(["--duplicate-policy", duplicate_policy]) + + # File mappings + if file_mapping: + for mapping in file_mapping: + argv.extend(["--file-mapping", mapping]) + + # Output flags + if json_output: + argv.append("--json-output") + + # Operational flags + if dry_run: + argv.append("--dry-run") + if fail_fast: + argv.append("--fail-fast") + if retry > 0: + argv.extend(["--retry", str(retry)]) + + # Verbosity flags + if debug: + argv.append("--debug") + elif verbose: + argv.append("--verbose") + elif quiet: + argv.append("--quiet") + + return argv + + def _parse_json_output(self, stdout: str) -> Optional[Dict]: + """ + Parse JSON output from captured stdout. + + Args: + stdout: Captured standard output + + Returns: + Parsed JSON dictionary, or None if parsing fails + """ + if not stdout or not stdout.strip(): + return None + + try: + # Try to parse the entire stdout as JSON + parsed = json.loads(stdout) + return parsed + except json.JSONDecodeError: + # Try to find JSON in the output (in case there's other text) + try: + # Look for JSON object starting with { and ending with } + start_idx = stdout.find("{") + end_idx = stdout.rfind("}") + if start_idx != -1 and end_idx != -1 and end_idx > start_idx: + json_str = stdout[start_idx : end_idx + 1] + parsed = json.loads(json_str) + return parsed + except (json.JSONDecodeError, ValueError): + pass + + return None + + def _extract_data_from_json(self, json_data: Dict) -> tuple[List[str], List[str]]: + """ + Extract uploaded files and URLs from JSON data. + + Args: + json_data: Parsed JSON output from script + + Returns: + Tuple of (uploaded_files, upload_urls) + """ + uploaded_files = [] + upload_urls = [] + + # Handle success case + if json_data.get("success", False): + successful_uploads = json_data.get("successful_uploads", []) + for upload in successful_uploads: + if isinstance(upload, dict): + # Extract target filename + target_filename = upload.get("target_filename", "") + if target_filename: + uploaded_files.append(target_filename) + + # Extract download URL + download_url = upload.get("download_url", "") + if download_url: + upload_urls.append(download_url) + + return uploaded_files, upload_urls + + def _extract_uploaded_files(self, stdout: str) -> List[str]: + """ + Extract uploaded file names from output. + + Args: + stdout: Captured standard output + + Returns: + List of uploaded file names + """ + import re + + uploaded_files = [] + + # Pattern for successful file uploads + upload_patterns = [ + r"✓ Uploaded: (.+)", + r"Successfully uploaded: (.+)", + r"File uploaded: (.+)", + ] + + for pattern in upload_patterns: + matches = re.findall(pattern, stdout) + uploaded_files.extend(matches) + + return uploaded_files + + def _extract_upload_urls(self, stdout: str) -> List[str]: + """ + Extract upload URLs from output. + + Args: + stdout: Captured standard output + + Returns: + List of upload URLs + """ + import re + + url_pattern = r"https?://[^\s]+" + matches = re.findall(url_pattern, stdout) + + return matches + + +def get_project_args( + project_path: Optional[str] = None, + gitlab_url: Optional[str] = None, +) -> List[str]: + """ + Get project arguments for CLI invocation. + + Args: + project_path: GitLab project path + gitlab_url: GitLab instance URL (optional, only included if provided) + + Returns: + List of command-line arguments for project specification + """ + if not project_path: + return [] + + if gitlab_url is None: + return ["--project-path", project_path] + + return ["--project-path", project_path, "--gitlab-url", gitlab_url] + + +def validate_json_result( + json_data: Dict, + expected_success: bool, + expected_files: Optional[List[str]] = None, +) -> bool: + """ + Validate JSON output from upload execution. + + Args: + json_data: Parsed JSON output from execution + expected_success: Expected success status + expected_files: Optional list of expected uploaded files + + Returns: + True if validation passes, False otherwise + + Example: + result = executor.execute_upload(argv, use_json_output=True) + if result.json_data: + is_valid = validate_json_result( + result.json_data, + expected_success=True, + expected_files=["file1.txt", "file2.txt"] + ) + """ + # Validate success status + if json_data.get("success", False) != expected_success: + return False + + # Validate exit code matches success status + expected_exit_code = 0 if expected_success else 1 + if json_data.get("exit_code", -1) != expected_exit_code: + return False + + # If expecting success, validate structure + if expected_success: + # Check required fields are present + required_fields = [ + "package_name", + "version", + "successful_uploads", + "statistics", + ] + for field in required_fields: + if field not in json_data: + return False + + # Validate statistics consistency + stats = json_data.get("statistics", {}) + successful_uploads = json_data.get("successful_uploads", []) + # Calculate expected successful count from new_uploads + replaced_duplicates + expected_successful = stats.get("new_uploads", 0) + stats.get( + "replaced_duplicates", 0 + ) + if expected_successful != len(successful_uploads): + return False + + # Validate expected files if provided + if expected_files: + uploaded_filenames = [ + upload.get("target_filename", "") + for upload in successful_uploads + if isinstance(upload, dict) + ] + for expected_file in expected_files: + file_name = Path(expected_file).name + if file_name not in uploaded_filenames: + return False + else: + # If expecting failure, check error fields + if "error" not in json_data or "error_type" not in json_data: + return False + + return True + + +def execute_with_retry( + executor: ModuleExecutor, + argv: List[str], + env_vars: Optional[Dict[str, str]] = None, + max_retries: int = 3, + retry_delay: float = 1.0, + use_json_output: bool = False, +) -> UploadResult: + """ + Execute upload with retry logic for handling transient failures. + + Args: + executor: Module executor instance + argv: Command-line arguments + env_vars: Optional environment variables + max_retries: Maximum number of retry attempts + retry_delay: Delay between retries in seconds + use_json_output: Whether JSON output mode is enabled + + Returns: + UploadResult from the final attempt + """ + last_result = None + + for attempt in range(max_retries + 1): + result = executor.execute_upload( + argv=argv, + env_vars=env_vars, + use_json_output=use_json_output, + ) + + if result.success: + return result + + last_result = result + + # Don't retry on certain types of failures + if result.exit_code in [2, 3]: # Argument or configuration errors + break + + if attempt < max_retries: + time.sleep(retry_delay * (2**attempt)) # Exponential backoff + + return last_result or UploadResult( + success=False, + exit_code=-1, + stdout="", + stderr="", + duration=0.0, + error_message="All retry attempts failed", + uploaded_files=[], + upload_urls=[], + ) diff --git a/tests/integration/test_multiple_files_upload.py b/tests/integration/test_multiple_files_upload.py new file mode 100644 index 0000000..e560186 --- /dev/null +++ b/tests/integration/test_multiple_files_upload.py @@ -0,0 +1,419 @@ +""" +Multiple files upload integration tests using direct module invocation. + +This module tests multiple file uploads, directory uploads, file mapping, +and large file uploads by calling the CLI main() function directly. +""" + +import os + +import pytest + +from .test_helpers_module import ( + ModuleExecutor, + validate_json_result, +) + +# Test markers for categorization +pytestmark = [ + pytest.mark.integration, # These are integration tests + pytest.mark.api, # These require GitLab API access +] + + +def _get_gitlab_token(): + """Get GitLab token from environment with proper error handling.""" + token = os.environ.get("GITLAB_TOKEN") + if not token: + pytest.skip("GITLAB_TOKEN environment variable not set") + return token + + +@pytest.mark.timeout(180) +def test_multiple_file_upload(gitlab_client, artifact_manager, project_path): + """ + Test multiple file upload functionality using direct module invocation. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create multiple test files with different characteristics + test_files = [ + artifact_manager.create_test_file("multi-module-1.txt", 512, "text"), + artifact_manager.create_test_file("multi-module-2.json", 1024, "json"), + artifact_manager.create_test_file("multi-module-3.bin", 2048, "binary"), + ] + + # Create unique package name + package_name = gitlab_client.create_test_package("multi-file-module", "1.0.0") + + file_paths = [str(f.path) for f in test_files] + + # Build argv for main() function + executor = ModuleExecutor() + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=file_paths, + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) + + # Execute upload via direct module invocation + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate JSON output is present + assert upload_result.json_data is not None, "JSON output not found in result" + + # Use helper function for structural validation + assert validate_json_result( + upload_result.json_data, + expected_success=True, + expected_files=file_paths, + ), "JSON validation failed" + + # Validate specific fields + assert upload_result.json_data["success"] is True + assert upload_result.json_data["statistics"]["new_uploads"] == 3 + assert upload_result.json_data["statistics"]["failed_uploads"] == 0 + assert len(upload_result.json_data["successful_uploads"]) == 3 + + # Verify each test file appears in successful_uploads + uploaded_filenames = [ + upload["target_filename"] + for upload in upload_result.json_data["successful_uploads"] + ] + for test_file in test_files: + assert test_file.path.name in uploaded_filenames, ( + f"File {test_file.path.name} not found in successful uploads" + ) + + # Validate upload execution succeeded + assert upload_result.exit_code == 0, ( + f"Unexpected exit code: {upload_result.exit_code}" + ) + + # Verify in GitLab registry + registry_failures = [] + for test_file in test_files: + registry_verification = gitlab_client.verify_upload( + package_name, "1.0.0", test_file.path.name, test_file.checksum + ) + if not registry_verification: + registry_failures.append(test_file.path.name) + + assert not registry_failures, ( + f"Registry verification failed for files: {', '.join(registry_failures)}" + ) + + +@pytest.mark.timeout(180) +def test_directory_upload(gitlab_client, artifact_manager, project_path): + """ + Test directory upload functionality using direct module invocation. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test directory with files + test_files = artifact_manager.create_test_directory("upload-dir-module", 4) + directory_path = artifact_manager.base_dir / "upload-dir-module" + + # Create unique package name + package_name = gitlab_client.create_test_package("directory-module", "1.0.0") + + # Build argv for main() function + executor = ModuleExecutor() + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + directory=str(directory_path), + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) + + # Execute upload via direct module invocation + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate JSON output is present + assert upload_result.json_data is not None, "JSON output not found in result" + + # Use helper function for structural validation + assert validate_json_result( + upload_result.json_data, + expected_success=True, + expected_files=[str(f.path) for f in test_files], + ), "JSON validation failed" + + # Validate specific fields + assert upload_result.json_data["success"] is True + assert upload_result.json_data["statistics"]["new_uploads"] == 4 + assert upload_result.json_data["statistics"]["failed_uploads"] == 0 + assert len(upload_result.json_data["successful_uploads"]) == 4 + + # Verify all directory files appear in successful_uploads + uploaded_filenames = [ + upload["target_filename"] + for upload in upload_result.json_data["successful_uploads"] + ] + for test_file in test_files: + assert test_file.path.name in uploaded_filenames, ( + f"File {test_file.path.name} not found in successful uploads" + ) + + # Validate upload execution succeeded + assert upload_result.exit_code == 0, ( + f"Unexpected exit code: {upload_result.exit_code}" + ) + + # Verify in GitLab registry + registry_failures = [] + for test_file in test_files: + registry_verification = gitlab_client.verify_upload( + package_name, "1.0.0", test_file.path.name, test_file.checksum + ) + if not registry_verification: + registry_failures.append(test_file.path.name) + + assert not registry_failures, ( + f"Registry verification failed for files: {', '.join(registry_failures)}" + ) + + +@pytest.mark.timeout(180) +def test_file_mapping_upload(gitlab_client, artifact_manager, project_path): + """ + Test file mapping upload functionality with custom target names. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test files + test_files = [ + artifact_manager.create_test_file("source1-module.txt", 1024, "text"), + artifact_manager.create_test_file("source2-module.json", 2048, "json"), + ] + + # Create unique package name + package_name = gitlab_client.create_test_package("file-mapping-module", "1.0.0") + + # Build argv with file mappings + executor = ModuleExecutor() + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(f.path) for f in test_files], + project_path=project_path, + duplicate_policy="skip", + file_mapping=[ + f"{test_files[0].path.name}:target1-module.txt", + f"{test_files[1].path.name}:config/target2-module.json", + ], + json_output=True, + ) + + # Execute upload via direct module invocation + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate JSON output is present + assert upload_result.json_data is not None, "JSON output not found in result" + + # Use helper function for structural validation + assert validate_json_result( + upload_result.json_data, + expected_success=True, + ), "JSON validation failed" + + # Validate specific fields + assert upload_result.json_data["success"] is True + assert upload_result.json_data["statistics"]["new_uploads"] == 2 + assert len(upload_result.json_data["successful_uploads"]) == 2 + + # Verify mapped filenames appear in successful_uploads + uploaded_filenames = [ + upload["target_filename"] + for upload in upload_result.json_data["successful_uploads"] + ] + assert "target1-module.txt" in uploaded_filenames, ( + "Mapped file target1-module.txt not found in successful uploads" + ) + assert "config/target2-module.json" in uploaded_filenames, ( + "Mapped file config/target2-module.json not found in successful uploads" + ) + + # Validate upload execution succeeded + assert upload_result.exit_code == 0, ( + f"Unexpected exit code: {upload_result.exit_code}" + ) + + # Verify in GitLab registry with mapped names + target_mappings = [ + ("target1-module.txt", test_files[0].checksum), + ("config/target2-module.json", test_files[1].checksum), + ] + + registry_failures = [] + for target_filename, expected_checksum in target_mappings: + registry_verification = gitlab_client.verify_upload( + package_name, "1.0.0", target_filename, expected_checksum + ) + if not registry_verification: + registry_failures.append(target_filename) + + assert not registry_failures, ( + f"Registry verification failed for mapped files: {', '.join(registry_failures)}" + ) + + +@pytest.mark.slow +@pytest.mark.timeout(180) +def test_large_file_upload(gitlab_client, artifact_manager, project_path): + """ + Test upload of a larger file to ensure the module handles various file sizes. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create a larger test file (10KB) + test_file = artifact_manager.create_test_file( + filename="large-test-module.bin", size_bytes=10240, content_pattern="binary" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("large-file-module", "1.0.0") + + # Build argv for main() function + executor = ModuleExecutor() + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) + + # Execute upload via direct module invocation + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate JSON output is present + assert upload_result.json_data is not None, "JSON output not found in result" + + # Use helper function for structural validation + assert validate_json_result( + upload_result.json_data, + expected_success=True, + expected_files=[str(test_file.path)], + ), "JSON validation failed" + + # Validate specific fields + assert upload_result.json_data["success"] is True + assert upload_result.json_data["statistics"]["new_uploads"] == 1 + assert upload_result.json_data["statistics"]["failed_uploads"] == 0 + + # Validate upload execution succeeded + assert upload_result.exit_code == 0, ( + f"Unexpected exit code: {upload_result.exit_code}" + ) + + # Verify in GitLab registry + registry_verification = gitlab_client.verify_upload( + package_name, "1.0.0", test_file.path.name, test_file.checksum + ) + assert registry_verification, "Large file verification failed in GitLab registry" + + +@pytest.mark.timeout(180) +def test_multiple_files_with_different_sizes( + gitlab_client, artifact_manager, project_path +): + """ + Test uploading multiple files with varying sizes. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test files with different sizes + test_files = [ + artifact_manager.create_test_file("size-small.txt", 256, "text"), + artifact_manager.create_test_file("size-medium.bin", 4096, "binary"), + artifact_manager.create_test_file("size-large.json", 8192, "json"), + ] + + # Create unique package name + package_name = gitlab_client.create_test_package("mixed-sizes-module", "1.0.0") + + file_paths = [str(f.path) for f in test_files] + + # Build argv for main() function + executor = ModuleExecutor() + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=file_paths, + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) + + # Execute upload + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate success + assert upload_result.json_data is not None + assert upload_result.json_data["success"] is True + assert upload_result.json_data["statistics"]["new_uploads"] == 3 + assert upload_result.exit_code == 0 + + # Verify all files in registry + for test_file in test_files: + assert gitlab_client.verify_upload( + package_name, "1.0.0", test_file.path.name, test_file.checksum + ), f"Verification failed for {test_file.path.name}" diff --git a/tests/integration/test_project_resolution.py b/tests/integration/test_project_resolution.py new file mode 100644 index 0000000..c3f404d --- /dev/null +++ b/tests/integration/test_project_resolution.py @@ -0,0 +1,480 @@ +""" +Project resolution integration tests using direct module invocation. + +This module tests Git auto-detection, manual project URL specification, +and manual project path specification by calling the CLI main() function directly. +""" + +import os + +import pytest + +from .test_helpers_module import ( + ModuleExecutor, + validate_json_result, +) + +# Test markers for categorization +pytestmark = [ + pytest.mark.integration, # These are integration tests + pytest.mark.api, # These require GitLab API access + pytest.mark.fast, # These tests are relatively fast (project resolution only) +] + + +def _get_gitlab_token(): + """Get GitLab token from environment with proper error handling.""" + token = os.environ.get("GITLAB_TOKEN") + if not token: + pytest.skip("GITLAB_TOKEN environment variable not set") + return token + + +class TestProjectResolution: + """ + Test class for project resolution functionality using direct module invocation. + """ + + @pytest.mark.timeout(120) + def test_git_auto_detection(self, gitlab_client, artifact_manager, project_path): + """ + Test Git auto-detection functionality when run from Git repository. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test file + test_file = artifact_manager.create_test_file( + filename="git-auto-module.txt", size_bytes=1024, content_pattern="text" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("git-auto-module", "1.0.0") + + executor = ModuleExecutor() + + # Build argv WITHOUT specifying project_path or project_url + # This should trigger Git auto-detection in the CLI + argv = [ + "--package-name", package_name, + "--package-version", "1.0.0", + "--files", str(test_file.path), + "--json-output", + ] + + # Execute upload + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate basic execution success + assert upload_result.success, f"Upload failed: {upload_result.error_message}" + assert upload_result.exit_code == 0, ( + f"Expected exit code 0, got {upload_result.exit_code}" + ) + + # Validate JSON output + assert upload_result.json_data is not None, "JSON output not found" + assert validate_json_result( + upload_result.json_data, + expected_success=True, + expected_files=[str(test_file.path)], + ) + assert upload_result.json_data["success"] is True + assert upload_result.json_data["exit_code"] == 0 + assert upload_result.json_data.get("package_name") == package_name + assert upload_result.json_data.get("statistics", {}).get("new_uploads") == 1 + assert len(upload_result.json_data.get("successful_uploads", [])) == 1 + + # Additional GitLab API verification + api_verification = gitlab_client.verify_upload( + package_name=package_name, + version="1.0.0", + filename=test_file.path.name, + expected_checksum=test_file.checksum, + ) + + assert api_verification, "GitLab API verification failed" + + @pytest.mark.timeout(120) + def test_manual_project_url_specification( + self, gitlab_client, artifact_manager, project_path + ): + """ + Test manual project specification via URL. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test file + test_file = artifact_manager.create_test_file( + filename="manual-url-module.txt", size_bytes=1024, content_pattern="text" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("manual-url-module", "1.0.0") + + executor = ModuleExecutor() + + # NOTE: The current upload script has a limitation in URL parsing where it only + # takes the first two path components. For projects with >2 path components, + # this test handles them differently. + + path_components = project_path.split("/") + if len(path_components) > 2: + # For projects with more than 2 path components, the URL parsing will fail + print( + f"Project path has {len(path_components)} components, URL parsing will fail" + ) + + gitlab_url = gitlab_client.gitlab_url + project_url = f"{gitlab_url}/{project_path}" + + # Build argv with explicit project URL (expecting failure) + argv = [ + "--package-name", package_name, + "--package-version", "1.0.0", + "--project-url", project_url, + "--files", str(test_file.path), + "--json-output", + ] + + # Execute upload (expecting it to fail) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + expected_exit_code=1, + use_json_output=True, + ) + + # Success means the error execution succeeded (i.e., upload failed as expected) + assert upload_result.success, ( + f"Expected upload to fail with exit code 1, but got: {upload_result.error_message}" + ) + assert upload_result.exit_code == 1, ( + f"Expected exit code 1, got {upload_result.exit_code}" + ) + + # Validate JSON error fields if available + if upload_result.json_data is not None: + assert upload_result.json_data.get("success") is False + assert upload_result.json_data.get("exit_code") == 1 + assert "error" in upload_result.json_data + + # Check for project-related keywords in error message + error_msg = upload_result.json_data["error"].lower() + project_keywords = ["project", "not found", "404", "resolution failed"] + project_error_found = any( + keyword in error_msg for keyword in project_keywords + ) + assert project_error_found, ( + f"Expected project error keywords in JSON error: {upload_result.json_data['error']}" + ) + + print( + f"URL parsing limitation correctly detected for project: {project_path}" + ) + return + + # If project path has 2 or fewer components, proceed with normal test + gitlab_url = gitlab_client.gitlab_url + project_url = f"{gitlab_url}/{project_path}" + + # Build argv with explicit project URL + argv = [ + "--package-name", package_name, + "--package-version", "1.0.0", + "--project-url", project_url, + "--files", str(test_file.path), + "--json-output", + ] + + # Execute upload + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate basic execution success + assert upload_result.success, f"Upload failed: {upload_result.error_message}" + assert upload_result.exit_code == 0 + + # Validate JSON output + assert upload_result.json_data is not None, "JSON output not found" + assert validate_json_result( + upload_result.json_data, + expected_success=True, + expected_files=[str(test_file.path)], + ) + + # Additional GitLab API verification + api_verification = gitlab_client.verify_upload( + package_name=package_name, + version="1.0.0", + filename=test_file.path.name, + expected_checksum=test_file.checksum, + ) + + assert api_verification, "GitLab API verification failed" + + @pytest.mark.timeout(120) + def test_manual_project_path_specification( + self, gitlab_client, artifact_manager, project_path + ): + """ + Test manual project specification via path. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test file + test_file = artifact_manager.create_test_file( + filename="manual-path-module.txt", size_bytes=1024, content_pattern="text" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("manual-path-module", "1.0.0") + + executor = ModuleExecutor() + + # Build argv with explicit project path + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + json_output=True, + ) + + # Execute upload + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate basic execution success + assert upload_result.success, f"Upload failed: {upload_result.error_message}" + assert upload_result.exit_code == 0 + + # Validate JSON output + assert upload_result.json_data is not None, "JSON output not found" + assert validate_json_result( + upload_result.json_data, + expected_success=True, + expected_files=[str(test_file.path)], + ) + assert upload_result.json_data["success"] is True + assert upload_result.json_data.get("package_name") == package_name + + # Additional GitLab API verification + api_verification = gitlab_client.verify_upload( + package_name=package_name, + version="1.0.0", + filename=test_file.path.name, + expected_checksum=test_file.checksum, + ) + + assert api_verification, "GitLab API verification failed" + + @pytest.mark.timeout(120) + def test_invalid_project_path_error_handling( + self, gitlab_client, artifact_manager, project_path + ): + """ + Test error handling for invalid project path specification. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test file + test_file = artifact_manager.create_test_file( + filename="invalid-project-module.txt", size_bytes=1024, content_pattern="text" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("invalid-project-module", "1.0.0") + + executor = ModuleExecutor() + + # Use an invalid project path that should not exist + invalid_project_path = "nonexistent/invalid-project-12345" + + # Build argv with invalid project path + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=invalid_project_path, + json_output=True, + ) + + # Execute upload (expecting it to fail) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + expected_exit_code=1, + use_json_output=True, + ) + + # Validate that the script failed as expected + assert upload_result.success, ( + f"Expected upload to fail with exit code 1, but got: {upload_result.error_message}" + ) + assert upload_result.exit_code == 1, ( + f"Expected exit code 1, got {upload_result.exit_code}" + ) + + # Validate JSON error fields if available + if upload_result.json_data is not None: + assert upload_result.json_data.get("success") is False + assert upload_result.json_data.get("exit_code") == 1 + assert "error" in upload_result.json_data + assert "error_type" in upload_result.json_data + + # Check for project-related keywords in error message + error_msg = upload_result.json_data["error"].lower() + project_keywords = [ + "project", + "404", + "not found", + "resolution failed", + "invalid", + ] + project_error_found = any( + keyword in error_msg for keyword in project_keywords + ) + assert project_error_found, ( + f"Expected project error keywords in JSON error: {upload_result.json_data['error']}" + ) + else: + # Fallback to stderr/stdout checking for early errors + error_output = upload_result.stdout + upload_result.stderr + error_indicated = any( + pattern in error_output.lower() + for pattern in [ + "project", + "not found", + "404", + "resolution failed", + "invalid", + ] + ) + if not error_indicated: + print("Note: Expected error message not found, but upload failed as expected") + + @pytest.mark.timeout(120) + def test_invalid_project_url_error_handling( + self, gitlab_client, artifact_manager, project_path + ): + """ + Test error handling for invalid project URL specification. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test file + test_file = artifact_manager.create_test_file( + filename="invalid-url-module.txt", size_bytes=1024, content_pattern="text" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("invalid-url-module", "1.0.0") + + executor = ModuleExecutor() + + # Use an invalid project URL that should not exist + invalid_project_url = ( + f"{gitlab_client.gitlab_url}/nonexistent/invalid-project-12345" + ) + + # Build argv with invalid project URL + argv = [ + "--package-name", package_name, + "--package-version", "1.0.0", + "--project-url", invalid_project_url, + "--files", str(test_file.path), + "--json-output", + ] + + # Execute upload (expecting it to fail) + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + expected_exit_code=1, + use_json_output=True, + ) + + # Validate that the script failed as expected + assert upload_result.success, ( + f"Expected upload to fail with exit code 1, but got: {upload_result.error_message}" + ) + assert upload_result.exit_code == 1, ( + f"Expected exit code 1, got {upload_result.exit_code}" + ) + + # Validate JSON error fields if available + if upload_result.json_data is not None: + assert upload_result.json_data.get("success") is False + assert upload_result.json_data.get("exit_code") == 1 + assert "error" in upload_result.json_data + assert "error_type" in upload_result.json_data + + # Check for project-related keywords in error message + error_msg = upload_result.json_data["error"].lower() + project_keywords = [ + "project", + "404", + "not found", + "resolution failed", + "invalid", + ] + project_error_found = any( + keyword in error_msg for keyword in project_keywords + ) + assert project_error_found, ( + f"Expected project error keywords in JSON error: {upload_result.json_data['error']}" + ) + else: + # Fallback to stderr/stdout checking for early errors + error_output = upload_result.stdout + upload_result.stderr + error_indicated = any( + pattern in error_output.lower() + for pattern in [ + "project", + "not found", + "404", + "resolution failed", + "invalid", + ] + ) + if not error_indicated: + print("Note: Expected error message not found, but upload failed as expected") diff --git a/tests/integration/test_single_file_upload.py b/tests/integration/test_single_file_upload.py new file mode 100644 index 0000000..0b025ce --- /dev/null +++ b/tests/integration/test_single_file_upload.py @@ -0,0 +1,270 @@ +""" +Single file upload integration test using direct module invocation. + +This module tests single file upload functionality by calling the CLI main() +function directly instead of using subprocess execution. +""" + +import os + +import pytest + +from .test_helpers_module import ( + ModuleExecutor, + get_project_args, + validate_json_result, +) + +# Test markers for categorization +pytestmark = [ + pytest.mark.integration, # These are integration tests + pytest.mark.api, # These require GitLab API access +] + + +def _get_gitlab_token(): + """Get GitLab token from environment with proper error handling.""" + token = os.environ.get("GITLAB_TOKEN") + if not token: + pytest.skip("GITLAB_TOKEN environment variable not set") + return token + + +@pytest.mark.timeout(180) +def test_single_file_upload(gitlab_client, artifact_manager, project_path): + """ + Test single file upload functionality using direct module invocation. + + This test validates that a single file can be uploaded successfully + by calling the CLI main() function directly. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test file + test_file = artifact_manager.create_test_file( + filename="single-test-module.txt", size_bytes=1024, content_pattern="text" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("single-file-module", "1.0.0") + + # Build argv for main() function + executor = ModuleExecutor() + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) + + # Execute upload via direct module invocation + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate JSON output is present + assert upload_result.json_data is not None, "JSON output not found in result" + + # Use helper function for structural validation + assert validate_json_result( + upload_result.json_data, + expected_success=True, + expected_files=[str(test_file.path)], + ), "JSON validation failed" + + # Validate specific fields + assert upload_result.json_data["success"] is True + assert upload_result.json_data["package_name"] == package_name + assert upload_result.json_data["version"] == "1.0.0" + assert upload_result.json_data["statistics"]["new_uploads"] == 1 + assert upload_result.json_data["statistics"]["failed_uploads"] == 0 + assert len(upload_result.json_data["successful_uploads"]) == 1 + + # Validate upload execution succeeded + assert upload_result.exit_code == 0, ( + f"Unexpected exit code: {upload_result.exit_code}" + ) + + # Verify in GitLab registry + registry_verification = gitlab_client.verify_upload( + package_name, "1.0.0", test_file.path.name, test_file.checksum + ) + + assert registry_verification, ( + "Upload verification failed - file not found in GitLab registry" + ) + + +@pytest.mark.timeout(180) +def test_single_file_upload_with_verbose(gitlab_client, artifact_manager, project_path): + """ + Test single file upload with verbose output enabled. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test file + test_file = artifact_manager.create_test_file( + filename="single-verbose-test.txt", size_bytes=512, content_pattern="text" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("single-verbose", "1.0.0") + + # Build argv with verbose flag + executor = ModuleExecutor() + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + duplicate_policy="skip", + json_output=True, + verbose=True, + ) + + # Execute upload + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate success + assert upload_result.json_data is not None + assert upload_result.json_data["success"] is True + assert upload_result.exit_code == 0 + + # Verify in GitLab registry + assert gitlab_client.verify_upload( + package_name, "1.0.0", test_file.path.name, test_file.checksum + ) + + +@pytest.mark.timeout(180) +def test_single_file_upload_with_quiet(gitlab_client, artifact_manager, project_path): + """ + Test single file upload with quiet output enabled. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + # Create test file + test_file = artifact_manager.create_test_file( + filename="single-quiet-test.txt", size_bytes=512, content_pattern="text" + ) + + # Create unique package name + package_name = gitlab_client.create_test_package("single-quiet", "1.0.0") + + # Build argv with quiet flag + executor = ModuleExecutor() + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + duplicate_policy="skip", + json_output=True, + quiet=True, + ) + + # Execute upload + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate success + assert upload_result.json_data is not None + assert upload_result.json_data["success"] is True + assert upload_result.exit_code == 0 + + # Verify in GitLab registry + assert gitlab_client.verify_upload( + package_name, "1.0.0", test_file.path.name, test_file.checksum + ) + + +@pytest.mark.timeout(180) +def test_single_file_upload_different_content_types( + gitlab_client, artifact_manager, project_path +): + """ + Test single file uploads with different content types. + + Args: + gitlab_client: GitLab test client fixture + artifact_manager: Artifact manager fixture + project_path: Project path fixture + """ + # Set up GitLab client with project + gitlab_client.set_project(project_path) + + executor = ModuleExecutor() + + # Test different file types + file_types = [ + ("content-type-text.txt", 1024, "text"), + ("content-type-json.json", 2048, "json"), + ("content-type-binary.bin", 512, "binary"), + ] + + for filename, size, pattern in file_types: + # Create test file + test_file = artifact_manager.create_test_file( + filename=filename, size_bytes=size, content_pattern=pattern + ) + + # Create unique package name + package_name = gitlab_client.create_test_package( + f"content-type-{pattern}", "1.0.0" + ) + + # Build argv + argv = executor.build_argv( + package_name=package_name, + version="1.0.0", + files=[str(test_file.path)], + project_path=project_path, + duplicate_policy="skip", + json_output=True, + ) + + # Execute upload + upload_result = executor.execute_upload( + argv=argv, + env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + use_json_output=True, + ) + + # Validate success + assert upload_result.json_data is not None, f"JSON output not found for {filename}" + assert upload_result.json_data["success"] is True, f"Upload failed for {filename}" + assert upload_result.exit_code == 0, f"Non-zero exit code for {filename}" + + # Verify in GitLab registry + assert gitlab_client.verify_upload( + package_name, "1.0.0", test_file.path.name, test_file.checksum + ), f"Registry verification failed for {filename}" From b14be02799a43e2435676cb206b8eb12b3550ca5 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 07:47:25 -0600 Subject: [PATCH 18/36] tests: Migrate test infrastructure to new gitlab_pkg_upload module The test suite imports utilities from the legacy monolithic gitlab_common module, which has been refactored into the modular gitlab_pkg_upload package. Tests fail to run because the expected import paths no longer exist, and error handling returns generic exceptions rather than typed ones that match the new exception hierarchy. Update all test imports to use the new package structure, pulling CLI utilities from gitlab_pkg_upload.cli and data models from gitlab_pkg_upload.models. Replace bare exception handlers in GitLab API helpers with typed exception handling that distinguishes authentication, project resolution, and network failures. Switch the script executor from subprocess-based execution to direct module invocation, enabling proper exception propagation and eliminating process overhead during test runs. Reuse the validators module for checksum calculation to avoid duplicate implementations. Signed-off-by: Javier Tia --- tests/README.md | 2 +- tests/conftest.py | 25 +-- tests/test_unit_basic.py | 15 +- tests/utils/artifact_factory.py | 15 +- tests/utils/gitlab_helpers.py | 287 +++++++++++++++++++++++++++----- tests/utils/test_helpers.py | 231 +++++++++++++++++++++++-- 6 files changed, 495 insertions(+), 80 deletions(-) diff --git a/tests/README.md b/tests/README.md index 45663e5..9f0e1bc 100644 --- a/tests/README.md +++ b/tests/README.md @@ -151,7 +151,7 @@ Any arguments not matching convenience flags are passed directly to pytest, enab ```bash # Run specific test -./run_tests.py tests/test_unit_basic.py::test_import_gitlab_common +./run_tests.py tests/test_unit_basic.py::TestBasicFunctionality::test_import_gitlab_pkg_upload # Filter tests by name pattern ./run_tests.py -v -k "test_import" tests/ diff --git a/tests/conftest.py b/tests/conftest.py index 6ff275f..aa6cd64 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,17 +18,21 @@ import pytest import requests -# Import the classes we need to extract from the monolithic test file +# Import from the new modular structure try: - from gitlab_common import ProjectResolver - from gitlab import Gitlab + from gitlab_pkg_upload.cli import GitAutoDetector, ProjectResolver + from gitlab_pkg_upload.models import GitRemoteInfo, ProjectInfo + GITLAB_AVAILABLE = True except ImportError: - # Handle case where python-gitlab is not available + # Handle case where python-gitlab or gitlab_pkg_upload is not available Gitlab = None ProjectResolver = None + GitAutoDetector = None + GitRemoteInfo = None + ProjectInfo = None GITLAB_AVAILABLE = False # Import our thread-safe rate limiter and performance utilities @@ -793,13 +797,13 @@ def artifact_manager(): @pytest.fixture def project_resolver(gitlab_client): """ - Provide project resolver fixture using existing ProjectResolver from gitlab_common. + Provide project resolver fixture using ProjectResolver from gitlab_pkg_upload.cli. This fixture creates a ProjectResolver instance using the authenticated GitLab client for project identification and validation. """ if not GITLAB_AVAILABLE or not ProjectResolver: - pytest.skip("python-gitlab or gitlab_common not available") + pytest.skip("python-gitlab or gitlab_pkg_upload not available") return ProjectResolver(gitlab_client.gl) @@ -850,7 +854,8 @@ def project_path(): """ # Try to auto-detect project path from Git repository try: - from gitlab_common import GitAutoDetector + if not GitAutoDetector: + raise ImportError("GitAutoDetector not available") detector = GitAutoDetector() repo = detector.find_git_repository() @@ -863,10 +868,10 @@ def project_path(): pass # Fallback to environment variable or skip - project_path = os.environ.get("GITLAB_PROJECT_PATH") - if not project_path: + project_path_env = os.environ.get("GITLAB_PROJECT_PATH") + if not project_path_env: pytest.skip( "Could not auto-detect project path and GITLAB_PROJECT_PATH not set" ) - return project_path + return project_path_env diff --git a/tests/test_unit_basic.py b/tests/test_unit_basic.py index d64d5f9..10cd1da 100644 --- a/tests/test_unit_basic.py +++ b/tests/test_unit_basic.py @@ -19,15 +19,18 @@ class TestBasicFunctionality: """Basic unit tests for core functionality.""" @pytest.mark.timeout(60) - def test_import_gitlab_common(self): - """Test that gitlab_common module can be imported.""" + def test_import_gitlab_pkg_upload(self): + """Test that gitlab_pkg_upload module can be imported.""" try: - import gitlab_common + from gitlab_pkg_upload import cli + from gitlab_pkg_upload import models - assert hasattr(gitlab_common, "ProjectResolver") - assert hasattr(gitlab_common, "GitAutoDetector") + assert hasattr(cli, "ProjectResolver") + assert hasattr(cli, "GitAutoDetector") + assert hasattr(models, "GitRemoteInfo") + assert hasattr(models, "ProjectInfo") except ImportError as e: - pytest.fail(f"Failed to import gitlab_common: {e}") + pytest.fail(f"Failed to import gitlab_pkg_upload: {e}") @pytest.mark.timeout(60) def test_import_main_script(self): diff --git a/tests/utils/artifact_factory.py b/tests/utils/artifact_factory.py index 43e8f95..b30f9da 100644 --- a/tests/utils/artifact_factory.py +++ b/tests/utils/artifact_factory.py @@ -6,7 +6,6 @@ class in the monolithic test file. It provides utilities for generating test dat with various characteristics for upload testing. """ -import hashlib import secrets import tempfile from dataclasses import dataclass @@ -14,6 +13,8 @@ class in the monolithic test file. It provides utilities for generating test dat from pathlib import Path from typing import Dict, List, Optional +from gitlab_pkg_upload.validators import calculate_sha256 + @dataclass class TestArtifact: @@ -69,8 +70,8 @@ def create_test_file( # Write file file_path.write_bytes(content) - # Calculate checksum - checksum = hashlib.sha256(content).hexdigest() + # Calculate checksum using validators module + checksum = calculate_sha256(file_path) # Determine content type content_type = ArtifactFactory._determine_content_type( @@ -404,13 +405,7 @@ def calculate_file_checksum(file_path: Path) -> str: Returns: SHA256 checksum as hexadecimal string """ - sha256_hash = hashlib.sha256() - - with open(file_path, "rb") as f: - for chunk in iter(lambda: f.read(4096), b""): - sha256_hash.update(chunk) - - return sha256_hash.hexdigest() + return calculate_sha256(file_path) def create_file_with_checksum( diff --git a/tests/utils/gitlab_helpers.py b/tests/utils/gitlab_helpers.py index 397ed47..6af4d54 100644 --- a/tests/utils/gitlab_helpers.py +++ b/tests/utils/gitlab_helpers.py @@ -4,11 +4,14 @@ This module contains GitLab verification methods extracted from the GitLabTestClient class in the monolithic test file. It provides utilities for interacting with the GitLab API for upload verification and package management. + +Updated to use exception models from the new gitlab_pkg_upload module +for better error categorization and testing of exception handling. """ import hashlib import time -from typing import List, Optional, Dict, Any, Tuple +from typing import Any, Dict, List, Optional, Tuple import requests @@ -20,6 +23,24 @@ class in the monolithic test file. It provides utilities for interacting with th Gitlab = None GitlabError = Exception +# Import exception models from the new modular structure +try: + from gitlab_pkg_upload.models import ( + AuthenticationError, + GitLabUploadError, + NetworkError, + ProjectResolutionError, + ) + + EXCEPTION_MODELS_AVAILABLE = True +except ImportError: + # Fall back to basic exceptions when gitlab_pkg_upload is not available + EXCEPTION_MODELS_AVAILABLE = False + GitLabUploadError = Exception + AuthenticationError = Exception + ProjectResolutionError = Exception + NetworkError = Exception + class GitLabVerifier: """ @@ -53,6 +74,11 @@ def verify_package_exists(self, package_name: str, version: str) -> bool: Returns: True if package exists, False otherwise + + Raises: + AuthenticationError: If authentication fails + ProjectResolutionError: If project cannot be accessed + NetworkError: If network operation fails """ try: project = self.gl.projects.get(self.project_id) @@ -65,9 +91,24 @@ def verify_package_exists(self, package_name: str, version: str) -> bool: return False - except Exception as e: - print(f"Error checking package existence: {e}") - return False + except GitlabError as e: + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed while checking package existence: {e}") + raise + elif "404" in error_str or "not found" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise ProjectResolutionError(f"Project not found while checking package existence: {e}") + raise + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error while checking package existence: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error while checking package existence: {e}") + raise def verify_file_upload( self, @@ -162,9 +203,24 @@ def verify_file_upload( return True - except Exception as e: - print(f"Upload verification failed for {filename}: {e}") - return False + except GitlabError as e: + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed during upload verification for {filename}: {e}") + raise + elif "404" in error_str or "not found" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise ProjectResolutionError(f"Project/package not found during upload verification for {filename}: {e}") + raise + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error during upload verification for {filename}: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error during upload verification for {filename}: {e}") + raise def get_download_url( self, package_name: str, version: str, filename: str @@ -232,9 +288,24 @@ def get_download_url( return None - except Exception as e: - print(f"Failed to get download URL for {filename}: {e}") - return None + except GitlabError as e: + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed getting download URL for {filename}: {e}") + raise + elif "404" in error_str or "not found" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise ProjectResolutionError(f"Project/package not found getting download URL for {filename}: {e}") + raise + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error getting download URL for {filename}: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error getting download URL for {filename}: {e}") + raise def download_and_verify_content( self, package_name: str, version: str, filename: str, expected_checksum: str @@ -282,16 +353,35 @@ def download_and_verify_content( print(f"Download and verification successful for {filename}") return True - except Exception as e: - print(f"Download and verification failed for {filename}: {e}") - # Special handling for subdirectory files - if "/" in filename: - print( - f"File '{filename}' contains subdirectory path. " - f"Assuming verification success due to GitLab limitations." - ) - return True - return False + except requests.exceptions.HTTPError as e: + status_code = e.response.status_code if e.response is not None else None + if status_code == 401 or status_code == 403: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed during download for {filename}: {e}") + raise + elif status_code == 404: + # Special handling for subdirectory files + if "/" in filename: + print( + f"File '{filename}' contains subdirectory path. " + f"Assuming verification success due to GitLab limitations." + ) + return True + if EXCEPTION_MODELS_AVAILABLE: + raise ProjectResolutionError(f"File not found during download for {filename}: {e}") + raise + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"HTTP error during download for {filename}: {e}") + raise + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error during download for {filename}: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error during download for {filename}: {e}") + raise def list_package_files( self, package_name: str, version: str @@ -337,9 +427,24 @@ def list_package_files( return file_list - except Exception as e: - print(f"Failed to list package files: {e}") - return [] + except GitlabError as e: + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed listing package files: {e}") + raise + elif "404" in error_str or "not found" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise ProjectResolutionError(f"Project/package not found listing package files: {e}") + raise + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error listing package files: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error listing package files: {e}") + raise def delete_package(self, package_name: str, version: str) -> bool: """ @@ -372,9 +477,24 @@ def delete_package(self, package_name: str, version: str) -> bool: print(f"Deleted package: {package_name} v{version}") return True - except Exception as e: - print(f"Failed to delete package {package_name} v{version}: {e}") - return False + except GitlabError as e: + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed deleting package {package_name} v{version}: {e}") + raise + elif "404" in error_str or "not found" in error_str: + # Package not found - consider deletion successful + print(f"Package {package_name} v{version} not found (already deleted)") + return True + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error deleting package {package_name} v{version}: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error deleting package {package_name} v{version}: {e}") + raise def cleanup_test_packages(self, package_prefix: str = "test-") -> Tuple[int, int]: """ @@ -403,15 +523,40 @@ def cleanup_test_packages(self, package_prefix: str = "test-") -> Tuple[int, int package.delete() print(f"Deleted test package: {package.name} v{package.version}") successful += 1 - except Exception as e: - print(f"Failed to delete test package {package.name}: {e}") - failed += 1 + except GitlabError as e: + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed deleting test package {package.name}: {e}") + raise + elif "404" in error_str or "not found" in error_str: + # Package already deleted + print(f"Test package {package.name} already deleted") + successful += 1 + else: + print(f"Failed to delete test package {package.name}: {e}") + failed += 1 return successful, failed - except Exception as e: - print(f"Failed to cleanup test packages: {e}") - return 0, 0 + except GitlabError as e: + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed during cleanup: {e}") + raise + elif "404" in error_str or "not found" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise ProjectResolutionError(f"Project not found during cleanup: {e}") + raise + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error during cleanup: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error during cleanup: {e}") + raise def validate_upload_consistency( @@ -463,9 +608,30 @@ def validate_upload_consistency( print(f"Upload consistency validation successful for {filename}") return True - except Exception as e: - print(f"Upload consistency validation failed with exception: {e}") - return False + except (AuthenticationError, ProjectResolutionError, NetworkError): + # Re-raise typed exceptions to propagate them + raise + except GitLabUploadError: + # Re-raise base upload error to preserve exit semantics + raise + except GitlabError as e: + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed during upload consistency validation: {e}") + raise + elif "404" in error_str or "not found" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise ProjectResolutionError(f"Project/package not found during upload consistency validation: {e}") + raise + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error during upload consistency validation: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error during upload consistency validation: {e}") + raise def wait_for_package_availability( @@ -518,13 +684,31 @@ def create_gitlab_verifier( GitLabVerifier instance Raises: - ValueError: If project cannot be accessed + AuthenticationError: If authentication fails + ProjectResolutionError: If project cannot be accessed + NetworkError: If network operation fails """ try: project = gitlab_client.projects.get(project_path) return GitLabVerifier(gitlab_client, project.id, token) except GitlabError as e: - raise ValueError(f"Failed to access project {project_path}: {e}") + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed accessing project {project_path}: {e}") + raise + elif "404" in error_str or "not found" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise ProjectResolutionError(f"Project not found: {project_path}: {e}") + raise + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error accessing project {project_path}: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error accessing project {project_path}: {e}") + raise def verify_gitlab_api_access(gitlab_client, project_path: str) -> bool: @@ -537,6 +721,11 @@ def verify_gitlab_api_access(gitlab_client, project_path: str) -> bool: Returns: True if access is verified, False otherwise + + Raises: + AuthenticationError: If authentication fails + ProjectResolutionError: If project cannot be accessed + NetworkError: If network operation fails """ try: # Test basic API access @@ -551,11 +740,27 @@ def verify_gitlab_api_access(gitlab_client, project_path: str) -> bool: try: _ = project.packages.list(per_page=1, get_all=False) print("Package registry access verified") - except Exception as e: + except GitlabError as e: + # Package registry access is optional - log but don't fail print(f"Package registry access may be limited: {e}") return True - except Exception as e: - print(f"GitLab API access verification failed: {e}") - return False + except GitlabError as e: + error_str = str(e).lower() + if "401" in error_str or "unauthorized" in error_str or "authentication" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise AuthenticationError(f"Authentication failed verifying API access: {e}") + raise + elif "404" in error_str or "not found" in error_str: + if EXCEPTION_MODELS_AVAILABLE: + raise ProjectResolutionError(f"Project not found verifying API access: {e}") + raise + else: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error verifying API access: {e}") + raise + except (ConnectionError, TimeoutError, OSError) as e: + if EXCEPTION_MODELS_AVAILABLE: + raise NetworkError(f"Network error verifying API access: {e}") + raise diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py index 48382b6..2b6b82e 100644 --- a/tests/utils/test_helpers.py +++ b/tests/utils/test_helpers.py @@ -3,16 +3,50 @@ This module contains script execution logic extracted from the TestOrchestrator class in the monolithic test file. It provides utilities for running the -upload script as a subprocess and validating results. +upload script via direct module invocation and validating results. + +Updated to use direct module invocation instead of subprocess execution +for better integration with the new modular structure in gitlab_pkg_upload. """ +import contextlib +import io import os -import subprocess +import sys import time +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional +# Import from the new modular structure +try: + from gitlab_pkg_upload.cli import main as cli_main + from gitlab_pkg_upload.models import ( + AuthenticationError, + ConfigurationError, + DuplicatePolicy, + FileValidationError, + GitLabUploadError, + NetworkError, + ProjectResolutionError, + UploadConfig, + ) + + CLI_AVAILABLE = True +except ImportError: + cli_main = None + CLI_AVAILABLE = False + # Define placeholder exit codes when module not available + AuthenticationError = None + ConfigurationError = None + DuplicatePolicy = None + FileValidationError = None + GitLabUploadError = None + NetworkError = None + ProjectResolutionError = None + UploadConfig = None + @dataclass class UploadExecution: @@ -89,10 +123,14 @@ def __post_init__(self): class ScriptExecutor: """ - Handles execution of the gitlab-pkg-upload.py script. + Handles execution of the gitlab-pkg-upload CLI. Extracted from the monolithic test file's UploadScriptInterface class. - This class manages subprocess execution of the upload script and result parsing. + This class manages execution of the upload CLI via direct module invocation + and result parsing. + + Updated to use direct module invocation instead of subprocess execution + for better integration with the new modular structure. """ def __init__(self, script_path: Optional[Path] = None): @@ -100,21 +138,30 @@ def __init__(self, script_path: Optional[Path] = None): Initialize script executor. Args: - script_path: Path to the upload script. If None, uses default location. + script_path: Path to the upload script. If None, uses direct module invocation. + This parameter is kept for backward compatibility but is + ignored when CLI_AVAILABLE is True. """ - if script_path is None: - # Default to the upload script in the same directory as the test - script_path = Path(__file__).parent.parent.parent / "gitlab-pkg-upload.py" - self.script_path = script_path + self._use_direct_invocation = CLI_AVAILABLE - if not self.script_path.exists(): - raise FileNotFoundError(f"Upload script not found at: {self.script_path}") + # If direct invocation is not available, fall back to subprocess + if not self._use_direct_invocation: + if script_path is None: + # Default to the upload script in the same directory as the test + script_path = Path(__file__).parent.parent.parent / "gitlab-pkg-upload.py" + self.script_path = script_path + + if not self.script_path.exists(): + raise FileNotFoundError(f"Upload script not found at: {self.script_path}") def execute_upload(self, execution: UploadExecution) -> UploadResult: """ Execute upload script with given configuration. + Uses direct module invocation when available, falls back to subprocess + when the gitlab_pkg_upload module is not importable. + Args: execution: Upload execution configuration @@ -133,6 +180,162 @@ def execute_upload(self, execution: UploadExecution) -> UploadResult: if result.json_data: print(result.json_data["success"]) """ + if self._use_direct_invocation: + return self._execute_direct(execution) + else: + return self._execute_subprocess(execution) + + def _execute_direct(self, execution: UploadExecution) -> UploadResult: + """ + Execute upload via direct module invocation with timeout handling. + + Args: + execution: Upload execution configuration + + Returns: + UploadResult with execution details + """ + start_time = time.time() + + # Extract argv from command (skip the script path) + argv = execution.command[1:] if len(execution.command) > 1 else [] + + # Capture stdout and stderr + stdout_capture = io.StringIO() + stderr_capture = io.StringIO() + + # Save original environment and argv + original_env = os.environ.copy() + original_cwd = os.getcwd() + + exit_code = 0 + timed_out = False + + def run_cli(): + """Inner function to run CLI, to be executed with timeout.""" + nonlocal exit_code + try: + cli_main(argv) + exit_code = 0 + except SystemExit as e: + exit_code = e.code if isinstance(e.code, int) else 1 + except GitLabUploadError as e: + exit_code = e.exit_code + print(str(e), file=sys.stderr) + except Exception as e: + exit_code = 1 + print(f"Error: {e}", file=sys.stderr) + + try: + # Update environment if needed + if execution.env_vars: + os.environ.update(execution.env_vars) + + # Change working directory if specified + if execution.working_directory: + os.chdir(execution.working_directory) + + # Execute CLI with captured output and timeout + with contextlib.redirect_stdout(stdout_capture), contextlib.redirect_stderr(stderr_capture): + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(run_cli) + try: + future.result(timeout=execution.timeout) + except FuturesTimeoutError: + timed_out = True + exit_code = -1 + + finally: + # Restore original environment + os.environ.clear() + os.environ.update(original_env) + # Restore working directory + os.chdir(original_cwd) + + duration = time.time() - start_time + + # Handle timeout case - return early with timeout error + if timed_out: + return UploadResult( + success=False, + exit_code=-1, + stdout=stdout_capture.getvalue(), + stderr=stderr_capture.getvalue(), + duration=duration, + error_message=f"Script execution timed out after {execution.timeout} seconds", + uploaded_files=[], + upload_urls=[], + json_data=None, + ) + + stdout = stdout_capture.getvalue() + stderr = stderr_capture.getvalue() + + # Parse JSON output if enabled + json_data = None + if execution.use_json_output: + json_data = self._parse_json_output(stdout) + + # Extract uploaded files and URLs + if json_data is not None: + uploaded_files, upload_urls = self._extract_data_from_json(json_data) + else: + uploaded_files = self._extract_uploaded_files(stdout) + upload_urls = self._extract_upload_urls(stdout) + + # Determine success + if json_data is not None: + # Use JSON data for success determination + success = ( + json_data.get("success", False) + and exit_code == execution.expected_exit_code + and json_data.get("exit_code", -1) == execution.expected_exit_code + ) + else: + # Use traditional pattern matching + success = ( + exit_code == execution.expected_exit_code + and self._check_output_patterns( + stdout, execution.expected_output_patterns + ) + ) + + error_message = None + if not success: + if json_data is not None and "error" in json_data: + error_message = f"{json_data.get('error_type', 'Error')}: {json_data.get('error', 'Unknown error')}" + elif exit_code != execution.expected_exit_code: + error_message = f"Unexpected exit code: {exit_code} (expected {execution.expected_exit_code})" + else: + error_message = "Expected output patterns not found" + + if stderr: + error_message += f". Stderr: {stderr}" + + return UploadResult( + success=success, + exit_code=exit_code, + stdout=stdout, + stderr=stderr, + duration=duration, + error_message=error_message, + uploaded_files=uploaded_files, + upload_urls=upload_urls, + json_data=json_data, + ) + + def _execute_subprocess(self, execution: UploadExecution) -> UploadResult: + """ + Execute upload via subprocess (fallback when module not available). + + Args: + execution: Upload execution configuration + + Returns: + UploadResult with execution details + """ + import subprocess + start_time = time.time() try: @@ -263,7 +466,11 @@ def build_command(self, use_json_output: bool = False, **kwargs) -> List[str]: ): raise ValueError("version is required and cannot be empty") - command = [str(self.script_path)] + # Use program name for direct invocation, script path for subprocess fallback + if self._use_direct_invocation: + command = ["gitlab-pkg-upload"] + else: + command = [str(self.script_path)] # Add common parameters if "package_name" in kwargs: From 1c83461d8f83304fe9c2e501fd94e2f152d71372 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 07:58:19 -0600 Subject: [PATCH 19/36] tests: Add centralized integration test environment validation Integration tests previously performed token validation independently in each test module, leading to duplicated code and inconsistent error messages when the environment was misconfigured. Users running tests without proper setup received cryptic skip messages that did not explain how to resolve the issue. Introduce a session-scoped autouse fixture that validates all environment requirements once before any integration tests execute. This centralizes the validation logic and provides detailed, actionable error messages that guide users through the exact steps needed to configure their environment. The fixture checks for GITLAB_TOKEN, verifies the presence of a Git repository, and confirms GitLab remotes are configured. Signed-off-by: Javier Tia --- tests/README.md | 63 +++++++ tests/integration/conftest.py | 176 ++++++++++++++++++ tests/integration/test_duplicate_handling.py | 28 +-- tests/integration/test_end_to_end.py | 34 ++-- .../test_environment_validation.py | 122 ++++++++++++ tests/integration/test_error_scenarios.py | 22 +-- .../integration/test_multiple_files_upload.py | 18 +- tests/integration/test_project_resolution.py | 20 +- tests/integration/test_single_file_upload.py | 16 +- 9 files changed, 406 insertions(+), 93 deletions(-) create mode 100644 tests/integration/test_environment_validation.py diff --git a/tests/README.md b/tests/README.md index 9f0e1bc..2e8dadf 100644 --- a/tests/README.md +++ b/tests/README.md @@ -109,6 +109,69 @@ Your GitLab token needs the following permissions: - Write access to the target project's Package Registry - Ability to create and delete packages in the registry +## Integration Test Requirements + +Integration tests automatically validate their environment before running. If requirements aren't met, tests will be skipped with clear, actionable error messages explaining what's missing and how to fix it. + +### Automatic Environment Validation + +When you run integration tests, the test suite automatically checks: + +1. **GITLAB_TOKEN environment variable** - Must be set with a valid GitLab API token +2. **Git repository** - Must run from within a Git repository +3. **GitLab remotes** - Repository must have at least one remote pointing to a GitLab instance + +### Verifying Your Setup + +Use these commands to verify your environment meets the requirements: + +```bash +# Check if in Git repository +git remote -v + +# Verify GitLab remote exists (should show at least one gitlab.com or your GitLab instance) +git remote -v | grep gitlab + +# Check token is set +echo $GITLAB_TOKEN + +# Or verify token is not empty +[ -n "$GITLAB_TOKEN" ] && echo "Token is set" || echo "Token is NOT set" +``` + +### When Validation Fails + +If integration tests are skipped, the error message will explain exactly what's missing: + +- **Missing GITLAB_TOKEN**: Set the environment variable with `export GITLAB_TOKEN='your-token'` +- **No Git repository**: Navigate to a Git repository or initialize one +- **No GitLab remotes**: Add a GitLab remote with `git remote add origin https://gitlab.com/namespace/project.git` +- **Alternative**: Use manual project specification with `export GITLAB_PROJECT_PATH='namespace/project'` + +### Example Output + +When the environment is properly configured, you'll see validation confirmation: +``` +Integration test environment validated: + - GITLAB_TOKEN: [set] + - Git repository: /path/to/your/repo + - GitLab remotes detected: origin=namespace/project +``` + +When something is missing, tests will be skipped with a detailed message: +``` +SKIPPED [1] tests/integration/conftest.py:163: GITLAB_TOKEN environment variable not set. + +Integration tests require a valid GitLab API token. + +SOLUTION: +1. Create a GitLab personal access token with 'api' scope: + GitLab → Settings → Access Tokens → Create token + +2. Set the environment variable: + export GITLAB_TOKEN='your-token-here' +``` + ## Pytest Plugins The test suite uses the following pytest plugins: diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 9d6fbef..4471329 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -4,6 +4,21 @@ This module provides fixtures and configuration specific to the integration tests that call the CLI main() function directly. +Environment Requirements: + Integration tests require the following environment setup: + + 1. GITLAB_TOKEN environment variable must be set + - Can be set via: export GITLAB_TOKEN="your-token" + - Token needs 'api' scope and write access to Package Registry + + 2. Must run from within a Git repository with GitLab remotes + - The repository must have at least one remote pointing to a GitLab instance + - Remotes are auto-detected using the GitAutoDetector class + - Alternatively, use GITLAB_PROJECT_PATH environment variable + + If requirements aren't met, all integration tests will be skipped with + clear, actionable error messages explaining what's missing and how to fix it. + Fixtures from parent conftest.py are automatically inherited: - gitlab_client: GitLab test client for API verification - artifact_manager: Test artifact management @@ -12,12 +27,173 @@ Usage: Fixtures are automatically available to all tests in this package. Import additional utilities from test_helpers_module as needed. + +Example commands to verify your environment: + # Check if in Git repository + git remote -v + + # Verify GitLab remote exists + git remote -v | grep gitlab + + # Check token is set + echo $GITLAB_TOKEN """ import logging +import os +from typing import Tuple import pytest +from gitlab_pkg_upload.cli import GitAutoDetector, ProjectResolutionError + + +def _validate_gitlab_repository() -> Tuple[bool, str, str]: + """ + Validate that the current environment has a GitLab repository. + + Uses GitAutoDetector to find Git repository and check for GitLab remotes. + + Returns: + Tuple of (is_valid, error_message, success_info): + - is_valid: True if GitLab repository is properly configured + - error_message: Detailed error message if validation fails, empty string otherwise + - success_info: Information about the detected repository if valid + """ + detector = GitAutoDetector() + + # Step 1: Find Git repository + try: + repo = detector.find_git_repository() + except ProjectResolutionError as e: + return ( + False, + f"Git repository access error: {e}\n\n" + "SOLUTION:\n" + "1. Check directory permissions\n" + "2. Use manual project specification:\n" + " export GITLAB_PROJECT_PATH='namespace/project'", + "", + ) + + if repo is None: + return ( + False, + "No Git repository found in the current directory or parent directories.\n\n" + "Integration tests require running from within a Git repository.\n\n" + "SOLUTION:\n" + "1. Navigate to a Git repository before running tests:\n" + " cd /path/to/your/git/repo\n\n" + "2. Or initialize a Git repository:\n" + " git init\n" + " git remote add origin https://gitlab.com/namespace/project.git\n\n" + "3. Or use manual project specification:\n" + " export GITLAB_PROJECT_PATH='namespace/project'", + "", + ) + + # Step 2: Check for remotes + remotes = list(repo.remotes) + if not remotes: + return ( + False, + f"Git repository found at '{repo.working_dir}' but no remotes are configured.\n\n" + "Integration tests require at least one GitLab remote.\n\n" + "SOLUTION:\n" + "1. Add a GitLab remote:\n" + " git remote add origin https://gitlab.com/namespace/project.git\n\n" + "2. Or use manual project specification:\n" + " export GITLAB_PROJECT_PATH='namespace/project'", + "", + ) + + # Step 3: Check for GitLab remotes + try: + gitlab_remotes = detector.get_gitlab_remotes(repo) + except ProjectResolutionError: + # No GitLab remotes found + remote_urls = [f" - {r.name}: {r.url}" for r in remotes] + return ( + False, + f"Git repository found at '{repo.working_dir}' but no GitLab remotes detected.\n\n" + "Current remotes:\n" + + "\n".join(remote_urls) + + "\n\n" + "Integration tests require at least one remote pointing to a GitLab instance.\n\n" + "SOLUTION:\n" + "1. Add a GitLab remote:\n" + " git remote add gitlab https://gitlab.com/namespace/project.git\n\n" + "2. Or update an existing remote to point to GitLab:\n" + " git remote set-url origin https://gitlab.com/namespace/project.git\n\n" + "3. Or use manual project specification:\n" + " export GITLAB_PROJECT_PATH='namespace/project'", + "", + ) + + # Success - build info string + remote_info = ", ".join(f"{r.name}={r.project_path}" for r in gitlab_remotes) + success_info = ( + f"Git repository: {repo.working_dir}\n" + f"GitLab remotes detected: {remote_info}" + ) + + return (True, "", success_info) + + +@pytest.fixture(scope="session", autouse=True) +def validate_integration_environment(): + """ + Validate that the integration test environment is properly configured. + + This session-scoped fixture runs once before any integration tests execute. + It validates: + 1. GITLAB_TOKEN environment variable is set + 2. Current directory is within a Git repository + 3. Git repository has at least one GitLab remote + + If any requirement is not met, all integration tests are skipped with + clear, actionable error messages. + + This fixture is marked autouse=True so it runs automatically for all + integration tests without needing to be explicitly requested. + """ + # Check 1: GITLAB_TOKEN environment variable + token = os.environ.get("GITLAB_TOKEN") + if not token: + pytest.skip( + "GITLAB_TOKEN environment variable not set.\n\n" + "Integration tests require a valid GitLab API token.\n\n" + "SOLUTION:\n" + "1. Create a GitLab personal access token with 'api' scope:\n" + " GitLab → Settings → Access Tokens → Create token\n\n" + "2. Set the environment variable:\n" + " export GITLAB_TOKEN='your-token-here'\n\n" + "3. Or add it to your shell profile for persistence:\n" + " echo 'export GITLAB_TOKEN=\"your-token\"' >> ~/.bashrc", + allow_module_level=True, + ) + + # Check 2 & 3: Git repository with GitLab remotes + # Skip this check if GITLAB_PROJECT_PATH is manually specified + if not os.environ.get("GITLAB_PROJECT_PATH"): + is_valid, error_message, success_info = _validate_gitlab_repository() + + if not is_valid: + pytest.skip(error_message, allow_module_level=True) + + # Log successful validation + print(f"\nIntegration test environment validated:") + print(f" - GITLAB_TOKEN: [set]") + print(f" - {success_info}") + else: + # Manual project path specified + project_path = os.environ.get("GITLAB_PROJECT_PATH") + print(f"\nIntegration test environment validated:") + print(f" - GITLAB_TOKEN: [set]") + print(f" - GITLAB_PROJECT_PATH: {project_path} (manually specified)") + + yield + def pytest_configure(config): """Configure pytest for integration tests.""" diff --git a/tests/integration/test_duplicate_handling.py b/tests/integration/test_duplicate_handling.py index c5426db..a157d90 100644 --- a/tests/integration/test_duplicate_handling.py +++ b/tests/integration/test_duplicate_handling.py @@ -23,14 +23,6 @@ ] -def _get_gitlab_token(): - """Get GitLab token from environment with proper error handling.""" - token = os.environ.get("GITLAB_TOKEN") - if not token: - pytest.skip("GITLAB_TOKEN environment variable not set") - return token - - def _validate_upload_consistency( gitlab_client, package_name: str, @@ -115,7 +107,7 @@ def test_skip_duplicate_policy(self, gitlab_client, artifact_manager, project_pa first_upload_result = executor.execute_upload( argv=first_argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -149,7 +141,7 @@ def test_skip_duplicate_policy(self, gitlab_client, artifact_manager, project_pa # Second upload - should skip duplicate second_upload_result = executor.execute_upload( argv=first_argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -219,7 +211,7 @@ def test_replace_duplicate_policy( first_upload_result = executor.execute_upload( argv=first_argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -256,7 +248,7 @@ def test_replace_duplicate_policy( second_upload_result = executor.execute_upload( argv=second_argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -342,7 +334,7 @@ def test_error_duplicate_policy( first_upload_result = executor.execute_upload( argv=first_argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -364,7 +356,7 @@ def test_error_duplicate_policy( # Second upload with same file - should fail due to error policy second_upload_result = executor.execute_upload( argv=first_argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, expected_exit_code=1, # Expect failure use_json_output=True, ) @@ -440,7 +432,7 @@ def test_multiple_file_skip_duplicates(gitlab_client, artifact_manager, project_ first_upload_result = executor.execute_upload( argv=first_argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -462,7 +454,7 @@ def test_multiple_file_skip_duplicates(gitlab_client, artifact_manager, project_ # Second upload with same files - should skip all duplicates second_upload_result = executor.execute_upload( argv=first_argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -539,7 +531,7 @@ def test_mixed_duplicate_and_new_files(gitlab_client, artifact_manager, project_ first_upload_result = executor.execute_upload( argv=first_argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -579,7 +571,7 @@ def test_mixed_duplicate_and_new_files(gitlab_client, artifact_manager, project_ second_upload_result = executor.execute_upload( argv=second_argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) diff --git a/tests/integration/test_end_to_end.py b/tests/integration/test_end_to_end.py index 13e4bcc..fc275a4 100644 --- a/tests/integration/test_end_to_end.py +++ b/tests/integration/test_end_to_end.py @@ -25,14 +25,6 @@ ] -def _get_gitlab_token(): - """Get GitLab token from environment with proper error handling.""" - token = os.environ.get("GITLAB_TOKEN") - if not token: - pytest.skip("GITLAB_TOKEN environment variable not set") - return token - - @pytest.mark.timeout(600) def test_comprehensive_upload_validation(gitlab_client, artifact_manager, project_path): """ @@ -76,7 +68,7 @@ def test_comprehensive_upload_validation(gitlab_client, artifact_manager, projec single_result = executor.execute_upload( argv=single_argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -122,7 +114,7 @@ def test_comprehensive_upload_validation(gitlab_client, artifact_manager, projec multi_result = executor.execute_upload( argv=multi_argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -167,7 +159,7 @@ def test_comprehensive_upload_validation(gitlab_client, artifact_manager, projec dir_result = executor.execute_upload( argv=dir_argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -247,7 +239,7 @@ def test_error_scenario_validation(gitlab_client, artifact_manager, project_path result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, expected_exit_code=1, use_json_output=True, ) @@ -277,7 +269,7 @@ def test_error_scenario_validation(gitlab_client, artifact_manager, project_path result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, expected_exit_code=1, use_json_output=True, ) @@ -308,7 +300,7 @@ def test_error_scenario_validation(gitlab_client, artifact_manager, project_path result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, expected_exit_code=1, use_json_output=True, ) @@ -485,7 +477,7 @@ def test_end_to_end_workflow_validation(gitlab_client, artifact_manager, project result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -560,7 +552,7 @@ def test_parallel_execution_safety(gitlab_client, artifact_manager, project_path result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -646,7 +638,7 @@ def test_comprehensive_cleanup_verification( result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -734,7 +726,7 @@ def test_multi_scenario_workflow_validation( result_1 = executor.execute_upload( argv=argv_1, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -753,7 +745,7 @@ def test_multi_scenario_workflow_validation( # Second upload (should skip duplicate) result_1_dup = executor.execute_upload( argv=argv_1, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -784,7 +776,7 @@ def test_multi_scenario_workflow_validation( result_2 = executor.execute_upload( argv=argv_2, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -826,7 +818,7 @@ def test_multi_scenario_workflow_validation( result_3 = executor.execute_upload( argv=argv_3, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) diff --git a/tests/integration/test_environment_validation.py b/tests/integration/test_environment_validation.py new file mode 100644 index 0000000..a69b209 --- /dev/null +++ b/tests/integration/test_environment_validation.py @@ -0,0 +1,122 @@ +""" +Environment validation tests for the integration test suite. + +This module contains tests that verify the integration test environment +validation mechanism works correctly. These tests serve as both documentation +and verification that the validation fixture properly detects: + - GITLAB_TOKEN environment variable + - Git repository presence + - GitLab remote configuration + +These tests run quickly and help users understand what environment setup +is required for the full integration test suite. +""" + +import os + +import pytest + +from gitlab_pkg_upload.cli import GitAutoDetector + +# Test markers for categorization +pytestmark = [ + pytest.mark.integration, + pytest.mark.fast, # These tests are quick validation checks +] + + +class TestEnvironmentValidation: + """ + Test class for environment validation functionality. + + These tests verify that the integration test environment is properly + configured and that the validation fixtures work correctly. + """ + + @pytest.mark.timeout(30) + def test_gitlab_token_is_set(self): + """ + Test that GITLAB_TOKEN environment variable is set. + + This test verifies that the GitLab API token is available. + The session-scoped validation fixture ensures this test only runs + if the token is present, so this test documents the requirement. + """ + token = os.environ.get("GITLAB_TOKEN") + assert token is not None, "GITLAB_TOKEN should be set" + assert len(token) > 0, "GITLAB_TOKEN should not be empty" + + @pytest.mark.timeout(30) + def test_git_repository_detected(self): + """ + Test that a Git repository is detected in the current environment. + + This test verifies that GitAutoDetector can find a Git repository. + """ + detector = GitAutoDetector() + repo = detector.find_git_repository() + + assert repo is not None, "Git repository should be detected" + assert repo.working_dir is not None, "Repository should have a working directory" + + @pytest.mark.timeout(30) + def test_gitlab_remotes_detected(self): + """ + Test that GitLab remotes are detected in the Git repository. + + This test verifies that at least one GitLab remote is configured. + """ + detector = GitAutoDetector() + repo = detector.find_git_repository() + + assert repo is not None, "Git repository should be detected" + + # This will raise ProjectResolutionError if no GitLab remotes found + gitlab_remotes = detector.get_gitlab_remotes(repo) + + assert len(gitlab_remotes) > 0, "At least one GitLab remote should be detected" + + # Verify remote structure + for remote in gitlab_remotes: + assert remote.name is not None, "Remote should have a name" + assert remote.project_path is not None, "Remote should have a project path" + assert remote.gitlab_url is not None, "Remote should have a GitLab URL" + + @pytest.mark.timeout(30) + def test_environment_validation_fixture_ran(self, validate_integration_environment): + """ + Test that the environment validation fixture executed successfully. + + This test explicitly requests the validation fixture to verify + it completes without skipping tests. + """ + # If we reach this point, the fixture ran successfully + # The fixture yields after validation, so this test body executes + # only if all validation checks passed + assert True, "Environment validation fixture completed successfully" + + +@pytest.mark.timeout(30) +def test_project_path_fixture_available(project_path): + """ + Test that the project_path fixture provides a valid project path. + + This test verifies that the project path can be resolved either + from Git auto-detection or from manual specification. + """ + assert project_path is not None, "project_path fixture should provide a value" + assert len(project_path) > 0, "project_path should not be empty" + assert "/" in project_path, "project_path should be in 'namespace/project' format" + + +@pytest.mark.timeout(30) +def test_gitlab_client_fixture_available(gitlab_client): + """ + Test that the gitlab_client fixture provides a usable client. + + This test verifies that the GitLab client fixture is properly + configured and can communicate with the GitLab API. + """ + assert gitlab_client is not None, "gitlab_client fixture should provide a client" + # Basic check that client has expected attributes + assert hasattr(gitlab_client, "gitlab_url"), "Client should have gitlab_url attribute" diff --git a/tests/integration/test_error_scenarios.py b/tests/integration/test_error_scenarios.py index cb95fc2..d0b727a 100644 --- a/tests/integration/test_error_scenarios.py +++ b/tests/integration/test_error_scenarios.py @@ -22,14 +22,6 @@ ] -def _get_gitlab_token(): - """Get GitLab token from environment with proper error handling.""" - token = os.environ.get("GITLAB_TOKEN") - if not token: - pytest.skip("GITLAB_TOKEN environment variable not set") - return token - - class TestErrorScenarios: """ Test class for error scenario handling using direct module invocation. @@ -66,7 +58,7 @@ def test_network_failure_simulation( "--package-version", "1.0.0", "--gitlab-url", "https://invalid-gitlab-url.example.com", "--project-path", project_path, - "--token", _get_gitlab_token(), + "--token", os.environ.get("GITLAB_TOKEN"), "--files", str(test_file.path), "--json-output", ] @@ -242,7 +234,7 @@ def test_error_message_validation( # Execute upload (should fail due to missing file) upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, expected_exit_code=1, use_json_output=True, ) @@ -299,7 +291,7 @@ def test_error_message_validation( # Execute upload (should fail due to invalid project) upload_result2 = executor.execute_upload( argv=argv2, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, expected_exit_code=1, use_json_output=True, ) @@ -369,7 +361,7 @@ def test_failure_continuation_behavior( # Execute upload (should fail due to invalid file) upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, expected_exit_code=1, use_json_output=True, ) @@ -442,7 +434,7 @@ def test_non_ascii_filename_rejection( # Execute upload (should fail due to non-ASCII filename) upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, expected_exit_code=1, use_json_output=True, ) @@ -523,7 +515,7 @@ def test_non_ascii_filename_in_directory(gitlab_client, artifact_manager, projec # Execute upload (should fail due to non-ASCII filename) upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, expected_exit_code=1, use_json_output=True, ) @@ -611,7 +603,7 @@ def test_mixed_ascii_non_ascii_filenames(gitlab_client, artifact_manager, projec # Execute upload (should fail due to non-ASCII filenames) upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, expected_exit_code=1, use_json_output=True, ) diff --git a/tests/integration/test_multiple_files_upload.py b/tests/integration/test_multiple_files_upload.py index e560186..9ed2f2d 100644 --- a/tests/integration/test_multiple_files_upload.py +++ b/tests/integration/test_multiple_files_upload.py @@ -21,14 +21,6 @@ ] -def _get_gitlab_token(): - """Get GitLab token from environment with proper error handling.""" - token = os.environ.get("GITLAB_TOKEN") - if not token: - pytest.skip("GITLAB_TOKEN environment variable not set") - return token - - @pytest.mark.timeout(180) def test_multiple_file_upload(gitlab_client, artifact_manager, project_path): """ @@ -68,7 +60,7 @@ def test_multiple_file_upload(gitlab_client, artifact_manager, project_path): # Execute upload via direct module invocation upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -151,7 +143,7 @@ def test_directory_upload(gitlab_client, artifact_manager, project_path): # Execute upload via direct module invocation upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -240,7 +232,7 @@ def test_file_mapping_upload(gitlab_client, artifact_manager, project_path): # Execute upload via direct module invocation upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -330,7 +322,7 @@ def test_large_file_upload(gitlab_client, artifact_manager, project_path): # Execute upload via direct module invocation upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -402,7 +394,7 @@ def test_multiple_files_with_different_sizes( # Execute upload upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) diff --git a/tests/integration/test_project_resolution.py b/tests/integration/test_project_resolution.py index c3f404d..bb7f039 100644 --- a/tests/integration/test_project_resolution.py +++ b/tests/integration/test_project_resolution.py @@ -22,14 +22,6 @@ ] -def _get_gitlab_token(): - """Get GitLab token from environment with proper error handling.""" - token = os.environ.get("GITLAB_TOKEN") - if not token: - pytest.skip("GITLAB_TOKEN environment variable not set") - return token - - class TestProjectResolution: """ Test class for project resolution functionality using direct module invocation. @@ -70,7 +62,7 @@ def test_git_auto_detection(self, gitlab_client, artifact_manager, project_path) # Execute upload upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -154,7 +146,7 @@ def test_manual_project_url_specification( # Execute upload (expecting it to fail) upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, expected_exit_code=1, use_json_output=True, ) @@ -204,7 +196,7 @@ def test_manual_project_url_specification( # Execute upload upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -267,7 +259,7 @@ def test_manual_project_path_specification( # Execute upload upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -335,7 +327,7 @@ def test_invalid_project_path_error_handling( # Execute upload (expecting it to fail) upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, expected_exit_code=1, use_json_output=True, ) @@ -428,7 +420,7 @@ def test_invalid_project_url_error_handling( # Execute upload (expecting it to fail) upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, expected_exit_code=1, use_json_output=True, ) diff --git a/tests/integration/test_single_file_upload.py b/tests/integration/test_single_file_upload.py index 0b025ce..86427e6 100644 --- a/tests/integration/test_single_file_upload.py +++ b/tests/integration/test_single_file_upload.py @@ -22,14 +22,6 @@ ] -def _get_gitlab_token(): - """Get GitLab token from environment with proper error handling.""" - token = os.environ.get("GITLAB_TOKEN") - if not token: - pytest.skip("GITLAB_TOKEN environment variable not set") - return token - - @pytest.mark.timeout(180) def test_single_file_upload(gitlab_client, artifact_manager, project_path): """ @@ -68,7 +60,7 @@ def test_single_file_upload(gitlab_client, artifact_manager, project_path): # Execute upload via direct module invocation upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -141,7 +133,7 @@ def test_single_file_upload_with_verbose(gitlab_client, artifact_manager, projec # Execute upload upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -192,7 +184,7 @@ def test_single_file_upload_with_quiet(gitlab_client, artifact_manager, project_ # Execute upload upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) @@ -255,7 +247,7 @@ def test_single_file_upload_different_content_types( # Execute upload upload_result = executor.execute_upload( argv=argv, - env_vars={"GITLAB_TOKEN": _get_gitlab_token()}, + env_vars={"GITLAB_TOKEN": os.environ.get("GITLAB_TOKEN")}, use_json_output=True, ) From b39504102463ad47e701e62279c79c503ee9dbce Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 08:13:49 -0600 Subject: [PATCH 20/36] gitlab_pkg_upload: refactor into installable package with modular CLI Currently, the upload script exists as a standalone script (gitlab-pkg-upload.py) that cannot be easily imported, tested, or distributed. The monolithic structure makes it difficult to maintain, test individual components, and package for distribution. Restructure the codebase into a proper Python package under src/gitlab_pkg_upload/ with modular components (cli.py, models.py, uploader.py, validators.py). Add package metadata in pyproject.toml with entry point for gitlab-pkg-upload command. Update documentation to reflect the new installation model using 'uv pip install -e .' before running tests. Reorganize test structure to separate unit tests (tests/unit/) from integration tests (tests/integration/). Simplify run_tests.py to delegate to 'uv run pytest' instead of managing dependencies directly. Remove obsolete standalone scripts (gitlab-pkg-upload.py, gitlab_common.py) and redundant integration test files. Update test helpers to import from the installed package instead of subprocess execution of scripts. Add uv.lock for reproducible dependency resolution. Add pytest-instafail to test dependencies for immediate failure reporting. Signed-off-by: Javier Tia --- README.md | 135 ++ gitlab-pkg-upload.py | 1622 ---------------------- gitlab_common.py | 1931 --------------------------- pyproject.toml | 1 + run_tests.py | 409 +++--- src/gitlab_pkg_upload/validators.py | 8 +- tests/README.md | 703 +++------- tests/conftest.py | 2 +- tests/test_basic_uploads.py | 471 ------- tests/test_duplicate_handling.py | 865 ------------ tests/test_error_scenarios.py | 983 -------------- tests/test_fixtures.py | 71 - tests/test_integration.py | 993 -------------- tests/test_project_resolution.py | 658 --------- tests/test_unit_basic.py | 437 ------ tests/utils/test_helpers.py | 24 +- uv.lock | 695 ++++++++++ 17 files changed, 1214 insertions(+), 8794 deletions(-) create mode 100644 README.md delete mode 100755 gitlab-pkg-upload.py delete mode 100644 gitlab_common.py delete mode 100644 tests/test_basic_uploads.py delete mode 100644 tests/test_duplicate_handling.py delete mode 100644 tests/test_error_scenarios.py delete mode 100644 tests/test_fixtures.py delete mode 100644 tests/test_integration.py delete mode 100644 tests/test_project_resolution.py delete mode 100644 tests/test_unit_basic.py create mode 100644 uv.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca84a09 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# GitLab Package Upload + +A CLI tool for uploading files to GitLab's Generic Package Registry. + +## Installation + +### Using uv (Recommended) + +```bash +# Install in development mode +uv pip install -e . + +# Or run directly without installing +uv run gitlab-pkg-upload --help +``` + +### Using pip + +```bash +pip install -e . +``` + +## Usage + +```bash +# Upload a single file +gitlab-pkg-upload file.tar.gz --package-name my-package --version 1.0.0 + +# Upload multiple files +gitlab-pkg-upload file1.tar.gz file2.zip --package-name my-package --version 1.0.0 + +# Upload with automatic project detection from git remote +gitlab-pkg-upload file.tar.gz --package-name my-package --version 1.0.0 + +# Specify project explicitly +gitlab-pkg-upload file.tar.gz --package-name my-package --version 1.0.0 \ + --project-path namespace/project + +# Handle duplicates (skip, replace, or error) +gitlab-pkg-upload file.tar.gz --package-name my-package --version 1.0.0 \ + --duplicate-policy replace +``` + +## Configuration + +### Environment Variables + +| Variable | Description | Required | +|----------|-------------|----------| +| `GITLAB_TOKEN` | GitLab personal access token with `api` scope | Yes | +| `GITLAB_URL` | GitLab instance URL | No (defaults to https://gitlab.com) | +| `GITLAB_PROJECT_PATH` | Project path (e.g., `namespace/project`) | No (auto-detected from git) | + +### Token Permissions + +Your GitLab token requires: +- `api` scope for full API access +- Write access to the target project's Package Registry + +## Development + +### Setup + +```bash +# Clone the repository +git clone https://gitlab.com/your-namespace/gitlab-pkg-upload.git +cd gitlab-pkg-upload + +# Install with development dependencies +uv sync --all-extras + +# Or using pip +pip install -e ".[dev,test]" +``` + +### Running Tests + +```bash +# Install the package in development mode first +uv pip install -e . + +# Run all tests +uv run pytest tests/ + +# Run only unit tests (fast, no external dependencies) +uv run pytest tests/unit/ + +# Run integration tests (requires GITLAB_TOKEN) +export GITLAB_TOKEN="your-token" +uv run pytest tests/integration/ -m integration + +# Run with parallel execution +uv run pytest tests/ -n auto + +# Run with verbose output +uv run pytest tests/ -v +``` + +See [tests/README.md](tests/README.md) for detailed testing documentation. + +### Code Quality + +```bash +# Run linter +uv run ruff check src/ + +# Run type checker +uv run mypy src/ + +# Format code +uv run ruff format src/ +``` + +## Project Structure + +``` +gitlab-pkg-upload/ +├── src/ +│ └── gitlab_pkg_upload/ +│ ├── __init__.py +│ ├── cli.py # Command-line interface +│ ├── models.py # Data models +│ ├── uploader.py # Upload logic +│ └── validators.py # Input validation +├── tests/ +│ ├── unit/ # Unit tests +│ ├── integration/ # Integration tests +│ └── utils/ # Test utilities +├── pyproject.toml # Project configuration +└── README.md # This file +``` + +## License + +MIT License diff --git a/gitlab-pkg-upload.py b/gitlab-pkg-upload.py deleted file mode 100755 index eefd9b5..0000000 --- a/gitlab-pkg-upload.py +++ /dev/null @@ -1,1622 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# dependencies = [ -# "python-gitlab>=4.0.0", -# "rich>=13.0.0", -# "GitPython>=3.1.0", -# ] -# /// - -""" -GitLab Generic Package Upload Script - -A standalone uv-compatible Python script that uploads single or multiple files to GitLab's -generic package registry with SHA256 checksum validation, retry logic, and rich progress output. -Supports uploading multiple files from explicit file lists or directories. -Features copy-paste friendly URL output to avoid terminal truncation. - -Filename Restrictions: - GitLab's Generic Package Registry API has limitations on filename characters. - Only ASCII characters are supported: letters (a-z, A-Z), digits (0-9), dots (.), - hyphens (-), underscores (_), and forward slashes (/) for directory paths. - - Files with non-ASCII characters or unsupported special characters will be rejected - with an error. Ensure filenames use only ASCII-safe characters before uploading. -""" - -import argparse -import hashlib -import json -import logging -import sys -import time -from dataclasses import dataclass -from enum import Enum -from pathlib import Path -from typing import Optional - -from gitlab.exceptions import GitlabAuthenticationError -from gitlab_common import ( - GitAutoDetector, - ProjectResolver, - enhance_error_message, - get_gitlab_token, - handle_network_error_with_retry, - setup_logging, - validate_configuration, - validate_project_input, -) -from rich.console import Console -from rich.status import Status - -from gitlab import Gitlab - -# Constants -DEFAULT_GITLAB_URL = "https://gitlab.com" -MAX_RETRIES = 3 -RETRY_DELAYS = [1, 2, 4] # Exponential backoff in seconds -RATE_LIMIT_RETRY_DELAY = 60 # Seconds to wait for rate limit reset - -# Setup rich console and logging -console = Console() -setup_logging(console=console) -logger = logging.getLogger(__name__) - - -@dataclass -class FileFingerprint: - """Represents a unique identifier for files to enable accurate duplicate detection.""" - - source_path: str - target_filename: str - sha256_checksum: str - file_size: int - timestamp: float - - -@dataclass -class RemoteFile: - """Represents a file that exists in the GitLab package registry.""" - - file_id: int - filename: str - sha256_checksum: Optional[str] - file_size: int - download_url: str - package_name: str - version: str - - -class DuplicatePolicy(Enum): - """Defines how the system should handle detected duplicates.""" - - SKIP = "skip" # Skip uploading duplicates (default) - REPLACE = "replace" # Delete existing and upload new - ERROR = "error" # Fail with error on duplicates - - -@dataclass -class UploadResult: - """Enhanced upload result structure with duplicate detection information.""" - - source_path: str - target_filename: str - success: bool - result: str # URL on success, error message on failure - was_duplicate: bool = False - duplicate_action: Optional[str] = None # "skipped", "replaced", "error" - existing_url: Optional[str] = None - - -class DuplicateDetector: - """Core component responsible for detecting duplicates both locally (within session) and remotely (in GitLab registry).""" - - def __init__(self, gitlab_client: Gitlab, project_id: int): - """ - Initialize DuplicateDetector with GitLab client and project ID. - - Args: - gitlab_client: Authenticated GitLab client - project_id: GitLab project ID - """ - self.gl = gitlab_client - self.project_id = project_id - self.session_registry: dict[str, FileFingerprint] = {} - - def check_session_duplicate( - self, file_path: Path, target_filename: str - ) -> Optional[FileFingerprint]: - """ - Check if file was already processed in current session. - - Args: - file_path: Path to the source file - target_filename: Target filename in registry - - Returns: - FileFingerprint if duplicate found, None otherwise - """ - logger.debug(f"Checking session duplicate for: {target_filename}") - - # Check if target filename already exists in session registry - if target_filename in self.session_registry: - existing_fingerprint = self.session_registry[target_filename] - logger.debug(f"Found existing session entry for {target_filename}") - - # Calculate checksum of current file to compare - current_checksum = calculate_sha256(file_path) - - # Compare checksums to determine if it's truly a duplicate - if existing_fingerprint.sha256_checksum == current_checksum: - logger.info( - f"Session duplicate detected: {target_filename} (checksum: {current_checksum})" - ) - logger.info( - f"Original source: {existing_fingerprint.source_path}, Current source: {file_path}" - ) - return existing_fingerprint - else: - logger.warning( - f"Same target filename {target_filename} but different content detected" - ) - logger.warning( - f"Existing checksum: {existing_fingerprint.sha256_checksum}, Current checksum: {current_checksum}" - ) - else: - logger.debug(f"No session duplicate found for {target_filename}") - - return None - - def check_remote_duplicate( - self, package_name: str, version: str, filename: str, checksum: str - ) -> Optional[RemoteFile]: - """ - Check if file exists in GitLab registry with enhanced retry logic. - - Args: - package_name: Package name in registry - version: Package version - filename: Target filename - checksum: SHA256 checksum to compare - - Returns: - RemoteFile if duplicate found, None otherwise - """ - logger.info( - f"Starting remote duplicate check for {filename} in {package_name} v{version}" - ) - logger.debug(f"Local checksum to compare: {checksum}") - - def _check_remote_duplicate(): - """Internal function to check remote duplicate.""" - project = self.gl.projects.get(self.project_id) - packages = project.packages.list(package_name=package_name, get_all=True) - - # Find the target package version - target_package = next((p for p in packages if p.version == version), None) - - if not target_package: - logger.debug( - f"Package {package_name} v{version} not found - no remote duplicate" - ) - return None - - logger.debug( - f"Found package {package_name} v{version} (ID: {target_package.id})" - ) - - # Get package files - package_obj = project.packages.get(target_package.id) - package_files = package_obj.package_files.list(get_all=True) - - logger.debug(f"Found {len(package_files)} files in package") - - # Find files with matching filename - matching_files = [f for f in package_files if f.file_name == filename] - - if not matching_files: - logger.debug( - f"No files named {filename} found in remote package - no duplicate" - ) - return None - - logger.debug( - f"Found {len(matching_files)} file(s) with matching filename {filename}" - ) - - # Check for checksum matches - for pkg_file in matching_files: - remote_sha256 = getattr(pkg_file, "file_sha256", None) - - if remote_sha256: - logger.debug( - f"Comparing checksums - Remote: {remote_sha256}, Local: {checksum}" - ) - if remote_sha256.lower() == checksum.lower(): - logger.info( - f"Remote duplicate detected: {filename} (checksum: {checksum})" - ) - logger.info( - f"Remote file ID: {pkg_file.id}, Size: {getattr(pkg_file, 'size', 'unknown')}" - ) - - # Generate download URL - download_url = ( - f"{self.gl.api_url.replace('/api/v4', '')}/api/v4/projects/{self.project_id}/packages/generic/" - f"{package_name}/{version}/{filename}" - ) - - return RemoteFile( - file_id=pkg_file.id, - filename=filename, - sha256_checksum=remote_sha256, - file_size=getattr(pkg_file, "size", 0), - download_url=download_url, - package_name=package_name, - version=version, - ) - else: - logger.debug( - f"File {filename} exists but checksum differs (remote: {remote_sha256}, local: {checksum})" - ) - else: - # Handle incomplete metadata gracefully - use file size as fallback - logger.warning( - f"Remote checksum not available for {filename}, using file size comparison" - ) - logger.debug( - f"Cannot verify duplicate without checksum for {filename}" - ) - - logger.debug( - f"No matching checksums found for {filename} - no remote duplicate" - ) - return None - - try: - return handle_network_error_with_retry( - operation_name=f"Remote duplicate check for {filename}", - operation_func=_check_remote_duplicate, - ) - except Exception as e: - logger.error(f"Remote duplicate check failed for {filename}: {e}") - logger.warning(f"Proceeding without duplicate detection for {filename}") - return None - - def register_file(self, file_path: Path, target_filename: str, checksum: str): - """ - Register file as processed in current session. - - Args: - file_path: Path to the source file - target_filename: Target filename in registry - checksum: SHA256 checksum of the file - """ - file_stats = file_path.stat() - - fingerprint = FileFingerprint( - source_path=str(file_path), - target_filename=target_filename, - sha256_checksum=checksum, - file_size=file_stats.st_size, - timestamp=time.time(), - ) - - self.session_registry[target_filename] = fingerprint - logger.info( - f"Registered file in session: {target_filename} (checksum: {checksum})" - ) - logger.debug( - f"Session registry now contains {len(self.session_registry)} file(s)" - ) - - -def validate_filename_ascii(filename: str) -> tuple[bool, str]: - """ - Validate that a filename contains only ASCII characters supported by GitLab Generic Package Registry. - - GitLab's API restricts filenames to ASCII-safe characters only. This function checks - if the provided filename complies with these restrictions. - - Args: - filename: Target filename to validate - - Returns: - Tuple of (is_valid, error_message) where: - - is_valid: True if filename is valid, False otherwise - - error_message: Empty string if valid, detailed error message if invalid - - Examples: - Valid filenames: "package.tar.gz", "my-file_v1.0.bin", "subdir/file.txt" - Invalid filenames: "café.tar.gz", "文件.bin", "file™.txt" - """ - logger.debug(f"Validating filename for ASCII compliance: {filename}") - - # Check if filename is ASCII - if not filename.isascii(): - logger.info(f"Filename validation failed: {filename}") - error_message = ( - f"GitLab Generic Package Registry does not support non-ASCII characters in filenames.\n" - f"Problematic filename: '{filename}'\n" - f"Allowed characters: letters (a-z, A-Z), digits (0-9), dots (.), hyphens (-), underscores (_), and forward slashes (/) for directory paths.\n" - f"Please rename the file to use only ASCII-safe characters before uploading." - ) - return False, error_message - - # Additional validation: check for allowed characters only - # Allowed: letters, digits, dots, hyphens, underscores, forward slashes - import re - - allowed_pattern = re.compile(r"^[a-zA-Z0-9._/-]+$") - - if not allowed_pattern.match(filename): - logger.info(f"Filename validation failed: {filename}") - error_message = ( - f"GitLab Generic Package Registry does not support special characters in filenames.\n" - f"Problematic filename: '{filename}'\n" - f"Allowed characters: letters (a-z, A-Z), digits (0-9), dots (.), hyphens (-), underscores (_), and forward slashes (/) for directory paths.\n" - f"Please rename the file to use only ASCII-safe characters before uploading." - ) - return False, error_message - - logger.debug(f"Filename validation passed: {filename}") - return True, "" - - -def calculate_sha256(file_path: Path) -> str: - """ - Calculate SHA256 checksum of a file. - - Args: - file_path: Path to the file - - Returns: - Hexadecimal SHA256 digest string - """ - sha256_hash = hashlib.sha256() - - with open(file_path, "rb") as f: - # Read in chunks for memory efficiency - for chunk in iter(lambda: f.read(8192), b""): - sha256_hash.update(chunk) - - checksum = sha256_hash.hexdigest() - logger.info(f"Calculated SHA256 checksum: {checksum}") - return checksum - - -def collect_files_to_upload( - args: argparse.Namespace, -) -> tuple[list[tuple[Path, str]], list[dict]]: - """ - Collect files to upload based on input mode (--files or --directory). - - Validates that all filenames contain only ASCII characters supported by GitLab. - - Args: - args: Parsed command-line arguments - - Returns: - Tuple of (valid_files, file_errors) where: - - valid_files: List of tuples containing (source_path, target_filename) - - file_errors: List of dicts with error information for invalid files - - Raises: - ValueError: If file paths are invalid, file mappings are malformed, - or filenames contain non-ASCII characters - FileNotFoundError: If specified directory does not exist - """ - files_to_upload: list[tuple[Path, str]] = [] - file_errors: list[dict] = [] - - if args.files: - # Parse file mappings if provided - file_mappings: dict[str, str] = {} - if args.file_mapping: - for mapping in args.file_mapping: - if mapping.count(":") != 1: - raise ValueError( - f"Invalid file mapping format '{mapping}'. " - "Expected format: 'local.bin:remote.bin'" - ) - local_name, remote_name = mapping.split(":", 1) - file_mappings[local_name] = remote_name - - # Validate that file mappings reference files in the --files list - if file_mappings: - files_set = {Path(f).name for f in args.files} - for local_name in file_mappings.keys(): - if local_name not in files_set: - raise ValueError( - f"File mapping references '{local_name}' which is not in --files list" - ) - - # Process each file - collect errors instead of raising immediately - for file_path_str in args.files: - source_path = Path(file_path_str) - - # Validate file existence and type - if not source_path.exists(): - target_filename = file_mappings.get(source_path.name, source_path.name) - file_errors.append( - { - "source_path": str(source_path), - "target_filename": target_filename, - "error_message": f"File not found: {source_path}", - "error_type": "FileNotFoundError", - } - ) - logger.error(f"File not found: {source_path}") - continue - - if not source_path.is_file(): - target_filename = file_mappings.get(source_path.name, source_path.name) - file_errors.append( - { - "source_path": str(source_path), - "target_filename": target_filename, - "error_message": f"Path is not a file: {source_path}", - "error_type": "ValueError", - } - ) - logger.error(f"Path is not a file: {source_path}") - continue - - # Apply mapping if exists, otherwise use original filename - target_filename = file_mappings.get(source_path.name, source_path.name) - - # Validate filename for GitLab API compatibility - is_valid, error_message = validate_filename_ascii(target_filename) - if not is_valid: - raise ValueError(error_message) - - files_to_upload.append((source_path, target_filename)) - - elif args.directory: - directory_path = Path(args.directory) - if not directory_path.exists(): - raise FileNotFoundError(f"Directory not found: {directory_path}") - if not directory_path.is_dir(): - raise ValueError(f"Path is not a directory: {directory_path}") - - # Collect only top-level files (not subdirectories) - for item in directory_path.iterdir(): - if item.is_file(): - # Validate filename for GitLab API compatibility - is_valid, error_message = validate_filename_ascii(item.name) - if not is_valid: - raise ValueError(error_message) - files_to_upload.append((item, item.name)) - - if not files_to_upload: - logger.warning(f"No files found in directory: {directory_path}") - - # Check for duplicate target filenames - target_filenames = [target for _, target in files_to_upload] - duplicates = [name for name in target_filenames if target_filenames.count(name) > 1] - if duplicates: - unique_duplicates = list(set(duplicates)) - raise ValueError( - f"Duplicate target filenames detected: {', '.join(unique_duplicates)}" - ) - - return files_to_upload, file_errors - - -def format_results_as_json( - package_name: str, - version: str, - successful_uploads: list[UploadResult], - skipped_duplicates: list[UploadResult], - failed_uploads: list[UploadResult], -) -> dict: - """ - Convert upload results into a JSON-serializable dictionary. - - Args: - package_name: Package name in registry - version: Package version - successful_uploads: List of successful upload results - skipped_duplicates: List of skipped duplicate results - failed_uploads: List of failed upload results - - Returns: - Dictionary with structured results ready for JSON serialization - """ - # Calculate statistics - replaced_count = sum( - 1 - for r in successful_uploads - if r.was_duplicate and r.duplicate_action == "replaced" - ) - new_uploads_count = len(successful_uploads) - replaced_count - total_processed = ( - len(successful_uploads) + len(skipped_duplicates) + len(failed_uploads) - ) - - # Determine overall success status - success = len(failed_uploads) == 0 - exit_code = 0 if success else 1 - - # Helper function to convert UploadResult to dict - def result_to_dict(result: UploadResult, is_skipped: bool = False) -> dict: - """Convert an UploadResult to a dictionary for JSON output.""" - # Determine download URL based on result type - if result.success: - if is_skipped: - download_url = result.existing_url or result.result - else: - download_url = result.result - else: - download_url = None - - return { - "source_path": result.source_path, - "target_filename": result.target_filename, - "download_url": download_url, - "checksum": None, # Checksum not currently stored in UploadResult - "was_duplicate": result.was_duplicate, - "duplicate_action": result.duplicate_action, - "existing_url": result.existing_url, - "error_message": result.result if not result.success else None, - } - - # Build the result structure - result_dict = { - "success": success, - "exit_code": exit_code, - "package_name": package_name, - "version": version, - "successful_uploads": [result_to_dict(r) for r in successful_uploads], - "skipped_duplicates": [ - result_to_dict(r, is_skipped=True) for r in skipped_duplicates - ], - "failed_uploads": [result_to_dict(r) for r in failed_uploads], - "statistics": { - "total_processed": total_processed, - "new_uploads": new_uploads_count, - "replaced_duplicates": replaced_count, - "skipped_duplicates": len(skipped_duplicates), - "failed_uploads": len(failed_uploads), - }, - } - - return result_dict - - -def handle_duplicate( - policy: DuplicatePolicy, - remote_file: RemoteFile, - detector: DuplicateDetector, - gl: Gitlab, - project_id: int, -) -> tuple[str, str, int]: - """ - Handle duplicate file according to policy. - - Args: - policy: Duplicate handling policy - remote_file: Remote file that was found as duplicate - detector: DuplicateDetector instance - gl: GitLab client - project_id: Project ID - - Returns: - Tuple of (action, result, deleted_count) where action is "skipped"/"replaced"/"error", - result is URL or error message, and deleted_count is the number of files deleted - - Raises: - ValueError: If policy is ERROR and duplicate is found - """ - logger.info( - f"Handling duplicate file {remote_file.filename} with policy: {policy.value}" - ) - logger.debug( - f"Remote file details - ID: {remote_file.file_id}, Size: {remote_file.file_size}, Checksum: {remote_file.sha256_checksum}" - ) - - if policy == DuplicatePolicy.SKIP: - logger.info( - f"Policy decision: SKIP - Skipping duplicate file: {remote_file.filename}" - ) - logger.info(f"Using existing file URL: {remote_file.download_url}") - return "skipped", remote_file.download_url, 0 - - elif policy == DuplicatePolicy.REPLACE: - logger.info( - f"Policy decision: REPLACE - Replacing duplicate file: {remote_file.filename}" - ) - logger.info("Deleting existing file(s) before upload") - deleted_count = delete_existing_files( - gl=gl, - project_id=project_id, - package_name=remote_file.package_name, - version=remote_file.version, - filename=remote_file.filename, - ) - logger.info(f"Deleted {deleted_count} existing file(s), proceeding with upload") - return "replaced", "proceed_with_upload", deleted_count - - elif policy == DuplicatePolicy.ERROR: - error_msg = f"Policy decision: ERROR - Duplicate file detected: {remote_file.filename} (checksum: {remote_file.sha256_checksum})" - logger.error(error_msg) - logger.error(f"Existing file URL: {remote_file.download_url}") - raise ValueError(error_msg) - - else: - error_msg = f"Unknown duplicate policy: {policy}" - logger.error(error_msg) - raise ValueError(error_msg) - - -def delete_existing_files( - gl: Gitlab, - project_id: int, - package_name: str, - version: str, - filename: str, -) -> int: - """ - Delete existing files with the same name from the package. - - This enables replacing files in the registry rather than creating duplicates. - - Args: - gl: Authenticated GitLab client - project_id: GitLab project ID - package_name: Package name in registry - version: Package version - filename: Filename to delete - - Returns: - Number of files deleted - """ - deleted_count = 0 - - try: - project = gl.projects.get(project_id) - packages = project.packages.list(package_name=package_name, get_all=True) - - target_package = next((p for p in packages if p.version == version), None) - - if not target_package: - logger.debug( - f"Package {package_name} v{version} not found, nothing to delete" - ) - return 0 - - # Get package files - package_obj = project.packages.get(target_package.id) - package_files = package_obj.package_files.list(get_all=True) - - # Find all files matching the target filename - matching_files = [f for f in package_files if f.file_name == filename] - - if not matching_files: - logger.debug(f"No existing files named {filename} found") - return 0 - - # Delete all matching files - for file_obj in matching_files: - try: - logger.info(f"Deleting existing file: {filename} (ID: {file_obj.id})") - file_obj.delete() - deleted_count += 1 - except Exception as e: - logger.warning( - f"Failed to delete file {filename} (ID: {file_obj.id}): {e}" - ) - - if deleted_count > 0: - logger.info(f"Deleted {deleted_count} existing file(s) named {filename}") - - except Exception as e: - logger.warning(f"Error checking for existing files: {e}") - - return deleted_count - - -def upload_file_with_retry( - gl: Gitlab, - project_id: int, - file_path: Path, - package_name: str, - version: str, - target_filename: str, - max_retries: int = MAX_RETRIES, -) -> bool: - """ - Upload file to GitLab generic package registry with enhanced retry logic. - - Args: - gl: Authenticated GitLab client - project_id: GitLab project ID - file_path: Path to file to upload - package_name: Package name in registry - version: Package version - target_filename: Target filename in registry - max_retries: Maximum number of retry attempts - - Returns: - True if upload succeeded, False otherwise - """ - - def _upload_file(): - """Internal function to upload file.""" - project = gl.projects.get(project_id) - - # Use spinner with elapsed time since GitLab API doesn't provide upload progress - file_size_mb = file_path.stat().st_size / (1024 * 1024) - start_time = time.time() - - with Status( - f"[bold blue]Uploading {target_filename} ({file_size_mb:.2f} MB)...[/bold blue]", - console=console, - spinner="dots", - ): - # Upload file to generic packages - project.generic_packages.upload( - package_name=package_name, - package_version=version, - file_name=target_filename, - path=file_path.as_posix(), - ) - - elapsed = time.time() - start_time - speed_mbps = file_size_mb / elapsed if elapsed > 0 else 0 - console.print( - f"[green]✓[/green] Uploaded {target_filename} " - f"({file_size_mb:.2f} MB in {elapsed:.1f}s, {speed_mbps:.2f} MB/s)" - ) - - logger.info(f"Upload successful: {target_filename}") - return True - - try: - return handle_network_error_with_retry( - operation_name=f"File upload for {target_filename}", - operation_func=_upload_file, - max_retries=max_retries, - ) - except Exception as e: - logger.error(f"Upload failed for {target_filename}: {e}") - return False - - -def validate_upload( - gl: Gitlab, - project_id: int, - package_name: str, - version: str, - filename: str, - expected_sha256: str, -) -> bool: - """ - Validate uploaded file by comparing checksums. - - Args: - gl: Authenticated GitLab client - project_id: GitLab project ID - package_name: Package name in registry - version: Package version - filename: Filename in registry - expected_sha256: Expected SHA256 checksum - - Returns: - True if validation succeeded, False otherwise - """ - try: - project = gl.projects.get(project_id) - - # Get package files - packages = project.packages.list(package_name=package_name, get_all=True) - - for package in packages: - if package.version == version: - # Get package files - package_obj = project.packages.get(package.id) - package_files = package_obj.package_files.list(get_all=True) - - # Debug: log all available filenames - available_files = [pkg_file.file_name for pkg_file in package_files] - logger.debug(f"Available files in package: {available_files}") - logger.debug(f"Looking for filename: {filename}") - - for pkg_file in package_files: - # Handle both direct filename match and path-based match - # GitLab might store subdirectory files differently - file_matches = ( - pkg_file.file_name == filename - or pkg_file.file_name.endswith(f"/{filename}") - or filename.endswith(f"/{pkg_file.file_name}") - or pkg_file.file_name.replace("/", "_") - == filename.replace("/", "_") - ) - - if file_matches: - # Check if SHA256 is available in the response - remote_sha256 = getattr(pkg_file, "file_sha256", None) - - if remote_sha256: - if remote_sha256.lower() == expected_sha256.lower(): - logger.info( - f"Checksum validation successful: {expected_sha256}" - ) - return True - else: - # Special handling for empty files - GitLab may compute checksums differently - if ( - expected_sha256.lower() - == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - ): - logger.warning( - f"Empty file checksum mismatch (GitLab limitation). " - f"Local: {expected_sha256}, Remote: {remote_sha256}. " - f"Assuming upload success for empty file." - ) - return True - else: - logger.error( - f"Checksum mismatch! Local: {expected_sha256}, " - f"Remote: {remote_sha256}" - ) - return False - else: - logger.warning( - "Remote checksum not available in API response, " - "skipping validation" - ) - return True - - logger.warning( - f"Could not find uploaded file {filename} in package {package_name} " - f"version {version}" - ) - - # Special handling for files with subdirectories - GitLab Generic Package Registry - # may not support subdirectories in filenames properly - if "/" in filename: - logger.warning( - f"File '{filename}' contains subdirectory path. GitLab Generic Package Registry " - f"may not support subdirectories in filenames. Upload may have succeeded but " - f"validation failed. Consider using flat filenames without subdirectories." - ) - # For subdirectory files, we'll assume success if upload didn't fail - # This is a workaround for GitLab API limitations - return True - - return False - - except Exception as e: - logger.warning(f"Checksum validation failed: {e}") - return False - - -def process_single_file( - gl: Gitlab, - project_id: int, - source_path: Path, - target_filename: str, - package_name: str, - version: str, - gitlab_url: str, - detector: DuplicateDetector, - duplicate_policy: DuplicatePolicy, -) -> UploadResult: - """ - Process upload and validation for a single file with duplicate detection. - - Args: - gl: Authenticated GitLab client - project_id: GitLab project ID - source_path: Path to source file - target_filename: Target filename in registry - package_name: Package name in registry - version: Package version - gitlab_url: GitLab instance URL - detector: DuplicateDetector instance - duplicate_policy: How to handle duplicates - - Returns: - UploadResult with success status and details - """ - try: - logger.info(f"Processing file: {source_path.name} -> {target_filename}") - - # Check session duplicate before processing - session_duplicate = detector.check_session_duplicate( - source_path, target_filename - ) - if session_duplicate: - logger.info(f"Session duplicate detected for {target_filename}, skipping") - return UploadResult( - source_path=str(source_path), - target_filename=target_filename, - success=True, - result="Skipped - already processed in this session", - was_duplicate=True, - duplicate_action="skipped", - existing_url=None, - ) - - # Calculate local file checksum for new files - logger.info(f"Calculating checksum for {source_path.name}...") - local_checksum = calculate_sha256(source_path) - - # Initialize variable to track file deletions - files_deleted = 0 - - # Check remote duplicate before upload - remote_duplicate = detector.check_remote_duplicate( - package_name, version, target_filename, local_checksum - ) - - if remote_duplicate: - logger.info(f"Remote duplicate detected for {target_filename}") - try: - # Handle duplicates according to policy - action, result, deleted_count = handle_duplicate( - duplicate_policy, remote_duplicate, detector, gl, project_id - ) - - if action == "skipped": - return UploadResult( - source_path=str(source_path), - target_filename=target_filename, - success=True, - result=result, # This is the download URL - was_duplicate=True, - duplicate_action="skipped", - existing_url=result, - ) - elif action == "replaced": - # Track deletions and continue with upload after deletion - files_deleted = deleted_count - logger.info( - f"Proceeding with upload after replacing {target_filename}" - ) - # If action is "error", handle_duplicate will raise an exception - - except ValueError as e: - return UploadResult( - source_path=str(source_path), - target_filename=target_filename, - success=False, - result=str(e), - was_duplicate=True, - duplicate_action="error", - ) - else: - # No remote duplicate found, delete existing files with same name for replacement - files_deleted = delete_existing_files( - gl=gl, - project_id=project_id, - package_name=package_name, - version=version, - filename=target_filename, - ) - - # Upload file with retry logic - upload_success = upload_file_with_retry( - gl=gl, - project_id=project_id, - file_path=source_path, - package_name=package_name, - version=version, - target_filename=target_filename, - ) - - if not upload_success: - return UploadResult( - source_path=str(source_path), - target_filename=target_filename, - success=False, - result="Upload failed after all retry attempts", - ) - - # Validate upload - logger.info(f"Validating upload for {target_filename}...") - validation_success = validate_upload( - gl=gl, - project_id=project_id, - package_name=package_name, - version=version, - filename=target_filename, - expected_sha256=local_checksum, - ) - - if not validation_success: - return UploadResult( - source_path=str(source_path), - target_filename=target_filename, - success=False, - result="Upload validation failed", - ) - - # Generate download URL - download_url = ( - f"{gitlab_url}/api/v4/projects/{project_id}/packages/generic/" - f"{package_name}/{version}/{target_filename}" - ) - - # Register successfully processed files - detector.register_file(source_path, target_filename, local_checksum) - - logger.info(f"Successfully uploaded {target_filename}") - - # Check if this was a replaced duplicate - # A file is considered replaced if either: - # 1. A remote duplicate was found (same name + same checksum), OR - # 2. Files were deleted (same name, possibly different checksum) - was_replaced_duplicate = (remote_duplicate is not None) or (files_deleted > 0) - replaced_existing_url = ( - remote_duplicate.download_url if remote_duplicate else None - ) - - return UploadResult( - source_path=str(source_path), - target_filename=target_filename, - success=True, - result=download_url, - was_duplicate=was_replaced_duplicate, - duplicate_action="replaced" if was_replaced_duplicate else None, - existing_url=replaced_existing_url, - ) - - except Exception as e: - error_msg = f"Unexpected error: {str(e)}" - logger.error(error_msg) - return UploadResult( - source_path=str(source_path), - target_filename=target_filename, - success=False, - result=error_msg, - ) - - -def main() -> None: - """Main function to handle argument parsing and orchestrate the multi-file upload process.""" - parser = argparse.ArgumentParser( - description="Upload single or multiple files to GitLab generic package registry with checksum validation and copy-paste friendly URL reporting. " - "Automatically detects GitLab project from Git repository when no project is specified." - ) - - # Required arguments - parser.add_argument( - "--package-name", - type=str, - required=True, - help="Package name in GitLab generic package registry", - ) - parser.add_argument("--version", type=str, required=True, help="Package version") - - # Mutually exclusive project specification group (now optional due to auto-detection) - project_group = parser.add_mutually_exclusive_group(required=False) - project_group.add_argument( - "--project-url", - type=str, - help="GitLab project URL (e.g., https://gitlab.com/namespace/project). If not specified, will attempt auto-detection from Git repository.", - ) - project_group.add_argument( - "--project-path", - type=str, - help="GitLab project path (e.g., namespace/project). If not specified, will attempt auto-detection from Git repository.", - ) - - # Mutually exclusive file input group - file_input_group = parser.add_mutually_exclusive_group(required=True) - file_input_group.add_argument( - "--files", - type=str, - nargs="+", - help="One or more file paths to upload (mutually exclusive with --directory)", - ) - file_input_group.add_argument( - "--directory", - type=str, - help="Directory path to upload all top-level files (mutually exclusive with --files)", - ) - - # Optional arguments - parser.add_argument( - "--file-mapping", - type=str, - action="append", - help="Map source file to target filename (format: local.bin:remote.bin). " - "Can be specified multiple times. Only valid with --files.", - ) - parser.add_argument( - "--token", - type=str, - default=None, - help="GitLab private token (takes precedence over GITLAB_TOKEN env var)", - ) - parser.add_argument( - "--gitlab-url", - type=str, - default=DEFAULT_GITLAB_URL, - help=f"GitLab instance URL (default: {DEFAULT_GITLAB_URL})", - ) - parser.add_argument( - "--duplicate-policy", - type=str, - choices=["skip", "replace", "error"], - default="skip", - help="How to handle duplicate files: skip (default), replace, or error", - ) - parser.add_argument( - "--json-output", - action="store_true", - help="Output results in JSON format for machine parsing (useful for testing and automation)", - ) - - args = parser.parse_args() - - # Validate that --file-mapping is only used with --files - if args.file_mapping and not args.files: - parser.error("--file-mapping can only be used with --files") - - logger.info(f"Package: {args.package_name}, Version: {args.version}") - - try: - # Validate configuration and dependencies - logger.info("Validating configuration and dependencies...") - validate_configuration( - token=args.token, - gitlab_url=args.gitlab_url, - require_git=not ( - args.project_url or args.project_path - ), # Git required only for auto-detection - working_directory=".", - ) - - # Collect files to upload - logger.info("Collecting files to upload...") - files_to_upload, file_errors = collect_files_to_upload(args) - - # Handle file validation errors - if file_errors: - if not files_to_upload: - # All files are invalid - exit with error - logger.error(f"All {len(file_errors)} file(s) failed validation") - if args.json_output: - error_json = { - "success": False, - "exit_code": 1, - "package_name": args.package_name, - "version": args.version, - "successful_uploads": [], - "skipped_duplicates": [], - "failed_uploads": file_errors, - "error": f"All {len(file_errors)} file(s) failed validation", - "error_type": "FileValidationError", - "statistics": { - "total_processed": len(file_errors), - "new_uploads": 0, - "replaced_duplicates": 0, - "skipped_duplicates": 0, - "failed_uploads": len(file_errors), - }, - } - print(json.dumps(error_json, indent=2)) - else: - for error in file_errors: - logger.error(f"{error['error_message']}") - sys.exit(1) - else: - # Some files are valid - log warnings and continue - logger.warning( - f"{len(file_errors)} file(s) failed validation, continuing with {len(files_to_upload)} valid file(s)" - ) - for error in file_errors: - logger.warning(f"{error['error_message']}") - - if not files_to_upload: - logger.error("No files to upload") - sys.exit(1) - - logger.info(f"Found {len(files_to_upload)} file(s) to upload") - - # Get authentication token - token = get_gitlab_token(args.token) - - # Initialize GitLab client - logger.info(f"Connecting to GitLab at {args.gitlab_url}") - try: - gl = Gitlab(args.gitlab_url, private_token=token) - gl.auth() - logger.info("Authentication successful") - except GitlabAuthenticationError as e: - context = { - "project_path": "authentication", - "gitlab_url": args.gitlab_url, - "operation": "GitLab authentication", - } - enhanced_message = enhance_error_message(e, context) - logger.error(enhanced_message) - sys.exit(1) - except Exception as e: - context = { - "project_path": "connection", - "gitlab_url": args.gitlab_url, - "operation": "GitLab connection", - } - enhanced_message = enhance_error_message(e, context) - logger.error(enhanced_message) - sys.exit(1) - - # Determine project ID with precedence logic - project_id = None - resolved_gitlab_url = args.gitlab_url - - try: - # Validate and parse project input (URL or path) - input_gitlab_url, project_path = validate_project_input(args) - - if input_gitlab_url and project_path: - # Project URL or path provided - use dynamic project resolution - logger.info( - "Project URL/path provided - using dynamic project resolution" - ) - - # Update GitLab URL if different from CLI argument - if input_gitlab_url != args.gitlab_url: - logger.info( - f"Updating GitLab URL from {args.gitlab_url} to {input_gitlab_url}" - ) - resolved_gitlab_url = input_gitlab_url - # Re-initialize GitLab client with correct URL - try: - gl = Gitlab(input_gitlab_url, private_token=token) - gl.auth() - logger.info(f"Re-authenticated with {input_gitlab_url}") - except Exception as e: - context = { - "project_path": "authentication", - "gitlab_url": input_gitlab_url, - "operation": "GitLab re-authentication", - } - enhanced_message = enhance_error_message(e, context) - logger.error(enhanced_message) - sys.exit(1) - - # Create ProjectResolver and resolve project ID - resolver = ProjectResolver(gl) - project_id = resolver.resolve_project_id(input_gitlab_url, project_path) - - # Validate project access - if not resolver.validate_project_access(project_id): - raise ValueError( - f"Access validation failed for project {project_path}" - ) - - logger.info( - f"Successfully resolved project ID: {project_id} for {project_path}" - ) - - else: - # No project URL/path provided and auto-detection failed - logger.error("Project specification required - auto-detection failed") - - # Try to provide more specific guidance based on what we found - detector = GitAutoDetector() - try: - repo = detector.find_git_repository() - if not repo: - # No Git repository found - error_msg = ( - "No project could be determined - Git auto-detection failed.\n\n" - "REASON: No Git repository found in current directory or parent directories.\n\n" - "SOLUTION: Please specify project information using one of:\n" - " --project-url https://gitlab.com/namespace/project\n" - " --project-path namespace/project\n\n" - "EXAMPLES of valid project specifications:\n" - " • --project-url https://gitlab.com/mycompany/my-project\n" - " • --project-url https://gitlab.example.com/team/backend-api\n" - " • --project-path mycompany/my-project\n" - " • --project-path group/subgroup/project-name\n\n" - "ALTERNATIVE: To enable auto-detection in the future:\n" - " 1. Initialize Git repository: git init\n" - " 2. Add GitLab remote: git remote add origin \n" - " 3. Or clone from GitLab: git clone \n\n" - "TROUBLESHOOTING:\n" - " • Ensure you're running from within a Git repository\n" - " • Check if .git directory exists: ls -la .git\n" - " • Verify Git is installed: git --version" - ) - else: - # Git repository found but no GitLab remotes - try: - detector.get_gitlab_remotes(repo) - # This should not happen since we're in the else block, but just in case - error_msg = ( - "No project could be determined - Git auto-detection failed unexpectedly.\n\n" - "Please specify project information using:\n" - " --project-url https://gitlab.com/namespace/project\n" - " --project-path namespace/project" - ) - except ValueError as remote_error: - # get_gitlab_remotes raised a detailed error - use it - error_msg = ( - "No project could be determined - Git auto-detection failed.\n\n" - f"REASON: {str(remote_error)}\n\n" - "SOLUTION: Please specify project information using one of:\n" - " --project-url https://gitlab.com/namespace/project\n" - " --project-path namespace/project\n\n" - "EXAMPLES of valid project specifications:\n" - " • --project-url https://gitlab.com/mycompany/my-project\n" - " • --project-url https://gitlab.example.com/team/backend-api\n" - " • --project-path mycompany/my-project\n" - " • --project-path group/subgroup/project-name" - ) - except ValueError as git_error: - # find_git_repository raised a detailed error - use it - error_msg = ( - "No project could be determined - Git auto-detection failed.\n\n" - f"REASON: {str(git_error)}\n\n" - "SOLUTION: Please specify project information using one of:\n" - " --project-url https://gitlab.com/namespace/project\n" - " --project-path namespace/project\n\n" - "EXAMPLES of valid project specifications:\n" - " • --project-url https://gitlab.com/mycompany/my-project\n" - " • --project-url https://gitlab.example.com/team/backend-api\n" - " • --project-path mycompany/my-project\n" - " • --project-path group/subgroup/project-name" - ) - except Exception: - # Generic fallback error message - error_msg = ( - "No project could be determined - Git auto-detection failed.\n\n" - "REASON: Unable to detect GitLab project from Git repository.\n\n" - "SOLUTION: Please specify project information using one of:\n" - " --project-url https://gitlab.com/namespace/project\n" - " --project-path namespace/project\n\n" - "EXAMPLES of valid project specifications:\n" - " • --project-url https://gitlab.com/mycompany/my-project\n" - " • --project-url https://gitlab.example.com/team/backend-api\n" - " • --project-path mycompany/my-project\n" - " • --project-path group/subgroup/project-name\n\n" - "TROUBLESHOOTING Git auto-detection:\n" - " 1. Ensure you're in a Git repository: git status\n" - " 2. Check Git remotes: git remote -v\n" - " 3. Verify remotes point to GitLab: git remote get-url origin\n" - " 4. Add GitLab remote if missing: git remote add origin \n\n" - "SUPPORTED Git remote formats:\n" - " • HTTPS: https://gitlab.com/namespace/project.git\n" - " • SSH: git@gitlab.com:namespace/project.git\n" - " • Custom GitLab: https://gitlab.example.com/namespace/project.git" - ) - - raise ValueError(error_msg) - - except ValueError as e: - logger.error(f"Project resolution failed: {e}") - sys.exit(1) - - # Create detector instance with resolved project ID - detector = DuplicateDetector(gl, project_id) - duplicate_policy = DuplicatePolicy(args.duplicate_policy) - logger.info(f"Using duplicate handling policy: {duplicate_policy.value}") - - # Fetch project details to get human-readable path - project = gl.projects.get(project_id) - project_path = project.path_with_namespace - logger.info(f"Uploading to project {project_path}") - - # Initialize result lists to track duplicate status - successful_uploads: list[ - UploadResult - ] = [] # Successfully uploaded files (new uploads) - failed_uploads: list[UploadResult] = [] # Failed uploads - skipped_duplicates: list[UploadResult] = [] # Files skipped due to duplication - - # Process each file - for source_path, target_filename in files_to_upload: - result = process_single_file( - gl=gl, - project_id=project_id, - source_path=source_path, - target_filename=target_filename, - package_name=args.package_name, - version=args.version, - gitlab_url=resolved_gitlab_url, - detector=detector, - duplicate_policy=duplicate_policy, - ) - - # Categorize results based on success and duplicate status - if result.success: - if result.was_duplicate and result.duplicate_action == "skipped": - # Track skipped duplicates with existing URLs - skipped_duplicates.append(result) - logger.info( - f"Categorized as skipped duplicate: {result.target_filename}" - ) - else: - # Track successful uploads (including replaced duplicates) - successful_uploads.append(result) - logger.info( - f"Categorized as successful upload: {result.target_filename}" - ) - else: - # Track failed uploads (including duplicate policy errors) - failed_uploads.append(result) - logger.info(f"Categorized as failed upload: {result.target_filename}") - - # Output results based on format preference - if args.json_output: - # JSON output mode - merge file_errors into failed_uploads - all_failed_uploads = failed_uploads + [ - UploadResult( - source_path=error["source_path"], - target_filename=error["target_filename"], - success=False, - result=error["error_message"], - ) - for error in file_errors - ] - result_dict = format_results_as_json( - package_name=args.package_name, - version=args.version, - successful_uploads=successful_uploads, - skipped_duplicates=skipped_duplicates, - failed_uploads=all_failed_uploads, - ) - print(json.dumps(result_dict, indent=2)) - sys.exit(result_dict["exit_code"]) - else: - # Rich console output mode (existing code) - # Print summary table with enhanced duplicate detection reporting - console.print("\n[bold]Upload Summary[/bold]\n") - - if successful_uploads: - console.print("[bold green]✓ Successful Uploads[/bold green]\n") - for result in successful_uploads: - console.print(f"[cyan]Source File:[/cyan] {result.source_path}") - console.print( - f"[cyan]Target Filename:[/cyan] {result.target_filename}" - ) - console.print( - f"[cyan]Download URL:[/cyan] [blue]{result.result}[/blue]" - ) - - # Show if this was a replaced duplicate - if result.was_duplicate and result.duplicate_action == "replaced": - console.print( - "[cyan]Action:[/cyan] [yellow]Replaced existing duplicate[/yellow]" - ) - if result.existing_url: - console.print( - f"[cyan]Previous URL:[/cyan] [dim]{result.existing_url}[/dim]" - ) - - console.print() # Add blank line between entries - - if skipped_duplicates: - console.print("[bold yellow]⚠ Skipped Duplicates[/bold yellow]\n") - for result in skipped_duplicates: - console.print(f"[cyan]Source File:[/cyan] {result.source_path}") - console.print( - f"[cyan]Target Filename:[/cyan] {result.target_filename}" - ) - console.print( - f"[cyan]Existing URL:[/cyan] [blue]{result.existing_url or result.result}[/blue]" - ) - console.print(f"[cyan]Reason:[/cyan] {result.result}") - console.print() # Add blank line between entries - - if failed_uploads: - console.print("[bold red]✗ Failed Uploads[/bold red]\n") - for result in failed_uploads: - console.print(f"[cyan]Source File:[/cyan] {result.source_path}") - console.print( - f"[cyan]Target Filename:[/cyan] {result.target_filename}" - ) - console.print(f"[cyan]Error:[/cyan] [red]{result.result}[/red]") - if result.was_duplicate: - console.print( - f"[cyan]Duplicate Action:[/cyan] {result.duplicate_action}" - ) - if result.existing_url: - console.print( - f"[cyan]Existing URL:[/cyan] [blue]{result.existing_url}[/blue]" - ) - console.print() # Add blank line between entries - - # Print comprehensive statistics including duplicate detection - total_processed = ( - len(successful_uploads) + len(skipped_duplicates) + len(failed_uploads) - ) - replaced_count = sum( - 1 - for r in successful_uploads - if r.was_duplicate and r.duplicate_action == "replaced" - ) - new_uploads_count = len(successful_uploads) - replaced_count - - console.print("\n[bold]Duplicate Detection Statistics:[/bold]") - console.print(f"• New uploads: {new_uploads_count}") - console.print(f"• Replaced duplicates: {replaced_count}") - console.print(f"• Skipped duplicates: {len(skipped_duplicates)}") - console.print(f"• Failed uploads: {len(failed_uploads)}") - console.print(f"• Total processed: {total_processed}") - - # Print final status distinguishing uploaded vs skipped - console.print( - f"\n[bold]Final Results:[/bold] {len(successful_uploads)} uploaded " - f"({new_uploads_count} new, {replaced_count} replaced), " - f"{len(skipped_duplicates)} skipped duplicates, " - f"{len(failed_uploads)} failed out of {total_processed} total" - ) - - # Exit with appropriate code - if failed_uploads: - sys.exit(1) - else: - replaced_count = sum( - 1 - for r in successful_uploads - if r.was_duplicate and r.duplicate_action == "replaced" - ) - new_uploads_count = len(successful_uploads) - replaced_count - - console.print( - f"\n[bold green]✓[/bold green] All files processed successfully for {args.package_name} v{args.version}: " - f"{new_uploads_count} new uploads, {replaced_count} replaced duplicates, " - f"{len(skipped_duplicates)} skipped duplicates" - ) - sys.exit(0) - - except ValueError as e: - if args.json_output: - error_json = { - "success": False, - "exit_code": 1, - "error": str(e), - "error_type": "ValueError", - } - print(json.dumps(error_json, indent=2)) - else: - logger.error(str(e)) - sys.exit(1) - except FileNotFoundError as e: - if args.json_output: - error_json = { - "success": False, - "exit_code": 1, - "error": str(e), - "error_type": "FileNotFoundError", - } - print(json.dumps(error_json, indent=2)) - else: - logger.error(str(e)) - sys.exit(1) - except GitlabAuthenticationError as e: - if args.json_output: - error_json = { - "success": False, - "exit_code": 1, - "error": f"GitLab authentication failed: {e}", - "error_type": "GitlabAuthenticationError", - } - print(json.dumps(error_json, indent=2)) - else: - logger.error(f"GitLab authentication failed: {e}") - sys.exit(1) - except Exception as e: - if args.json_output: - error_json = { - "success": False, - "exit_code": 1, - "error": str(e), - "error_type": type(e).__name__, - } - print(json.dumps(error_json, indent=2)) - else: - logger.error(f"Unexpected error: {e}") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/gitlab_common.py b/gitlab_common.py deleted file mode 100644 index a78a245..0000000 --- a/gitlab_common.py +++ /dev/null @@ -1,1931 +0,0 @@ -#!/usr/bin/env python3 -""" -GitLab Common Module - -Shared functionality for GitLab repository detection, project resolution, and error handling. -This module provides common classes and functions used by both the upload script and test script -to eliminate code duplication and ensure consistent behavior. - -Classes: - - GitAutoDetector: Handles automatic Git project detection - - ProjectResolver: Resolves GitLab project IDs from URLs or paths - - GitRemoteInfo: Represents Git remote information - - ProjectInfo: Represents parsed project information - -Functions: - - parse_https_git_url: Parse HTTPS Git URLs - - parse_ssh_git_url: Parse SSH Git URLs - - is_gitlab_url: Check if URL is a GitLab instance - - get_gitlab_token: Get GitLab token from environment or CLI - - validate_project_input: Validate project input with auto-detection fallback - - enhance_error_message: Enhance error messages with context - - handle_*_error: Specific error handling functions - - setup_logging: Configure consistent logging across scripts -""" - -import argparse -import logging -import os -import time -from dataclasses import dataclass -from typing import Optional -from urllib.parse import urlparse - -import git - -# Constants -DEFAULT_GITLAB_URL = "https://gitlab.com" -MAX_RETRIES = 3 -RETRY_DELAYS = [1, 2, 4] # Exponential backoff in seconds -RATE_LIMIT_RETRY_DELAY = 60 # Seconds to wait for rate limit reset - -# Setup logging -logger = logging.getLogger(__name__) - - -# Logging Configuration - - -def setup_logging(console=None, level=logging.INFO, verbose=False): - """ - Setup consistent logging configuration across both scripts. - - Args: - console: Rich console instance (optional) - level: Logging level (default: INFO) - verbose: Enable verbose logging (sets level to DEBUG) - """ - if verbose: - level = logging.DEBUG - - # Only configure if not already configured - if not logging.getLogger().handlers: - try: - from rich.console import Console - from rich.logging import RichHandler - - if console is None: - console = Console() - - logging.basicConfig( - level=level, - format="%(message)s", - handlers=[RichHandler(console=console, rich_tracebacks=True)], - ) - except ImportError: - # Fallback to basic logging if rich is not available - logging.basicConfig( - level=level, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - ) - else: - # Update existing logger level - logging.getLogger().setLevel(level) - - if verbose: - logger.debug("Verbose logging enabled") - - -# Data Models - - -@dataclass -class ProjectInfo: - """Represents parsed project information from URLs.""" - - gitlab_url: str # Base GitLab instance URL - namespace: str # Project namespace/group - project_name: str # Project name - project_path: str # Full project path (namespace/project) - original_url: str # Original URL provided by user - - -@dataclass -class ProjectResolutionResult: - """Represents the result of project ID resolution.""" - - success: bool - project_id: Optional[int] - error_message: Optional[str] - project_info: Optional[ProjectInfo] - gitlab_url: str - - -@dataclass -class GitRemoteInfo: - """Represents Git remote information.""" - - name: str # Remote name (e.g., 'origin') - url: str # Remote URL - gitlab_url: str # Extracted GitLab instance URL - project_path: str # Extracted project path - - -# Git Repository Detection Classes - - -class GitAutoDetector: - """Handles automatic Git project detection.""" - - def __init__(self, working_directory: str = "."): - """Initialize with working directory.""" - self.working_directory = working_directory - - def find_git_repository(self) -> Optional[git.Repo]: - """ - Find Git repository in current or parent directories. - - Returns: - Git repository object if found, None otherwise - - Raises: - ValueError: If Git repository access fails due to permissions or corruption - """ - try: - # GitPython's Repo.search_parent_directories will find the repo - # in the current directory or any parent directory - repo = git.Repo(self.working_directory, search_parent_directories=True) - logger.debug(f"Found Git repository at: {repo.working_dir}") - return repo - except git.InvalidGitRepositoryError: - logger.debug( - f"No Git repository found starting from: {self.working_directory}" - ) - return None - except PermissionError as e: - error_msg = ( - "Permission denied accessing Git repository.\n\n" - "Please check the following:\n" - " • You have read permissions for the current directory and parent directories\n" - " • The .git directory is accessible\n" - " • No file system restrictions are blocking access\n\n" - f"Working directory: {self.working_directory}\n" - f"Error details: {e}\n\n" - "To resolve this issue:\n" - f" 1. Check directory permissions: ls -la {self.working_directory}\n" - " 2. Ensure you can read the .git directory\n" - " 3. Try running from a different directory with proper permissions\n" - " 4. Use --project-url or --project-path to specify project manually" - ) - raise ValueError(error_msg) - except git.GitCommandError as e: - error_msg = ( - "Git command failed while searching for repository.\n\n" - "This may indicate:\n" - " • Corrupted Git repository\n" - " • Git is not properly installed\n" - " • File system issues\n\n" - f"Working directory: {self.working_directory}\n" - f"Git error: {e}\n\n" - "To resolve this issue:\n" - " 1. Verify Git installation: git --version\n" - " 2. Check repository integrity: git fsck (from repository root)\n" - " 3. Try re-cloning the repository if corrupted\n" - " 4. Use --project-url or --project-path to specify project manually" - ) - raise ValueError(error_msg) - except OSError as e: - error_msg = ( - "File system error while searching for Git repository.\n\n" - "This may indicate:\n" - " • Network drive connectivity issues\n" - " • Disk space or I/O problems\n" - " • Path length limitations\n\n" - f"Working directory: {self.working_directory}\n" - f"System error: {e}\n\n" - "To resolve this issue:\n" - " 1. Check disk space and file system health\n" - " 2. Verify network drive connectivity (if applicable)\n" - " 3. Try from a local directory with shorter path\n" - " 4. Use --project-url or --project-path to specify project manually" - ) - raise ValueError(error_msg) - except Exception as e: - error_msg = ( - "Unexpected error while searching for Git repository.\n\n" - f"Working directory: {self.working_directory}\n" - f"Error: {e}\n\n" - "To resolve this issue:\n" - " 1. Ensure Git is properly installed and accessible\n" - " 2. Check that the current directory is accessible\n" - " 3. Try running from a different directory\n" - " 4. Use --project-url or --project-path to specify project manually" - ) - raise ValueError(error_msg) - - def get_gitlab_remotes(self, repo: git.Repo) -> list[GitRemoteInfo]: - """ - Extract GitLab remotes from repository. - - Args: - repo: Git repository object - - Returns: - List of GitRemoteInfo objects for GitLab remotes - - Raises: - ValueError: If remote access fails or no GitLab remotes are found - """ - gitlab_remotes = [] - all_remotes = [] - - try: - # First, try to get all remotes - try: - remotes_list = list(repo.remotes) - if not remotes_list: - error_msg = ( - "No Git remotes found in repository.\n\n" - f"Repository location: {repo.working_dir}\n\n" - "This usually means:\n" - " • Repository was created locally without remotes\n" - " • Repository was cloned but remotes were removed\n" - " • Repository is in an incomplete state\n\n" - "To resolve this issue:\n" - " 1. Add a GitLab remote: git remote add origin \n" - " 2. Check existing remotes: git remote -v\n" - " 3. Clone from GitLab if this is a local-only repository\n" - " 4. Use --project-url or --project-path to specify project manually\n\n" - "Examples of adding GitLab remotes:\n" - " • git remote add origin https://gitlab.com/namespace/project.git\n" - " • git remote add origin git@gitlab.com:namespace/project.git" - ) - raise ValueError(error_msg) - except git.GitCommandError as e: - error_msg = ( - f"Failed to read Git remotes from repository.\n\n" - f"Repository location: {repo.working_dir}\n" - f"Git error: {e}\n\n" - f"This may indicate:\n" - f" • Corrupted Git repository\n" - f" • Git configuration issues\n" - f" • File system problems\n\n" - f"To resolve this issue:\n" - f" 1. Check repository integrity: git fsck\n" - f" 2. Verify Git configuration: git config --list\n" - f" 3. Try re-cloning the repository\n" - f" 4. Use --project-url or --project-path to specify project manually" - ) - raise ValueError(error_msg) - - # Process each remote - for remote in remotes_list: - try: - # Get the first URL if multiple URLs exist - if remote.urls: - remote_url = next(iter(remote.urls)) - all_remotes.append(f"{remote.name}: {remote_url}") - logger.debug(f"Found remote '{remote.name}': {remote_url}") - - # Try to parse as GitLab URL - parsed_info = self.parse_git_url(remote_url) - if parsed_info: - gitlab_url, project_path = parsed_info - gitlab_remote = GitRemoteInfo( - name=remote.name, - url=remote_url, - gitlab_url=gitlab_url, - project_path=project_path, - ) - gitlab_remotes.append(gitlab_remote) - logger.debug( - f"Parsed GitLab remote '{remote.name}': {gitlab_url}/{project_path}" - ) - else: - logger.debug( - f"Remote '{remote.name}' is not a GitLab URL: {remote_url}" - ) - else: - logger.warning(f"Remote '{remote.name}' has no URLs configured") - all_remotes.append(f"{remote.name}: ") - - except Exception as e: - logger.warning(f"Error processing remote '{remote.name}': {e}") - all_remotes.append(f"{remote.name}: ") - continue - - # Check if we found any GitLab remotes - if not gitlab_remotes: - error_msg = ( - f"No GitLab remotes found in repository.\n\n" - f"Repository location: {repo.working_dir}\n" - f"Found {len(all_remotes)} remote(s), but none point to GitLab instances:\n" - ) - - for remote_info in all_remotes: - error_msg += f" • {remote_info}\n" - - error_msg += ( - "\nThis usually means:\n" - " • Repository remotes point to GitHub, Bitbucket, or other Git services\n" - " • Repository remotes point to private Git servers that aren't GitLab\n" - " • Remote URLs are malformed or unrecognizable\n\n" - "To resolve this issue:\n" - " 1. Add a GitLab remote: git remote add gitlab \n" - " 2. Change existing remote to GitLab: git remote set-url origin \n" - " 3. Use --project-url or --project-path to specify project manually\n\n" - "Examples of supported GitLab remote formats:\n" - " • HTTPS: https://gitlab.com/namespace/project.git\n" - " • SSH: git@gitlab.com:namespace/project.git\n" - " • Custom GitLab: https://gitlab.example.com/namespace/project.git" - ) - raise ValueError(error_msg) - - logger.info( - f"Found {len(gitlab_remotes)} GitLab remote(s): {[r.name for r in gitlab_remotes]}" - ) - - # Handle multiple GitLab remotes - prioritize 'origin' - if len(gitlab_remotes) > 1: - origin_remote = next( - (r for r in gitlab_remotes if r.name == "origin"), None - ) - if origin_remote: - logger.info("Multiple GitLab remotes found, prioritizing 'origin'") - other_remotes = [ - r.name for r in gitlab_remotes if r.name != "origin" - ] - logger.info(f"Other GitLab remotes available: {other_remotes}") - return [origin_remote] - else: - # No 'origin' remote, use first one but warn about the choice - selected_remote = gitlab_remotes[0] - other_remotes = [r.name for r in gitlab_remotes[1:]] - logger.warning( - f"Multiple GitLab remotes found without 'origin'. " - f"Using '{selected_remote.name}', others available: {other_remotes}" - ) - logger.info( - f"To specify a different remote, you can:\n" - f" 1. Rename your preferred remote to 'origin': git remote rename {selected_remote.name} origin\n" - f" 2. Use --project-url or --project-path to specify project manually" - ) - return [selected_remote] - - return gitlab_remotes - - except ValueError: - # Re-raise ValueError exceptions (our custom error messages) - raise - except Exception as e: - error_msg = ( - f"Unexpected error reading Git remotes.\n\n" - f"Repository location: {repo.working_dir}\n" - f"Error: {e}\n\n" - f"To resolve this issue:\n" - f" 1. Check repository integrity: git fsck\n" - f" 2. Verify you can access remotes: git remote -v\n" - f" 3. Try re-cloning the repository\n" - f" 4. Use --project-url or --project-path to specify project manually" - ) - raise ValueError(error_msg) - - def parse_git_url(self, remote_url: str) -> Optional[tuple[str, str]]: - """ - Parse Git URL to extract GitLab URL and project path. - - Args: - remote_url: Git remote URL - - Returns: - Tuple of (gitlab_url, project_path) if successful, None otherwise - - Raises: - ValueError: If URL format is unrecognized but appears to be intended for GitLab - """ - if not remote_url: - return None - - logger.debug(f"Parsing Git URL: {remote_url}") - - # Try parsing as HTTPS URL first - result = parse_https_git_url(remote_url) - if result: - gitlab_url, project_path = result - if is_gitlab_url(gitlab_url): - logger.debug( - f"Successfully parsed HTTPS Git URL: {gitlab_url}/{project_path}" - ) - return result - else: - logger.debug( - f"HTTPS URL parsed but not a GitLab instance: {gitlab_url}" - ) - - # Try parsing as SSH URL - result = parse_ssh_git_url(remote_url) - if result: - gitlab_url, project_path = result - if is_gitlab_url(gitlab_url): - logger.debug( - f"Successfully parsed SSH Git URL: {gitlab_url}/{project_path}" - ) - return result - else: - logger.debug(f"SSH URL parsed but not a GitLab instance: {gitlab_url}") - - # Check if this looks like it might be a GitLab URL but in an unrecognized format - if self._looks_like_gitlab_url(remote_url): - error_msg = ( - f"Unrecognized GitLab URL format: {remote_url}\n\n" - f"This URL appears to be for GitLab but is in an unsupported format.\n\n" - f"Supported GitLab remote formats:\n" - f" • HTTPS: https://gitlab.com/namespace/project.git\n" - f" • HTTPS (no .git): https://gitlab.com/namespace/project\n" - f" • SSH: git@gitlab.com:namespace/project.git\n" - f" • SSH (no .git): git@gitlab.com:namespace/project\n\n" - f"To resolve this issue:\n" - f" 1. Update remote URL to supported format: git remote set-url origin \n" - f" 2. Use --project-url or --project-path to specify project manually\n\n" - f"Examples of correct formats:\n" - f" • git remote set-url origin https://gitlab.com/namespace/project.git\n" - f" • git remote set-url origin git@gitlab.com:namespace/project.git" - ) - raise ValueError(error_msg) - - logger.debug(f"Could not parse Git URL as GitLab URL: {remote_url}") - return None - - def _looks_like_gitlab_url(self, url: str) -> bool: - """ - Check if URL looks like it might be intended for GitLab but in wrong format. - - Args: - url: URL to check - - Returns: - True if URL appears to be GitLab-related but unparseable - """ - if not url: - return False - - url_lower = url.lower() - - # Check for GitLab-related keywords in the URL - gitlab_indicators = [ - "gitlab.com", - "gitlab.", - ".gitlab.", - "git.lab", - ] - - return any(indicator in url_lower for indicator in gitlab_indicators) - - -class ProjectResolver: - """Core component responsible for parsing URLs and resolving project IDs from GitLab API.""" - - def __init__(self, gitlab_client): - """ - Initialize ProjectResolver with GitLab client. - - Args: - gitlab_client: Authenticated GitLab client - """ - self.gl = gitlab_client - self.project_cache: dict[str, int] = {} - - def parse_project_url(self, url: str) -> ProjectInfo: - """ - Parse GitLab project URL into components. - - Args: - url: GitLab project URL to parse - - Returns: - ProjectInfo with parsed components - - Raises: - ValueError: If URL format is invalid - """ - if not url or not isinstance(url, str): - raise ValueError("URL must be a non-empty string") - - # Normalize URL - remove trailing slashes - normalized_url = url.rstrip("/") - - try: - parsed = urlparse(normalized_url) - except Exception as e: - raise ValueError(f"Invalid URL format: {e}") - - # Validate protocol - if parsed.scheme not in ["http", "https"]: - raise ValueError( - f"Unsupported protocol '{parsed.scheme}'. Only HTTP and HTTPS are supported." - ) - - # Validate that we have a hostname - if not parsed.netloc: - raise ValueError("URL must include a hostname") - - # Extract path components - path = parsed.path.strip("/") - if not path: - raise ValueError("URL must include a project path") - - # Split path into components - path_parts = path.split("/") - if len(path_parts) < 2: - raise ValueError( - "URL must include both namespace and project name (e.g., /namespace/project)" - ) - - # Extract namespace and project name - # Handle cases where there might be additional path components after the project name - namespace = path_parts[0] - project_name = path_parts[1] - - if not namespace or not project_name: - raise ValueError("Both namespace and project name must be non-empty") - - # Construct GitLab instance URL - gitlab_url = f"{parsed.scheme}://{parsed.netloc}" - - # Construct project path - project_path = f"{namespace}/{project_name}" - - return ProjectInfo( - gitlab_url=gitlab_url, - namespace=namespace, - project_name=project_name, - project_path=project_path, - original_url=url, - ) - - def resolve_project_id(self, gitlab_url: str, project_path: str) -> int: - """ - Resolve project ID from GitLab API with enhanced retry logic. - - Args: - gitlab_url: GitLab instance URL - project_path: Project path (namespace/project) - - Returns: - Numeric project ID - - Raises: - ValueError: If project cannot be resolved - """ - # Check cache first - cache_key = f"{gitlab_url}/{project_path}" - if cache_key in self.project_cache: - logger.info(f"Using cached project ID for {project_path}") - return self.project_cache[cache_key] - - logger.info(f"Resolving project ID for {project_path} from {gitlab_url}") - - def _resolve_project(): - """Internal function to resolve project ID.""" - project = self.gl.projects.get(project_path) - return project.id - - try: - project_id = handle_network_error_with_retry( - operation_name=f"Project resolution for {project_path}", - operation_func=_resolve_project, - ) - - # Cache the result - self.project_cache[cache_key] = project_id - logger.info( - f"Successfully resolved project ID {project_id} for {project_path}" - ) - return project_id - - except Exception as e: - # Use enhanced error handling with context - context = { - "project_path": project_path, - "gitlab_url": gitlab_url, - "operation": "project resolution", - } - enhanced_message = enhance_error_message(e, context) - raise ValueError(enhanced_message) - - def validate_project_access(self, project_id: int) -> bool: - """ - Validate that the user has access to the project. - - Args: - project_id: GitLab project ID - - Returns: - True if user has access, False otherwise - """ - try: - logger.debug(f"Validating access to project ID {project_id}") - - def _validate_access(): - """Internal function to validate project access.""" - project = self.gl.projects.get(project_id) - # Check if we can access basic project information - project_name = getattr(project, "name", None) - project_path = getattr(project, "path_with_namespace", None) - return project_name, project_path - - project_name, project_path = handle_network_error_with_retry( - operation_name=f"Access validation for project ID {project_id}", - operation_func=_validate_access, - ) - - if project_name and project_path: - logger.info( - f"Access validated for project: {project_path} (ID: {project_id})" - ) - return True - else: - logger.warning( - f"Project {project_id} exists but has limited metadata access" - ) - return False - - except Exception as e: - error_msg = str(e) - logger.warning( - f"Access validation failed for project ID {project_id}: {error_msg}" - ) - - # Provide specific guidance based on error type - context = { - "project_path": f"project ID {project_id}", - "gitlab_url": self.gl.api_url.replace("/api/v4", ""), - "operation": "access validation", - } - - enhanced_message = enhance_error_message(e, context) - logger.error(enhanced_message) - - return False - - -# URL Parsing Functions - - -def parse_https_git_url(url: str) -> Optional[tuple[str, str]]: - """ - Parse HTTPS Git URL to extract GitLab URL and project path. - - Args: - url: HTTPS Git URL (e.g., https://gitlab.com/namespace/project.git) - - Returns: - Tuple of (gitlab_url, project_path) if successful, None otherwise - - Note: - Returns None for invalid formats. For detailed error messages, - use the GitAutoDetector.parse_git_url method which provides - comprehensive error handling. - """ - if not url or not isinstance(url, str): - return None - - try: - parsed = urlparse(url.strip()) - - # Validate HTTPS protocol - if parsed.scheme != "https": - return None - - # Validate hostname exists - if not parsed.netloc: - return None - - # Extract path and remove leading/trailing slashes - path = parsed.path.strip("/") - if not path: - return None - - # Remove .git suffix if present - if path.endswith(".git"): - path = path[:-4] - - # Split path into components - path_parts = path.split("/") - if len(path_parts) < 2: - return None - - # Use all path components to construct the full project path - # This handles multi-level paths like "LinaroLtd/iotil/meta-onelab" - project_path = "/".join(path_parts) - - # Construct GitLab instance URL - gitlab_url = f"{parsed.scheme}://{parsed.netloc}" - - return gitlab_url, project_path - - except Exception: - return None - - -def parse_ssh_git_url(url: str) -> Optional[tuple[str, str]]: - """ - Parse SSH Git URL to extract GitLab URL and project path. - - Args: - url: SSH Git URL (e.g., git@gitlab.com:namespace/project.git) - - Returns: - Tuple of (gitlab_url, project_path) if successful, None otherwise - - Note: - Returns None for invalid formats. For detailed error messages, - use the GitAutoDetector.parse_git_url method which provides - comprehensive error handling. - """ - if not url or not isinstance(url, str): - return None - - try: - url = url.strip() - - # Check for SSH format: git@hostname:path - if not url.startswith("git@") or ":" not in url: - return None - - # Split on the first colon to separate host and path - host_part, path_part = url.split(":", 1) - - # Extract hostname from git@hostname - if not host_part.startswith("git@"): - return None - - hostname = host_part[4:] # Remove "git@" prefix - if not hostname: - return None - - # Process path part - path = path_part.strip("/") - if not path: - return None - - # Remove .git suffix if present - if path.endswith(".git"): - path = path[:-4] - - # Split path into components - path_parts = path.split("/") - if len(path_parts) < 2: - return None - - # Use all path components to construct the full project path - # This handles multi-level paths like "LinaroLtd/iotil/meta-onelab" - project_path = "/".join(path_parts) - - # Construct GitLab instance URL (assume HTTPS) - gitlab_url = f"https://{hostname}" - - return gitlab_url, project_path - - except Exception: - return None - - -def is_gitlab_url(url: str) -> bool: - """ - Check if URL is a GitLab instance. - - Args: - url: URL to check - - Returns: - True if URL appears to be a GitLab instance, False otherwise - """ - if not url or not isinstance(url, str): - return False - - try: - parsed = urlparse(url.strip()) - - # Must have valid scheme and hostname - if parsed.scheme not in ["http", "https"] or not parsed.netloc: - return False - - hostname = parsed.netloc.lower() - - # Explicitly exclude known non-GitLab services - non_gitlab_services = [ - "github.com", - "bitbucket.org", - "sourceforge.net", - "codeberg.org", - ] - - for service in non_gitlab_services: - if hostname == service or hostname.endswith(f".{service}"): - return False - - # Check for common GitLab hostnames - gitlab_indicators = [ - "gitlab.com", - "gitlab.", - ".gitlab.", - "git.lab", - ] - - # Check if hostname contains GitLab indicators - for indicator in gitlab_indicators: - if indicator in hostname: - return True - - # If no obvious indicators, assume it could be a GitLab instance - # This is a permissive approach since many organizations use custom domains - return True - - except Exception: - return False - - -# Utility Functions - - -def get_gitlab_token(cli_token: str | None) -> str: - """ - Get GitLab token from environment variable or CLI argument. - - Priority: - 1. CLI argument (--token) - explicit user choice takes precedence - 2. GITLAB_TOKEN environment variable - fallback - - Args: - cli_token: Token provided via CLI argument - - Returns: - GitLab authentication token - - Raises: - ValueError: If no token is provided - """ - if cli_token: - logger.info("Using GitLab token from CLI argument") - return cli_token - - token = os.environ.get("GITLAB_TOKEN") - if token: - logger.info("Using GitLab token from GITLAB_TOKEN environment variable") - return token - - raise ValueError( - "No GitLab token provided. Set GITLAB_TOKEN environment variable or use --token argument" - ) - - -def validate_project_input( - args: argparse.Namespace, -) -> tuple[Optional[str], Optional[str]]: - """ - Validate project URL or path input and return GitLab URL and project path. - Attempts Git auto-detection when no project is explicitly specified. - - Args: - args: Parsed command-line arguments - - Returns: - Tuple of (gitlab_url, project_path) or (None, None) if no project input provided and auto-detection fails - - Raises: - ValueError: If project input is invalid - """ - if args.project_url: - logger.info(f"Validating project URL: {args.project_url}") - validation_result = validate_url_format(args.project_url) - - if not validation_result.is_valid: - raise ValueError(f"Invalid project URL: {validation_result.error_message}") - - gitlab_url = validation_result.parsed_components["gitlab_url"] - project_path = validation_result.parsed_components["project_path"] - - logger.info(f"Parsed GitLab URL: {gitlab_url}") - logger.info(f"Parsed project path: {project_path}") - - return gitlab_url, project_path - - elif args.project_path: - logger.info(f"Validating project path: {args.project_path}") - - try: - normalized_path = normalize_project_path(args.project_path) - logger.info(f"Normalized project path: {normalized_path}") - - # Use the provided GitLab URL or default - gitlab_url = getattr(args, "gitlab_url", DEFAULT_GITLAB_URL) - logger.info(f"Using GitLab URL: {gitlab_url}") - - return gitlab_url, normalized_path - - except ValueError as e: - raise ValueError(f"Invalid project path: {e}") - - else: - # No project URL or path provided - attempt Git auto-detection - logger.info("No project specified, attempting Git auto-detection...") - - try: - detector = GitAutoDetector() - repo = detector.find_git_repository() - - if not repo: - logger.info("No Git repository found for auto-detection") - return None, None - - logger.info(f"Found Git repository at: {repo.working_dir}") - - gitlab_remotes = detector.get_gitlab_remotes(repo) - - if not gitlab_remotes: - logger.info("No GitLab remotes found in repository") - return None, None - - # Use the first (prioritized) GitLab remote - selected_remote = gitlab_remotes[0] - logger.info( - f"Auto-detected project from Git remote '{selected_remote.name}': {selected_remote.gitlab_url}/{selected_remote.project_path}" - ) - - return selected_remote.gitlab_url, selected_remote.project_path - - except Exception as e: - logger.warning(f"Git auto-detection failed: {e}") - return None, None - - -# Network Error Handling Functions - - -def is_network_error(exception: Exception) -> bool: - """ - Determine if an exception is a network-related error that should be retried. - - Args: - exception: Exception to check - - Returns: - True if the exception is a network error that should be retried - """ - try: - from gitlab.exceptions import GitlabError - - # Check for GitLab-specific network errors - if isinstance(exception, GitlabError): - error_msg = str(exception).lower() - # Network-related GitLab errors - if any( - keyword in error_msg - for keyword in [ - "connection", - "timeout", - "network", - "unreachable", - "temporary", - "service unavailable", - "502", - "503", - "504", - ] - ): - return True - except ImportError: - # gitlab module not available, skip GitLab-specific checks - pass - - # Check for generic network-related error messages - error_msg = str(exception).lower() - network_keywords = [ - "connection refused", - "connection reset", - "connection timeout", - "network is unreachable", - "temporary failure", - "service unavailable", - "bad gateway", - "gateway timeout", - "connection aborted", - "connection error", - "timeout", - "dns", - ] - - return any(keyword in error_msg for keyword in network_keywords) - - -def is_rate_limit_error(exception: Exception) -> bool: - """ - Determine if an exception is a rate limiting error. - - Args: - exception: Exception to check - - Returns: - True if the exception is a rate limiting error - """ - error_msg = str(exception).lower() - rate_limit_keywords = [ - "rate limit", - "too many requests", - "429", - "quota exceeded", - "api rate limit exceeded", - "rate limited", - ] - - return any(keyword in error_msg for keyword in rate_limit_keywords) - - -def calculate_retry_delay(attempt: int, base_delays: list[int] = None) -> int: - """ - Calculate retry delay with exponential backoff. - - Args: - attempt: Current attempt number (0-based) - base_delays: List of base delays for exponential backoff - - Returns: - Delay in seconds - """ - if base_delays is None: - base_delays = RETRY_DELAYS - - if attempt < len(base_delays): - return base_delays[attempt] - else: - # For attempts beyond the base delays, use exponential backoff - return base_delays[-1] * (2 ** (attempt - len(base_delays) + 1)) - - -def handle_network_error_with_retry( - operation_name: str, operation_func, max_retries: int = MAX_RETRIES, *args, **kwargs -): - """ - Execute an operation with comprehensive network error handling and retry logic. - - Args: - operation_name: Human-readable name of the operation for logging - operation_func: Function to execute - max_retries: Maximum number of retry attempts - *args: Arguments to pass to operation_func - **kwargs: Keyword arguments to pass to operation_func - - Returns: - Result of operation_func - - Raises: - Exception: If operation fails after all retries - """ - last_exception = None - - for attempt in range(max_retries): - try: - logger.debug(f"{operation_name} attempt {attempt + 1}/{max_retries}") - return operation_func(*args, **kwargs) - - except Exception as e: - last_exception = e - error_msg = str(e) - - logger.warning( - f"{operation_name} attempt {attempt + 1} failed: {error_msg}" - ) - - # Check if this is a rate limit error - if is_rate_limit_error(e): - if attempt < max_retries - 1: - logger.warning( - f"Rate limit detected. Waiting {RATE_LIMIT_RETRY_DELAY} seconds before retry..." - ) - time.sleep(RATE_LIMIT_RETRY_DELAY) - continue - else: - raise ValueError( - f"{operation_name} failed due to rate limiting after {max_retries} attempts. " - f"Please wait before retrying. Last error: {error_msg}" - ) - - # Check if this is a network error that should be retried - elif is_network_error(e): - if attempt < max_retries - 1: - delay = calculate_retry_delay(attempt) - logger.info( - f"Network error detected. Retrying {operation_name} in {delay} seconds..." - ) - time.sleep(delay) - continue - else: - raise ValueError( - f"{operation_name} failed due to persistent network errors after {max_retries} attempts. " - f"Please check your network connection and GitLab instance availability. " - f"Last error: {error_msg}" - ) - - # For non-network errors, don't retry but provide context - else: - raise e - - # This should never be reached, but just in case - if last_exception: - raise last_exception - else: - raise RuntimeError(f"{operation_name} failed unexpectedly") - - -# Specific Error Handling Functions - - -def handle_project_not_found_error( - project_path: str, gitlab_url: str, original_error: str -) -> str: - """ - Generate helpful error message for project not found errors. - - Args: - project_path: Project path that was not found - gitlab_url: GitLab instance URL - original_error: Original error message - - Returns: - Enhanced error message with suggestions - """ - return ( - f"Project '{project_path}' not found at {gitlab_url}.\n\n" - f"Please check the following:\n" - f" • Project path format is correct (should be: namespace/project-name)\n" - f" • Project exists and is accessible at {gitlab_url}\n" - f" • You have permission to view the project\n" - f" • GitLab instance URL is correct\n" - f" • Project is not private (if using public access)\n\n" - f"Examples of valid project paths:\n" - f" • mycompany/my-project\n" - f" • group/subgroup/project-name\n" - f" • username/personal-project\n\n" - f"You can verify the project exists by visiting:\n" - f" {gitlab_url}/{project_path}\n\n" - f"Original error: {original_error}" - ) - - -def handle_authentication_error( - project_path: str, gitlab_url: str, original_error: str -) -> str: - """ - Generate helpful error message for authentication failures. - - Args: - project_path: Project path being accessed - gitlab_url: GitLab instance URL - original_error: Original error message - - Returns: - Enhanced error message with guidance - """ - return ( - f"Authentication failed for project '{project_path}' at {gitlab_url}.\n\n" - f"Please check the following:\n" - f" • GitLab token is valid and not expired\n" - f" • Token has appropriate permissions (minimum: 'read_api' scope)\n" - f" • Token is configured for the correct GitLab instance\n" - f" • You have access to the project '{project_path}'\n" - f" • Project is not private (if token lacks permissions)\n\n" - f"Token configuration:\n" - f" • Set GITLAB_TOKEN environment variable, or\n" - f" • Use --token command line argument\n\n" - f"To create a new token:\n" - f" 1. Visit: {gitlab_url}/-/profile/personal_access_tokens\n" - f" 2. Create token with 'api' or 'read_api' scope\n" - f" 3. Set GITLAB_TOKEN environment variable\n\n" - f"Original error: {original_error}" - ) - - -def handle_permission_error( - project_path: str, gitlab_url: str, operation: str, original_error: str -) -> str: - """ - Generate helpful error message for permission errors. - - Args: - project_path: Project path being accessed - gitlab_url: GitLab instance URL - operation: Operation that failed (e.g., "upload", "read packages") - original_error: Original error message - - Returns: - Enhanced error message with guidance - """ - return ( - f"Permission denied for {operation} in project '{project_path}' at {gitlab_url}.\n\n" - f"Please check the following:\n" - f" • You have the required permissions for this operation\n" - f" • Your GitLab token has sufficient scope (may need 'api' instead of 'read_api')\n" - f" • You are a member of the project with appropriate role\n" - f" • Project settings allow the requested operation\n\n" - f"Required permissions for {operation}:\n" - f" • Package uploads: Developer role or higher\n" - f" • Package downloads: Reporter role or higher\n" - f" • Project access: Guest role or higher\n\n" - f"To check your permissions:\n" - f" 1. Visit: {gitlab_url}/{project_path}/-/project_members\n" - f" 2. Verify your role and permissions\n" - f" 3. Contact project maintainer if access is needed\n\n" - f"Original error: {original_error}" - ) - - -def handle_network_connectivity_error(gitlab_url: str, original_error: str) -> str: - """ - Generate helpful error message for network connectivity issues. - - Args: - gitlab_url: GitLab instance URL - original_error: Original error message - - Returns: - Enhanced error message with troubleshooting steps - """ - return ( - f"Network connectivity issue with GitLab instance at {gitlab_url}.\n\n" - f"Please check the following:\n" - f" • Internet connection is working\n" - f" • GitLab instance URL is correct and accessible\n" - f" • No firewall or proxy blocking the connection\n" - f" • GitLab instance is not experiencing downtime\n\n" - f"Troubleshooting steps:\n" - f" 1. Test connectivity: curl -I {gitlab_url}\n" - f" 2. Check GitLab status page (if available)\n" - f" 3. Try accessing {gitlab_url} in a web browser\n" - f" 4. Verify DNS resolution: nslookup {gitlab_url.replace('https://', '').replace('http://', '')}\n\n" - f"If using a corporate network:\n" - f" • Check proxy settings\n" - f" • Verify SSL certificate trust\n" - f" • Contact IT support if needed\n\n" - f"Original error: {original_error}" - ) - - -def enhance_error_message(error: Exception, context: dict) -> str: - """ - Enhance error messages with context and helpful suggestions. - - Args: - error: Original exception - context: Context dictionary with keys like 'project_path', 'gitlab_url', 'operation' - - Returns: - Enhanced error message - """ - error_msg = str(error).lower() - original_error = str(error) - - project_path = context.get("project_path", "unknown") - gitlab_url = context.get("gitlab_url", "unknown") - operation = context.get("operation", "operation") - - # Handle specific error types - if "404" in error_msg or "not found" in error_msg: - return handle_project_not_found_error(project_path, gitlab_url, original_error) - - elif any( - keyword in error_msg - for keyword in ["401", "403", "authentication", "unauthorized"] - ): - if "permission" in error_msg or "forbidden" in error_msg: - return handle_permission_error( - project_path, gitlab_url, operation, original_error - ) - else: - return handle_authentication_error(project_path, gitlab_url, original_error) - - elif any( - keyword in error_msg - for keyword in [ - "connection", - "network", - "timeout", - "unreachable", - "dns", - "resolve", - ] - ): - return handle_network_connectivity_error(gitlab_url, original_error) - - elif "rate limit" in error_msg or "too many requests" in error_msg: - return ( - f"GitLab API rate limit exceeded.\n\n" - f"Please wait a few minutes before retrying.\n" - f"Rate limits help ensure fair usage of GitLab resources.\n\n" - f"If you frequently hit rate limits:\n" - f" • Reduce the frequency of API calls\n" - f" • Consider using GitLab Premium for higher limits\n" - f" • Contact GitLab support for assistance\n\n" - f"Original error: {original_error}" - ) - - else: - # Generic enhancement with context - return ( - f"Operation failed: {operation}\n" - f"Project: {project_path}\n" - f"GitLab URL: {gitlab_url}\n\n" - f"Error details: {original_error}\n\n" - f"If this error persists:\n" - f" • Check GitLab instance status\n" - f" • Verify your network connection\n" - f" • Review your authentication and permissions\n" - f" • Contact support with the error details above" - ) - - -# Helper functions for URL validation (used by validate_project_input) - - -@dataclass -class URLValidationResult: - """Represents URL parsing and validation results.""" - - is_valid: bool - error_message: Optional[str] - suggested_format: Optional[str] - parsed_components: Optional[dict[str, str]] - - -def parse_gitlab_url(url: str) -> tuple[str, str]: - """ - Extract GitLab instance URL and project path from full URL. - - Args: - url: Full GitLab project URL - - Returns: - Tuple of (gitlab_instance_url, project_path) - - Raises: - ValueError: If URL format is invalid - """ - if not url or not isinstance(url, str): - raise ValueError( - f"URL must be a non-empty string.\n" - f"Attempted URL: '{url}'\n\n" - f"Valid format examples:\n" - f" - https://gitlab.com/namespace/project\n" - f" - http://gitlab.example.com/group/subgroup/project" - ) - - # Normalize URL - remove trailing slashes - normalized_url = url.rstrip("/") - - try: - parsed = urlparse(normalized_url) - except Exception as e: - raise ValueError( - f"Invalid URL format: {e}\n" - f"Attempted URL: '{url}'\n\n" - f"Valid format examples:\n" - f" - https://gitlab.com/namespace/project\n" - f" - http://gitlab.example.com/group/subgroup/project" - ) - - # Validate protocol - if parsed.scheme not in ["http", "https"]: - raise ValueError( - f"Unsupported protocol '{parsed.scheme}'. Only HTTP and HTTPS are supported.\n" - f"Attempted URL: '{url}'\n\n" - f"Valid format examples:\n" - f" - https://gitlab.com/namespace/project\n" - f" - http://gitlab.example.com/group/subgroup/project" - ) - - # Validate that we have a hostname - if not parsed.netloc: - raise ValueError( - f"URL must include a hostname.\n" - f"Attempted URL: '{url}'\n\n" - f"Valid format examples:\n" - f" - https://gitlab.com/namespace/project\n" - f" - http://gitlab.example.com/group/subgroup/project" - ) - - # Extract path components - path = parsed.path.strip("/") - if not path: - raise ValueError( - f"URL must include a project path.\n" - f"Attempted URL: '{url}'\n\n" - f"Valid format examples:\n" - f" - https://gitlab.com/namespace/project\n" - f" - http://gitlab.example.com/group/subgroup/project" - ) - - # Split path into components - path_parts = path.split("/") - if len(path_parts) < 2: - raise ValueError( - f"URL must include both namespace and project name.\n" - f"Attempted URL: '{url}'\n\n" - f"Valid format examples:\n" - f" - https://gitlab.com/namespace/project\n" - f" - http://gitlab.example.com/group/subgroup/project" - ) - - # Extract namespace and project name - namespace = path_parts[0] - project_name = path_parts[1] - - if not namespace or not project_name: - raise ValueError( - f"Both namespace and project name must be non-empty.\n" - f"Attempted URL: '{url}'\n\n" - f"Valid format examples:\n" - f" - https://gitlab.com/namespace/project\n" - f" - http://gitlab.example.com/group/subgroup/project" - ) - - # Construct GitLab instance URL - gitlab_url = f"{parsed.scheme}://{parsed.netloc}" - - # Construct project path - project_path = f"{namespace}/{project_name}" - - return gitlab_url, project_path - - -def validate_url_format(url: str) -> URLValidationResult: - """ - Validate that URL follows expected GitLab project URL format. - - Args: - url: URL to validate - - Returns: - URLValidationResult with validation status and details - """ - try: - gitlab_url, project_path = parse_gitlab_url(url) - return URLValidationResult( - is_valid=True, - error_message=None, - suggested_format=None, - parsed_components={"gitlab_url": gitlab_url, "project_path": project_path}, - ) - except ValueError as e: - return URLValidationResult( - is_valid=False, - error_message=str(e), - suggested_format="https://gitlab.com/namespace/project", - parsed_components=None, - ) - - -def normalize_project_path(path: str) -> str: - """ - Normalize project path handling URL encoding and special characters. - - Args: - path: Project path to normalize - - Returns: - Normalized project path - - Raises: - ValueError: If path format is invalid - """ - if not path or not isinstance(path, str): - raise ValueError( - f"Project path must be a non-empty string.\n" - f"Attempted path: '{path}'\n\n" - f"Valid format examples:\n" - f" - namespace/project\n" - f" - group/subgroup/project" - ) - - # Remove leading/trailing slashes and whitespace - normalized = path.strip().strip("/") - - if not normalized: - raise ValueError( - f"Project path cannot be empty.\n" - f"Attempted path: '{path}'\n\n" - f"Valid format examples:\n" - f" - namespace/project\n" - f" - group/subgroup/project" - ) - - # Split into components - parts = normalized.split("/") - if len(parts) < 2: - raise ValueError( - f"Project path must include both namespace and project name.\n" - f"Attempted path: '{path}'\n\n" - f"Valid format examples:\n" - f" - namespace/project\n" - f" - group/subgroup/project" - ) - - # Validate that all parts are non-empty - for i, part in enumerate(parts): - if not part.strip(): - raise ValueError( - f"Project path component {i + 1} cannot be empty.\n" - f"Attempted path: '{path}'\n\n" - f"Valid format examples:\n" - f" - namespace/project\n" - f" - group/subgroup/project" - ) - - return normalized - - -# Configuration Validation Functions - - -def validate_dependencies() -> None: - """ - Validate that all required dependencies are available. - - Raises: - ValueError: If required dependencies are missing with specific resolution steps - """ - logger.debug("Validating required dependencies...") - - # Check Python version - import sys - - if sys.version_info < (3, 12): - raise ValueError( - f"Python 3.12 or higher is required. Current version: {sys.version}\n\n" - "SOLUTION:\n" - "1. Install Python 3.12 or higher:\n" - " • Ubuntu/Debian: sudo apt update && sudo apt install python3.12\n" - " • macOS: brew install python@3.12\n" - " • Windows: Download from https://python.org/downloads/\n\n" - "2. Use pyenv to manage Python versions:\n" - " • Install pyenv: curl https://pyenv.run | bash\n" - " • Install Python: pyenv install 3.12\n" - " • Set local version: pyenv local 3.12\n\n" - "3. Use uv to run with correct Python version:\n" - " • Install uv: pip install uv\n" - " • Run script: uv run --python 3.12 ./gitlab/gitlab-pkg-upload.py\n\n" - "For more help, see: https://docs.python.org/3/installing/" - ) - - # Check required modules - required_modules = { - "gitlab": "python-gitlab>=4.0.0", - "git": "GitPython>=3.1.0", - "rich": "rich>=13.0.0", - } - - missing_modules = [] - for module_name, package_spec in required_modules.items(): - try: - __import__(module_name) - logger.debug(f"✓ Module {module_name} available") - except ImportError: - missing_modules.append((module_name, package_spec)) - logger.debug(f"✗ Module {module_name} not available") - - if missing_modules: - error_msg = "Required dependencies are not available:\n" - for module_name, package_spec in missing_modules: - error_msg += f" • {module_name} (install: {package_spec})\n" - - error_msg += ( - "\nSOLUTION:\n" - "1. If using uv (recommended):\n" - " • Ensure script has proper shebang: #!/usr/bin/env -S uv run --script\n" - " • Run directly: ./gitlab/gitlab-pkg-upload.py\n" - " • uv will automatically install dependencies\n\n" - "2. Manual installation with pip:\n" - ) - - for module_name, package_spec in missing_modules: - error_msg += f" pip install '{package_spec}'\n" - - error_msg += ( - "\n3. Install all at once:\n" - " pip install python-gitlab>=4.0.0 rich>=13.0.0 GitPython>=3.1.0\n\n" - "4. Using virtual environment (recommended):\n" - " python -m venv venv\n" - " source venv/bin/activate # On Windows: venv\\Scripts\\activate\n" - " pip install python-gitlab>=4.0.0 rich>=13.0.0 GitPython>=3.1.0\n\n" - "TROUBLESHOOTING:\n" - "• Check Python version: python --version\n" - "• Check pip version: pip --version\n" - "• Update pip: pip install --upgrade pip\n" - "• For corporate networks: pip install --trusted-host pypi.org --trusted-host pypi.python.org\n\n" - "For more help: https://packaging.python.org/tutorials/installing-packages/" - ) - - raise ValueError(error_msg) - - logger.debug("✓ All required dependencies are available") - - -def validate_gitlab_token(token: str, gitlab_url: str = DEFAULT_GITLAB_URL) -> None: - """ - Validate GitLab token availability and basic validity. - - Args: - token: GitLab authentication token - gitlab_url: GitLab instance URL - - Raises: - ValueError: If token validation fails with specific resolution steps - """ - logger.debug("Validating GitLab token...") - - if not token or not isinstance(token, str): - raise ValueError( - "GitLab token is required but not provided.\n\n" - "SOLUTION:\n" - "1. Create a GitLab personal access token:\n" - f" • Visit: {gitlab_url}/-/profile/personal_access_tokens\n" - " • Click 'Add new token'\n" - " • Name: 'Package Upload Token'\n" - " • Scopes: Select 'api' (required for package operations)\n" - " • Expiration: Set appropriate date\n" - " • Click 'Create personal access token'\n" - " • Copy the generated token immediately\n\n" - "2. Set the token as environment variable:\n" - " export GITLAB_TOKEN='your-token-here'\n\n" - "3. Or use command line argument:\n" - " --token your-token-here\n\n" - "4. For CI/CD pipelines:\n" - " export GITLAB_TOKEN=$CI_JOB_TOKEN\n\n" - "IMPORTANT:\n" - "• Token must have 'api' scope (not just 'read_api')\n" - "• Token must not be expired\n" - "• Keep token secure and never commit to version control\n\n" - "TROUBLESHOOTING:\n" - "• Check token format: should be 20+ characters\n" - "• Verify token hasn't expired\n" - "• Test token manually: curl -H 'PRIVATE-TOKEN: your-token' {gitlab_url}/api/v4/user\n\n" - "For more help: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html" - ) - - # Basic token format validation - token = token.strip() - if len(token) < 20: - raise ValueError( - f"GitLab token appears to be invalid (too short: {len(token)} characters).\n\n" - "SOLUTION:\n" - "1. Verify you copied the complete token:\n" - " • GitLab personal access tokens are typically 20+ characters\n" - " • Ensure no whitespace or truncation occurred\n" - " • Check for copy/paste errors\n\n" - "2. Generate a new token if needed:\n" - f" • Visit: {gitlab_url}/-/profile/personal_access_tokens\n" - " • Create new token with 'api' scope\n" - " • Copy the complete token\n\n" - "3. Test token format:\n" - f" echo $GITLAB_TOKEN | wc -c # Should be 20+ characters\n\n" - "For more help: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html" - ) - - # Check for common token format issues - if token.startswith("glpat-") and len(token) < 26: - raise ValueError( - f"GitLab personal access token appears incomplete.\n" - f"Token length: {len(token)} characters (expected 26+ for glpat- tokens)\n\n" - "SOLUTION:\n" - "1. Verify complete token was copied:\n" - " • Personal access tokens start with 'glpat-' and are 26+ characters\n" - " • Check for truncation during copy/paste\n" - " • Ensure no line breaks or extra characters\n\n" - "2. Generate new token if corrupted:\n" - f" • Visit: {gitlab_url}/-/profile/personal_access_tokens\n" - " • Revoke old token if compromised\n" - " • Create new token with 'api' scope\n\n" - "For more help: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html" - ) - - logger.debug("✓ GitLab token format validation passed") - - -def validate_git_installation() -> None: - """ - Validate that Git is installed and accessible. - - Raises: - ValueError: If Git installation validation fails with specific resolution steps - """ - logger.debug("Validating Git installation...") - - try: - import subprocess - - result = subprocess.run( - ["git", "--version"], capture_output=True, text=True, timeout=10 - ) - - if result.returncode != 0: - raise ValueError( - f"Git command failed with exit code {result.returncode}.\n" - f"Error output: {result.stderr}\n\n" - "SOLUTION:\n" - "1. Install Git:\n" - " • Ubuntu/Debian: sudo apt update && sudo apt install git\n" - " • CentOS/RHEL: sudo yum install git\n" - " • macOS: brew install git (or install Xcode Command Line Tools)\n" - " • Windows: Download from https://git-scm.com/download/windows\n\n" - "2. Verify installation:\n" - " git --version\n\n" - "3. Check PATH configuration:\n" - " which git # On Unix-like systems\n" - " where git # On Windows\n\n" - "For more help: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git" - ) - - git_version = result.stdout.strip() - logger.debug(f"✓ Git is available: {git_version}") - - except FileNotFoundError: - raise ValueError( - "Git is not installed or not available in PATH.\n\n" - "SOLUTION:\n" - "1. Install Git:\n" - " • Ubuntu/Debian: sudo apt update && sudo apt install git\n" - " • CentOS/RHEL: sudo yum install git\n" - " • macOS: brew install git (or install Xcode Command Line Tools)\n" - " • Windows: Download from https://git-scm.com/download/windows\n\n" - "2. Add Git to PATH (if installed but not in PATH):\n" - " • Find Git installation directory\n" - " • Add to PATH environment variable\n" - " • Restart terminal/command prompt\n\n" - "3. Verify installation:\n" - " git --version\n\n" - "TROUBLESHOOTING:\n" - "• Check if Git is installed: ls /usr/bin/git\n" - "• Check PATH: echo $PATH\n" - "• For Windows: Check 'Program Files\\Git\\bin' is in PATH\n\n" - "For more help: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git" - ) - - except subprocess.TimeoutExpired: - raise ValueError( - "Git command timed out. This may indicate system issues.\n\n" - "SOLUTION:\n" - "1. Check system resources:\n" - " • Ensure sufficient memory and CPU available\n" - " • Check for system overload\n\n" - "2. Verify Git installation:\n" - " git --version\n\n" - "3. Try running Git commands manually:\n" - " git status\n\n" - "If problem persists, consider reinstalling Git." - ) - - except Exception as e: - raise ValueError( - f"Unexpected error checking Git installation: {e}\n\n" - "SOLUTION:\n" - "1. Verify Git is properly installed:\n" - " git --version\n\n" - "2. Check system permissions:\n" - " • Ensure user can execute Git commands\n" - " • Check file permissions on Git executable\n\n" - "3. Reinstall Git if necessary:\n" - " • Download from https://git-scm.com/downloads\n" - " • Follow installation instructions for your OS\n\n" - "For more help: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git" - ) - - -def validate_git_repository_access(working_directory: str = ".") -> None: - """ - Validate Git repository access when Git operations are needed. - - Args: - working_directory: Directory to check for Git repository - - Raises: - ValueError: If Git repository access validation fails with specific resolution steps - """ - logger.debug(f"Validating Git repository access in: {working_directory}") - - try: - detector = GitAutoDetector(working_directory) - repo = detector.find_git_repository() - - if repo: - logger.debug(f"✓ Git repository found at: {repo.working_dir}") - - # Test basic repository operations - try: - # Try to read repository configuration - repo.config_reader() # Just verify it's accessible - logger.debug("✓ Git repository configuration accessible") - - # Try to read remotes - remotes = list(repo.remotes) - logger.debug(f"✓ Git remotes accessible: {len(remotes)} remote(s)") - - except Exception as e: - raise ValueError( - f"Git repository found but not fully accessible: {e}\n\n" - "SOLUTION:\n" - "1. Check repository integrity:\n" - " git fsck\n\n" - "2. Check file permissions:\n" - f" ls -la {repo.working_dir}/.git\n" - " • Ensure .git directory is readable\n" - " • Check ownership and permissions\n\n" - "3. Try repository repair:\n" - " git gc --prune=now\n" - " git repack -ad\n\n" - "4. If corrupted, consider re-cloning:\n" - " • Backup any uncommitted changes\n" - " • Clone fresh copy from remote\n\n" - "TROUBLESHOOTING:\n" - "• Check disk space: df -h\n" - "• Check file system errors: dmesg | grep -i error\n" - "• Verify Git version: git --version\n\n" - "For more help: https://git-scm.com/docs/git-fsck" - ) - else: - logger.debug( - "No Git repository found - this is acceptable for manual project specification" - ) - - except ValueError: - # Re-raise ValueError exceptions (our custom error messages) - raise - except Exception as e: - raise ValueError( - f"Unexpected error validating Git repository access: {e}\n\n" - "SOLUTION:\n" - "1. Ensure you're in a Git repository:\n" - " git status\n\n" - "2. Initialize repository if needed:\n" - " git init\n" - " git remote add origin \n\n" - "3. Check directory permissions:\n" - f" ls -la {working_directory}\n" - " • Ensure directory is readable and accessible\n\n" - "4. Use manual project specification if Git auto-detection isn't needed:\n" - " --project-url https://gitlab.com/namespace/project\n" - " --project-path namespace/project\n\n" - "For more help: https://git-scm.com/docs/git-init" - ) - - -def validate_configuration( - token: Optional[str] = None, - gitlab_url: str = DEFAULT_GITLAB_URL, - require_git: bool = False, - working_directory: str = ".", -) -> None: - """ - Comprehensive configuration validation for GitLab scripts. - - Args: - token: GitLab authentication token (if None, will try to get from environment) - gitlab_url: GitLab instance URL - require_git: Whether Git operations are required - working_directory: Working directory for Git operations - - Raises: - ValueError: If any configuration validation fails - """ - logger.info("Starting configuration validation...") - - # 1. Validate dependencies - try: - validate_dependencies() - logger.info("✓ Dependencies validation passed") - except ValueError: - logger.error("✗ Dependencies validation failed") - raise - - # 2. Validate GitLab token - try: - if token is None: - token = get_gitlab_token(None) - validate_gitlab_token(token, gitlab_url) - logger.info("✓ GitLab token validation passed") - except ValueError: - logger.error("✗ GitLab token validation failed") - raise - - # 3. Validate Git installation (always check since it might be needed) - try: - validate_git_installation() - logger.info("✓ Git installation validation passed") - except ValueError as e: - if require_git: - logger.error("✗ Git installation validation failed") - raise - else: - logger.warning( - "⚠ Git installation validation failed (not required for this operation)" - ) - logger.debug(f"Git validation error: {e}") - - # 4. Validate Git repository access (only if Git operations are required) - if require_git: - try: - validate_git_repository_access(working_directory) - logger.info("✓ Git repository access validation passed") - except ValueError: - logger.error("✗ Git repository access validation failed") - raise - - logger.info("✓ Configuration validation completed successfully") diff --git a/pyproject.toml b/pyproject.toml index ecfba14..91ae71e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ test = [ "pytest-xdist", "pytest-timeout", "pytest-sugar", + "pytest-instafail", ] [project.scripts] diff --git a/run_tests.py b/run_tests.py index 4ab4470..37125cd 100755 --- a/run_tests.py +++ b/run_tests.py @@ -1,44 +1,31 @@ -#!/usr/bin/env -S uv run --quiet --script -# /// script -# requires-python = ">=3.12" -# dependencies = [ -# "python-gitlab>=4.0.0", -# "requests>=2.25.0", -# "rich>=10.0.0", -# "pytest>=7.0.0", -# "pytest-xdist>=3.0.0", -# "pytest-timeout>=2.1.0", -# "pytest-sugar>=0.9.7", -# "pytest-instafail>=0.5.0", -# "GitPython>=3.1.0", -# ] -# /// - +#!/usr/bin/env python3 """ Test Runner Wrapper for GitLab Upload Script Tests -A standalone uv-compatible Python script that wraps pytest with convenient -command-line options and pass-through support for advanced pytest usage. +A convenience wrapper that delegates to `uv run pytest` for test execution. +All test dependencies are automatically managed by uv. Usage Examples: Basic (runs all available tests): ./run_tests.py + # or directly: uv run pytest tests/ Convenience commands: ./run_tests.py --unit # Run only unit tests ./run_tests.py --integration # Run integration tests (requires GITLAB_TOKEN) - ./run_tests.py --config # Run configuration validation tests ./run_tests.py --all # Run all test categories sequentially Individual test execution: - ./run_tests.py tests/test_unit_basic.py::test_import_gitlab_common + ./run_tests.py tests/unit/test_cli.py::test_parse_args + # or directly: uv run pytest tests/unit/test_cli.py::test_parse_args With pytest options: ./run_tests.py -v -k "test_import" tests/ - ./run_tests.py -v --tb=short tests/test_unit_basic.py + ./run_tests.py -v --tb=short tests/unit/ Parallel execution: ./run_tests.py -n auto tests/ + # or directly: uv run pytest tests/ -n auto Specific markers: ./run_tests.py -m "unit and not slow" @@ -46,7 +33,6 @@ Duration reporting: ./run_tests.py --durations=5 tests/ # Show 5 slowest tests ./run_tests.py --durations=0 tests/ # Show all test durations - ./run_tests.py --durations-min=2.0 tests/ # Only show tests >= 2 seconds Common pytest options: -v, --verbose Verbose output @@ -57,14 +43,13 @@ -n auto Run tests in parallel (requires pytest-xdist) --timeout=SECONDS Set test timeout --durations=N Show N slowest test durations (0 for all) - --durations-min=N Minimum duration in seconds to include in report --instafail Show failures instantly (enabled by default) -Progress Reporting: - - pytest-sugar provides real-time progress bars during execution - - pytest-instafail shows failures immediately as they occur - - --durations flag shows test timing information at the end - - Performance summary is displayed automatically after test completion +Note: + You can also run tests directly with `uv run pytest`: + uv run pytest tests/ # All tests + uv run pytest tests/unit/ # Unit tests only + uv run pytest tests/integration/ # Integration tests only """ import argparse @@ -74,22 +59,9 @@ import time from pathlib import Path -from rich.console import Console - -# Setup rich console for colored output -console = Console() - def format_duration(seconds: float) -> str: - """ - Format duration in seconds to human-readable format. - - Args: - seconds: Duration in seconds - - Returns: - Formatted duration string (e.g., "2m 30s", "45s", "1h 5m") - """ + """Format duration in seconds to human-readable format.""" if seconds < 60: return f"{seconds:.1f}s" elif seconds < 3600: @@ -102,25 +74,59 @@ def format_duration(seconds: float) -> str: return f"{hours}h {minutes}m" -def run_pytest( +def ensure_package_installed() -> bool: + """ + Ensure the gitlab_pkg_upload package is installed in development mode. + + Returns: + True if package is available (already installed or successfully installed), + False if installation failed. + """ + try: + import gitlab_pkg_upload # noqa: F401 + + return True + except ImportError: + pass + + print("Installing gitlab_pkg_upload package in development mode...") + try: + result = subprocess.run( + ["uv", "pip", "install", "-e", "."], + capture_output=True, + text=True, + timeout=60, + ) + if result.returncode != 0: + print(f"ERROR: Failed to install package: {result.stderr}") + return False + print("Package installed successfully.\n") + return True + except subprocess.TimeoutExpired: + print("ERROR: Package installation timed out") + return False + except Exception as e: + print(f"ERROR: Failed to install package: {e}") + return False + + +def run_uv_pytest( args: list[str], env: dict | None = None, timeout: int = 900, - show_duration_context: bool = False, ) -> tuple[int, float]: """ - Execute pytest with the given arguments. + Execute pytest via uv run with the given arguments. Args: args: List of pytest arguments env: Optional environment variables to pass to subprocess timeout: Timeout in seconds (default: 900) - show_duration_context: Whether to show duration context message Returns: - Tuple of (exit code from pytest execution, elapsed time in seconds) + Tuple of (exit code, elapsed time in seconds) """ - cmd = ["pytest"] + args + cmd = ["uv", "run", "pytest"] + args start_time = time.time() @@ -131,109 +137,31 @@ def run_pytest( timeout=timeout, ) elapsed = time.time() - start_time - - if show_duration_context: - console.print(f"\n[dim]Elapsed time: {format_duration(elapsed)}[/dim]") - return result.returncode, elapsed except subprocess.TimeoutExpired: elapsed = time.time() - start_time - console.print( - f"[bold red]ERROR:[/bold red] Tests timed out after {timeout} seconds" - ) + print(f"ERROR: Tests timed out after {timeout} seconds") return 1, elapsed except Exception as e: elapsed = time.time() - start_time - console.print(f"[bold red]ERROR:[/bold red] Failed to run tests: {e}") + print(f"ERROR: Failed to run tests: {e}") return 1, elapsed -def run_unit_tests() -> list[str]: - """ - Build pytest arguments for unit tests. - - Returns: - List of pytest arguments for unit tests - """ - return [ - "-m", - "unit or fast", - "-v", - "--tb=auto", - "--durations=10", - "--durations-min=1.0", - "tests/test_unit_basic.py", - ] - - -def run_integration_tests(gitlab_token: str) -> tuple[list[str], dict]: - """ - Build pytest arguments and environment for integration tests. - - Args: - gitlab_token: GitLab API token - - Returns: - Tuple of (pytest_args, environment_dict) - """ - env = os.environ.copy() - env["GITLAB_TOKEN"] = gitlab_token - - args = [ - "-m", - "integration", - "-v", - "--tb=auto", - "--durations=0", - "--durations-min=1.0", - "tests/", - ] - - return args, env - - -def run_configuration_tests() -> int: - """ - Run configuration validation tests (not pytest-based). - - Returns: - Exit code from configuration test execution - """ - console.print("\n[bold]Running configuration tests...[/bold]") - console.print("=" * 60) - - try: - result = subprocess.run( - ["python", "test_parallel_config.py"], - timeout=60, - ) - return result.returncode - - except subprocess.TimeoutExpired: - console.print("[bold red]ERROR:[/bold red] Configuration tests timed out") - return 1 - except Exception as e: - console.print( - f"[bold red]ERROR:[/bold red] Failed to run configuration tests: {e}" - ) - return 1 - - def main(): """Main function to handle argument parsing and test execution.""" # Check if any convenience flags are used - convenience_flags = {"--unit", "--integration", "--config", "--all", "--help", "-h"} + convenience_flags = {"--unit", "--integration", "--all", "--help", "-h"} has_convenience_flag = any(arg in convenience_flags for arg in sys.argv[1:]) if has_convenience_flag: # Use argparse for convenience flags parser = argparse.ArgumentParser( - description="Test runner wrapper for GitLab upload script tests", + description="Test runner wrapper for GitLab upload script tests. " + "Delegates to `uv run pytest` for execution.", epilog="Any additional arguments are passed directly to pytest. " - "Common pytest options: -v (verbose), -k (filter), -m (markers), " - "-x (exit on first failure), --tb=short (short traceback), " - "-n auto (parallel execution)", + "You can also run tests directly with `uv run pytest tests/`.", ) # Convenience command flags (mutually exclusive) @@ -248,11 +176,6 @@ def main(): action="store_true", help="Run only integration tests (requires GITLAB_TOKEN)", ) - command_group.add_argument( - "--config", - action="store_true", - help="Run configuration validation tests", - ) command_group.add_argument( "--all", action="store_true", @@ -266,20 +189,25 @@ def main(): class Args: unit = False integration = False - config = False all = False pytest_args = sys.argv[1:] args = Args() # Print header - console.print("[bold]GitLab Upload Script - Test Suite Runner[/bold]") - console.print("=" * 60) - console.print("Using uv for dependency management\n") - - # Change to the gitlab directory - gitlab_dir = Path(__file__).parent - os.chdir(gitlab_dir) + print("GitLab Package Upload - Test Suite Runner") + print("=" * 60) + print("Using uv run pytest for test execution\n") + + # Change to the project directory + project_dir = Path(__file__).parent + os.chdir(project_dir) + + # Ensure the package is installed before running tests + if not ensure_package_installed(): + print("\nFailed to install the gitlab_pkg_upload package.") + print("Please install it manually with: uv pip install -e .") + return 1 # Get GitLab token from environment gitlab_token = os.environ.get("GITLAB_TOKEN") @@ -290,47 +218,48 @@ class Args: # Handle convenience commands if args.all: - console.print("[bold]Running all test categories sequentially...[/bold]\n") + print("Running all test categories sequentially...\n") overall_start = time.time() # Run unit tests - console.print("\n[bold cyan]1. Unit Tests[/bold cyan]") - console.print("=" * 60) - unit_args = run_unit_tests() - unit_result, unit_time = run_pytest( - unit_args, timeout=120, show_duration_context=True - ) + print("\n1. Unit Tests") + print("=" * 60) + unit_args = [ + "-v", + "--tb=auto", + "--durations=10", + "--durations-min=1.0", + "tests/unit/", + ] + unit_result, unit_time = run_uv_pytest(unit_args, timeout=120) results.append(("Unit Tests", unit_result == 0, unit_time)) - # Run configuration tests - console.print("\n[bold cyan]2. Configuration Tests[/bold cyan]") - console.print("=" * 60) - config_start = time.time() - config_result = run_configuration_tests() - config_time = time.time() - config_start - results.append(("Configuration Tests", config_result == 0, config_time)) - # Run integration tests if token available if gitlab_token: - console.print("\n[bold cyan]3. Integration Tests[/bold cyan]") - console.print("=" * 60) - console.print( - "[dim]This may take 10-15 minutes due to GitLab API operations...[/dim]\n" - ) - integration_args, integration_env = run_integration_tests(gitlab_token) - integration_result, integration_time = run_pytest( + print("\n2. Integration Tests") + print("=" * 60) + print("This may take several minutes due to GitLab API operations...\n") + env = os.environ.copy() + env["GITLAB_TOKEN"] = gitlab_token + integration_args = [ + "-m", + "integration", + "-v", + "--tb=auto", + "--durations=0", + "--durations-min=1.0", + "tests/integration/", + ] + integration_result, integration_time = run_uv_pytest( integration_args, - env=integration_env, + env=env, timeout=900, - show_duration_context=True, ) results.append( ("Integration Tests", integration_result == 0, integration_time) ) else: - console.print( - "\n[bold yellow]⚠ Skipping integration tests (no GITLAB_TOKEN)[/bold yellow]" - ) + print("\nSkipping integration tests (no GITLAB_TOKEN)") results.append(("Integration Tests", None, 0)) overall_time = time.time() - overall_start @@ -339,138 +268,126 @@ class Args: exit_code = 0 if all(r[1] for r in results if r[1] is not None) else 1 elif args.unit: - console.print("[bold]Running unit tests (no external dependencies)...[/bold]") - console.print("=" * 60 + "\n") - unit_args = run_unit_tests() - exit_code, _ = run_pytest(unit_args, timeout=120, show_duration_context=True) + print("Running unit tests (no external dependencies)...") + print("=" * 60 + "\n") + unit_args = [ + "-v", + "--tb=auto", + "--durations=10", + "--durations-min=1.0", + "tests/unit/", + ] + exit_code, _ = run_uv_pytest(unit_args, timeout=120) elif args.integration: if not gitlab_token: - console.print( - "[bold red]ERROR:[/bold red] GITLAB_TOKEN environment variable not set" - ) - console.print("\nTo run integration tests:") - console.print(" export GITLAB_TOKEN=your_token") - console.print(" ./run_tests.py --integration") + print("ERROR: GITLAB_TOKEN environment variable not set") + print("\nTo run integration tests:") + print(" export GITLAB_TOKEN=your_token") + print(" ./run_tests.py --integration") + print("\nOr run directly with uv:") + print(" GITLAB_TOKEN=your_token uv run pytest tests/integration/ -m integration") return 1 - console.print( - "[bold]Running integration tests (requires GitLab API access)...[/bold]" - ) - console.print("=" * 60) - console.print( - "[dim]This may take 10-15 minutes due to GitLab API operations...[/dim]\n" - ) - integration_args, integration_env = run_integration_tests(gitlab_token) - exit_code, _ = run_pytest( + print("Running integration tests (requires GitLab API access)...") + print("=" * 60) + print("This may take several minutes due to GitLab API operations...\n") + env = os.environ.copy() + env["GITLAB_TOKEN"] = gitlab_token + integration_args = [ + "-m", + "integration", + "-v", + "--tb=auto", + "--durations=0", + "--durations-min=1.0", + "tests/integration/", + ] + exit_code, _ = run_uv_pytest( integration_args, - env=integration_env, + env=env, timeout=900, - show_duration_context=True, ) - elif args.config: - exit_code = run_configuration_tests() - elif args.pytest_args: # Pass-through mode: run pytest with provided arguments - console.print("[bold]Running pytest with custom arguments...[/bold]") - console.print(f"Arguments: {' '.join(args.pytest_args)}") - console.print("=" * 60 + "\n") + print("Running pytest with custom arguments...") + print(f"Arguments: {' '.join(args.pytest_args)}") + print("=" * 60 + "\n") # Add duration flags if not already present enhanced_args = args.pytest_args.copy() if not any(arg.startswith("--durations") for arg in enhanced_args): enhanced_args.extend(["--durations=10", "--durations-min=1.0"]) - exit_code, _ = run_pytest(enhanced_args, show_duration_context=True) + exit_code, _ = run_uv_pytest(enhanced_args) else: # Default behavior: run all available tests based on token presence if gitlab_token: - console.print("[bold]Running all tests (unit + integration)...[/bold]") - console.print("=" * 60) - console.print( - "[dim]This may take 10-15 minutes due to GitLab API operations...[/dim]\n" - ) + print("Running all tests (unit + integration)...") + print("=" * 60) + print("This may take several minutes due to GitLab API operations...\n") env = os.environ.copy() env["GITLAB_TOKEN"] = gitlab_token - exit_code, _ = run_pytest( + exit_code, _ = run_uv_pytest( ["-v", "--tb=short", "--durations=10", "--durations-min=1.0", "tests/"], env=env, timeout=900, - show_duration_context=True, ) else: - console.print( - "[bold]Running all available tests (unit tests only, no GITLAB_TOKEN)...[/bold]" - ) - console.print("=" * 60 + "\n") - exit_code, _ = run_pytest( + print("Running unit tests only (no GITLAB_TOKEN set)...") + print("=" * 60 + "\n") + exit_code, _ = run_uv_pytest( [ "-v", "--tb=short", "--durations=10", "--durations-min=1.0", - "tests/test_unit_basic.py", + "tests/unit/", ], timeout=180, - show_duration_context=True, ) # Print summary - console.print("\n" + "=" * 60) - console.print("[bold]TEST SUMMARY[/bold]") - console.print("=" * 60) + print("\n" + "=" * 60) + print("TEST SUMMARY") + print("=" * 60) if args.all: # Detailed summary for --all mode for test_name, success, duration in results: duration_str = format_duration(duration) if duration > 0 else "N/A" if success is None: - console.print( - f"[yellow]⚠[/yellow] {test_name}: Skipped ({duration_str})" - ) + print(f" {test_name}: Skipped ({duration_str})") elif success: - console.print(f"[green]✅[/green] {test_name}: Passed ({duration_str})") + print(f" {test_name}: Passed ({duration_str})") else: - console.print(f"[red]❌[/red] {test_name}: Failed ({duration_str})") + print(f" {test_name}: Failed ({duration_str})") - console.print( - f"\n[dim]Total elapsed time: {format_duration(overall_time)}[/dim]" - ) + print(f"\nTotal elapsed time: {format_duration(overall_time)}") if exit_code == 0: - console.print("[bold green]✅ All tests passed![/bold green]") + print("\nAll tests passed!") else: - console.print("[bold red]❌ Some tests failed![/bold red]") + print("\nSome tests failed!") else: if exit_code == 0: - console.print("[bold green]✅ All tests passed![/bold green]") + print("All tests passed!") # Show helpful hints if not gitlab_token and not args.integration: - console.print( - "\n[dim]Note: Integration tests were skipped (no GITLAB_TOKEN)[/dim]" - ) - console.print("[dim]To run integration tests:[/dim]") - console.print("[dim] export GITLAB_TOKEN=your_token[/dim]") - console.print("[dim] ./run_tests.py --integration[/dim]") - - # Show usage hints - if not args.pytest_args: - console.print("\n[dim]Tip: Run with -v for verbose output[/dim]") - console.print("[dim]Tip: Use -n auto for parallel execution[/dim]") - console.print( - "[dim]Tip: Run ./run_tests.py --help for more options[/dim]" - ) + print("\nNote: Integration tests were skipped (no GITLAB_TOKEN)") + print("To run integration tests:") + print(" export GITLAB_TOKEN=your_token") + print(" uv run pytest tests/integration/ -m integration") else: - console.print("[bold red]❌ Some tests failed![/bold red]") - console.print("\n[dim]Tip: Run with -v for more details[/dim]") - console.print("[dim]Tip: Use -x to stop on first failure[/dim]") - console.print("[dim]Tip: Use --tb=short for shorter tracebacks[/dim]") + print("Some tests failed!") + print("\nTip: Run with -v for more details") + print("Tip: Use -x to stop on first failure") + print("Tip: Use --tb=short for shorter tracebacks") return exit_code diff --git a/src/gitlab_pkg_upload/validators.py b/src/gitlab_pkg_upload/validators.py index e9d4888..a751000 100644 --- a/src/gitlab_pkg_upload/validators.py +++ b/src/gitlab_pkg_upload/validators.py @@ -655,7 +655,7 @@ def validate_dependencies() -> None: " • Set local version: pyenv local 3.11\n\n" "3. Use uv to run with correct Python version:\n" " • Install uv: pip install uv\n" - " • Run script: uv run --python 3.11 ./gitlab/gitlab-pkg-upload.py\n\n" + " • Run command: uv run --python 3.11 gitlab-pkg-upload\n\n" "For more help, see: https://docs.python.org/3/installing/" ) @@ -683,9 +683,9 @@ def validate_dependencies() -> None: error_msg += ( "\nSOLUTION:\n" "1. If using uv (recommended):\n" - " • Ensure script has proper shebang: #!/usr/bin/env -S uv run --script\n" - " • Run directly: ./gitlab/gitlab-pkg-upload.py\n" - " • uv will automatically install dependencies\n\n" + " • Install package: uv pip install -e .\n" + " • Run command: gitlab-pkg-upload\n" + " • Or run directly: uv run gitlab-pkg-upload\n\n" "2. Manual installation with pip:\n" ) diff --git a/tests/README.md b/tests/README.md index 2e8dadf..6132739 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,60 +1,55 @@ # GitLab Package Upload Test Suite -This directory contains a comprehensive pytest-based test suite for the GitLab package upload functionality. The test suite has been refactored from a monolithic test file into focused, maintainable modules that can be run individually or as a complete suite. +This directory contains a comprehensive pytest-based test suite for the GitLab package upload functionality. The test suite validates the `gitlab-pkg-upload` command through both unit tests and end-to-end integration testing. ## Overview -The test suite validates the [`gitlab-pkg-upload.py`](../gitlab-pkg-upload.py) script through end-to-end testing, executing the actual script and verifying results against the GitLab Package Registry. Tests use real GitLab API interactions for authentic validation without mocking. +The test suite is organized into two categories: + +- **Unit tests** (`tests/unit/`): Fast tests that validate individual components without external dependencies +- **Integration tests** (`tests/integration/`): End-to-end tests that execute the actual upload script and verify results against the GitLab Package Registry ## Quick Start -### Basic Usage +All test dependencies are automatically managed by uv. However, the `gitlab_pkg_upload` package must be installed in development mode before running tests. ```bash -# Run all available tests (unit tests if no GITLAB_TOKEN) -./run_tests.py +# Install the package in development mode (required before running tests) +uv pip install -e . + +# Run all tests +uv run pytest tests/ # Run only unit tests (fast, no external dependencies) -./run_tests.py --unit +uv run pytest tests/unit/ -# Run integration tests (requires GITLAB_TOKEN, takes 10-15 minutes) +# Run integration tests (requires GITLAB_TOKEN) export GITLAB_TOKEN="your-token" -./run_tests.py --integration - -# Run all test categories sequentially -./run_tests.py --all -``` - -### Advanced Usage - -```bash -# Run specific test file -./run_tests.py tests/test_basic_uploads.py - -# Run specific test function -./run_tests.py tests/test_basic_uploads.py::test_single_file_upload +uv run pytest tests/integration/ -m integration # Run with parallel execution -./run_tests.py -n auto tests/ +uv run pytest tests/ -n auto -# Run with verbose output and stop on first failure -./run_tests.py -v -x tests/ - -# Filter tests by marker -./run_tests.py -m "unit and not slow" - -# Show 10 slowest tests -./run_tests.py --durations=10 tests/ +# Run with verbose output +uv run pytest tests/ -v ``` -### Getting Help +### Using the Convenience Wrapper + +The `run_tests.py` script provides convenience commands that delegate to `uv run pytest`: ```bash -# Show wrapper help -./run_tests.py --help +# Run unit tests +./run_tests.py --unit -# Show pytest help (pass-through mode) -./run_tests.py --help +# Run integration tests +./run_tests.py --integration + +# Run all test categories +./run_tests.py --all + +# Pass-through to pytest +./run_tests.py -v -k "test_import" tests/ ``` ## Test Structure @@ -62,24 +57,32 @@ export GITLAB_TOKEN="your-token" ``` tests/ ├── conftest.py # Shared fixtures and configuration -├── conftest_performance.py # Performance tracking plugin -├── test_basic_uploads.py # Single file, multiple files, directory uploads -├── test_duplicate_handling.py # Skip, replace, error policies -├── test_project_resolution.py # Auto-detection and manual specification -├── test_error_scenarios.py # Network failures, auth errors, validation -├── test_integration.py # End-to-end comprehensive scenarios -├── test_fixtures.py # Fixture validation tests +├── unit/ # Unit tests (no external dependencies) +│ ├── __init__.py +│ ├── test_cli.py # CLI argument parsing and validation +│ ├── test_models.py # Data models and structures +│ ├── test_uploader.py # Upload logic and file handling +│ └── test_validators.py # Input validation functions +├── integration/ # Integration tests (requires GITLAB_TOKEN) +│ ├── __init__.py +│ ├── conftest.py # Integration-specific fixtures +│ ├── test_single_file_upload.py # Single file upload tests +│ ├── test_multiple_files_upload.py # Multiple files upload tests +│ ├── test_duplicate_handling.py # Skip, replace, error policies +│ ├── test_project_resolution.py # Auto-detection and manual specification +│ ├── test_error_scenarios.py # Network failures, auth errors +│ └── test_end_to_end.py # Comprehensive end-to-end scenarios ├── utils/ │ ├── __init__.py │ ├── test_helpers.py # Common test utilities -│ ├── artifact_factory.py # Test file creation utilities +│ ├── artifact_factory.py # Test file creation utilities │ ├── gitlab_helpers.py # GitLab API interaction utilities │ ├── rate_limiter.py # API rate limiting utilities │ └── performance.py # Performance monitoring utilities └── README.md # This file ../ -├── run_tests.py # Test runner with uv dependency management +├── run_tests.py # Convenience wrapper for uv run pytest └── pyproject.toml # Project configuration and pytest settings ``` @@ -87,21 +90,32 @@ tests/ ### Dependency Management -All test dependencies are managed automatically by the uv package manager through the [`run_tests.py`](../run_tests.py) script. No manual installation is required. +All test dependencies are automatically installed by uv when running `uv run pytest`. The dependencies are defined in `pyproject.toml` under `[project.optional-dependencies]`: -### GitLab Configuration +- pytest +- pytest-xdist (parallel execution) +- pytest-timeout (timeout management) +- pytest-sugar (progress visualization) +- pytest-instafail (instant failure reporting) + +**Important**: The `gitlab_pkg_upload` package itself must be installed in development mode before running tests: + +```bash +uv pip install -e . +``` + +Alternatively, you can use `pip install -e .` if not using uv. + +### GitLab Configuration (Integration Tests Only) + +Integration tests require a GitLab API token: -Set the following environment variables: ```bash export GITLAB_TOKEN="your-gitlab-token" export GITLAB_URL="https://gitlab.example.com" # Optional, defaults to GitLab.com export GITLAB_PROJECT_PATH="group/project" # Optional, can auto-detect from git ``` -### Git Repository - -Tests can auto-detect GitLab project from the current git repository, or you can specify manually via environment variables. - ### Required Permissions Your GitLab token needs the following permissions: @@ -109,445 +123,230 @@ Your GitLab token needs the following permissions: - Write access to the target project's Package Registry - Ability to create and delete packages in the registry -## Integration Test Requirements - -Integration tests automatically validate their environment before running. If requirements aren't met, tests will be skipped with clear, actionable error messages explaining what's missing and how to fix it. - -### Automatic Environment Validation - -When you run integration tests, the test suite automatically checks: - -1. **GITLAB_TOKEN environment variable** - Must be set with a valid GitLab API token -2. **Git repository** - Must run from within a Git repository -3. **GitLab remotes** - Repository must have at least one remote pointing to a GitLab instance - -### Verifying Your Setup +## Running Tests -Use these commands to verify your environment meets the requirements: +### Primary Method: uv run pytest ```bash -# Check if in Git repository -git remote -v - -# Verify GitLab remote exists (should show at least one gitlab.com or your GitLab instance) -git remote -v | grep gitlab - -# Check token is set -echo $GITLAB_TOKEN +# Run all tests +uv run pytest tests/ -# Or verify token is not empty -[ -n "$GITLAB_TOKEN" ] && echo "Token is set" || echo "Token is NOT set" -``` +# Run only unit tests +uv run pytest tests/unit/ -### When Validation Fails - -If integration tests are skipped, the error message will explain exactly what's missing: - -- **Missing GITLAB_TOKEN**: Set the environment variable with `export GITLAB_TOKEN='your-token'` -- **No Git repository**: Navigate to a Git repository or initialize one -- **No GitLab remotes**: Add a GitLab remote with `git remote add origin https://gitlab.com/namespace/project.git` -- **Alternative**: Use manual project specification with `export GITLAB_PROJECT_PATH='namespace/project'` +# Run only integration tests +uv run pytest tests/integration/ -m integration -### Example Output +# Run specific test file +uv run pytest tests/unit/test_cli.py -When the environment is properly configured, you'll see validation confirmation: -``` -Integration test environment validated: - - GITLAB_TOKEN: [set] - - Git repository: /path/to/your/repo - - GitLab remotes detected: origin=namespace/project -``` +# Run specific test function +uv run pytest tests/unit/test_cli.py::test_parse_args -When something is missing, tests will be skipped with a detailed message: -``` -SKIPPED [1] tests/integration/conftest.py:163: GITLAB_TOKEN environment variable not set. +# Run with verbose output +uv run pytest tests/ -v -Integration tests require a valid GitLab API token. +# Run with parallel execution +uv run pytest tests/ -n auto -SOLUTION: -1. Create a GitLab personal access token with 'api' scope: - GitLab → Settings → Access Tokens → Create token +# Stop on first failure +uv run pytest tests/ -x -2. Set the environment variable: - export GITLAB_TOKEN='your-token-here' +# Show 10 slowest tests +uv run pytest tests/ --durations=10 ``` -## Pytest Plugins +### Run Tests by Markers -The test suite uses the following pytest plugins: +```bash +# Run only unit tests +uv run pytest tests/ -m unit -| Plugin | Purpose | Usage | -|--------|---------|-------| -| **pytest-xdist** | Parallel test execution | Enables running tests across multiple CPU cores with `-n auto` flag for faster execution | -| **pytest-timeout** | Test timeout management | Automatically fails tests that exceed time limits; configured with markers like `@pytest.mark.timeout(60)` | -| **pytest-sugar** | Progress visualization | Provides real-time progress bars and improved test output formatting | -| **pytest-instafail** | Instant failure reporting | Shows test failures immediately as they occur, enabled by default with `--instafail` flag | +# Run only integration tests +uv run pytest tests/ -m integration -**Note**: These plugins are automatically installed when running tests via [`run_tests.py`](../run_tests.py) using the uv package manager. Plugin configuration is defined in [`pyproject.toml`](../pyproject.toml) under `[tool.pytest.ini_options]`. +# Run only fast tests +uv run pytest tests/ -m fast -## Running Tests +# Skip slow tests +uv run pytest tests/ -m "not slow" -### Using the Test Runner (Primary Method) +# Run API tests +uv run pytest tests/ -m api +``` -The [`run_tests.py`](../run_tests.py) script is the primary and recommended method for running tests. It automatically manages dependencies using uv and provides convenient test execution options. +### Using the Convenience Wrapper ```bash -# Run all available tests (auto-detects GITLAB_TOKEN) -./run_tests.py - -# Run only unit tests (fast, no external dependencies) +# Run unit tests ./run_tests.py --unit -# Run only integration tests (requires GITLAB_TOKEN, takes 10-15 minutes) +# Run integration tests (requires GITLAB_TOKEN) ./run_tests.py --integration -# Run configuration validation tests -./run_tests.py --config - -# Run all test categories sequentially +# Run all test categories ./run_tests.py --all -``` - -### Pass-Through Mode for Advanced Usage - -Any arguments not matching convenience flags are passed directly to pytest, enabling advanced usage: - -```bash -# Run specific test -./run_tests.py tests/test_unit_basic.py::TestBasicFunctionality::test_import_gitlab_pkg_upload -# Filter tests by name pattern -./run_tests.py -v -k "test_import" tests/ - -# Run with parallel execution +# Pass-through to pytest with custom arguments +./run_tests.py -v -k "upload" tests/ ./run_tests.py -n auto tests/ - -# Filter by marker -./run_tests.py -m "unit and not slow" - -# Combine multiple pytest options -./run_tests.py -v -x -n auto tests/test_basic_uploads.py -``` - -### Duration Reporting - -The wrapper automatically adds `--durations` flags to show test timing information: - -```bash -# Show 5 slowest tests ./run_tests.py --durations=5 tests/ - -# Show tests taking at least 2 seconds -./run_tests.py --durations-min=2.0 tests/ - -# Disable duration reporting -./run_tests.py --durations=0 tests/ ``` -### Direct pytest Execution (Not Recommended) - -Direct pytest execution requires manual dependency installation with uv and is only for advanced users who need full pytest control: - -```bash -# Install dependencies manually -uv pip install -r requirements-test.txt - -# Run pytest directly -pytest - -# Run with verbose output -pytest -v - -# Run with parallel execution -pytest -n auto -``` - -### Run Specific Test Modules - -```bash -# Test basic upload functionality -./run_tests.py tests/test_basic_uploads.py +## Integration Test Requirements -# Test duplicate handling policies -./run_tests.py tests/test_duplicate_handling.py +Integration tests automatically validate their environment before running. -# Test project resolution -./run_tests.py tests/test_project_resolution.py +### Automatic Environment Validation -# Test error scenarios -./run_tests.py tests/test_error_scenarios.py +When you run integration tests, the test suite checks: -# Test integration scenarios -./run_tests.py tests/test_integration.py -``` +1. **GITLAB_TOKEN environment variable** - Must be set with a valid GitLab API token +2. **Git repository** - Must run from within a Git repository +3. **GitLab remotes** - Repository must have at least one remote pointing to a GitLab instance -### Run Tests by Markers +### Verifying Your Setup ```bash -# Run only fast tests -./run_tests.py -m fast - -# Run only integration tests -./run_tests.py -m integration +# Check if in Git repository +git remote -v -# Skip slow tests -./run_tests.py -m "not slow" +# Verify GitLab remote exists +git remote -v | grep gitlab -# Run only API tests -./run_tests.py -m api +# Check token is set +echo $GITLAB_TOKEN -# Run sequential tests only -./run_tests.py -m sequential +# Verify token is not empty +[ -n "$GITLAB_TOKEN" ] && echo "Token is set" || echo "Token is NOT set" ``` -## Test Execution Time - -### Expected Duration - -- **Unit tests**: 10-30 seconds (no external dependencies) -- **Integration tests**: 10-15 minutes (full suite with GitLab API operations) -- **Individual integration tests**: 30-120 seconds each - -### Reasons for Slow Execution - -Integration tests interact with real GitLab infrastructure, which introduces several time factors: - -- **Real GitLab API Operations**: Tests perform actual uploads, deletions, and verifications against the GitLab Package Registry -- **Rate Limiting**: Thread-safe rate limiter prevents API overload and respects GitLab rate limits (implemented in [`utils/rate_limiter.py`](utils/rate_limiter.py)) -- **Sequential Package Operations**: Each test creates unique packages, uploads files, verifies checksums, and cleans up -- **Network Latency**: Communication with GitLab servers introduces inherent delays -- **Checksum Verification**: Post-upload validation downloads files and verifies SHA256 checksums -- **Parallel Execution Overhead**: When using `-n auto`, pytest-xdist coordination adds slight overhead - -### Progress Monitoring +### When Validation Fails -The test suite provides several mechanisms for monitoring progress: +If integration tests are skipped, the error message explains what's missing: -- **pytest-sugar**: Provides real-time progress bars during execution -- **pytest-instafail**: Shows failures immediately as they occur -- **Duration reporting**: Shows timing for each test at the end -- **Wrapper script**: Shows elapsed time after completion +- **Missing GITLAB_TOKEN**: Set the environment variable with `export GITLAB_TOKEN='your-token'` +- **No Git repository**: Navigate to a Git repository or initialize one +- **No GitLab remotes**: Add a GitLab remote with `git remote add origin https://gitlab.com/namespace/project.git` ## Debugging Test Failures -### Using the Test Runner for Debugging - -The [`run_tests.py`](../run_tests.py) wrapper supports all pytest debugging options: +### Common Debugging Commands ```bash # Verbose output -./run_tests.py -v tests/test_basic_uploads.py +uv run pytest tests/ -v # Extra verbose output -./run_tests.py -vv tests/test_basic_uploads.py +uv run pytest tests/ -vv # Stop on first failure -./run_tests.py -x tests/ +uv run pytest tests/ -x # Full traceback -./run_tests.py --tb=long tests/ +uv run pytest tests/ --tb=long -# Short traceback (default) -./run_tests.py --tb=short tests/ +# Short traceback +uv run pytest tests/ --tb=short # Maximum verbosity for specific test -./run_tests.py -vvv --tb=long tests/test_basic_uploads.py::test_single_file_upload -``` - -### Common Debugging Scenarios +uv run pytest tests/unit/test_cli.py::test_parse_args -vvv --tb=long -#### Authentication Issues +# Enable debug logging +uv run pytest tests/ -v --log-cli-level=DEBUG +``` -If you encounter authentication errors: +### Common Issues -- Check `GITLAB_TOKEN` is set and valid -- Verify token hasn't expired -- Ensure token has `api` scope and write access to Package Registry -- Test with: `./run_tests.py --unit` (doesn't require token) +#### Authentication Errors +- Verify `GITLAB_TOKEN` is set and valid +- Check token has required permissions (`api` scope) +- Test with unit tests: `uv run pytest tests/unit/` (doesn't require token) #### Timeout Errors - -Integration tests have extended timeouts to accommodate GitLab API operations: - -- Integration tests: 600s timeout (configured in [`pyproject.toml`](../pyproject.toml)) -- Slow integration tests: 900s timeout -- If tests timeout, check network connectivity to GitLab -- Consider running sequentially: `./run_tests.py -m integration` (without `-n auto`) -- For very slow networks: `./run_tests.py --timeout=1200 tests/` +- Check network connectivity to GitLab +- Run sequentially: `uv run pytest tests/integration/` (without `-n auto`) +- Increase timeout: `uv run pytest tests/ --timeout=1200` #### Parallel Execution Issues - -If tests fail in parallel but pass sequentially: - -- May indicate a race condition or resource conflict -- Run sequentially for debugging: `./run_tests.py tests/` (without `-n auto`) -- Check rate limiter is working properly in [`utils/rate_limiter.py`](utils/rate_limiter.py) -- Verify unique package naming includes worker ID - -#### Package Cleanup Failures - -Tests automatically clean up packages in fixture teardown: - -- If cleanup fails, packages may remain in GitLab registry -- Manual cleanup: access GitLab project → Packages & Registries → Delete test packages -- Test packages are prefixed with `test-` and include timestamps -- Run cleanup tests: `./run_tests.py -m cleanup` - -### Viewing Test Logs - -Control log output verbosity: - -```bash -# pytest-sugar provides clean output by default -./run_tests.py tests/ - -# Enable debug logging -./run_tests.py --log-cli-level=DEBUG tests/ - -# Show INFO level logs -./run_tests.py --log-cli-level=INFO tests/ - -# Combine with verbose output -./run_tests.py -v --log-cli-level=DEBUG tests/test_basic_uploads.py -``` - -**Note**: GitLab API calls are rate-limited and logged in verbose mode. +- Run sequentially for debugging: `uv run pytest tests/` (without `-n auto`) +- Check rate limiter is working properly ## Parallel Execution -### Using Parallel Execution - -Parallel execution uses the pytest-xdist plugin to run tests across multiple CPU cores: +Tests are designed for safe parallel execution using pytest-xdist: ```bash # Auto-detect CPU cores -./run_tests.py -n auto tests/ +uv run pytest tests/ -n auto -# Specify worker count manually -./run_tests.py -n 4 tests/ +# Specify worker count +uv run pytest tests/ -n 4 # Use worksteal distribution for better load balancing -./run_tests.py --dist=worksteal -n auto tests/ +uv run pytest tests/ --dist=worksteal -n auto ``` ### Parallel Execution Safety -Tests are designed for safe parallel execution with: - - **Unique package names per test**: Timestamp + worker ID + random suffix - **Thread-safe rate limiting**: For GitLab API calls across all workers - **Isolated temporary directories**: Per worker isolation - **Automatic cleanup**: In fixture teardown -**Note**: Tests marked with `@pytest.mark.sequential` run sequentially even with `-n auto`. +## Test Categories -### Performance Considerations +### Unit Tests (`tests/unit/`) -- **Parallel execution reduces total time** but increases API load -- **Rate limiter prevents API overload** across all workers -- **Optimal worker count**: `-n auto` (matches CPU cores) -- **For debugging**: Run sequentially without `-n` flag +Fast tests that validate individual components: -## Test Categories +- **test_cli.py**: CLI argument parsing and validation +- **test_models.py**: Data models and structures +- **test_uploader.py**: Upload logic and file handling +- **test_validators.py**: Input validation functions -### Basic Upload Tests ([`test_basic_uploads.py`](test_basic_uploads.py)) -- **Single file upload**: Tests uploading individual files -- **Multiple file upload**: Tests uploading multiple files in one command -- **Directory upload**: Tests uploading entire directories -- **File mapping**: Tests custom file-to-package mapping - -### Duplicate Handling Tests ([`test_duplicate_handling.py`](test_duplicate_handling.py)) -- **Skip policy**: Tests skipping existing packages -- **Replace policy**: Tests replacing existing packages -- **Error policy**: Tests error handling for duplicates - -### Project Resolution Tests ([`test_project_resolution.py`](test_project_resolution.py)) -- **Git auto-detection**: Tests automatic project detection from git remote -- **Manual URL specification**: Tests explicit project URL specification -- **Manual path specification**: Tests explicit project path specification - -### Error Scenario Tests ([`test_error_scenarios.py`](test_error_scenarios.py)) -- **Network failures**: Tests handling of network connectivity issues -- **Authentication errors**: Tests handling of invalid tokens or permissions -- **Invalid inputs**: Tests handling of invalid file paths or project specifications -- **Error message validation**: Tests that error messages are informative - -### Integration Tests ([`test_integration.py`](test_integration.py)) -- **End-to-end scenarios**: Tests complete upload workflows -- **Multi-scenario validation**: Tests complex scenarios with multiple operations -- **Performance validation**: Tests upload performance and reliability +### Integration Tests (`tests/integration/`) -## Adding New Tests +End-to-end tests requiring GitLab API access: -### Creating a New Test Function +- **test_single_file_upload.py**: Single file upload tests +- **test_multiple_files_upload.py**: Multiple files upload tests +- **test_duplicate_handling.py**: Skip, replace, error policies +- **test_project_resolution.py**: Auto-detection and manual specification +- **test_error_scenarios.py**: Network failures, auth errors +- **test_end_to_end.py**: Comprehensive end-to-end scenarios -1. **Choose the appropriate test module** based on functionality -2. **Follow pytest naming conventions** (`test_*` functions) -3. **Use existing fixtures** for common setup -4. **Add appropriate markers** for categorization +## Test Execution Time -Example: -```python -import pytest -from utils.test_helpers import execute_upload_script -from utils.gitlab_helpers import verify_package_exists - -@pytest.mark.api -@pytest.mark.fast -def test_new_upload_scenario(gitlab_client, artifact_manager, temp_dir): - """Test a new upload scenario.""" - # Create test file - test_file = artifact_manager.create_test_file("test.txt", size=100) - - # Execute upload script - result = execute_upload_script( - files=[test_file.path], - package_name="test-package", - version="1.0.0", - working_dir=temp_dir - ) - - # Verify success - assert result.exit_code == 0 - assert "Upload successful" in result.stdout - - # Verify in GitLab - assert verify_package_exists( - gitlab_client, - "test-package", - "1.0.0", - "test.txt" - ) -``` +| Test Category | Duration | Requirements | +|---------------|----------|--------------| +| Unit tests | 10-30 seconds | None | +| Integration tests | 10-15 minutes | GITLAB_TOKEN | +| All tests (parallel) | 5-10 minutes | GITLAB_TOKEN | + +## Adding New Tests -### Creating a New Test Module +### Creating a New Test -1. **Create new file** following `test_*.py` naming convention -2. **Import required fixtures** from [`conftest.py`](conftest.py) -3. **Add module docstring** describing the test category -4. **Use appropriate markers** for the entire module +1. Choose the appropriate directory (`tests/unit/` or `tests/integration/`) +2. Follow pytest naming conventions (`test_*` functions) +3. Use existing fixtures from `conftest.py` +4. Add appropriate markers for categorization Example: ```python -"""Tests for new functionality category.""" - import pytest -from utils.test_helpers import execute_upload_script - -# Mark all tests in this module -pytestmark = [pytest.mark.api, pytest.mark.integration] -def test_new_functionality(gitlab_client, artifact_manager): +@pytest.mark.unit +def test_new_functionality(): """Test new functionality.""" # Test implementation - pass + assert True ``` ### Available Fixtures -The test suite provides several fixtures for common operations: - -- **`gitlab_client`**: Authenticated GitLab client +- **`gitlab_client`**: Authenticated GitLab client (integration tests) - **`artifact_manager`**: Test file creation and cleanup - **`temp_dir`**: Isolated temporary directory - **`project_resolver`**: Project identification utilities @@ -555,132 +354,34 @@ The test suite provides several fixtures for common operations: ### Test Markers -Use these markers to categorize your tests: - -- **`@pytest.mark.fast`**: Quick tests that can run in parallel -- **`@pytest.mark.slow`**: Tests that take longer to execute -- **`@pytest.mark.integration`**: End-to-end integration tests -- **`@pytest.mark.api`**: Tests requiring GitLab API access +- **`@pytest.mark.unit`**: Unit tests (no external dependencies) +- **`@pytest.mark.integration`**: Integration tests (requires GITLAB_TOKEN) +- **`@pytest.mark.fast`**: Quick tests +- **`@pytest.mark.slow`**: Slow tests +- **`@pytest.mark.api`**: Tests requiring API access - **`@pytest.mark.sequential`**: Tests that must run sequentially -- **`@pytest.mark.cleanup`**: Tests that perform cleanup operations - -## Troubleshooting -### Common Issues - -#### Authentication Errors -- Verify `GITLAB_TOKEN` is set and valid -- Check token has required permissions (`api` scope) -- Ensure token hasn't expired -- Test with unit tests: `./run_tests.py --unit` (doesn't require token) - -#### Project Not Found -- Verify `GITLAB_PROJECT_PATH` is correct -- Check git remote URL is accessible -- Ensure project exists and is accessible -- Try manual specification: `export GITLAB_PROJECT_PATH="group/project"` - -#### Network Timeouts -- Check network connectivity to GitLab instance -- Verify GitLab URL is correct -- Consider running tests sequentially if parallel execution fails -- Increase timeout: `./run_tests.py --timeout=1200 tests/` - -#### Integration Test Timeouts -- Integration tests have extended timeouts (600-900s) -- Timeout errors indicate network issues or GitLab API slowness -- Try running sequentially: `./run_tests.py -m integration` -- For very slow networks: `./run_tests.py --timeout=1200 tests/` - -#### Test Failures -- Check test output for specific error messages -- Verify all environment variables are set -- Ensure no leftover test artifacts from previous runs -- See [Debugging Test Failures](#debugging-test-failures) section - -### Debug Mode - -Run tests with additional debugging information using the [`run_tests.py`](../run_tests.py) wrapper: +## Getting Help ```bash -# Enable debug logging -./run_tests.py -v --log-cli-level=DEBUG tests/ - -# Show local variables on failure -./run_tests.py --tb=long tests/ - -# Stop on first failure -./run_tests.py -x tests/ - -# Run specific test with maximum verbosity -./run_tests.py -vvv --tb=long tests/test_basic_uploads.py::test_single_file_upload - -# Combine multiple debugging options -./run_tests.py -vvv -x --tb=long --log-cli-level=DEBUG tests/ -``` - -### Cleanup +# Show pytest help +uv run pytest --help -If tests are interrupted and leave artifacts: +# Show available markers +uv run pytest --markers -```bash -# Run cleanup tests specifically -./run_tests.py -m cleanup - -# Manual cleanup (if needed) -python -c " -from tests.conftest import cleanup_test_packages -cleanup_test_packages() -" +# Show available fixtures +uv run pytest --fixtures ``` -## Performance Considerations - -### Parallel Execution - -Tests are designed to run in parallel safely. See the [Parallel Execution](#parallel-execution) section for detailed information. - -- Use `./run_tests.py -n auto tests/` for optimal parallel execution -- Sequential tests are marked with `@pytest.mark.sequential` -- Rate limiting prevents API overload across all workers -- Unique package names include worker ID to prevent conflicts - -### Test Isolation - -- Each test uses unique package names and versions -- Package names include worker ID to prevent conflicts -- Temporary directories provide file system isolation -- Automatic cleanup prevents resource leaks -- Fixtures ensure proper setup and teardown - -### Resource Management - -- API rate limiting prevents GitLab API overload (see [`utils/rate_limiter.py`](utils/rate_limiter.py)) -- Temporary files are cleaned up automatically -- Test packages are removed from GitLab registry -- Memory usage is minimized through efficient fixtures -- Performance tracking is enabled via [`conftest_performance.py`](conftest_performance.py) plugin -- Duration reporting shows timing for each test automatically - -## Test Execution Methods Summary - -| Method | Use Case | Duration | Requirements | -|--------|----------|----------|--------------| -| `./run_tests.py` | Default, all available tests | 10s-15m | None (auto-detects token) | -| `./run_tests.py --unit` | Fast validation, no external deps | 10-30s | None | -| `./run_tests.py --integration` | Full GitLab API testing | 10-15m | GITLAB_TOKEN | -| `./run_tests.py --all` | Complete test suite | 15-20m | GITLAB_TOKEN (optional) | -| `./run_tests.py -n auto` | Parallel execution | 5-10m | GITLAB_TOKEN | -| `./run_tests.py -v -x` | Debugging failures | Varies | None | - ## Contributing When contributing new tests: -1. **Follow existing patterns** for consistency -2. **Add appropriate documentation** and docstrings -3. **Use existing fixtures** to avoid duplication -4. **Add proper markers** for categorization -5. **Ensure cleanup** of any created resources -6. **Test both success and failure scenarios** -7. **Run tests locally** before submitting: `./run_tests.py --all` +1. Follow existing patterns for consistency +2. Add appropriate documentation and docstrings +3. Use existing fixtures to avoid duplication +4. Add proper markers for categorization +5. Ensure cleanup of any created resources +6. Test both success and failure scenarios +7. Run tests locally before submitting: `uv run pytest tests/` diff --git a/tests/conftest.py b/tests/conftest.py index aa6cd64..034837c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -240,7 +240,7 @@ def verify_upload( "GitLab Generic Package Registry may not support subdirectories properly." ) # For subdirectory files, we'll assume success if the file was uploaded - # This matches the behavior in gitlab-pkg-upload.py validate_upload() + # This matches the behavior in gitlab_pkg_upload.uploader.validate_upload() return True return False diff --git a/tests/test_basic_uploads.py b/tests/test_basic_uploads.py deleted file mode 100644 index 0fcf7d6..0000000 --- a/tests/test_basic_uploads.py +++ /dev/null @@ -1,471 +0,0 @@ -""" -Basic upload functionality tests for GitLab package upload script. - -This module contains tests for basic upload scenarios extracted from the -monolithic test file. It validates single file uploads, multiple file uploads, -directory uploads, and file mapping functionality using pytest framework. -""" - -import os - -import pytest - -from .utils.test_helpers import ( - ScriptExecutor, - UploadExecution, - get_project_args, - validate_json_result, -) - -# Test markers for categorization -pytestmark = [ - pytest.mark.integration, # These are integration tests - pytest.mark.api, # These require GitLab API access - pytest.mark.slow, # These tests take longer to run due to real API calls -] - - -def _get_gitlab_token(): - """Get GitLab token from environment with proper error handling.""" - token = os.environ.get("GITLAB_TOKEN") - if not token: - pytest.skip("GITLAB_TOKEN environment variable not set") - return token - - -class TestBasicUploads: - """ - Test class for basic upload functionality. - - Extracted and adapted from TestOrchestrator._test_single_file_upload, - _test_multiple_file_upload, _test_directory_upload, and - _test_file_mapping_upload methods. - """ - - @pytest.mark.timeout(180) - def test_single_file_upload(self, gitlab_client, artifact_manager, project_path): - """ - Test single file upload functionality using subprocess execution of upload script. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test file - test_file = artifact_manager.create_test_file( - filename="single-test.txt", size_bytes=1024, content_pattern="text" - ) - - # Create unique package name - package_name = gitlab_client.create_test_package("single-file", "1.0.0") - - executor = ScriptExecutor() - upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--files", - str(test_file.path), - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=120, - use_json_output=True, - ) - - # Add GitLab token to environment - upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - upload_result = executor.execute_upload(upload_execution) - - # Validate JSON output is present - assert upload_result.json_data is not None, "JSON output not found in result" - - # Use helper function for structural validation - assert validate_json_result( - upload_result.json_data, - expected_success=True, - expected_files=[str(test_file.path)], - ), "JSON validation failed" - - # Validate specific fields - assert upload_result.json_data["success"] is True - assert upload_result.json_data["package_name"] == package_name - assert upload_result.json_data["version"] == "1.0.0" - assert upload_result.json_data["statistics"]["new_uploads"] == 1 - assert upload_result.json_data["statistics"]["failed_uploads"] == 0 - assert len(upload_result.json_data["successful_uploads"]) == 1 - - # Validate upload script execution succeeded - assert upload_result.exit_code == 0, ( - f"Unexpected exit code: {upload_result.exit_code}" - ) - - registry_verification = gitlab_client.verify_upload( - package_name, "1.0.0", test_file.path.name, test_file.checksum - ) - - assert registry_verification, ( - "Upload verification failed - file not found in GitLab registry" - ) - - @pytest.mark.timeout(180) - def test_multiple_file_upload(self, gitlab_client, artifact_manager, project_path): - """ - Test multiple file upload functionality using subprocess execution. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create multiple test files with different characteristics - test_files = [ - artifact_manager.create_test_file("multi-1.txt", 512, "text"), - artifact_manager.create_test_file("multi-2.json", 1024, "json"), - artifact_manager.create_test_file("multi-3.bin", 2048, "binary"), - ] - - # Create unique package name - package_name = gitlab_client.create_test_package("multi-file", "1.0.0") - - file_paths = [str(f.path) for f in test_files] - executor = ScriptExecutor() - upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--files", - ] - + file_paths - + ["--json-output"] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - use_json_output=True, - ) - - # Add GitLab token to environment - upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - upload_result = executor.execute_upload(upload_execution) - - # Validate JSON output is present - assert upload_result.json_data is not None, "JSON output not found in result" - - # Use helper function for structural validation - assert validate_json_result( - upload_result.json_data, - expected_success=True, - expected_files=file_paths, - ), "JSON validation failed" - - # Validate specific fields - assert upload_result.json_data["success"] is True - assert upload_result.json_data["statistics"]["new_uploads"] == 3 - assert upload_result.json_data["statistics"]["failed_uploads"] == 0 - assert len(upload_result.json_data["successful_uploads"]) == 3 - - # Verify each test file appears in successful_uploads - uploaded_filenames = [ - upload["target_filename"] - for upload in upload_result.json_data["successful_uploads"] - ] - for test_file in test_files: - assert test_file.path.name in uploaded_filenames, ( - f"File {test_file.path.name} not found in successful uploads" - ) - - # Validate upload script execution succeeded - assert upload_result.exit_code == 0, ( - f"Unexpected exit code: {upload_result.exit_code}" - ) - - registry_failures = [] - for test_file in test_files: - registry_verification = gitlab_client.verify_upload( - package_name, "1.0.0", test_file.path.name, test_file.checksum - ) - if not registry_verification: - registry_failures.append(test_file.path.name) - - assert not registry_failures, ( - f"Registry verification failed for files: {', '.join(registry_failures)}" - ) - - @pytest.mark.timeout(180) - def test_directory_upload(self, gitlab_client, artifact_manager, project_path): - """ - Test directory upload functionality using subprocess execution. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test directory with files - test_files = artifact_manager.create_test_directory("upload-dir", 4) - directory_path = artifact_manager.base_dir / "upload-dir" - - # Create unique package name - package_name = gitlab_client.create_test_package("directory", "1.0.0") - - executor = ScriptExecutor() - upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--directory", - str(directory_path), - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - use_json_output=True, - ) - - # Add GitLab token to environment - upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - upload_result = executor.execute_upload(upload_execution) - - # Validate JSON output is present - assert upload_result.json_data is not None, "JSON output not found in result" - - # Use helper function for structural validation - assert validate_json_result( - upload_result.json_data, - expected_success=True, - expected_files=[str(f.path) for f in test_files], - ), "JSON validation failed" - - # Validate specific fields - assert upload_result.json_data["success"] is True - assert upload_result.json_data["statistics"]["new_uploads"] == 4 - assert upload_result.json_data["statistics"]["failed_uploads"] == 0 - assert len(upload_result.json_data["successful_uploads"]) == 4 - - # Verify all directory files appear in successful_uploads - uploaded_filenames = [ - upload["target_filename"] - for upload in upload_result.json_data["successful_uploads"] - ] - for test_file in test_files: - assert test_file.path.name in uploaded_filenames, ( - f"File {test_file.path.name} not found in successful uploads" - ) - - # Validate upload script execution succeeded - assert upload_result.exit_code == 0, ( - f"Unexpected exit code: {upload_result.exit_code}" - ) - - registry_failures = [] - for test_file in test_files: - registry_verification = gitlab_client.verify_upload( - package_name, "1.0.0", test_file.path.name, test_file.checksum - ) - if not registry_verification: - registry_failures.append(test_file.path.name) - - assert not registry_failures, ( - f"Registry verification failed for files: {', '.join(registry_failures)}" - ) - - @pytest.mark.timeout(180) - def test_file_mapping_upload(self, gitlab_client, artifact_manager, project_path): - """ - Test file mapping upload functionality with custom target names using subprocess execution. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test files - test_files = [ - artifact_manager.create_test_file("source1.txt", 1024, "text"), - artifact_manager.create_test_file("source2.json", 2048, "json"), - ] - - # Create unique package name - package_name = gitlab_client.create_test_package("file-mapping", "1.0.0") - - executor = ScriptExecutor() - upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--files", - str(test_files[0].path), - str(test_files[1].path), - "--file-mapping", - f"{test_files[0].path.name}:target1.txt", - "--file-mapping", - f"{test_files[1].path.name}:config/target2.json", - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - use_json_output=True, - ) - - # Add GitLab token to environment - upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - upload_result = executor.execute_upload(upload_execution) - - # Validate JSON output is present - assert upload_result.json_data is not None, "JSON output not found in result" - - # Use helper function for structural validation - assert validate_json_result( - upload_result.json_data, - expected_success=True, - ), "JSON validation failed" - - # Validate specific fields - assert upload_result.json_data["success"] is True - assert upload_result.json_data["statistics"]["new_uploads"] == 2 - assert len(upload_result.json_data["successful_uploads"]) == 2 - - # Verify mapped filenames appear in successful_uploads - uploaded_filenames = [ - upload["target_filename"] - for upload in upload_result.json_data["successful_uploads"] - ] - assert "target1.txt" in uploaded_filenames, ( - "Mapped file target1.txt not found in successful uploads" - ) - assert "config/target2.json" in uploaded_filenames, ( - "Mapped file config/target2.json not found in successful uploads" - ) - - # Validate upload script execution succeeded - assert upload_result.exit_code == 0, ( - f"Unexpected exit code: {upload_result.exit_code}" - ) - - target_mappings = [ - ("target1.txt", test_files[0].checksum), - ("config/target2.json", test_files[1].checksum), - ] - - registry_failures = [] - for target_filename, expected_checksum in target_mappings: - registry_verification = gitlab_client.verify_upload( - package_name, "1.0.0", target_filename, expected_checksum - ) - if not registry_verification: - registry_failures.append(target_filename) - - assert not registry_failures, ( - f"Registry verification failed for mapped files: {', '.join(registry_failures)}" - ) - - -# Additional test functions for edge cases and specific scenarios - - -@pytest.mark.slow -@pytest.mark.timeout(180) -def test_large_file_upload(gitlab_client, artifact_manager, project_path): - """ - Test upload of a larger file to ensure the script handles various file sizes. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create a larger test file (10KB) - test_file = artifact_manager.create_test_file( - filename="large-test.bin", size_bytes=10240, content_pattern="binary" - ) - - # Create unique package name - package_name = gitlab_client.create_test_package("large-file", "1.0.0") - - # Execute upload script - executor = ScriptExecutor() - upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--files", - str(test_file.path), - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, # Longer timeout for larger file - use_json_output=True, - ) - - upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - upload_result = executor.execute_upload(upload_execution) - - # Validate JSON output is present - assert upload_result.json_data is not None, "JSON output not found in result" - - # Use helper function for structural validation - assert validate_json_result( - upload_result.json_data, - expected_success=True, - expected_files=[str(test_file.path)], - ), "JSON validation failed" - - # Validate specific fields - assert upload_result.json_data["success"] is True - assert upload_result.json_data["statistics"]["new_uploads"] == 1 - assert upload_result.json_data["statistics"]["failed_uploads"] == 0 - - # Validate results - assert upload_result.exit_code == 0, ( - f"Unexpected exit code: {upload_result.exit_code}" - ) - - # Verify in GitLab registry - registry_verification = gitlab_client.verify_upload( - package_name, "1.0.0", test_file.path.name, test_file.checksum - ) - assert registry_verification, "Large file verification failed in GitLab registry" diff --git a/tests/test_duplicate_handling.py b/tests/test_duplicate_handling.py deleted file mode 100644 index 219473c..0000000 --- a/tests/test_duplicate_handling.py +++ /dev/null @@ -1,865 +0,0 @@ -""" -Duplicate handling policy tests for GitLab package upload script. - -This module contains tests for duplicate handling policies extracted from the -monolithic test file. It validates skip, replace, and error duplicate policies -using pytest framework with real GitLab API interactions. -""" - -import os -import time - -import pytest - -from .utils.test_helpers import ( - ScriptExecutor, - UploadExecution, - get_project_args, - validate_json_result, -) - - -def _validate_upload_consistency( - gitlab_client, - package_name: str, - version: str, - filename: str, - expected_checksum: str, -) -> bool: - """ - Validate upload results using the same logic as the upload script. - - Args: - gitlab_client: GitLab test client - package_name: Name of the uploaded package - version: Package version - filename: Name of the uploaded file - expected_checksum: Expected SHA256 checksum - - Returns: - True if validation succeeds using upload script logic, False otherwise - """ - try: - # Step 1: Verify file exists in registry (same as upload script verification) - if not gitlab_client.verify_upload( - package_name, version, filename, expected_checksum - ): - return False - - # Step 2: Verify download URL is accessible (same as upload script URL generation) - download_url = gitlab_client.get_download_url(package_name, version, filename) - if not download_url: - return False - - # Step 3: Verify downloaded content matches expected checksum (same as upload script validation) - if not gitlab_client.download_and_verify( - package_name, version, filename, expected_checksum - ): - return False - - return True - - except Exception: - return False - - -def _validate_duplicate_behavior( - upload_result, expected_behavior: str, json_data=None -) -> bool: - """ - Validate that the upload result indicates the expected duplicate handling behavior. - - Args: - upload_result: Result from script execution - expected_behavior: Expected behavior ("skip", "replace", "error") - json_data: Optional JSON data for structured validation - - Returns: - True if behavior is indicated in output or JSON, False otherwise - """ - # If JSON data is provided, use structured validation - if json_data is not None: - if expected_behavior == "skip": - # Check for skip indicators in JSON - stats = json_data.get("statistics", {}) - skipped_list = json_data.get("skipped_duplicates", []) - successful = json_data.get("successful_uploads", []) - - # Check if any files were skipped - if stats.get("skipped_duplicates", 0) > 0: - return True - if len(skipped_list) > 0: - return True - # Check if any successful upload was marked as skipped duplicate - for upload in successful: - if ( - upload.get("was_duplicate") - and upload.get("duplicate_action") == "skipped" - ): - return True - return False - - elif expected_behavior == "replace": - # Check for replace indicators in JSON - stats = json_data.get("statistics", {}) - successful = json_data.get("successful_uploads", []) - - # Check if any files were replaced - if stats.get("replaced_duplicates", 0) > 0: - return True - # Check if any successful upload was marked as replaced duplicate - for upload in successful: - if ( - upload.get("was_duplicate") - and upload.get("duplicate_action") == "replaced" - ): - return True - return False - - elif expected_behavior == "error": - # Check for error indicators in JSON - failed = json_data.get("failed_uploads", []) - success = json_data.get("success", True) - - # Check if upload failed - if not success: - return True - if len(failed) > 0: - # Check if error message mentions duplicates - for failure in failed: - error_msg = failure.get("error_message", "").lower() - if "duplicate" in error_msg or "already exists" in error_msg: - return True - return False - - # Fallback to regex matching if JSON data not provided - output_text = (upload_result.stdout + upload_result.stderr).lower() - - if expected_behavior == "skip": - # Look for skip indicators - skip_patterns = ["skip", "already exists", "duplicate", "existing"] - return any(pattern in output_text for pattern in skip_patterns) - - elif expected_behavior == "replace": - # Look for replace indicators - replace_patterns = ["replac", "overwrit", "updat"] - return any(pattern in output_text for pattern in replace_patterns) - - elif expected_behavior == "error": - # Look for error indicators - error_patterns = [ - "duplicate", - "error", - "already exists", - "file exists", - "conflict", - ] - return any(pattern in output_text for pattern in error_patterns) - - return False - - -# Test markers for categorization -pytestmark = [ - pytest.mark.integration, # These are integration tests - pytest.mark.api, # These require GitLab API access - pytest.mark.slow, # These tests take longer due to multiple uploads and API calls -] - - -def _get_gitlab_token(): - """Get GitLab token from environment with proper error handling.""" - token = os.environ.get("GITLAB_TOKEN") - if not token: - pytest.skip("GITLAB_TOKEN environment variable not set") - return token - - -class TestDuplicateHandling: - """ - Test class for duplicate handling policies. - - Extracted and adapted from TestOrchestrator._test_skip_duplicate_policy, - _test_replace_duplicate_policy, and _test_error_duplicate_policy methods. - """ - - @pytest.mark.timeout(180) - def test_skip_duplicate_policy(self, gitlab_client, artifact_manager, project_path): - """ - Test skip duplicate policy using subprocess execution. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test file - test_file = artifact_manager.create_test_file( - filename="duplicate-skip-test.txt", size_bytes=2048, content_pattern="text" - ) - - # Create unique package name - package_name = gitlab_client.create_test_package("skip-duplicate", "1.0.0") - - executor = ScriptExecutor() - first_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--files", - str(test_file.path), - "--duplicate-policy", - "skip", - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=120, - use_json_output=True, - ) - - # Add GitLab token to environment - first_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - first_upload_result = executor.execute_upload(first_upload_execution) - - # Validate first upload succeeded - assert first_upload_result.json_data is not None - assert validate_json_result( - first_upload_result.json_data, - expected_success=True, - expected_files=[str(test_file.path)], - ) - assert first_upload_result.json_data["success"] is True - assert first_upload_result.json_data["statistics"]["new_uploads"] == 1 - assert first_upload_result.json_data["statistics"]["skipped_duplicates"] == 0 - assert first_upload_result.success, ( - f"First upload failed: {first_upload_result.error_message}" - ) - assert first_upload_result.exit_code == 0, ( - f"Unexpected exit code: {first_upload_result.exit_code}" - ) - - first_validation = _validate_upload_consistency( - gitlab_client, - package_name, - "1.0.0", - test_file.path.name, - test_file.checksum, - ) - assert first_validation, ( - "First upload validation failed using upload script consistency logic" - ) - - # Wait to ensure first upload is processed - time.sleep(2) - - second_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--files", - str(test_file.path), - "--duplicate-policy", - "skip", - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=120, - use_json_output=True, - ) - - second_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - second_upload_result = executor.execute_upload(second_upload_execution) - - # Validate second upload succeeded (skip behavior) - assert second_upload_result.success, ( - f"Second upload failed: {second_upload_result.error_message}" - ) - assert second_upload_result.exit_code == 0, ( - f"Unexpected exit code: {second_upload_result.exit_code}" - ) - - registry_verification = _validate_upload_consistency( - gitlab_client, - package_name, - "1.0.0", - test_file.path.name, - test_file.checksum, - ) - assert registry_verification, ( - "Registry verification failed after skip duplicate test using upload script consistency logic" - ) - - assert second_upload_result.json_data is not None - assert ( - second_upload_result.json_data["statistics"]["skipped_duplicates"] >= 1 - ), "Expected at least one skipped duplicate" - assert len(second_upload_result.json_data["skipped_duplicates"]) >= 1, ( - "Expected files in skipped_duplicates list" - ) - skipped = second_upload_result.json_data["skipped_duplicates"][0] - assert skipped["was_duplicate"] is True - assert skipped["duplicate_action"] == "skipped" - assert skipped["target_filename"] == test_file.path.name - - @pytest.mark.timeout(180) - def test_replace_duplicate_policy( - self, gitlab_client, artifact_manager, project_path - ): - """ - Test replace duplicate policy - should replace existing duplicate files. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create first test file - first_test_file = artifact_manager.create_test_file( - filename="duplicate-replace-test.txt", - size_bytes=1024, - content_pattern="text", - ) - - # Create unique package name - package_name = gitlab_client.create_test_package("replace-duplicate", "1.0.0") - - # First upload - should succeed - executor = ScriptExecutor() - first_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--files", - str(first_test_file.path), - "--duplicate-policy", - "replace", - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=120, - use_json_output=True, - ) - - first_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - first_upload_result = executor.execute_upload(first_upload_execution) - - # Validate first upload succeeded - assert first_upload_result.json_data is not None - assert validate_json_result( - first_upload_result.json_data, - expected_success=True, - expected_files=[str(first_test_file.path)], - ) - assert first_upload_result.json_data["success"] is True - assert first_upload_result.json_data["statistics"]["new_uploads"] == 1 - assert first_upload_result.json_data["statistics"]["skipped_duplicates"] == 0 - assert first_upload_result.success, ( - f"First upload failed: {first_upload_result.error_message}" - ) - assert first_upload_result.exit_code == 0, ( - f"Unexpected exit code: {first_upload_result.exit_code}" - ) - - # Wait a moment to ensure the first upload is processed - time.sleep(2) - - # Create second test file with same name but different content - second_test_file = artifact_manager.create_test_file( - filename="duplicate-replace-test.txt", - size_bytes=2048, # Different size - content_pattern="json", # Different content pattern - ) - - # Second upload with same filename but different content - should replace - second_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--files", - str(second_test_file.path), - "--duplicate-policy", - "replace", - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=120, - use_json_output=True, - ) - - second_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - second_upload_result = executor.execute_upload(second_upload_execution) - - # Verify that both uploads succeeded - assert first_upload_result.success, ( - f"First upload failed: {first_upload_result.error_message}" - ) - assert second_upload_result.success, ( - f"Second upload failed: {second_upload_result.error_message}" - ) - - # GitLab API verification - file should exist with the second file's checksum (indicating replacement) - api_verification = _validate_upload_consistency( - gitlab_client, - package_name, - "1.0.0", - second_test_file.path.name, - second_test_file.checksum, - ) - assert api_verification, ( - "GitLab API verification failed - file was not replaced using upload script consistency logic" - ) - - # Additional check: download and verify content matches second file - download_verification = gitlab_client.download_and_verify( - package_name=package_name, - version="1.0.0", - filename=second_test_file.path.name, - expected_checksum=second_test_file.checksum, - ) - assert download_verification, ( - "Download verification failed - file content does not match second file" - ) - - assert second_upload_result.json_data is not None - assert second_upload_result.json_data["success"] is True - assert ( - second_upload_result.json_data["statistics"]["replaced_duplicates"] >= 1 - ), "Expected at least one replaced duplicate" - replaced_upload = next( - ( - u - for u in second_upload_result.json_data["successful_uploads"] - if u["was_duplicate"] and u["duplicate_action"] == "replaced" - ), - None, - ) - assert replaced_upload is not None, ( - "Expected replaced upload in successful_uploads" - ) - assert replaced_upload["target_filename"] == second_test_file.path.name - - @pytest.mark.timeout(180) - def test_error_duplicate_policy( - self, gitlab_client, artifact_manager, project_path - ): - """ - Test error duplicate policy - should fail when duplicate files are detected. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test file - test_file = artifact_manager.create_test_file( - filename="duplicate-error-test.txt", size_bytes=1536, content_pattern="text" - ) - - # Create unique package name - package_name = gitlab_client.create_test_package("error-duplicate", "1.0.0") - - # First upload - should succeed - executor = ScriptExecutor() - first_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--files", - str(test_file.path), - "--duplicate-policy", - "error", - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=120, - use_json_output=True, - ) - - first_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - first_upload_result = executor.execute_upload(first_upload_execution) - - # Validate first upload succeeded - assert first_upload_result.json_data is not None - assert validate_json_result( - first_upload_result.json_data, - expected_success=True, - expected_files=[str(test_file.path)], - ) - assert first_upload_result.json_data["success"] is True - assert first_upload_result.json_data["statistics"]["new_uploads"] == 1 - assert first_upload_result.json_data["statistics"]["skipped_duplicates"] == 0 - assert first_upload_result.success, ( - f"First upload failed: {first_upload_result.error_message}" - ) - assert first_upload_result.exit_code == 0, ( - f"Unexpected exit code: {first_upload_result.exit_code}" - ) - - # Wait a moment to ensure the first upload is processed - time.sleep(2) - - # Second upload with same file - should fail due to error policy - second_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--files", - str(test_file.path), - "--duplicate-policy", - "error", - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=1, # Expect failure - expected_output_patterns=[], - timeout=120, - use_json_output=True, - ) - - second_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - second_upload_result = executor.execute_upload(second_upload_execution) - - # For error policy, we expect the second upload to fail - assert not second_upload_result.success, ( - "Second upload should have failed with error policy but succeeded" - ) - assert second_upload_result.exit_code != 0, ( - "Second upload should have returned non-zero exit code" - ) - - assert second_upload_result.json_data is not None, ( - "JSON output should be present even on failure" - ) - assert second_upload_result.json_data["success"] is False - assert second_upload_result.json_data["exit_code"] == 1 - assert len(second_upload_result.json_data["failed_uploads"]) > 0, ( - "Expected failed uploads" - ) - failed = second_upload_result.json_data["failed_uploads"][0] - assert "duplicate" in failed.get("error_message", "").lower(), ( - "Error message should mention duplicate" - ) - - # GitLab API verification - original file should still exist - api_verification = _validate_upload_consistency( - gitlab_client, - package_name, - "1.0.0", - test_file.path.name, - test_file.checksum, - ) - assert api_verification, ( - "GitLab API verification failed - original file should still exist using upload script consistency logic" - ) - - -# Additional test functions for edge cases and specific duplicate scenarios - - -@pytest.mark.slow -@pytest.mark.timeout(180) -def test_multiple_file_skip_duplicates(gitlab_client, artifact_manager, project_path): - """ - Test skip duplicate policy with multiple files where some are duplicates. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test files - test_files = [ - artifact_manager.create_test_file("multi-skip-1.txt", 1024, "text"), - artifact_manager.create_test_file("multi-skip-2.json", 2048, "json"), - artifact_manager.create_test_file("multi-skip-3.bin", 512, "binary"), - ] - - # Create unique package name - package_name = gitlab_client.create_test_package("multi-skip-duplicate", "1.0.0") - - # First upload - all files should succeed - executor = ScriptExecutor() - file_paths = [str(f.path) for f in test_files] - first_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--files", - ] - + file_paths - + ["--duplicate-policy", "skip"] - + ["--json-output"] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - use_json_output=True, - ) - - first_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - first_upload_result = executor.execute_upload(first_upload_execution) - - # Validate first upload succeeded - assert first_upload_result.json_data is not None - assert validate_json_result( - first_upload_result.json_data, - expected_success=True, - expected_files=file_paths, - ) - assert first_upload_result.json_data["success"] is True - assert first_upload_result.json_data["statistics"]["new_uploads"] == 3 - assert first_upload_result.json_data["statistics"]["skipped_duplicates"] == 0 - assert first_upload_result.success, ( - f"First upload failed: {first_upload_result.error_message}" - ) - - # Wait for processing - time.sleep(3) - - # Second upload with same files - should skip all duplicates - second_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--files", - ] - + file_paths - + ["--duplicate-policy", "skip"] - + ["--json-output"] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - use_json_output=True, - ) - - second_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - second_upload_result = executor.execute_upload(second_upload_execution) - - # Validate second upload succeeded (skip behavior) - assert second_upload_result.success, ( - f"Second upload failed: {second_upload_result.error_message}" - ) - - # Verify all files still exist in registry using comprehensive validation - registry_failures = [] - for test_file in test_files: - registry_verification = _validate_upload_consistency( - gitlab_client, - package_name, - "1.0.0", - test_file.path.name, - test_file.checksum, - ) - if not registry_verification: - registry_failures.append(test_file.path.name) - - assert not registry_failures, ( - f"Registry verification failed for files: {', '.join(registry_failures)}" - ) - - # Verify skip behavior for multiple files - assert second_upload_result.json_data is not None - assert second_upload_result.json_data["statistics"]["skipped_duplicates"] == 3, ( - "All 3 files should be skipped as duplicates" - ) - assert len(second_upload_result.json_data["skipped_duplicates"]) == 3 - skipped_filenames = [ - s["target_filename"] - for s in second_upload_result.json_data["skipped_duplicates"] - ] - for test_file in test_files: - assert test_file.path.name in skipped_filenames - - -@pytest.mark.timeout(180) -def test_mixed_duplicate_and_new_files(gitlab_client, artifact_manager, project_path): - """ - Test skip duplicate policy with a mix of duplicate and new files. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create initial test files - initial_files = [ - artifact_manager.create_test_file("mixed-1.txt", 1024, "text"), - artifact_manager.create_test_file("mixed-2.json", 2048, "json"), - ] - - # Create unique package name - package_name = gitlab_client.create_test_package("mixed-duplicate", "1.0.0") - - # First upload - initial files - executor = ScriptExecutor() - initial_file_paths = [str(f.path) for f in initial_files] - first_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--files", - ] - + initial_file_paths - + ["--duplicate-policy", "skip"] - + ["--json-output"] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - use_json_output=True, - ) - - first_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - first_upload_result = executor.execute_upload(first_upload_execution) - - # Validate first upload succeeded - assert first_upload_result.json_data is not None - assert validate_json_result( - first_upload_result.json_data, - expected_success=True, - expected_files=initial_file_paths, - ) - assert first_upload_result.json_data["success"] is True - assert first_upload_result.json_data["statistics"]["new_uploads"] == 2 - assert first_upload_result.json_data["statistics"]["skipped_duplicates"] == 0 - assert first_upload_result.success, ( - f"First upload failed: {first_upload_result.error_message}" - ) - - # Wait for processing - time.sleep(2) - - # Create additional new files - new_files = [ - artifact_manager.create_test_file("mixed-3.bin", 512, "binary"), - artifact_manager.create_test_file("mixed-4.xml", 1536, "xml"), - ] - - # Second upload with mix of duplicate and new files - all_files = initial_files + new_files - all_file_paths = [str(f.path) for f in all_files] - - second_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--files", - ] - + all_file_paths - + ["--duplicate-policy", "skip"] - + ["--json-output"] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - use_json_output=True, - ) - - second_upload_execution.env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - second_upload_result = executor.execute_upload(second_upload_execution) - - # Validate second upload succeeded - assert second_upload_result.success, ( - f"Second upload failed: {second_upload_result.error_message}" - ) - - # Verify all files exist in registry (duplicates skipped, new files uploaded) using comprehensive validation - registry_failures = [] - for test_file in all_files: - registry_verification = _validate_upload_consistency( - gitlab_client, - package_name, - "1.0.0", - test_file.path.name, - test_file.checksum, - ) - if not registry_verification: - registry_failures.append(test_file.path.name) - - assert not registry_failures, ( - f"Registry verification failed for files: {', '.join(registry_failures)}" - ) - - # Verify mixed behavior (skip duplicates, upload new files) - assert second_upload_result.json_data is not None - assert second_upload_result.json_data["statistics"]["skipped_duplicates"] == 2, ( - "Initial 2 files should be skipped" - ) - assert second_upload_result.json_data["statistics"]["new_uploads"] == 2, ( - "New 2 files should be uploaded" - ) - skipped_filenames = [ - s["target_filename"] - for s in second_upload_result.json_data["skipped_duplicates"] - ] - new_filenames = [ - u["target_filename"] - for u in second_upload_result.json_data["successful_uploads"] - if not u["was_duplicate"] - ] - for initial_file in initial_files: - assert initial_file.path.name in skipped_filenames - for new_file in new_files: - assert new_file.path.name in new_filenames diff --git a/tests/test_error_scenarios.py b/tests/test_error_scenarios.py deleted file mode 100644 index 4cd9cfd..0000000 --- a/tests/test_error_scenarios.py +++ /dev/null @@ -1,983 +0,0 @@ -""" -Error scenario tests for GitLab package upload script. - -This module contains tests for error handling scenarios extracted from the -monolithic test file. It validates network failures, authentication errors, -error message validation, failure continuation behavior, and non-ASCII filename -rejection using pytest framework. -""" - -import os -from pathlib import Path - -import pytest - -from .utils.test_helpers import ( - ScriptExecutor, - UploadExecution, - get_project_args, -) - -# Test markers for categorization -pytestmark = [ - pytest.mark.integration, # These are integration tests - pytest.mark.api, # These require GitLab API access - pytest.mark.slow, # These tests simulate failures and take longer -] - - -def _get_gitlab_token(): - """Get GitLab token from environment with proper error handling.""" - token = os.environ.get("GITLAB_TOKEN") - if not token: - pytest.skip("GITLAB_TOKEN environment variable not set") - return token - - -class TestErrorScenarios: - """ - Test class for error scenario handling. - - Extracted and adapted from TestOrchestrator._test_network_failure_simulation, - _test_authentication_error, _test_error_message_validation, and - _test_failure_continuation_behavior methods. - """ - - @pytest.mark.timeout(90) - def test_network_failure_simulation( - self, gitlab_client, artifact_manager, project_path - ): - """ - Test network failure simulation and recovery. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test file - test_file = artifact_manager.create_test_file( - "network-test.txt", size_bytes=1024, content_pattern="network-test" - ) - - # Create unique package name - package_name = gitlab_client.create_test_package("network-failure", "1.0.0") - - # Get project arguments - project_args = get_project_args(project_path, gitlab_url=None) - - # Build command with invalid GitLab URL to simulate network failure - command = [ - "python", - str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--gitlab-url", - "https://invalid-gitlab-url.example.com", - "--token", - _get_gitlab_token(), - "--json-output", - "--files", - str(test_file.path), - ] + project_args - - # Create execution configuration expecting network failure - execution_config = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure - expected_output_patterns=[], - timeout=60, - use_json_output=True, - env_vars={ - "GITLAB_TOKEN": "" - }, # Clear GITLAB_TOKEN to force use of --token argument - ) - - # Execute upload (should fail due to network issues) - executor = ScriptExecutor() - upload_result = executor.execute_upload(execution_config) - - # Verify that it failed as expected - assert upload_result.exit_code == 1, ( - f"Expected exit code 1, got {upload_result.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert upload_result.json_data.get("exit_code") == 1, ( - "Expected exit_code to be 1" - ) - assert "error" in upload_result.json_data, ( - "Expected error field in JSON output" - ) - assert "error_type" in upload_result.json_data, ( - "Expected error_type field in JSON output" - ) - - # Check for network-related keywords in error message - error_msg = upload_result.json_data["error"].lower() - network_keywords = [ - "network", - "connection", - "timeout", - "failed to connect", - "resolve", - "dns", - ] - network_error_found = any( - keyword in error_msg for keyword in network_keywords - ) - assert network_error_found, ( - f"Expected network error keywords in JSON error: {upload_result.json_data['error']}" - ) - else: - # Fallback to stderr/stdout checking for early script errors - error_output = upload_result.stdout + upload_result.stderr - network_error_patterns = [ - "network", - "connection", - "timeout", - "failed to connect", - ] - network_error_found = any( - pattern in error_output.lower() for pattern in network_error_patterns - ) - assert network_error_found, ( - f"Expected network error patterns in output: {error_output}" - ) - - @pytest.mark.timeout(90) - def test_authentication_error(self, gitlab_client, artifact_manager, project_path): - """ - Test authentication error handling with invalid token. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test file - test_file = artifact_manager.create_test_file( - filename="auth-error-test.txt", size_bytes=1024, content_pattern="text" - ) - - # Create unique package name - package_name = gitlab_client.create_test_package("auth-error", "1.0.0") - - # Get project arguments - project_args = get_project_args(project_path, gitlab_url=None) - - # Build command with invalid token - invalid_token = "invalid-token-that-should-fail-authentication" - command = [ - "python", - str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--token", - invalid_token, - "--json-output", - "--files", - str(test_file.path), - ] + project_args - - # Create execution configuration expecting authentication failure - execution_config = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure - expected_output_patterns=[], - timeout=60, - use_json_output=True, - env_vars={ - "GITLAB_TOKEN": "" - }, # Clear GITLAB_TOKEN to force use of --token argument - ) - - # Execute upload (should fail due to authentication issues) - executor = ScriptExecutor() - upload_result = executor.execute_upload(execution_config) - - # Validate that the upload failed as expected - assert upload_result.exit_code != 0, ( - f"Expected upload to fail but got exit code: {upload_result.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert upload_result.json_data.get("exit_code") != 0, ( - "Expected non-zero exit_code" - ) - assert "error" in upload_result.json_data, ( - "Expected error field in JSON output" - ) - assert "error_type" in upload_result.json_data, ( - "Expected error_type field in JSON output" - ) - - # Check for authentication-related keywords in error message - error_msg = upload_result.json_data["error"].lower() - auth_keywords = [ - "authentication", - "token", - "unauthorized", - "401", - "403", - "access denied", - ] - auth_error_found = any(keyword in error_msg for keyword in auth_keywords) - assert auth_error_found, ( - f"Expected authentication error keywords in JSON error: {upload_result.json_data['error']}" - ) - else: - # Fallback to stderr/stdout checking for early script errors - error_output = upload_result.stdout + upload_result.stderr - auth_error_indicators = [ - "authentication", - "token", - "unauthorized", - "401", - "403", - "access denied", - ] - auth_error_present = any( - indicator in error_output.lower() for indicator in auth_error_indicators - ) - assert auth_error_present, ( - f"Expected authentication error patterns in output: {error_output}" - ) - - @pytest.mark.timeout(90) - def test_error_message_validation( - self, gitlab_client, artifact_manager, project_path - ): - """ - Test error message validation for various error scenarios. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Test scenario 1: Non-existent file - nonexistent_file = str(artifact_manager.base_dir / "nonexistent-file.txt") - package_name = gitlab_client.create_test_package("error-msg", "1.0.0") - - # Get project arguments - project_args = get_project_args(project_path) - - # Build command with non-existent file - command = [ - "python", - str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--token", - _get_gitlab_token(), - "--json-output", - "--files", - nonexistent_file, - ] + project_args - - # Create execution configuration expecting file not found error - execution_config = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure - expected_output_patterns=[], - timeout=30, - use_json_output=True, - ) - - # Execute upload (should fail due to missing file) - executor = ScriptExecutor() - upload_result = executor.execute_upload(execution_config) - - # Validate error message quality - assert upload_result.exit_code != 0, ( - f"Expected upload to fail but got exit code: {upload_result.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert "error" in upload_result.json_data, ( - "Expected error field in JSON output" - ) - - # Check for file-related keywords in error message - error_msg = upload_result.json_data["error"].lower() - file_keywords = ["file", "not found", "does not exist", "missing"] - file_error_found = any(keyword in error_msg for keyword in file_keywords) - assert file_error_found, ( - f"Expected file error keywords in JSON error: {upload_result.json_data['error']}" - ) - - # Check that error message is informative - informative_error = ( - nonexistent_file.lower() in error_msg - or "nonexistent-file.txt" in error_msg - or any(word in error_msg for word in file_keywords) - ) - assert informative_error, ( - f"Expected informative error message mentioning the file: {upload_result.json_data['error']}" - ) - else: - # Fallback to stderr/stdout checking for early script errors - error_output = upload_result.stdout + upload_result.stderr - file_error_patterns = [ - "file", - "not found", - "does not exist", - "missing", - "cannot find", - ] - file_error_found = any( - pattern in error_output.lower() for pattern in file_error_patterns - ) - assert file_error_found, ( - f"Expected file error patterns in output: {error_output}" - ) - - # Check that error message is informative - informative_error = ( - nonexistent_file in error_output - or "nonexistent-file.txt" in error_output - or any( - word in error_output.lower() - for word in ["file", "path", "not found", "missing"] - ) - ) - assert informative_error, ( - f"Expected informative error message mentioning the file: {error_output}" - ) - - # Test scenario 2: Invalid project path - test_file = artifact_manager.create_test_file( - filename="error-msg-test2.txt", size_bytes=512, content_pattern="text" - ) - - invalid_project_path = "invalid/nonexistent-project" - - # Build command with invalid project path - command2 = [ - "python", - str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), - "--package-name", - package_name, - "--package-version", - "1.0.1", - "--project-path", - invalid_project_path, - "--token", - _get_gitlab_token(), - "--json-output", - "--files", - str(test_file.path), - ] - - # Create execution configuration expecting project error - execution_config2 = UploadExecution( - command=command2, - expected_exit_code=1, # Expect failure - expected_output_patterns=[], - timeout=30, - use_json_output=True, - ) - - # Execute upload (should fail due to invalid project) - upload_result2 = executor.execute_upload(execution_config2) - - # Validate second error scenario - assert upload_result2.exit_code != 0, ( - f"Expected upload to fail but got exit code: {upload_result2.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result2.json_data is not None: - assert upload_result2.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert "error" in upload_result2.json_data, ( - "Expected error field in JSON output" - ) - - # Check for project-related keywords in error message - error_msg2 = upload_result2.json_data["error"].lower() - project_keywords = ["project", "404", "not found", "access", "invalid"] - project_error_found = any( - keyword in error_msg2 for keyword in project_keywords - ) - assert project_error_found, ( - f"Expected project error keywords in JSON error: {upload_result2.json_data['error']}" - ) - - # Check that error message mentions the project path - informative_error2 = invalid_project_path.lower() in error_msg2 or any( - word in error_msg2 for word in project_keywords - ) - assert informative_error2, ( - f"Expected informative error message mentioning the project: {upload_result2.json_data['error']}" - ) - else: - # Fallback to stderr/stdout checking for early script errors - error_output2 = upload_result2.stdout + upload_result2.stderr - project_error_patterns = [ - "project", - "404", - "not found", - "access", - "invalid", - ] - project_error_found = any( - pattern in error_output2.lower() for pattern in project_error_patterns - ) - assert project_error_found, ( - f"Expected project error patterns in output: {error_output2}" - ) - - # Check that error message mentions the project path - informative_error2 = invalid_project_path in error_output2 or any( - word in error_output2.lower() - for word in ["project", "404", "not found", "access"] - ) - assert informative_error2, ( - f"Expected informative error message mentioning the project: {error_output2}" - ) - - @pytest.mark.timeout(90) - def test_failure_continuation_behavior( - self, gitlab_client, artifact_manager, project_path - ): - """ - Test that the system continues processing after individual failures. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create a mix of valid and invalid files for testing continuation behavior - valid_file = artifact_manager.create_test_file( - filename="valid-continuation-test.txt", - size_bytes=1024, - content_pattern="text", - ) - - nonexistent_file = str( - artifact_manager.base_dir / "nonexistent-continuation-test.txt" - ) - - # Create unique package name - package_name = gitlab_client.create_test_package( - "failure-continuation", "1.0.0" - ) - - # Get project arguments - project_args = get_project_args(project_path, gitlab_url=None) - - # Test multiple file upload with one invalid file - # The upload script should handle the error gracefully and continue or report appropriately - command = [ - "python", - str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--token", - _get_gitlab_token(), - "--json-output", - "--files", - str(valid_file.path), - nonexistent_file, # Mix of valid and invalid - ] + project_args - - # Create execution configuration expecting failure but graceful handling - execution_config = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure due to invalid file - expected_output_patterns=[], - timeout=60, - use_json_output=True, - env_vars={ - "GITLAB_TOKEN": "" - }, # Clear GITLAB_TOKEN to force use of --token argument - ) - - # Execute upload (should fail but handle error gracefully) - executor = ScriptExecutor() - upload_result = executor.execute_upload(execution_config) - - # Validate failure continuation behavior - assert upload_result.exit_code != 0, ( - f"Expected upload to fail but got exit code: {upload_result.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert "failed_uploads" in upload_result.json_data, ( - "Expected failed_uploads field in JSON output" - ) - - # Check that the problematic file is mentioned in failed_uploads - failed_uploads = upload_result.json_data.get("failed_uploads", []) - file_mentioned = any( - "nonexistent-continuation-test.txt" in str(item).lower() - or "nonexistent" in str(item).lower() - for item in failed_uploads - ) - assert file_mentioned, ( - f"Expected problematic file in failed_uploads: {failed_uploads}" - ) - else: - # Fallback to stderr/stdout checking for early script errors - error_output = upload_result.stdout + upload_result.stderr - error_mentions_file = ( - "nonexistent-continuation-test.txt" in error_output - or "nonexistent" in error_output.lower() - ) - assert error_mentions_file, ( - f"Expected error to mention the problematic file: {error_output}" - ) - - # Check that the error is descriptive and doesn't just crash - descriptive_error = any( - word in error_output.lower() - for word in ["file", "not found", "error", "failed", "missing"] - ) - assert descriptive_error, ( - f"Expected descriptive error message: {error_output}" - ) - - # Test a second scenario: Invalid duplicate policy - test_file2 = artifact_manager.create_test_file( - filename="continuation-test2.txt", size_bytes=512, content_pattern="text" - ) - - # Build command with invalid duplicate policy - command2 = [ - "python", - str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), - "--package-name", - package_name, - "--package-version", - "1.0.1", - "--duplicate-policy", - "invalid-policy", # Invalid policy - "--token", - _get_gitlab_token(), - "--json-output", - "--files", - str(test_file2.path), - ] + project_args - - # Create execution configuration expecting policy error - execution_config2 = UploadExecution( - command=command2, - expected_exit_code=1, # Expect failure - expected_output_patterns=[], - timeout=30, - use_json_output=True, - ) - - # Execute upload (should fail due to invalid policy) - upload_result2 = executor.execute_upload(execution_config2) - - # Validate second error scenario - assert upload_result2.exit_code != 0, ( - f"Expected upload to fail but got exit code: {upload_result2.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result2.json_data is not None: - assert upload_result2.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert "error" in upload_result2.json_data, ( - "Expected error field in JSON output" - ) - - # Check for policy-related keywords in error message - error_msg2 = upload_result2.json_data["error"].lower() - policy_keywords = ["policy", "invalid", "choice", "option"] - policy_error_found = any( - keyword in error_msg2 for keyword in policy_keywords - ) - assert policy_error_found, ( - f"Expected policy error keywords in JSON error: {upload_result2.json_data['error']}" - ) - else: - # Fallback to stderr/stdout checking for early script errors - error_output2 = upload_result2.stdout + upload_result2.stderr - policy_error_mentioned = "invalid-policy" in error_output2 or any( - word in error_output2.lower() - for word in ["policy", "invalid", "choice", "option"] - ) - assert policy_error_mentioned, ( - f"Expected policy error to be mentioned: {error_output2}" - ) - - @pytest.mark.timeout(90) - def test_non_ascii_filename_rejection( - self, gitlab_client, artifact_manager, project_path - ): - """ - Test that non-ASCII filenames are properly rejected with detailed error messages. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test file with ASCII filename - test_file = artifact_manager.create_test_file( - filename="unicode-名前.txt", size_bytes=1024, content_pattern="text" - ) - - # Create unique package name - package_name = gitlab_client.create_test_package("non-ascii-test", "1.0.0") - - # Get project arguments - project_args = get_project_args(project_path, gitlab_url=None) - - command = [ - "python", - str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--token", - _get_gitlab_token(), - "--json-output", - "--files", - f"{test_file.path}", - ] + project_args - - # Create execution configuration expecting failure - execution_config = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure - expected_output_patterns=[], - timeout=60, - use_json_output=True, - env_vars={ - "GITLAB_TOKEN": "" - }, # Clear GITLAB_TOKEN to force use of --token argument - ) - - # Execute upload (should fail due to non-ASCII filename) - executor = ScriptExecutor() - upload_result = executor.execute_upload(execution_config) - - # Verify that it failed as expected - assert upload_result.exit_code == 1, ( - f"Expected exit code 1, got {upload_result.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert upload_result.json_data.get("exit_code") == 1, ( - "Expected exit_code to be 1" - ) - assert "error" in upload_result.json_data, ( - "Expected error field in JSON output" - ) - - # Check for ASCII-related keywords in error message - error_msg = upload_result.json_data["error"].lower() - ascii_keywords = ["ascii", "non-ascii", "character"] - ascii_error_found = any(keyword in error_msg for keyword in ascii_keywords) - assert ascii_error_found, ( - f"Expected ASCII-related error keywords in JSON error: {upload_result.json_data['error']}" - ) - - # Check that error message mentions the problematic filename - filename_mentioned = "名前" in upload_result.json_data["error"] - assert filename_mentioned, ( - f"Expected error to mention the problematic filename: {upload_result.json_data['error']}" - ) - - # Check that error message suggests ASCII-only characters - suggestion_keywords = [ - "letter", - "digit", - "dot", - "hyphen", - "underscore", - "slash", - ] - suggestion_found = any( - keyword in error_msg for keyword in suggestion_keywords - ) - assert suggestion_found, ( - f"Expected error to suggest ASCII-only characters: {upload_result.json_data['error']}" - ) - else: - # Fallback to stderr/stdout checking for early script errors - error_output = upload_result.stdout + upload_result.stderr - ascii_error_patterns = ["ascii", "non-ascii", "character"] - ascii_error_found = any( - pattern in error_output.lower() for pattern in ascii_error_patterns - ) - assert ascii_error_found, ( - f"Expected ASCII-related error patterns in output: {error_output}" - ) - - # Check that error message mentions the problematic filename - filename_mentioned = "名前" in error_output - assert filename_mentioned, ( - f"Expected error to mention the problematic filename: {error_output}" - ) - - -@pytest.mark.slow -@pytest.mark.timeout(90) -def test_non_ascii_filename_in_directory(gitlab_client, artifact_manager, project_path): - """ - Test that non-ASCII filenames in directories are properly rejected. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create a temporary directory - test_dir = artifact_manager.base_dir / "non-ascii-dir-test" - test_dir.mkdir(parents=True, exist_ok=True) - - # Create a file with non-ASCII filename directly - non_ascii_filename = "unicode-测试文件.txt" - non_ascii_file_path = test_dir / non_ascii_filename - non_ascii_file_path.write_text("Test content with non-ASCII filename") - - # Create unique package name - package_name = gitlab_client.create_test_package("non-ascii-dir", "1.0.0") - - # Get project arguments - project_args = get_project_args(project_path, gitlab_url=None) - - # Build command to upload directory with non-ASCII filename - command = [ - "python", - str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--token", - _get_gitlab_token(), - "--json-output", - "--directory", - str(test_dir), - ] + project_args - - # Create execution configuration expecting failure - execution_config = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure - expected_output_patterns=[], - timeout=60, - use_json_output=True, - env_vars={ - "GITLAB_TOKEN": "" - }, # Clear GITLAB_TOKEN to force use of --token argument - ) - - # Execute upload (should fail due to non-ASCII filename) - executor = ScriptExecutor() - upload_result = executor.execute_upload(execution_config) - - # Verify that it failed as expected - assert upload_result.exit_code == 1, ( - f"Expected exit code 1, got {upload_result.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert "error" in upload_result.json_data, "Expected error field in JSON output" - - # Check that error message mentions the specific non-ASCII filename - error_msg = upload_result.json_data["error"] - filename_mentioned = ( - non_ascii_filename in error_msg - or "测试文件" in error_msg - or "unicode-" in error_msg.lower() - ) - assert filename_mentioned, ( - f"Expected error to mention the specific non-ASCII filename: {error_msg}" - ) - - # Check for ASCII-related keywords - ascii_keywords = ["ascii", "non-ascii", "character"] - ascii_error_found = any( - keyword in error_msg.lower() for keyword in ascii_keywords - ) - assert ascii_error_found, ( - f"Expected ASCII-related error keywords in JSON error: {error_msg}" - ) - else: - # Fallback to stderr/stdout checking - error_output = upload_result.stdout + upload_result.stderr - filename_mentioned = ( - non_ascii_filename in error_output or "测试文件" in error_output - ) - assert filename_mentioned, ( - f"Expected error to mention the specific non-ASCII filename: {error_output}" - ) - - -@pytest.mark.timeout(120) -def test_mixed_ascii_non_ascii_filenames(gitlab_client, artifact_manager, project_path): - """ - Test that mixed ASCII and non-ASCII filenames are handled correctly. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create multiple test files: some with ASCII names, some with non-ASCII names - ascii_file1 = artifact_manager.create_test_file( - filename="ascii-file1.txt", size_bytes=512, content_pattern="text" - ) - ascii_file2 = artifact_manager.create_test_file( - filename="ascii-file2.txt", size_bytes=512, content_pattern="text" - ) - - # Create files with non-ASCII names - test_dir = artifact_manager.base_dir / "mixed-test" - test_dir.mkdir(parents=True, exist_ok=True) - - non_ascii_file1 = test_dir / "unicode-名前.txt" - non_ascii_file1.write_text("Non-ASCII content 1") - - non_ascii_file2 = test_dir / "unicode-测试.txt" - non_ascii_file2.write_text("Non-ASCII content 2") - - # Create unique package name - package_name = gitlab_client.create_test_package("mixed-ascii", "1.0.0") - - # Get project arguments - project_args = get_project_args(project_path, gitlab_url=None) - - # Build command to upload all files together - command = [ - "python", - str(Path(__file__).parent.parent / "gitlab-pkg-upload.py"), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--token", - _get_gitlab_token(), - "--json-output", - "--files", - str(ascii_file1.path), - str(ascii_file2.path), - str(non_ascii_file1), - str(non_ascii_file2), - ] + project_args - - # Create execution configuration expecting failure - execution_config = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure - expected_output_patterns=[], - timeout=90, - use_json_output=True, - env_vars={ - "GITLAB_TOKEN": "" - }, # Clear GITLAB_TOKEN to force use of --token argument - ) - - # Execute upload (should fail due to non-ASCII filenames) - executor = ScriptExecutor() - upload_result = executor.execute_upload(execution_config) - - # Verify that it failed as expected - assert upload_result.exit_code == 1, ( - f"Expected exit code 1, got {upload_result.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert "error" in upload_result.json_data, "Expected error field in JSON output" - - error_msg = upload_result.json_data["error"] - - # Check that error identifies non-ASCII filenames - non_ascii_mentioned = ( - "名前" in error_msg - or "测试" in error_msg - or "unicode-" in error_msg.lower() - ) - assert non_ascii_mentioned, ( - f"Expected error to identify non-ASCII filenames: {error_msg}" - ) - - # Check that ASCII files are not mentioned in the error - # (or if mentioned, it's clear they're not the problem) - ascii_file_names = ["ascii-file1.txt", "ascii-file2.txt"] - if any(name in error_msg for name in ascii_file_names): - # If ASCII files are mentioned, ensure the error is clear about which files are problematic - clear_about_problem = any( - keyword in error_msg.lower() - for keyword in ["non-ascii", "invalid", "problematic"] - ) - assert clear_about_problem, ( - f"Expected error to be clear about which files are problematic: {error_msg}" - ) - else: - # Fallback to stderr/stdout checking - error_output = upload_result.stdout + upload_result.stderr - non_ascii_mentioned = "名前" in error_output or "测试" in error_output - assert non_ascii_mentioned, ( - f"Expected error to identify non-ASCII filenames: {error_output}" - ) diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py deleted file mode 100644 index 65333fc..0000000 --- a/tests/test_fixtures.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -Test the basic fixture functionality to ensure the extracted code works correctly. -""" - -from pathlib import Path - -import pytest - - -def test_artifact_manager_fixture(artifact_manager): - """Test that the artifact manager fixture works correctly.""" - # Create a test file - artifact = artifact_manager.create_test_file("test.txt", 100, "text") - - # Verify the artifact was created - assert artifact.path.exists() - assert artifact.size == 100 - assert artifact.artifact_type == "file" - assert len(artifact.checksum) == 64 # SHA256 hex length - - # Verify the file has the expected content - content = artifact.path.read_bytes() - assert len(content) == 100 - assert b"test content" in content - - -def test_temp_dir_fixture(temp_dir): - """Test that the temporary directory fixture works correctly.""" - # Verify temp_dir is a Path object and exists - assert isinstance(temp_dir, Path) - assert temp_dir.exists() - assert temp_dir.is_dir() - - # Create a file in the temp directory - test_file = temp_dir / "test.txt" - test_file.write_text("test content") - - assert test_file.exists() - assert test_file.read_text() == "test content" - - -def test_gitlab_token_fixture(gitlab_token): - """Test that the GitLab token fixture works correctly.""" - # This test will be skipped if GITLAB_TOKEN is not set - assert isinstance(gitlab_token, str) - assert len(gitlab_token) > 0 - - -@pytest.mark.skipif( - not pytest.importorskip("gitlab", minversion=None), - reason="python-gitlab not available", -) -def test_gitlab_client_fixture(gitlab_client): - """Test that the GitLab client fixture works correctly.""" - # This test will be skipped if python-gitlab is not available - assert gitlab_client is not None - assert hasattr(gitlab_client, "gl") - assert hasattr(gitlab_client, "token") - assert gitlab_client._authenticated - - -def test_project_resolver_fixture_skip_if_no_gitlab(): - """Test that project resolver fixture is properly skipped when GitLab is not available.""" - try: - import importlib.util - - if importlib.util.find_spec("gitlab") is not None: - pytest.skip("GitLab is available, this test is for when it's not available") - except ImportError: - # This is expected when GitLab is not available - pass diff --git a/tests/test_integration.py b/tests/test_integration.py deleted file mode 100644 index 5b28031..0000000 --- a/tests/test_integration.py +++ /dev/null @@ -1,993 +0,0 @@ -""" -Integration tests for GitLab upload script. - -This module contains comprehensive integration tests extracted from the monolithic -test file. These tests validate end-to-end scenarios, error handling, and overall -test coverage to ensure the upload script works correctly in real-world conditions. -""" - -from pathlib import Path - -import pytest - -from .utils.test_helpers import ScriptExecutor, UploadExecution, get_project_args - -# Test markers for categorization -pytestmark = [ - pytest.mark.integration, # These are comprehensive integration tests - pytest.mark.api, # These require GitLab API access - pytest.mark.slow, # These tests are the slowest (comprehensive scenarios) -] - - -@pytest.mark.timeout(600) -def test_comprehensive_upload_validation(gitlab_client, artifact_manager, project_path): - """ - Test comprehensive upload validation covering all major scenarios. - - Extracted from TestOrchestrator._test_comprehensive_upload_validation - """ - executor = ScriptExecutor() - - # Create a variety of test files and scenarios - single_file = artifact_manager.create_test_file( - "comprehensive-single.txt", 1024, "text" - ) - - multiple_files = [ - artifact_manager.create_test_file("comp-multi-1.json", 2048, "json"), - artifact_manager.create_test_file("comp-multi-2.bin", 4096, "binary"), - artifact_manager.create_test_file("comp-multi-3.csv", 1536, "text"), - ] - - directory_files = artifact_manager.create_test_directory("comp-directory", 3) - directory_path = artifact_manager.base_dir / "comp-directory" - - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create unique package names for each scenario - single_package = gitlab_client.create_test_package("comp-single", "1.0.0") - multi_package = gitlab_client.create_test_package("comp-multi", "1.0.0") - dir_package = gitlab_client.create_test_package("comp-dir", "1.0.0") - - # Test 1: Single file upload - single_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - single_package, - "--package-version", - "1.0.0", - "--files", - str(single_file.path), - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=120, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, - use_json_output=True, - ) - - single_result = executor.execute_upload(single_upload_execution) - assert single_result.success, ( - f"Single file upload failed: {single_result.error_message}" - ) - - # Validate JSON output - from .utils.test_helpers import validate_json_result - - assert single_result.json_data is not None, "JSON output not available" - assert validate_json_result( - single_result.json_data, - expected_success=True, - expected_files=[str(single_file.path)], - ), "JSON validation failed for single file upload" - assert single_result.json_data["success"] is True - assert single_result.json_data["package_name"] == single_package - assert single_result.json_data["version"] == "1.0.0" - assert single_result.json_data["statistics"]["new_uploads"] == 1 - assert single_result.json_data["statistics"]["failed_uploads"] == 0 - assert len(single_result.json_data["successful_uploads"]) == 1 - - # Verify uploaded filename appears in successful_uploads - uploaded_filenames = [ - upload["target_filename"] - for upload in single_result.json_data["successful_uploads"] - ] - assert single_file.path.name in uploaded_filenames - - # Validate single file upload via GitLab API - assert gitlab_client.verify_upload( - single_package, "1.0.0", single_file.path.name, single_file.checksum - ), "Single file upload validation failed" - - # Test 2: Multiple files upload - multi_file_paths = [str(f.path) for f in multiple_files] - multi_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - multi_package, - "--package-version", - "1.0.0", - "--files", - ] - + multi_file_paths - + ["--json-output"] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, - use_json_output=True, - ) - - multi_result = executor.execute_upload(multi_upload_execution) - assert multi_result.success, ( - f"Multiple files upload failed: {multi_result.error_message}" - ) - - # Validate JSON output - assert multi_result.json_data is not None, "JSON output not available" - assert validate_json_result( - multi_result.json_data, expected_success=True, expected_files=multi_file_paths - ), "JSON validation failed for multiple files upload" - assert multi_result.json_data["success"] is True - assert multi_result.json_data["package_name"] == multi_package - assert multi_result.json_data["version"] == "1.0.0" - assert multi_result.json_data["statistics"]["new_uploads"] == 3 - assert multi_result.json_data["statistics"]["failed_uploads"] == 0 - assert len(multi_result.json_data["successful_uploads"]) == 3 - - # Verify uploaded filenames appear in successful_uploads - uploaded_filenames = [ - upload["target_filename"] - for upload in multi_result.json_data["successful_uploads"] - ] - for test_file in multiple_files: - assert test_file.path.name in uploaded_filenames - - # Validate multiple files upload via GitLab API - for test_file in multiple_files: - assert gitlab_client.verify_upload( - multi_package, "1.0.0", test_file.path.name, test_file.checksum - ), f"Multiple files validation failed for {test_file.path.name}" - - # Test 3: Directory upload - dir_upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - dir_package, - "--package-version", - "1.0.0", - "--directory", - str(directory_path), - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, - use_json_output=True, - ) - - dir_result = executor.execute_upload(dir_upload_execution) - assert dir_result.success, f"Directory upload failed: {dir_result.error_message}" - - # Validate JSON output - assert dir_result.json_data is not None, "JSON output not available" - assert validate_json_result( - dir_result.json_data, - expected_success=True, - expected_files=[str(f.path) for f in directory_files], - ), "JSON validation failed for directory upload" - assert dir_result.json_data["success"] is True - assert dir_result.json_data["package_name"] == dir_package - assert dir_result.json_data["version"] == "1.0.0" - assert dir_result.json_data["statistics"]["new_uploads"] == len(directory_files) - assert dir_result.json_data["statistics"]["failed_uploads"] == 0 - assert len(dir_result.json_data["successful_uploads"]) == len(directory_files) - - # Verify uploaded filenames appear in successful_uploads - uploaded_filenames = [ - upload["target_filename"] - for upload in dir_result.json_data["successful_uploads"] - ] - for test_file in directory_files: - assert test_file.path.name in uploaded_filenames - - # Validate directory upload via GitLab API - for test_file in directory_files: - assert gitlab_client.verify_upload( - dir_package, "1.0.0", test_file.path.name, test_file.checksum - ), f"Directory upload validation failed for {test_file.path.name}" - - # Final registry verification for all scenarios - all_test_cases = [ - (single_package, "1.0.0", [single_file]), - (multi_package, "1.0.0", multiple_files), - (dir_package, "1.0.0", directory_files), - ] - - for package_name, version, test_files in all_test_cases: - for test_file in test_files: - assert gitlab_client.verify_upload( - package_name, version, test_file.path.name, test_file.checksum - ), ( - f"Registry verification failed for {test_file.path.name} in {package_name}" - ) - - total_files = len([single_file]) + len(multiple_files) + len(directory_files) - print(f"All {total_files} files across 3 scenarios verified successfully") - - -@pytest.mark.integration -@pytest.mark.slow -@pytest.mark.timeout(600) -def test_error_scenario_validation(gitlab_client, artifact_manager, project_path): - """ - Test comprehensive error scenario validation. - - This test validates that various error scenarios are handled correctly - and produce appropriate error messages and exit codes. Tests include: - 1. Invalid file paths - 2. Permission errors - 3. Network connectivity issues - 4. Authentication failures - 5. Invalid project specifications - - Extracted from TestOrchestrator._test_error_scenario_validation - """ - executor = ScriptExecutor() - gitlab_client.set_project(project_path) - - test_results = [] - - # Error scenario 1: Invalid file path - command = executor.build_command( - package_name="error-test", - version="1.0.0", - files=["/nonexistent/invalid/file.txt"], - project_path=project_path, - duplicate_policy="skip", - use_json_output=True, - ) - - result = executor.execute_upload( - UploadExecution( - command=command, - expected_exit_code=1, - expected_output_patterns=[], - timeout=30, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, - use_json_output=True, - ) - ) - - test_results.append(("invalid_file_path", result)) - - # Validate error handling with JSON - assert not result.success or result.exit_code != 0, ( - "Invalid file path should have caused failure" - ) - if result.json_data is not None: - assert result.json_data["success"] is False - assert result.json_data["exit_code"] == 1 - assert "error" in result.json_data - else: - # Fallback to stderr for early failures - assert result.stderr or result.error_message or result.stdout, ( - "No error message for invalid file path" - ) - - # Error scenario 2: Invalid project path - test_artifact = artifact_manager.create_test_file("valid.txt", 512, "text") - - command = executor.build_command( - package_name="error-test", - version="1.0.0", - files=[str(test_artifact.path)], - project_path="nonexistent/invalid-project-12345", - duplicate_policy="skip", - use_json_output=True, - ) - - result = executor.execute_upload( - UploadExecution( - command=command, - expected_exit_code=1, - expected_output_patterns=[], - timeout=30, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, - use_json_output=True, - ) - ) - - test_results.append(("invalid_project_path", result)) - - # Validate error handling with JSON - assert not result.success or result.exit_code != 0, ( - "Invalid project path should have caused failure" - ) - if result.json_data is not None: - assert result.json_data["success"] is False - assert result.json_data["exit_code"] == 1 - assert "error" in result.json_data - else: - # Fallback to stderr for early failures - assert result.stderr or result.error_message or result.stdout, ( - "No error message for invalid project path" - ) - - # Error scenario 3: Invalid GitLab URL - test_artifact2 = artifact_manager.create_test_file("valid2.txt", 512, "text") - - command = executor.build_command( - package_name="error-test", - version="1.0.0", - files=[str(test_artifact2.path)], - project_path=project_path, - gitlab_url="https://invalid-gitlab-instance-12345.com", - duplicate_policy="skip", - use_json_output=True, - ) - - result = executor.execute_upload( - UploadExecution( - command=command, - expected_exit_code=1, - expected_output_patterns=[], - timeout=30, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, - use_json_output=True, - ) - ) - - test_results.append(("invalid_gitlab_url", result)) - - # Validate error handling with JSON - assert not result.success or result.exit_code != 0, ( - "Invalid GitLab URL should have caused failure" - ) - if result.json_data is not None: - assert result.json_data["success"] is False - assert result.json_data["exit_code"] == 1 - assert "error" in result.json_data - else: - # Fallback to stderr for early failures - assert result.stderr or result.error_message or result.stdout, ( - "No error message for invalid GitLab URL" - ) - - # Error scenario 4: Missing required arguments - # Try to build command without required package name - with pytest.raises((ValueError, TypeError)): - executor.build_command( - package_name="", # Empty package name should cause error - version="1.0.0", - files=["dummy.txt"], - project_path=project_path, - ) - - # Validate that all error scenarios produced appropriate responses - for scenario_name, result in test_results: - # Check that exit code indicates failure - assert result.exit_code != 0, ( - f"Scenario {scenario_name} exit code should be non-zero" - ) - - # Check that some error information is provided - assert result.stderr or result.error_message or result.stdout, ( - f"Scenario {scenario_name} provided no error information" - ) - - -@pytest.mark.integration -@pytest.mark.timeout(60) -def test_coverage_verification(): - """ - Test coverage verification to ensure all required functionality is tested. - - This test verifies that the test suite covers all required functionality - by checking that all major features have been tested and that the - test results provide comprehensive coverage of the upload script's - capabilities. - - Extracted from TestOrchestrator._test_coverage_verification - """ - # Define required test coverage areas - required_coverage = { - "single_file_upload": False, - "multiple_file_upload": False, - "directory_upload": False, - "file_mapping": False, - "duplicate_handling_skip": False, - "duplicate_handling_replace": False, - "duplicate_handling_error": False, - "git_auto_detection": False, - "manual_project_url": False, - "manual_project_path": False, - "error_handling": False, - "network_failure": False, - "authentication_error": False, - "comprehensive_validation": False, - "error_scenario_validation": False, - } - - # In a real implementation, this would check the results of previously run tests - # For now, we'll simulate checking test module existence and basic functionality - - # Check that test modules exist - test_modules = [ - "test_basic_uploads.py", - "test_duplicate_handling.py", - "test_project_resolution.py", - "test_error_scenarios.py", - "test_integration.py", - ] - - tests_dir = Path(__file__).parent - existing_modules = [] - - for module in test_modules: - module_path = tests_dir / module - if module_path.exists(): - existing_modules.append(module) - - # Mark coverage areas as covered based on module existence - if module == "test_basic_uploads.py": - required_coverage["single_file_upload"] = True - required_coverage["multiple_file_upload"] = True - required_coverage["directory_upload"] = True - required_coverage["file_mapping"] = True - elif module == "test_duplicate_handling.py": - required_coverage["duplicate_handling_skip"] = True - required_coverage["duplicate_handling_replace"] = True - required_coverage["duplicate_handling_error"] = True - elif module == "test_project_resolution.py": - required_coverage["git_auto_detection"] = True - required_coverage["manual_project_url"] = True - required_coverage["manual_project_path"] = True - elif module == "test_error_scenarios.py": - required_coverage["error_handling"] = True - required_coverage["network_failure"] = True - required_coverage["authentication_error"] = True - elif module == "test_integration.py": - required_coverage["comprehensive_validation"] = True - required_coverage["error_scenario_validation"] = True - - # Calculate coverage statistics - total_areas = len(required_coverage) - covered_areas = sum(1 for covered in required_coverage.values() if covered) - coverage_percentage = (covered_areas / total_areas) * 100 - - # Identify missing coverage - missing_coverage = [ - area for area, covered in required_coverage.items() if not covered - ] - - # Determine success criteria - # Require at least 80% coverage for success, with all critical areas covered - critical_areas = [ - "single_file_upload", - "multiple_file_upload", - "directory_upload", - "duplicate_handling_skip", - "error_handling", - ] - - critical_covered = all( - required_coverage.get(area, False) for area in critical_areas - ) - sufficient_coverage = coverage_percentage >= 80.0 - - # Generate detailed coverage report (for potential future use) - # coverage_report = { - # "total_areas": total_areas, - # "covered_areas": covered_areas, - # "coverage_percentage": coverage_percentage, - # "missing_coverage": missing_coverage, - # "critical_areas_covered": critical_covered, - # "detailed_coverage": required_coverage, - # "existing_modules": existing_modules, - # } - - print( - f"Test coverage: {covered_areas}/{total_areas} areas ({coverage_percentage:.1f}%)" - ) - print(f"Existing test modules: {existing_modules}") - - if missing_coverage: - print(f"Missing coverage: {missing_coverage}") - - # Assert coverage requirements - assert critical_covered, ( - f"Missing critical coverage areas: {[area for area in critical_areas if not required_coverage.get(area, False)]}" - ) - assert sufficient_coverage, ( - f"Insufficient coverage: {coverage_percentage:.1f}% (need 80%)" - ) - - print("✓ Test coverage verification passed") - - -@pytest.mark.integration -@pytest.mark.slow -@pytest.mark.timeout(600) -def test_end_to_end_workflow_validation(gitlab_client, artifact_manager, project_path): - """ - Test end-to-end workflow validation with comprehensive cleanup verification. - - This test validates the complete workflow from file creation through upload - to cleanup, ensuring that all components work together correctly and that - cleanup operations function properly in the pytest context. - - """ - executor = ScriptExecutor() - gitlab_client.set_project(project_path) - - # Create test artifacts - test_files = [ - artifact_manager.create_test_file("workflow-test-1.txt", 1024, "text"), - artifact_manager.create_test_file("workflow-test-2.json", 2048, "json"), - artifact_manager.create_test_file("workflow-test-3.bin", 512, "binary"), - ] - - # Create unique package for this workflow test - package_name = gitlab_client.create_test_package("workflow-validation", "1.0.0") - - # Execute upload - from .utils.test_helpers import validate_json_result - - file_paths = [str(f.path) for f in test_files] - upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--files", - ] - + file_paths - + ["--json-output"] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, - use_json_output=True, - ) - - result = executor.execute_upload(upload_execution) - assert result.success, f"End-to-end upload failed: {result.error_message}" - - # Validate JSON output - assert result.json_data is not None, "JSON output not available" - assert validate_json_result( - result.json_data, expected_success=True, expected_files=file_paths - ), "JSON validation failed for end-to-end workflow" - assert result.json_data["success"] is True - assert result.json_data["package_name"] == package_name - assert result.json_data["statistics"]["new_uploads"] == 3 - assert len(result.json_data["successful_uploads"]) == 3 - - # Verify all test file names appear in successful_uploads - uploaded_filenames = [ - upload["target_filename"] for upload in result.json_data["successful_uploads"] - ] - for test_file in test_files: - assert test_file.path.name in uploaded_filenames - - # Verify all files were uploaded correctly via GitLab API - for test_file in test_files: - assert gitlab_client.verify_upload( - package_name, "1.0.0", test_file.path.name, test_file.checksum - ), f"End-to-end verification failed for {test_file.path.name}" - - # Test cleanup verification - this will be handled by fixtures - # but we can verify that the artifacts exist before cleanup - for test_file in test_files: - assert test_file.path.exists(), ( - f"Test artifact {test_file.path} should exist before cleanup" - ) - - print( - f"✓ End-to-end workflow validation completed successfully for package {package_name}" - ) - - -@pytest.mark.integration -@pytest.mark.timeout(600) -def test_parallel_execution_safety(gitlab_client, artifact_manager, project_path): - """ - Test that integration tests can run safely in parallel without conflicts. - - This test validates that the test infrastructure properly isolates tests - when running in parallel using pytest-xdist, ensuring no race conditions - or shared state issues occur. - - """ - executor = ScriptExecutor() - gitlab_client.set_project(project_path) - - # Create unique test artifacts with process-specific naming - import os - import secrets - - process_id = os.getpid() - random_suffix = secrets.token_hex(4) - unique_prefix = f"parallel-{process_id}-{random_suffix}" - - test_file = artifact_manager.create_test_file( - f"{unique_prefix}-test.txt", 1024, "text" - ) - package_name = gitlab_client.create_test_package( - f"parallel-test-{random_suffix}", "1.0.0" - ) - - # Execute upload with unique identifiers - from .utils.test_helpers import validate_json_result - - upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--files", - str(test_file.path), - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=120, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, - use_json_output=True, - ) - - result = executor.execute_upload(upload_execution) - assert result.success, f"Parallel execution test failed: {result.error_message}" - - # Validate JSON output - assert result.json_data is not None, "JSON output not available" - assert validate_json_result( - result.json_data, expected_success=True, expected_files=[str(test_file.path)] - ), "JSON validation failed for parallel execution" - assert result.json_data["success"] is True - assert result.json_data["statistics"]["new_uploads"] == 1 - assert len(result.json_data["successful_uploads"]) == 1 - - # Verify uploaded filename appears in successful_uploads - uploaded_filenames = [ - upload["target_filename"] for upload in result.json_data["successful_uploads"] - ] - assert test_file.path.name in uploaded_filenames - - # Verify upload via GitLab API - assert gitlab_client.verify_upload( - package_name, "1.0.0", test_file.path.name, test_file.checksum - ), "Parallel execution upload verification failed" - - print(f"✓ Parallel execution safety test completed for process {process_id}") - - -@pytest.mark.integration -@pytest.mark.cleanup -@pytest.mark.timeout(900) -def test_comprehensive_cleanup_verification( - gitlab_client, artifact_manager, project_path -): - """ - Test comprehensive cleanup verification to ensure all test artifacts are properly cleaned up. - - This test validates that the pytest fixture cleanup mechanisms work correctly - and that no test artifacts are left behind after test execution, preserving - the cleanup verification functionality from the original monolithic test. - - """ - executor = ScriptExecutor() - gitlab_client.set_project(project_path) - - # Track initial state - initial_artifacts = len(artifact_manager.artifacts) - initial_packages = len(gitlab_client.created_packages) - - # Create test artifacts that should be cleaned up - test_files = [] - for i in range(3): - test_file = artifact_manager.create_test_file( - f"cleanup-test-{i}.txt", 1024, "text" - ) - test_files.append(test_file) - - # Create test packages that should be cleaned up - package_names = [] - for i in range(2): - package_name = gitlab_client.create_test_package(f"cleanup-test-{i}", "1.0.0") - package_names.append(package_name) - - # Verify artifacts were created - assert len(artifact_manager.artifacts) == initial_artifacts + 3, ( - "Test artifacts were not created properly" - ) - assert len(gitlab_client.created_packages) == initial_packages + 2, ( - "Test packages were not tracked properly" - ) - - # Verify files exist on disk - for test_file in test_files: - assert test_file.path.exists(), f"Test file {test_file.path} should exist" - - # Perform some uploads to create actual GitLab packages - from .utils.test_helpers import validate_json_result - - for i, package_name in enumerate(package_names): - upload_execution = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--files", - str(test_files[i].path), - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=240, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, - use_json_output=True, - ) - - result = executor.execute_upload(upload_execution) - assert result.success, ( - f"Upload failed for cleanup test package {package_name}: {result.error_message}" - ) - - # Validate JSON output - assert result.json_data is not None, "JSON output not available" - assert validate_json_result( - result.json_data, - expected_success=True, - expected_files=[str(test_files[i].path)], - ), f"JSON validation failed for cleanup test package {package_name}" - assert result.json_data["success"] is True - assert result.json_data["statistics"]["new_uploads"] == 1 - assert len(result.json_data["successful_uploads"]) == 1 - - # Verify uploaded filename appears in successful_uploads - uploaded_filenames = [ - upload["target_filename"] - for upload in result.json_data["successful_uploads"] - ] - assert test_files[i].path.name in uploaded_filenames - - # Verify upload was successful via GitLab API - assert gitlab_client.verify_upload( - package_name, "1.0.0", test_files[i].path.name, test_files[i].checksum - ), f"Upload verification failed for cleanup test package {package_name}" - - # Test manual cleanup to verify it works (fixtures will also clean up automatically) - artifact_successful, artifact_failed = artifact_manager.cleanup_artifacts( - force=True - ) - assert artifact_failed == 0, f"Artifact cleanup failed for {artifact_failed} items" - assert artifact_successful >= 3, ( - f"Expected at least 3 artifacts cleaned up, got {artifact_successful}" - ) - - package_successful, package_failed = gitlab_client.cleanup_test_packages(force=True) - assert package_failed == 0, f"Package cleanup failed for {package_failed} items" - assert package_successful >= 2, ( - f"Expected at least 2 packages cleaned up, got {package_successful}" - ) - - # Verify cleanup was effective - for test_file in test_files: - assert not test_file.path.exists(), ( - f"Test file {test_file.path} should have been cleaned up" - ) - - assert len(artifact_manager.artifacts) == 0, ( - "Artifact manager should have no tracked artifacts after cleanup" - ) - assert len(gitlab_client.created_packages) == 0, ( - "GitLab client should have no tracked packages after cleanup" - ) - - print("✓ Comprehensive cleanup verification completed successfully") - - -@pytest.mark.integration -@pytest.mark.slow -@pytest.mark.timeout(600) -def test_multi_scenario_workflow_validation( - gitlab_client, artifact_manager, project_path -): - """ - Test multi-scenario workflow validation combining different upload types and policies. - - This test validates complex workflows that combine multiple upload scenarios, - duplicate policies, and error conditions to ensure the system handles - real-world usage patterns correctly. - - """ - executor = ScriptExecutor() - gitlab_client.set_project(project_path) - - # Scenario 1: Upload with skip duplicate policy - from .utils.test_helpers import validate_json_result - - test_file_1 = artifact_manager.create_test_file( - "multi-scenario-1.txt", 1024, "text" - ) - package_name_1 = gitlab_client.create_test_package("multi-scenario-skip", "1.0.0") - - # First upload - upload_execution_1 = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name_1, - "--package-version", - "1.0.0", - "--files", - str(test_file_1.path), - "--duplicate-policy", - "skip", - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=120, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, - use_json_output=True, - ) - - result_1 = executor.execute_upload(upload_execution_1) - assert result_1.success, f"First upload failed: {result_1.error_message}" - - # Validate JSON output for first upload - assert result_1.json_data is not None, "JSON output not available" - assert validate_json_result( - result_1.json_data, - expected_success=True, - expected_files=[str(test_file_1.path)], - ), "JSON validation failed for first upload" - assert result_1.json_data["success"] is True - assert result_1.json_data["statistics"]["new_uploads"] == 1 - - # Second upload (should skip duplicate) - result_1_dup = executor.execute_upload(upload_execution_1) - assert result_1_dup.success, ( - f"Duplicate upload with skip policy failed: {result_1_dup.error_message}" - ) - - # Validate JSON output for duplicate upload - assert result_1_dup.json_data is not None, "JSON output not available" - assert result_1_dup.json_data["success"] is True - assert result_1_dup.json_data["statistics"]["skipped_duplicates"] == 1 - - # Scenario 2: Directory upload with replace policy - directory_files = artifact_manager.create_test_directory("multi-scenario-dir", 2) - directory_path = artifact_manager.base_dir / "multi-scenario-dir" - package_name_2 = gitlab_client.create_test_package( - "multi-scenario-replace", "1.0.0" - ) - - upload_execution_2 = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name_2, - "--package-version", - "1.0.0", - "--directory", - str(directory_path), - "--duplicate-policy", - "replace", - "--json-output", - ] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, - use_json_output=True, - ) - - result_2 = executor.execute_upload(upload_execution_2) - assert result_2.success, f"Directory upload failed: {result_2.error_message}" - - # Validate JSON output for directory upload - assert result_2.json_data is not None, "JSON output not available" - assert validate_json_result( - result_2.json_data, - expected_success=True, - expected_files=[str(f.path) for f in directory_files], - ), "JSON validation failed for directory upload" - assert result_2.json_data["success"] is True - assert result_2.json_data["statistics"]["new_uploads"] == 2 - - # Verify directory files in successful_uploads - uploaded_filenames = [ - upload["target_filename"] for upload in result_2.json_data["successful_uploads"] - ] - for test_file in directory_files: - assert test_file.path.name in uploaded_filenames - - # Scenario 3: Multiple files with error handling - multiple_files = [ - artifact_manager.create_test_file("multi-scenario-3a.json", 2048, "json"), - artifact_manager.create_test_file("multi-scenario-3b.bin", 1024, "binary"), - ] - package_name_3 = gitlab_client.create_test_package("multi-scenario-multi", "1.0.0") - - file_paths = [str(f.path) for f in multiple_files] - upload_execution_3 = UploadExecution( - command=[ - str(executor.script_path), - "--package-name", - package_name_3, - "--package-version", - "1.0.0", - "--files", - ] - + file_paths - + ["--duplicate-policy", "error", "--json-output"] - + get_project_args(project_path), - expected_exit_code=0, - expected_output_patterns=[], - timeout=180, - env_vars={"GITLAB_TOKEN": gitlab_client.token}, - use_json_output=True, - ) - - result_3 = executor.execute_upload(upload_execution_3) - assert result_3.success, f"Multiple files upload failed: {result_3.error_message}" - - # Validate JSON output for multiple files upload - assert result_3.json_data is not None, "JSON output not available" - assert validate_json_result( - result_3.json_data, expected_success=True, expected_files=file_paths - ), "JSON validation failed for multiple files upload" - assert result_3.json_data["success"] is True - assert result_3.json_data["statistics"]["new_uploads"] == 2 - - # Verify multiple files in successful_uploads - uploaded_filenames = [ - upload["target_filename"] for upload in result_3.json_data["successful_uploads"] - ] - for test_file in multiple_files: - assert test_file.path.name in uploaded_filenames - - # Verify all uploads - assert gitlab_client.verify_upload( - package_name_1, "1.0.0", test_file_1.path.name, test_file_1.checksum - ), "Multi-scenario validation failed for single file upload" - - for test_file in directory_files: - assert gitlab_client.verify_upload( - package_name_2, "1.0.0", test_file.path.name, test_file.checksum - ), f"Multi-scenario validation failed for directory file {test_file.path.name}" - - for test_file in multiple_files: - assert gitlab_client.verify_upload( - package_name_3, "1.0.0", test_file.path.name, test_file.checksum - ), f"Multi-scenario validation failed for multiple file {test_file.path.name}" - - total_files = 1 + len(directory_files) + len(multiple_files) - print( - f"✓ Multi-scenario workflow validation completed successfully for {total_files} files across 3 scenarios" - ) diff --git a/tests/test_project_resolution.py b/tests/test_project_resolution.py deleted file mode 100644 index 9193963..0000000 --- a/tests/test_project_resolution.py +++ /dev/null @@ -1,658 +0,0 @@ -""" -Project resolution functionality tests for GitLab package upload script. - -This module contains tests for project resolution scenarios extracted from the -monolithic test file. It validates Git auto-detection, manual project URL -specification, and manual project path specification using pytest framework. -""" - -import os - -import pytest - -from .utils.test_helpers import ScriptExecutor, UploadExecution, validate_json_result - -# Test markers for categorization -pytestmark = [ - pytest.mark.integration, # These are integration tests - pytest.mark.api, # These require GitLab API access - pytest.mark.fast, # These tests are relatively fast (project resolution only) -] - - -def _get_gitlab_token(): - """Get GitLab token from environment with proper error handling.""" - token = os.environ.get("GITLAB_TOKEN") - if not token: - pytest.skip("GITLAB_TOKEN environment variable not set") - return token - - -class TestProjectResolution: - """ - Test class for project resolution functionality. - - Extracted and adapted from TestOrchestrator._test_git_auto_detection, - _test_manual_project_url_specification, and - _test_manual_project_path_specification methods. - """ - - @pytest.mark.timeout(120) - def test_git_auto_detection(self, gitlab_client, artifact_manager, project_path): - """ - Test Git auto-detection functionality when run from Git repository. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test file - test_file = artifact_manager.create_test_file( - filename="git-auto-test.txt", size_bytes=1024, content_pattern="text" - ) - - # Create unique package name - package_name = gitlab_client.create_test_package("git-auto", "1.0.0") - - # Create script executor - executor = ScriptExecutor() - - # Build command WITHOUT specifying project_path or project_url - # This should trigger Git auto-detection in the upload script - command = [ - "python", - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--json-output", - "--files", - str(test_file.path), - ] - - # Add GitLab token to environment - env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - # Create execution configuration - execution = UploadExecution( - command=command, - expected_exit_code=0, - expected_output_patterns=[], - env_vars=env_vars, - timeout=120, - use_json_output=True, - ) - - # Execute upload - upload_result = executor.execute_upload(execution) - - # Validate basic execution success - assert upload_result.success, f"Upload failed: {upload_result.error_message}" - assert upload_result.exit_code == 0, ( - f"Expected exit code 0, got {upload_result.exit_code}" - ) - - # Validate JSON output - assert upload_result.json_data is not None, "JSON output not found" - assert validate_json_result( - upload_result.json_data, - expected_success=True, - expected_files=[str(test_file.path)], - ) - assert upload_result.json_data["success"] is True - assert upload_result.json_data["exit_code"] == 0 - assert upload_result.json_data.get("package_name") == package_name - assert upload_result.json_data.get("statistics", {}).get("new_uploads") == 1 - assert len(upload_result.json_data.get("successful_uploads", [])) == 1 - - # Additional GitLab API verification - api_verification = gitlab_client.verify_upload( - package_name=package_name, - version="1.0.0", - filename=test_file.path.name, - expected_checksum=test_file.checksum, - ) - - assert api_verification, "GitLab API verification failed" - - @pytest.mark.timeout(120) - def test_manual_project_url_specification( - self, gitlab_client, artifact_manager, project_path - ): - """ - Test manual project specification via URL. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test file - test_file = artifact_manager.create_test_file( - filename="manual-url-test.txt", size_bytes=1024, content_pattern="text" - ) - - # Create unique package name - package_name = gitlab_client.create_test_package("manual-url", "1.0.0") - - # Create script executor - executor = ScriptExecutor() - - # NOTE: The current upload script has a limitation in URL parsing where it only - # takes the first two path components. For "LinaroLtd/iotil/meta-onelab", it - # extracts "LinaroLtd/iotil" which doesn't exist. This is a known limitation. - # For this test, we'll handle projects with more than 2 path components differently. - - path_components = project_path.split("/") - if len(path_components) > 2: - # For projects with more than 2 path components, the URL parsing will fail - # This is a limitation of the current upload script implementation - print( - f"Project path has {len(path_components)} components, URL parsing will fail" - ) - - gitlab_url = gitlab_client.gitlab_url - project_url = f"{gitlab_url}/{project_path}" - - # Build command with explicit project URL (expecting failure) - command = [ - "python", - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--project-url", - project_url, - "--json-output", - "--files", - str(test_file.path), - ] - - # Add GitLab token to environment - env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - # Create execution configuration expecting failure - execution = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure due to URL parsing limitation - expected_output_patterns=[], - env_vars=env_vars, - timeout=120, - use_json_output=True, - ) - - # Execute upload (expecting it to fail) - upload_result = executor.execute_upload(execution) - - # For this test, success means the error execution succeeded (i.e., the upload failed as expected) - assert upload_result.success, ( - f"Expected upload to fail with exit code 1, but got: {upload_result.error_message}" - ) - assert upload_result.exit_code == 1, ( - f"Expected exit code 1, got {upload_result.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert upload_result.json_data.get("exit_code") == 1, ( - "Expected exit_code to be 1" - ) - assert "error" in upload_result.json_data, ( - "Expected error field in JSON output" - ) - - # Check for project-related keywords in error message - error_msg = upload_result.json_data["error"].lower() - project_keywords = ["project", "not found", "404", "resolution failed"] - project_error_found = any( - keyword in error_msg for keyword in project_keywords - ) - assert project_error_found, ( - f"Expected project error keywords in JSON error: {upload_result.json_data['error']}" - ) - else: - # Fallback to stderr/stdout checking for early script errors - error_indicated = any( - pattern in upload_result.stderr.lower() - for pattern in ["project", "not found", "404", "resolution failed"] - ) - - if not error_indicated: - # Check stdout as well - error_indicated = any( - pattern in upload_result.stdout.lower() - for pattern in [ - "project", - "not found", - "404", - "resolution failed", - ] - ) - - # We don't strictly require the error message to be present as long as the script failed - if not error_indicated: - print( - "Note: Expected error message not found in output, but upload failed as expected" - ) - - print( - f"URL parsing limitation correctly detected for project: {project_path}" - ) - return - - # If project path has 2 or fewer components, proceed with normal test - # (This branch would be used for simpler project structures) - gitlab_url = gitlab_client.gitlab_url - project_url = f"{gitlab_url}/{project_path}" - - # Build command with explicit project URL - command = [ - "python", - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--project-url", - project_url, - "--json-output", - "--files", - str(test_file.path), - ] - - # Add GitLab token to environment - env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - # Create execution configuration - execution = UploadExecution( - command=command, - expected_exit_code=0, - expected_output_patterns=[], - env_vars=env_vars, - timeout=120, - use_json_output=True, - ) - - # Execute upload - upload_result = executor.execute_upload(execution) - - # Validate basic execution success - assert upload_result.success, f"Upload failed: {upload_result.error_message}" - assert upload_result.exit_code == 0, ( - f"Expected exit code 0, got {upload_result.exit_code}" - ) - - # Validate JSON output - assert upload_result.json_data is not None, "JSON output not found" - assert validate_json_result( - upload_result.json_data, - expected_success=True, - expected_files=[str(test_file.path)], - ) - - # Additional GitLab API verification - api_verification = gitlab_client.verify_upload( - package_name=package_name, - version="1.0.0", - filename=test_file.path.name, - expected_checksum=test_file.checksum, - ) - - assert api_verification, "GitLab API verification failed" - - @pytest.mark.timeout(120) - def test_manual_project_path_specification( - self, gitlab_client, artifact_manager, project_path - ): - """ - Test manual project specification via path. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test file - test_file = artifact_manager.create_test_file( - filename="manual-path-test.txt", size_bytes=1024, content_pattern="text" - ) - - # Create unique package name - package_name = gitlab_client.create_test_package("manual-path", "1.0.0") - - # Create script executor - executor = ScriptExecutor() - - # Build command with explicit project path - command = [ - "python", - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--project-path", - project_path, - "--json-output", - "--files", - str(test_file.path), - ] - - # Add GitLab token to environment - env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - # Create execution configuration - execution = UploadExecution( - command=command, - expected_exit_code=0, - expected_output_patterns=[], - env_vars=env_vars, - timeout=120, - use_json_output=True, - ) - - # Execute upload - upload_result = executor.execute_upload(execution) - - # Validate basic execution success - assert upload_result.success, f"Upload failed: {upload_result.error_message}" - assert upload_result.exit_code == 0, ( - f"Expected exit code 0, got {upload_result.exit_code}" - ) - - # Validate JSON output - assert upload_result.json_data is not None, "JSON output not found" - assert validate_json_result( - upload_result.json_data, - expected_success=True, - expected_files=[str(test_file.path)], - ) - assert upload_result.json_data["success"] is True - assert upload_result.json_data.get("package_name") == package_name - - # Additional GitLab API verification - api_verification = gitlab_client.verify_upload( - package_name=package_name, - version="1.0.0", - filename=test_file.path.name, - expected_checksum=test_file.checksum, - ) - - assert api_verification, "GitLab API verification failed" - - @pytest.mark.timeout(120) - def test_invalid_project_path_error_handling( - self, gitlab_client, artifact_manager, project_path - ): - """ - Test error handling for invalid project path specification. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test file - test_file = artifact_manager.create_test_file( - filename="invalid-project-test.txt", size_bytes=1024, content_pattern="text" - ) - - # Create unique package name - package_name = gitlab_client.create_test_package("invalid-project", "1.0.0") - - # Create script executor - executor = ScriptExecutor() - - # Use an invalid project path that should not exist - invalid_project_path = "nonexistent/invalid-project-12345" - - # Build command with invalid project path - command = [ - "python", - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--project-path", - invalid_project_path, - "--json-output", - "--files", - str(test_file.path), - ] - - # Add GitLab token to environment - env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - # Create execution configuration expecting failure - execution = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure due to invalid project - expected_output_patterns=[], - env_vars=env_vars, - timeout=120, - use_json_output=True, - ) - - # Execute upload (expecting it to fail) - upload_result = executor.execute_upload(execution) - - # Validate that the script failed as expected - assert upload_result.success, ( - f"Expected upload to fail with exit code 1, but got: {upload_result.error_message}" - ) - assert upload_result.exit_code == 1, ( - f"Expected exit code 1, got {upload_result.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert upload_result.json_data.get("exit_code") == 1, ( - "Expected exit_code to be 1" - ) - assert "error" in upload_result.json_data, ( - "Expected error field in JSON output" - ) - assert "error_type" in upload_result.json_data, ( - "Expected error_type field in JSON output" - ) - - # Check for project-related keywords in error message - error_msg = upload_result.json_data["error"].lower() - project_keywords = [ - "project", - "404", - "not found", - "resolution failed", - "invalid", - ] - project_error_found = any( - keyword in error_msg for keyword in project_keywords - ) - assert project_error_found, ( - f"Expected project error keywords in JSON error: {upload_result.json_data['error']}" - ) - else: - # Fallback to stderr/stdout checking for early script errors - error_indicated = any( - pattern in upload_result.stderr.lower() - for pattern in [ - "project", - "not found", - "404", - "resolution failed", - "invalid", - ] - ) - - if not error_indicated: - # Check stdout as well - error_indicated = any( - pattern in upload_result.stdout.lower() - for pattern in [ - "project", - "not found", - "404", - "resolution failed", - "invalid", - ] - ) - - # We don't strictly require the error message to be present as long as the script failed - if not error_indicated: - print( - "Note: Expected error message not found in output, but upload failed as expected" - ) - - @pytest.mark.timeout(120) - def test_invalid_project_url_error_handling( - self, gitlab_client, artifact_manager, project_path - ): - """ - Test error handling for invalid project URL specification. - - Args: - gitlab_client: GitLab test client fixture - artifact_manager: Artifact manager fixture - project_path: Project path fixture - """ - # Set up GitLab client with project - gitlab_client.set_project(project_path) - - # Create test file - test_file = artifact_manager.create_test_file( - filename="invalid-url-test.txt", size_bytes=1024, content_pattern="text" - ) - - # Create unique package name - package_name = gitlab_client.create_test_package("invalid-url", "1.0.0") - - # Create script executor - executor = ScriptExecutor() - - # Use an invalid project URL that should not exist - invalid_project_url = ( - f"{gitlab_client.gitlab_url}/nonexistent/invalid-project-12345" - ) - - # Build command with invalid project URL - command = [ - "python", - str(executor.script_path), - "--package-name", - package_name, - "--package-version", - "1.0.0", - "--project-url", - invalid_project_url, - "--json-output", - "--files", - str(test_file.path), - ] - - # Add GitLab token to environment - env_vars = {"GITLAB_TOKEN": _get_gitlab_token()} - - # Create execution configuration expecting failure - execution = UploadExecution( - command=command, - expected_exit_code=1, # Expect failure due to invalid project - expected_output_patterns=[], - env_vars=env_vars, - timeout=120, - use_json_output=True, - ) - - # Execute upload (expecting it to fail) - upload_result = executor.execute_upload(execution) - - # Validate that the script failed as expected - assert upload_result.success, ( - f"Expected upload to fail with exit code 1, but got: {upload_result.error_message}" - ) - assert upload_result.exit_code == 1, ( - f"Expected exit code 1, got {upload_result.exit_code}" - ) - - # Validate JSON error fields if available - if upload_result.json_data is not None: - assert upload_result.json_data.get("success") is False, ( - "Expected success to be False" - ) - assert upload_result.json_data.get("exit_code") == 1, ( - "Expected exit_code to be 1" - ) - assert "error" in upload_result.json_data, ( - "Expected error field in JSON output" - ) - assert "error_type" in upload_result.json_data, ( - "Expected error_type field in JSON output" - ) - - # Check for project-related keywords in error message - error_msg = upload_result.json_data["error"].lower() - project_keywords = [ - "project", - "404", - "not found", - "resolution failed", - "invalid", - ] - project_error_found = any( - keyword in error_msg for keyword in project_keywords - ) - assert project_error_found, ( - f"Expected project error keywords in JSON error: {upload_result.json_data['error']}" - ) - else: - # Fallback to stderr/stdout checking for early script errors - error_indicated = any( - pattern in upload_result.stderr.lower() - for pattern in [ - "project", - "not found", - "404", - "resolution failed", - "invalid", - ] - ) - - if not error_indicated: - # Check stdout as well - error_indicated = any( - pattern in upload_result.stdout.lower() - for pattern in [ - "project", - "not found", - "404", - "resolution failed", - "invalid", - ] - ) - - # We don't strictly require the error message to be present as long as the script failed - if not error_indicated: - print( - "Note: Expected error message not found in output, but upload failed as expected" - ) diff --git a/tests/test_unit_basic.py b/tests/test_unit_basic.py deleted file mode 100644 index 10cd1da..0000000 --- a/tests/test_unit_basic.py +++ /dev/null @@ -1,437 +0,0 @@ -""" -Basic unit tests that don't require GitLab API access. - -These tests validate the core functionality of the upload script components -without requiring external dependencies like GitLab tokens or network access. -""" - -import os -import tempfile -from pathlib import Path - -import pytest - -# Mark these as fast unit tests -pytestmark = [pytest.mark.fast, pytest.mark.unit] - - -class TestBasicFunctionality: - """Basic unit tests for core functionality.""" - - @pytest.mark.timeout(60) - def test_import_gitlab_pkg_upload(self): - """Test that gitlab_pkg_upload module can be imported.""" - try: - from gitlab_pkg_upload import cli - from gitlab_pkg_upload import models - - assert hasattr(cli, "ProjectResolver") - assert hasattr(cli, "GitAutoDetector") - assert hasattr(models, "GitRemoteInfo") - assert hasattr(models, "ProjectInfo") - except ImportError as e: - pytest.fail(f"Failed to import gitlab_pkg_upload: {e}") - - @pytest.mark.timeout(60) - def test_import_main_script(self): - """Test that the main upload script can be imported.""" - import sys - - script_dir = Path(__file__).parent.parent - sys.path.insert(0, str(script_dir)) - - try: - # Check if the script file exists - script_path = script_dir / "gitlab-pkg-upload.py" - if not script_path.exists(): - pytest.skip("Main script file not found") - - # Try to read the script to check basic syntax - script_content = script_path.read_text() - - # Check for key components without importing - assert "def main(" in script_content - assert "argparse" in script_content - assert "upload" in script_content.lower() - - except Exception as e: - pytest.skip(f"Cannot test main script import: {e}") - finally: - if str(script_dir) in sys.path: - sys.path.remove(str(script_dir)) - - @pytest.mark.timeout(60) - def test_file_operations(self): - """Test basic file operations used by the script.""" - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - - # Create a test file - test_file = temp_path / "test.txt" - test_content = b"Hello, World!" - test_file.write_bytes(test_content) - - # Verify file exists and has correct content - assert test_file.exists() - assert test_file.read_bytes() == test_content - assert test_file.stat().st_size == len(test_content) - - @pytest.mark.timeout(60) - def test_checksum_calculation(self): - """Test checksum calculation functionality.""" - import hashlib - - test_data = b"Test data for checksum" - expected_sha256 = hashlib.sha256(test_data).hexdigest() - - # Calculate checksum - calculated_sha256 = hashlib.sha256(test_data).hexdigest() - - assert calculated_sha256 == expected_sha256 - assert len(calculated_sha256) == 64 # SHA256 is 64 hex characters - - @pytest.mark.timeout(60) - def test_path_handling(self): - """Test path handling functionality.""" - # Test various path operations - test_path = Path("/some/test/path/file.txt") - - assert test_path.name == "file.txt" - assert test_path.suffix == ".txt" - assert test_path.stem == "file" - assert test_path.parent.name == "path" - - @pytest.mark.timeout(60) - def test_environment_variable_handling(self): - """Test environment variable handling.""" - # Test setting and getting environment variables - test_var = "TEST_GITLAB_VAR" - test_value = "test_value_123" - - # Set environment variable - os.environ[test_var] = test_value - - # Verify it can be retrieved - assert os.environ.get(test_var) == test_value - - # Clean up - del os.environ[test_var] - - # Verify it's gone - assert os.environ.get(test_var) is None - - -class TestUtilityFunctions: - """Test utility functions and helpers.""" - - @pytest.mark.timeout(60) - def test_rate_limiter_import(self): - """Test that rate limiter utilities can be imported.""" - try: - from .utils.rate_limiter import get_rate_limiter - - limiter = get_rate_limiter() - assert limiter is not None - assert hasattr(limiter, "acquire") - assert hasattr(limiter, "_lock") - - except ImportError as e: - pytest.fail(f"Failed to import rate limiter: {e}") - - @pytest.mark.timeout(60) - def test_performance_utilities_import(self): - """Test that performance utilities can be imported.""" - try: - from .utils.performance import get_data_generator, get_performance_tracker - - tracker = get_performance_tracker() - generator = get_data_generator() - - assert tracker is not None - assert generator is not None - assert hasattr(generator, "generate_content") - - except ImportError as e: - pytest.fail(f"Failed to import performance utilities: {e}") - - @pytest.mark.timeout(60) - def test_data_generation(self): - """Test data generation functionality.""" - try: - from .utils.performance import get_data_generator - - generator = get_data_generator() - - # Test different content types - text_content = generator.generate_content(100, "text") - assert len(text_content) == 100 - assert isinstance(text_content, bytes) - - binary_content = generator.generate_content(50, "binary") - assert len(binary_content) == 50 - assert isinstance(binary_content, bytes) - - except ImportError as e: - pytest.skip(f"Performance utilities not available: {e}") - - -class TestConfigurationValidation: - """Test configuration and setup validation.""" - - @pytest.mark.timeout(60) - def test_pytest_markers_available(self): - """Test that pytest markers are properly configured.""" - # This test verifies that the markers we use are available - import pytest - - # These should not raise warnings when used - pytest.mark.fast - pytest.mark.slow - pytest.mark.integration - pytest.mark.api - pytest.mark.unit - pytest.mark.sequential - - # If we get here without exceptions, markers are working - assert True - - @pytest.mark.timeout(60) - def test_test_directory_structure(self): - """Test that test directory structure is correct.""" - test_dir = Path(__file__).parent - - # Check for required files - assert (test_dir / "conftest.py").exists() - assert (test_dir / "utils").exists() - assert (test_dir / "utils" / "rate_limiter.py").exists() - assert (test_dir / "utils" / "performance.py").exists() - - # Check for test files - test_files = list(test_dir.glob("test_*.py")) - assert len(test_files) > 0 - - # This file should be in the list - assert Path(__file__) in test_files - - -class TestFilenameValidation: - """Test filename validation functionality.""" - - @pytest.mark.timeout(60) - def test_validate_filename_ascii_valid_filenames(self): - """Test that valid ASCII filenames pass validation.""" - import sys - from pathlib import Path - - # Add parent directory to path to import the main script - script_dir = Path(__file__).parent.parent - sys.path.insert(0, str(script_dir)) - - try: - # Import the validation function from the main script - import importlib.util - - spec = importlib.util.spec_from_file_location( - "gitlab_pkg_upload", script_dir / "gitlab-pkg-upload.py" - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - validate_filename_ascii = module.validate_filename_ascii - - # Test valid ASCII filenames - valid_filenames = [ - "package.tar.gz", - "my-file_v1.0.bin", - "subdir/file.txt", - "test123.txt", - "file-name_with.dots.tar.gz", - "a/b/c/deep/path/file.bin", - "UPPERCASE.TXT", - "MixedCase_File-123.tar.gz", - ] - - for filename in valid_filenames: - is_valid, error_message = validate_filename_ascii(filename) - assert is_valid, ( - f"Expected '{filename}' to be valid, but got error: {error_message}" - ) - assert error_message == "", ( - f"Expected empty error message for valid filename '{filename}', got: {error_message}" - ) - - finally: - if str(script_dir) in sys.path: - sys.path.remove(str(script_dir)) - - @pytest.mark.timeout(60) - def test_validate_filename_ascii_invalid_non_ascii(self): - """Test that non-ASCII filenames are rejected.""" - import sys - from pathlib import Path - - # Add parent directory to path to import the main script - script_dir = Path(__file__).parent.parent - sys.path.insert(0, str(script_dir)) - - try: - # Import the validation function from the main script - import importlib.util - - spec = importlib.util.spec_from_file_location( - "gitlab_pkg_upload", script_dir / "gitlab-pkg-upload.py" - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - validate_filename_ascii = module.validate_filename_ascii - - # Test invalid non-ASCII filenames - invalid_filenames = [ - ("café.tar.gz", "café"), - ("文件.bin", "文件"), - ("file™.txt", "™"), - ("tëst.txt", "ë"), - ("файл.tar.gz", "файл"), - ("αρχείο.bin", "αρχείο"), - ] - - for filename, non_ascii_part in invalid_filenames: - is_valid, error_message = validate_filename_ascii(filename) - assert not is_valid, f"Expected '{filename}' to be invalid" - assert error_message != "", ( - f"Expected error message for invalid filename '{filename}'" - ) - - # Check that error message contains key information - assert ( - "non-ascii" in error_message.lower() - or "ascii" in error_message.lower() - ), ( - f"Expected error message to mention ASCII for '{filename}': {error_message}" - ) - assert filename in error_message, ( - f"Expected error message to mention the problematic filename '{filename}': {error_message}" - ) - assert "allowed characters" in error_message.lower(), ( - f"Expected error message to mention allowed characters for '{filename}': {error_message}" - ) - - finally: - if str(script_dir) in sys.path: - sys.path.remove(str(script_dir)) - - @pytest.mark.timeout(60) - def test_validate_filename_ascii_invalid_special_chars(self): - """Test that filenames with unsupported special characters are rejected.""" - import sys - from pathlib import Path - - # Add parent directory to path to import the main script - script_dir = Path(__file__).parent.parent - sys.path.insert(0, str(script_dir)) - - try: - # Import the validation function from the main script - import importlib.util - - spec = importlib.util.spec_from_file_location( - "gitlab_pkg_upload", script_dir / "gitlab-pkg-upload.py" - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - validate_filename_ascii = module.validate_filename_ascii - - # Test filenames with unsupported special characters (but still ASCII) - invalid_filenames = [ - "file@name.txt", - "file#name.txt", - "file$name.txt", - "file%name.txt", - "file&name.txt", - "file*name.txt", - "file(name).txt", - "file[name].txt", - "file{name}.txt", - "file name.txt", # space - "file+name.txt", - "file=name.txt", - "file!name.txt", - "file?name.txt", - ] - - for filename in invalid_filenames: - is_valid, error_message = validate_filename_ascii(filename) - assert not is_valid, f"Expected '{filename}' to be invalid" - assert error_message != "", ( - f"Expected error message for invalid filename '{filename}'" - ) - - # Check that error message contains key information - assert ( - "special characters" in error_message.lower() - or "allowed characters" in error_message.lower() - ), ( - f"Expected error message to mention special characters for '{filename}': {error_message}" - ) - assert filename in error_message, ( - f"Expected error message to mention the problematic filename '{filename}': {error_message}" - ) - - finally: - if str(script_dir) in sys.path: - sys.path.remove(str(script_dir)) - - @pytest.mark.timeout(60) - def test_validate_filename_ascii_error_message_quality(self): - """Test that error messages are detailed and helpful.""" - import sys - from pathlib import Path - - # Add parent directory to path to import the main script - script_dir = Path(__file__).parent.parent - sys.path.insert(0, str(script_dir)) - - try: - # Import the validation function from the main script - import importlib.util - - spec = importlib.util.spec_from_file_location( - "gitlab_pkg_upload", script_dir / "gitlab-pkg-upload.py" - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - validate_filename_ascii = module.validate_filename_ascii - - # Test with a non-ASCII filename - is_valid, error_message = validate_filename_ascii("café.tar.gz") - - assert not is_valid - assert error_message != "" - - # Check that error message contains all required elements - required_elements = [ - "café.tar.gz", # The problematic filename - "allowed characters", # Explanation of what's allowed - "rename", # Suggestion to fix the issue - ] - - for element in required_elements: - assert element.lower() in error_message.lower(), ( - f"Expected error message to contain '{element}': {error_message}" - ) - - # Check that error message mentions specific allowed characters - allowed_chars = ["letter", "digit", "dot", "hyphen", "underscore", "slash"] - chars_mentioned = sum( - 1 for char in allowed_chars if char in error_message.lower() - ) - assert chars_mentioned >= 4, ( - f"Expected error message to mention at least 4 allowed character types: {error_message}" - ) - - finally: - if str(script_dir) in sys.path: - sys.path.remove(str(script_dir)) diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py index 2b6b82e..b6b1248 100644 --- a/tests/utils/test_helpers.py +++ b/tests/utils/test_helpers.py @@ -19,6 +19,13 @@ class in the monolithic test file. It provides utilities for running the from pathlib import Path from typing import Dict, List, Optional +# Add src directory to sys.path as a fallback for running tests without +# installing the package. This allows tests to run from the repository +# without requiring `uv pip install -e .` first. +_src_path = Path(__file__).parent.parent.parent / "src" +if _src_path.exists() and str(_src_path) not in sys.path: + sys.path.insert(0, str(_src_path)) + # Import from the new modular structure try: from gitlab_pkg_upload.cli import main as cli_main @@ -138,22 +145,17 @@ def __init__(self, script_path: Optional[Path] = None): Initialize script executor. Args: - script_path: Path to the upload script. If None, uses direct module invocation. - This parameter is kept for backward compatibility but is - ignored when CLI_AVAILABLE is True. + script_path: Deprecated parameter, kept for backward compatibility. + Direct module invocation is always used via gitlab_pkg_upload.cli. """ self.script_path = script_path self._use_direct_invocation = CLI_AVAILABLE - # If direct invocation is not available, fall back to subprocess if not self._use_direct_invocation: - if script_path is None: - # Default to the upload script in the same directory as the test - script_path = Path(__file__).parent.parent.parent / "gitlab-pkg-upload.py" - self.script_path = script_path - - if not self.script_path.exists(): - raise FileNotFoundError(f"Upload script not found at: {self.script_path}") + raise ImportError( + "gitlab_pkg_upload module is not available. " + "Install the package with: uv pip install -e ." + ) def execute_upload(self, execution: UploadExecution) -> UploadResult: """ diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..682ccef --- /dev/null +++ b/uv.lock @@ -0,0 +1,695 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "argcomplete" +version = "3.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitlab-pkg-upload" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "argcomplete" }, + { name = "gitpython" }, + { name = "python-gitlab" }, + { name = "rich" }, + { name = "tenacity" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pre-commit" }, + { name = "ruff" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-instafail" }, + { name = "pytest-sugar" }, + { name = "pytest-timeout" }, + { name = "pytest-xdist" }, +] + +[package.metadata] +requires-dist = [ + { name = "argcomplete", specifier = ">=3.0.0" }, + { name = "gitpython", specifier = ">=3.1.0" }, + { name = "mypy", marker = "extra == 'dev'" }, + { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "pytest", marker = "extra == 'test'" }, + { name = "pytest-instafail", marker = "extra == 'test'" }, + { name = "pytest-sugar", marker = "extra == 'test'" }, + { name = "pytest-timeout", marker = "extra == 'test'" }, + { name = "pytest-xdist", marker = "extra == 'test'" }, + { name = "python-gitlab", specifier = ">=4.0.0" }, + { name = "rich", specifier = ">=13.0.0" }, + { name = "ruff", marker = "extra == 'dev'" }, + { name = "tenacity", specifier = ">=8.0.0" }, +] +provides-extras = ["dev", "test"] + +[[package]] +name = "gitpython" +version = "3.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.7.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/29/47f29026ca17f35cf299290292d5f8331f5077364974b7675a353179afa2/librt-0.7.7.tar.gz", hash = "sha256:81d957b069fed1890953c3b9c3895c7689960f233eea9a1d9607f71ce7f00b2c", size = 145910, upload-time = "2026-01-01T23:52:22.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/56/30b5c342518005546df78841cb0820ae85a17e7d07d521c10ef367306d0d/librt-0.7.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a487b71fbf8a9edb72a8c7a456dda0184642d99cd007bc819c0b7ab93676a8ee", size = 54709, upload-time = "2026-01-01T23:51:02.774Z" }, + { url = "https://files.pythonhosted.org/packages/72/78/9f120e3920b22504d4f3835e28b55acc2cc47c9586d2e1b6ba04c3c1bf01/librt-0.7.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f4d4efb218264ecf0f8516196c9e2d1a0679d9fb3bb15df1155a35220062eba8", size = 56663, upload-time = "2026-01-01T23:51:03.838Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/7d7a1ee7dfc1151836028eba25629afcf45b56bbc721293e41aa2e9b8934/librt-0.7.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b8bb331aad734b059c4b450cd0a225652f16889e286b2345af5e2c3c625c3d85", size = 161705, upload-time = "2026-01-01T23:51:04.917Z" }, + { url = "https://files.pythonhosted.org/packages/45/a5/952bc840ac8917fbcefd6bc5f51ad02b89721729814f3e2bfcc1337a76d6/librt-0.7.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:467dbd7443bda08338fc8ad701ed38cef48194017554f4c798b0a237904b3f99", size = 171029, upload-time = "2026-01-01T23:51:06.09Z" }, + { url = "https://files.pythonhosted.org/packages/fa/bf/c017ff7da82dc9192cf40d5e802a48a25d00e7639b6465cfdcee5893a22c/librt-0.7.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50d1d1ee813d2d1a3baf2873634ba506b263032418d16287c92ec1cc9c1a00cb", size = 184704, upload-time = "2026-01-01T23:51:07.549Z" }, + { url = "https://files.pythonhosted.org/packages/77/ec/72f3dd39d2cdfd6402ab10836dc9cbf854d145226062a185b419c4f1624a/librt-0.7.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e5070cf3ec92d98f57574da0224f8c73faf1ddd6d8afa0b8c9f6e86997bc74", size = 180719, upload-time = "2026-01-01T23:51:09.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/86/06e7a1a81b246f3313bf515dd9613a1c81583e6fd7843a9f4d625c4e926d/librt-0.7.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bdb9f3d865b2dafe7f9ad7f30ef563c80d0ddd2fdc8cc9b8e4f242f475e34d75", size = 174537, upload-time = "2026-01-01T23:51:10.611Z" }, + { url = "https://files.pythonhosted.org/packages/83/08/f9fb2edc9c7a76e95b2924ce81d545673f5b034e8c5dd92159d1c7dae0c6/librt-0.7.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8185c8497d45164e256376f9da5aed2bb26ff636c798c9dabe313b90e9f25b28", size = 195238, upload-time = "2026-01-01T23:51:11.762Z" }, + { url = "https://files.pythonhosted.org/packages/ba/56/ea2d2489d3ea1f47b301120e03a099e22de7b32c93df9a211e6ff4f9bf38/librt-0.7.7-cp311-cp311-win32.whl", hash = "sha256:44d63ce643f34a903f09ff7ca355aae019a3730c7afd6a3c037d569beeb5d151", size = 42939, upload-time = "2026-01-01T23:51:13.192Z" }, + { url = "https://files.pythonhosted.org/packages/58/7b/c288f417e42ba2a037f1c0753219e277b33090ed4f72f292fb6fe175db4c/librt-0.7.7-cp311-cp311-win_amd64.whl", hash = "sha256:7d13cc340b3b82134f8038a2bfe7137093693dcad8ba5773da18f95ad6b77a8a", size = 49240, upload-time = "2026-01-01T23:51:14.264Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/738eb33a6c1516fdb2dfd2a35db6e5300f7616679b573585be0409bc6890/librt-0.7.7-cp311-cp311-win_arm64.whl", hash = "sha256:983de36b5a83fe9222f4f7dcd071f9b1ac6f3f17c0af0238dadfb8229588f890", size = 42613, upload-time = "2026-01-01T23:51:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/1cd9d752070011641e8aee046c851912d5f196ecd726fffa7aed2070f3e0/librt-0.7.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a85a1fc4ed11ea0eb0a632459ce004a2d14afc085a50ae3463cd3dfe1ce43fc", size = 55687, upload-time = "2026-01-01T23:51:16.291Z" }, + { url = "https://files.pythonhosted.org/packages/50/aa/d5a1d4221c4fe7e76ae1459d24d6037783cb83c7645164c07d7daf1576ec/librt-0.7.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c87654e29a35938baead1c4559858f346f4a2a7588574a14d784f300ffba0efd", size = 57136, upload-time = "2026-01-01T23:51:17.363Z" }, + { url = "https://files.pythonhosted.org/packages/23/6f/0c86b5cb5e7ef63208c8cc22534df10ecc5278efc0d47fb8815577f3ca2f/librt-0.7.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c9faaebb1c6212c20afd8043cd6ed9de0a47d77f91a6b5b48f4e46ed470703fe", size = 165320, upload-time = "2026-01-01T23:51:18.455Z" }, + { url = "https://files.pythonhosted.org/packages/16/37/df4652690c29f645ffe405b58285a4109e9fe855c5bb56e817e3e75840b3/librt-0.7.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1908c3e5a5ef86b23391448b47759298f87f997c3bd153a770828f58c2bb4630", size = 174216, upload-time = "2026-01-01T23:51:19.599Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d6/d3afe071910a43133ec9c0f3e4ce99ee6df0d4e44e4bddf4b9e1c6ed41cc/librt-0.7.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dbc4900e95a98fc0729523be9d93a8fedebb026f32ed9ffc08acd82e3e181503", size = 189005, upload-time = "2026-01-01T23:51:21.052Z" }, + { url = "https://files.pythonhosted.org/packages/d5/18/74060a870fe2d9fd9f47824eba6717ce7ce03124a0d1e85498e0e7efc1b2/librt-0.7.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a7ea4e1fbd253e5c68ea0fe63d08577f9d288a73f17d82f652ebc61fa48d878d", size = 183961, upload-time = "2026-01-01T23:51:22.493Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5e/918a86c66304af66a3c1d46d54df1b2d0b8894babc42a14fb6f25511497f/librt-0.7.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ef7699b7a5a244b1119f85c5bbc13f152cd38240cbb2baa19b769433bae98e50", size = 177610, upload-time = "2026-01-01T23:51:23.874Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d7/b5e58dc2d570f162e99201b8c0151acf40a03a39c32ab824dd4febf12736/librt-0.7.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:955c62571de0b181d9e9e0a0303c8bc90d47670a5eff54cf71bf5da61d1899cf", size = 199272, upload-time = "2026-01-01T23:51:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/87/8202c9bd0968bdddc188ec3811985f47f58ed161b3749299f2c0dd0f63fb/librt-0.7.7-cp312-cp312-win32.whl", hash = "sha256:1bcd79be209313b270b0e1a51c67ae1af28adad0e0c7e84c3ad4b5cb57aaa75b", size = 43189, upload-time = "2026-01-01T23:51:26.799Z" }, + { url = "https://files.pythonhosted.org/packages/61/8d/80244b267b585e7aa79ffdac19f66c4861effc3a24598e77909ecdd0850e/librt-0.7.7-cp312-cp312-win_amd64.whl", hash = "sha256:4353ee891a1834567e0302d4bd5e60f531912179578c36f3d0430f8c5e16b456", size = 49462, upload-time = "2026-01-01T23:51:27.813Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/75db802d6a4992d95e8a889682601af9b49d5a13bbfa246d414eede1b56c/librt-0.7.7-cp312-cp312-win_arm64.whl", hash = "sha256:a76f1d679beccccdf8c1958e732a1dfcd6e749f8821ee59d7bec009ac308c029", size = 42828, upload-time = "2026-01-01T23:51:28.804Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5e/d979ccb0a81407ec47c14ea68fb217ff4315521730033e1dd9faa4f3e2c1/librt-0.7.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f4a0b0a3c86ba9193a8e23bb18f100d647bf192390ae195d84dfa0a10fb6244", size = 55746, upload-time = "2026-01-01T23:51:29.828Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/3b65861fb32f802c3783d6ac66fc5589564d07452a47a8cf9980d531cad3/librt-0.7.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5335890fea9f9e6c4fdf8683061b9ccdcbe47c6dc03ab8e9b68c10acf78be78d", size = 57174, upload-time = "2026-01-01T23:51:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/030b50614b29e443607220097ebaf438531ea218c7a9a3e21ea862a919cd/librt-0.7.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b4346b1225be26def3ccc6c965751c74868f0578cbcba293c8ae9168483d811", size = 165834, upload-time = "2026-01-01T23:51:32.278Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e1/bd8d1eacacb24be26a47f157719553bbd1b3fe812c30dddf121c0436fd0b/librt-0.7.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a10b8eebdaca6e9fdbaf88b5aefc0e324b763a5f40b1266532590d5afb268a4c", size = 174819, upload-time = "2026-01-01T23:51:33.461Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/91d6c3372acf54a019c1ad8da4c9ecf4fc27d039708880bf95f48dbe426a/librt-0.7.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:067be973d90d9e319e6eb4ee2a9b9307f0ecd648b8a9002fa237289a4a07a9e7", size = 189607, upload-time = "2026-01-01T23:51:34.604Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ac/44604d6d3886f791fbd1c6ae12d5a782a8f4aca927484731979f5e92c200/librt-0.7.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23d2299ed007812cccc1ecef018db7d922733382561230de1f3954db28433977", size = 184586, upload-time = "2026-01-01T23:51:35.845Z" }, + { url = "https://files.pythonhosted.org/packages/5c/26/d8a6e4c17117b7f9b83301319d9a9de862ae56b133efb4bad8b3aa0808c9/librt-0.7.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6b6f8ea465524aa4c7420c7cc4ca7d46fe00981de8debc67b1cc2e9957bb5b9d", size = 178251, upload-time = "2026-01-01T23:51:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/99/ab/98d857e254376f8e2f668e807daccc1f445e4b4fc2f6f9c1cc08866b0227/librt-0.7.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8df32a99cc46eb0ee90afd9ada113ae2cafe7e8d673686cf03ec53e49635439", size = 199853, upload-time = "2026-01-01T23:51:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/7c/55/4523210d6ae5134a5da959900be43ad8bab2e4206687b6620befddb5b5fd/librt-0.7.7-cp313-cp313-win32.whl", hash = "sha256:86f86b3b785487c7760247bcdac0b11aa8bf13245a13ed05206286135877564b", size = 43247, upload-time = "2026-01-01T23:51:39.629Z" }, + { url = "https://files.pythonhosted.org/packages/25/40/3ec0fed5e8e9297b1cf1a3836fb589d3de55f9930e3aba988d379e8ef67c/librt-0.7.7-cp313-cp313-win_amd64.whl", hash = "sha256:4862cb2c702b1f905c0503b72d9d4daf65a7fdf5a9e84560e563471e57a56949", size = 49419, upload-time = "2026-01-01T23:51:40.674Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7a/aab5f0fb122822e2acbc776addf8b9abfb4944a9056c00c393e46e543177/librt-0.7.7-cp313-cp313-win_arm64.whl", hash = "sha256:0996c83b1cb43c00e8c87835a284f9057bc647abd42b5871e5f941d30010c832", size = 42828, upload-time = "2026-01-01T23:51:41.731Z" }, + { url = "https://files.pythonhosted.org/packages/69/9c/228a5c1224bd23809a635490a162e9cbdc68d99f0eeb4a696f07886b8206/librt-0.7.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:23daa1ab0512bafdd677eb1bfc9611d8ffbe2e328895671e64cb34166bc1b8c8", size = 55188, upload-time = "2026-01-01T23:51:43.14Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c2/0e7c6067e2b32a156308205e5728f4ed6478c501947e9142f525afbc6bd2/librt-0.7.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:558a9e5a6f3cc1e20b3168fb1dc802d0d8fa40731f6e9932dcc52bbcfbd37111", size = 56895, upload-time = "2026-01-01T23:51:44.534Z" }, + { url = "https://files.pythonhosted.org/packages/0e/77/de50ff70c80855eb79d1d74035ef06f664dd073fb7fb9d9fb4429651b8eb/librt-0.7.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2567cb48dc03e5b246927ab35cbb343376e24501260a9b5e30b8e255dca0d1d2", size = 163724, upload-time = "2026-01-01T23:51:45.571Z" }, + { url = "https://files.pythonhosted.org/packages/6e/19/f8e4bf537899bdef9e0bb9f0e4b18912c2d0f858ad02091b6019864c9a6d/librt-0.7.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6066c638cdf85ff92fc6f932d2d73c93a0e03492cdfa8778e6d58c489a3d7259", size = 172470, upload-time = "2026-01-01T23:51:46.823Z" }, + { url = "https://files.pythonhosted.org/packages/42/4c/dcc575b69d99076768e8dd6141d9aecd4234cba7f0e09217937f52edb6ed/librt-0.7.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a609849aca463074c17de9cda173c276eb8fee9e441053529e7b9e249dc8b8ee", size = 186806, upload-time = "2026-01-01T23:51:48.009Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f8/4094a2b7816c88de81239a83ede6e87f1138477d7ee956c30f136009eb29/librt-0.7.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:add4e0a000858fe9bb39ed55f31085506a5c38363e6eb4a1e5943a10c2bfc3d1", size = 181809, upload-time = "2026-01-01T23:51:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/821b7c0ab1b5a6cd9aee7ace8309c91545a2607185101827f79122219a7e/librt-0.7.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a3bfe73a32bd0bdb9a87d586b05a23c0a1729205d79df66dee65bb2e40d671ba", size = 175597, upload-time = "2026-01-01T23:51:50.636Z" }, + { url = "https://files.pythonhosted.org/packages/71/f9/27f6bfbcc764805864c04211c6ed636fe1d58f57a7b68d1f4ae5ed74e0e0/librt-0.7.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0ecce0544d3db91a40f8b57ae26928c02130a997b540f908cefd4d279d6c5848", size = 196506, upload-time = "2026-01-01T23:51:52.535Z" }, + { url = "https://files.pythonhosted.org/packages/46/ba/c9b9c6fc931dd7ea856c573174ccaf48714905b1a7499904db2552e3bbaf/librt-0.7.7-cp314-cp314-win32.whl", hash = "sha256:8f7a74cf3a80f0c3b0ec75b0c650b2f0a894a2cec57ef75f6f72c1e82cdac61d", size = 39747, upload-time = "2026-01-01T23:51:53.683Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/cd1269337c4cde3ee70176ee611ab0058aa42fc8ce5c9dce55f48facfcd8/librt-0.7.7-cp314-cp314-win_amd64.whl", hash = "sha256:3d1fe2e8df3268dd6734dba33ededae72ad5c3a859b9577bc00b715759c5aaab", size = 45971, upload-time = "2026-01-01T23:51:54.697Z" }, + { url = "https://files.pythonhosted.org/packages/79/fd/e0844794423f5583108c5991313c15e2b400995f44f6ec6871f8aaf8243c/librt-0.7.7-cp314-cp314-win_arm64.whl", hash = "sha256:2987cf827011907d3dfd109f1be0d61e173d68b1270107bb0e89f2fca7f2ed6b", size = 39075, upload-time = "2026-01-01T23:51:55.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/02/211fd8f7c381e7b2a11d0fdfcd410f409e89967be2e705983f7c6342209a/librt-0.7.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8e92c8de62b40bfce91d5e12c6e8b15434da268979b1af1a6589463549d491e6", size = 57368, upload-time = "2026-01-01T23:51:56.706Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/aca257affae73ece26041ae76032153266d110453173f67d7603058e708c/librt-0.7.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f683dcd49e2494a7535e30f779aa1ad6e3732a019d80abe1309ea91ccd3230e3", size = 59238, upload-time = "2026-01-01T23:51:58.066Z" }, + { url = "https://files.pythonhosted.org/packages/96/47/7383a507d8e0c11c78ca34c9d36eab9000db5989d446a2f05dc40e76c64f/librt-0.7.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b15e5d17812d4d629ff576699954f74e2cc24a02a4fc401882dd94f81daba45", size = 183870, upload-time = "2026-01-01T23:51:59.204Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b8/50f3d8eec8efdaf79443963624175c92cec0ba84827a66b7fcfa78598e51/librt-0.7.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c084841b879c4d9b9fa34e5d5263994f21aea7fd9c6add29194dbb41a6210536", size = 194608, upload-time = "2026-01-01T23:52:00.419Z" }, + { url = "https://files.pythonhosted.org/packages/23/d9/1b6520793aadb59d891e3b98ee057a75de7f737e4a8b4b37fdbecb10d60f/librt-0.7.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c8fb9966f84737115513fecbaf257f9553d067a7dd45a69c2c7e5339e6a8dc", size = 206776, upload-time = "2026-01-01T23:52:01.705Z" }, + { url = "https://files.pythonhosted.org/packages/ff/db/331edc3bba929d2756fa335bfcf736f36eff4efcb4f2600b545a35c2ae58/librt-0.7.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5fb1ecb2c35362eab2dbd354fd1efa5a8440d3e73a68be11921042a0edc0ff", size = 203206, upload-time = "2026-01-01T23:52:03.315Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e1/6af79ec77204e85f6f2294fc171a30a91bb0e35d78493532ed680f5d98be/librt-0.7.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d1454899909d63cc9199a89fcc4f81bdd9004aef577d4ffc022e600c412d57f3", size = 196697, upload-time = "2026-01-01T23:52:04.857Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/de55ecce4b2796d6d243295c221082ca3a944dc2fb3a52dcc8660ce7727d/librt-0.7.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7ef28f2e7a016b29792fe0a2dd04dec75725b32a1264e390c366103f834a9c3a", size = 217193, upload-time = "2026-01-01T23:52:06.159Z" }, + { url = "https://files.pythonhosted.org/packages/41/61/33063e271949787a2f8dd33c5260357e3d512a114fc82ca7890b65a76e2d/librt-0.7.7-cp314-cp314t-win32.whl", hash = "sha256:5e419e0db70991b6ba037b70c1d5bbe92b20ddf82f31ad01d77a347ed9781398", size = 40277, upload-time = "2026-01-01T23:52:07.625Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/1abd972349f83a696ea73159ac964e63e2d14086fdd9bc7ca878c25fced4/librt-0.7.7-cp314-cp314t-win_amd64.whl", hash = "sha256:d6b7d93657332c817b8d674ef6bf1ab7796b4f7ce05e420fd45bd258a72ac804", size = 46765, upload-time = "2026-01-01T23:52:08.647Z" }, + { url = "https://files.pythonhosted.org/packages/51/0e/b756c7708143a63fca65a51ca07990fa647db2cc8fcd65177b9e96680255/librt-0.7.7-cp314-cp314t-win_arm64.whl", hash = "sha256:142c2cd91794b79fd0ce113bd658993b7ede0fe93057668c2f98a45ca00b7e91", size = 39724, upload-time = "2026-01-01T23:52:09.745Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-instafail" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/bd/e0ba6c3cd20b9aa445f0af229f3a9582cce589f083537978a23e6f14e310/pytest-instafail-0.5.0.tar.gz", hash = "sha256:33a606f7e0c8e646dc3bfee0d5e3a4b7b78ef7c36168cfa1f3d93af7ca706c9e", size = 5849, upload-time = "2023-03-31T17:17:32.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/c0/c32dc39fc172e684fdb3d30169843efb65c067be1e12689af4345731126e/pytest_instafail-0.5.0-py3-none-any.whl", hash = "sha256:6855414487e9e4bb76a118ce952c3c27d3866af15487506c4ded92eb72387819", size = 4176, upload-time = "2023-03-31T17:17:30.065Z" }, +] + +[[package]] +name = "pytest-sugar" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "termcolor" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/4e/60fed105549297ba1a700e1ea7b828044842ea27d72c898990510b79b0e2/pytest-sugar-1.1.1.tar.gz", hash = "sha256:73b8b65163ebf10f9f671efab9eed3d56f20d2ca68bda83fa64740a92c08f65d", size = 16533, upload-time = "2025-08-23T12:19:35.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/d5/81d38a91c1fdafb6711f053f5a9b92ff788013b19821257c2c38c1e132df/pytest_sugar-1.1.1-py3-none-any.whl", hash = "sha256:2f8319b907548d5b9d03a171515c1d43d2e38e32bd8182a1781eb20b43344cc8", size = 11440, upload-time = "2025-08-23T12:19:34.894Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-gitlab" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "requests-toolbelt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/98/0b5d0a0367b90aec818298390b60ae65e6a08989cf5140271d0ee0206882/python_gitlab-7.1.0.tar.gz", hash = "sha256:1c34da3de40ad21675d788136f73d20a60649513e692f52c5a9720434db97c46", size = 401058, upload-time = "2025-12-28T01:27:01.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/44/70fa1e395731b6a4b1f249d5f7326f3bb6281e2cf94d6535f679239f4b93/python_gitlab-7.1.0-py3-none-any.whl", hash = "sha256:8e42030cf27674e7ec9ea1f6d2fedcaaef0a6210f5fa22c80721abaa3a4fec90", size = 144441, upload-time = "2025-12-28T01:26:59.726Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/77/9a7fe084d268f8855d493e5031ea03fa0af8cc05887f638bf1c4e3363eb8/ruff-0.14.11.tar.gz", hash = "sha256:f6dc463bfa5c07a59b1ff2c3b9767373e541346ea105503b4c0369c520a66958", size = 5993417, upload-time = "2026-01-08T19:11:58.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/a6/a4c40a5aaa7e331f245d2dc1ac8ece306681f52b636b40ef87c88b9f7afd/ruff-0.14.11-py3-none-linux_armv6l.whl", hash = "sha256:f6ff2d95cbd335841a7217bdfd9c1d2e44eac2c584197ab1385579d55ff8830e", size = 12951208, upload-time = "2026-01-08T19:12:09.218Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/360a35cb7204b328b685d3129c08aca24765ff92b5a7efedbdd6c150d555/ruff-0.14.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f6eb5c1c8033680f4172ea9c8d3706c156223010b8b97b05e82c59bdc774ee6", size = 13330075, upload-time = "2026-01-08T19:12:02.549Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9e/0cc2f1be7a7d33cae541824cf3f95b4ff40d03557b575912b5b70273c9ec/ruff-0.14.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2fc34cc896f90080fca01259f96c566f74069a04b25b6205d55379d12a6855e", size = 12257809, upload-time = "2026-01-08T19:12:00.366Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e5/5faab97c15bb75228d9f74637e775d26ac703cc2b4898564c01ab3637c02/ruff-0.14.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53386375001773ae812b43205d6064dae49ff0968774e6befe16a994fc233caa", size = 12678447, upload-time = "2026-01-08T19:12:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/e9767f60a2bef779fb5855cab0af76c488e0ce90f7bb7b8a45c8a2ba4178/ruff-0.14.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a697737dce1ca97a0a55b5ff0434ee7205943d4874d638fe3ae66166ff46edbe", size = 12758560, upload-time = "2026-01-08T19:11:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/eb/84/4c6cf627a21462bb5102f7be2a320b084228ff26e105510cd2255ea868e5/ruff-0.14.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6845ca1da8ab81ab1dce755a32ad13f1db72e7fba27c486d5d90d65e04d17b8f", size = 13599296, upload-time = "2026-01-08T19:11:30.371Z" }, + { url = "https://files.pythonhosted.org/packages/88/e1/92b5ed7ea66d849f6157e695dc23d5d6d982bd6aa8d077895652c38a7cae/ruff-0.14.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e36ce2fd31b54065ec6f76cb08d60159e1b32bdf08507862e32f47e6dde8bcbf", size = 15048981, upload-time = "2026-01-08T19:12:04.742Z" }, + { url = "https://files.pythonhosted.org/packages/61/df/c1bd30992615ac17c2fb64b8a7376ca22c04a70555b5d05b8f717163cf9f/ruff-0.14.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:590bcc0e2097ecf74e62a5c10a6b71f008ad82eb97b0a0079e85defe19fe74d9", size = 14633183, upload-time = "2026-01-08T19:11:40.069Z" }, + { url = "https://files.pythonhosted.org/packages/04/e9/fe552902f25013dd28a5428a42347d9ad20c4b534834a325a28305747d64/ruff-0.14.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53fe71125fc158210d57fe4da26e622c9c294022988d08d9347ec1cf782adafe", size = 14050453, upload-time = "2026-01-08T19:11:37.555Z" }, + { url = "https://files.pythonhosted.org/packages/ae/93/f36d89fa021543187f98991609ce6e47e24f35f008dfe1af01379d248a41/ruff-0.14.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a35c9da08562f1598ded8470fcfef2afb5cf881996e6c0a502ceb61f4bc9c8a3", size = 13757889, upload-time = "2026-01-08T19:12:07.094Z" }, + { url = "https://files.pythonhosted.org/packages/b7/9f/c7fb6ecf554f28709a6a1f2a7f74750d400979e8cd47ed29feeaa1bd4db8/ruff-0.14.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0f3727189a52179393ecf92ec7057c2210203e6af2676f08d92140d3e1ee72c1", size = 13955832, upload-time = "2026-01-08T19:11:55.064Z" }, + { url = "https://files.pythonhosted.org/packages/db/a0/153315310f250f76900a98278cf878c64dfb6d044e184491dd3289796734/ruff-0.14.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:eb09f849bd37147a789b85995ff734a6c4a095bed5fd1608c4f56afc3634cde2", size = 12586522, upload-time = "2026-01-08T19:11:35.356Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2b/a73a2b6e6d2df1d74bf2b78098be1572191e54bec0e59e29382d13c3adc5/ruff-0.14.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c61782543c1231bf71041461c1f28c64b961d457d0f238ac388e2ab173d7ecb7", size = 12724637, upload-time = "2026-01-08T19:11:47.796Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/09100590320394401cd3c48fc718a8ba71c7ddb1ffd07e0ad6576b3a3df2/ruff-0.14.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:82ff352ea68fb6766140381748e1f67f83c39860b6446966cff48a315c3e2491", size = 13145837, upload-time = "2026-01-08T19:11:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d8/e035db859d1d3edf909381eb8ff3e89a672d6572e9454093538fe6f164b0/ruff-0.14.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:728e56879df4ca5b62a9dde2dd0eb0edda2a55160c0ea28c4025f18c03f86984", size = 13850469, upload-time = "2026-01-08T19:12:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/4e/02/bb3ff8b6e6d02ce9e3740f4c17dfbbfb55f34c789c139e9cd91985f356c7/ruff-0.14.11-py3-none-win32.whl", hash = "sha256:337c5dd11f16ee52ae217757d9b82a26400be7efac883e9e852646f1557ed841", size = 12851094, upload-time = "2026-01-08T19:11:45.163Z" }, + { url = "https://files.pythonhosted.org/packages/58/f1/90ddc533918d3a2ad628bc3044cdfc094949e6d4b929220c3f0eb8a1c998/ruff-0.14.11-py3-none-win_amd64.whl", hash = "sha256:f981cea63d08456b2c070e64b79cb62f951aa1305282974d4d5216e6e0178ae6", size = 14001379, upload-time = "2026-01-08T19:11:52.591Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1c/1dbe51782c0e1e9cfce1d1004752672d2d4629ea46945d19d731ad772b3b/ruff-0.14.11-py3-none-win_arm64.whl", hash = "sha256:649fb6c9edd7f751db276ef42df1f3df41c38d67d199570ae2a7bd6cbc3590f0", size = 12938644, upload-time = "2026-01-08T19:11:50.027Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.36.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, +] From 6aa486802f634e29071264e4a78cd3d82726539c Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 09:21:12 -0600 Subject: [PATCH 21/36] ci: Add GitHub Actions workflow for automated testing The project currently lacks continuous integration, meaning tests must be run manually on each developer's machine. This makes it easy for regressions to slip through during code review and creates uncertainty about whether code works across different Python versions. Introduce a GitHub Actions workflow that runs the test suite on Python 3.11, 3.12, and 3.13 for every push and pull request. Unit tests run unconditionally while integration tests only execute when GitLab credentials are available, allowing the workflow to function in contexts where secrets are not configured. Signed-off-by: Javier Tia --- .github/workflows/test.yml | 58 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6ba6ce0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,58 @@ +name: Tests + +on: + push: + branches: + - '**' + pull_request: + branches: + - main + - master + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install package in editable mode + run: uv pip install -e . --system + + - name: Install test dependencies + run: uv pip install -e ".[test]" --system + + - name: Run unit tests + run: uv run pytest tests/unit/ -m unit -v -n auto + + - name: Run integration tests + if: ${{ secrets.GITLAB_TOKEN != '' && secrets.GITLAB_REPO != '' }} + env: + GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} + GITLAB_PROJECT_PATH: ${{ secrets.GITLAB_REPO }} + run: uv run pytest tests/integration/ -m integration -v -n auto + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: pytest-results-${{ matrix.python-version }} + path: | + .pytest_cache/ + htmlcov/ + retention-days: 7 From 06e410c6a6bd53ffdbb7f4f31fee0fa1765b3c27 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 11:01:03 -0600 Subject: [PATCH 22/36] github: Add CI workflows for linting, docs validation, and publishing The project lacks automated quality checks and a release pipeline. Without CI workflows, code style violations, documentation issues, and broken links can slip into the codebase undetected, and publishing to PyPI requires manual intervention. Introduce three GitHub Actions workflows to automate these concerns. The lint workflow runs Ruff and Mypy on every push and pull request to catch style and type errors early. The docs workflow validates markdown formatting and link integrity in README files. The publish workflow triggers on GitHub releases to build and upload the package to PyPI automatically, removing the need for manual release steps. Signed-off-by: Javier Tia --- .github/workflows/docs.yml | 41 +++++++++++++++++++++++++++++++++++ .github/workflows/lint.yml | 40 ++++++++++++++++++++++++++++++++++ .github/workflows/publish.yml | 35 ++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..49dcfd5 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,41 @@ +name: Documentation Validation + +on: + push: + branches: + - "**" + pull_request: + branches: + - main + - master + workflow_dispatch: + +jobs: + validate-docs: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install markdownlint-cli2 + run: npm install -g markdownlint-cli2 + + - name: Install markdown-link-check + run: npm install -g markdown-link-check + + - name: Lint markdown files + run: markdownlint-cli2 "README.md" "tests/README.md" + continue-on-error: false + + - name: Check links in README.md + run: markdown-link-check README.md + continue-on-error: false + + - name: Check links in tests/README.md + run: markdown-link-check tests/README.md + continue-on-error: false diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..13c64f0 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,40 @@ +name: Lint + +on: + push: + branches: + - '**' + pull_request: + branches: + - main + - master + workflow_dispatch: + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install dependencies + run: uv pip install -e ".[dev]" --system + + - name: Run Ruff linting + run: uv run ruff check src/ + + - name: Run Ruff formatting check + run: uv run ruff format --check src/ + + - name: Run Mypy type checking + run: uv run mypy src/gitlab_pkg_upload/ --strict diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..cd82eee --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,35 @@ +name: Publish to PyPI + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install build dependencies + run: uv pip install build --system + + - name: Build package distributions + run: python -m build + + - name: List built artifacts + run: ls -la dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} From b459ca78783c9d4a2e301042f12a98271f95f196 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 11:50:47 -0600 Subject: [PATCH 23/36] Add pre-commit hooks for automated code quality checks Manual code quality enforcement before commits relies on developers remembering to run linting, formatting, and type checking commands individually. This creates inconsistency between local development and CI/CD pipeline results, leading to failed builds and unnecessary review cycles. Introduce pre-commit configuration that automatically runs ruff linting, ruff formatting, and mypy type checking on staged files. This catches issues at commit time rather than in CI, reducing feedback loops and ensuring code quality standards are enforced consistently across all contributors. Signed-off-by: Javier Tia --- .pre-commit-config.yaml | 34 ++++++++++++++++++++++++++++++++++ README.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a7ecf13 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,34 @@ +repos: + # File maintenance hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-added-large-files + + # Ruff linting and formatting + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.4 + hooks: + - id: ruff + args: [--fix] + files: ^src/ + - id: ruff-format + files: ^src/ + + # Mypy type checking + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.14.1 + hooks: + - id: mypy + args: [--strict] + files: ^src/gitlab_pkg_upload/ + additional_dependencies: + - python-gitlab + - rich + - GitPython + - tenacity + - argcomplete diff --git a/README.md b/README.md index ca84a09..cb24991 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,33 @@ uv sync --all-extras pip install -e ".[dev,test]" ``` +### Pre-commit Hooks + +Pre-commit hooks automate code quality checks locally before each commit, ensuring consistency with the CI/CD pipeline. + +```bash +# Install pre-commit hooks +uv run pre-commit install + +# Or using pip +pre-commit install +``` + +Once installed, hooks run automatically on `git commit`. You can also run them manually: + +```bash +# Run all hooks on all files +uv run pre-commit run --all-files + +# Update hook versions +uv run pre-commit autoupdate +``` + +The configured hooks include: +- **Ruff**: Linting and code formatting +- **Mypy**: Static type checking with strict mode +- **File maintenance**: Trailing whitespace removal, end-of-file fixes, YAML/TOML validation + ### Running Tests ```bash @@ -100,6 +127,10 @@ See [tests/README.md](tests/README.md) for detailed testing documentation. ### Code Quality +Pre-commit hooks automate these checks on every commit. See [Pre-commit Hooks](#pre-commit-hooks) for setup. + +To run checks manually: + ```bash # Run linter uv run ruff check src/ From 3c60303d708c7bd54fb2f4b1c74046081979019e Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 12:04:42 -0600 Subject: [PATCH 24/36] Add automated versioning with bump-my-version Currently there is no standardized way to manage version numbers across the project. Updating versions requires manual edits to multiple files, which is error-prone and inconsistent with semantic versioning best practices. Integrate bump-my-version as a dev dependency with configuration to automatically synchronize version updates between pyproject.toml and the package init file. This approach ensures single-command version bumps that create git commits and tags, following the standard release workflow pattern used by modern Python projects. Signed-off-by: Javier Tia --- README.md | 58 +++++++++ pyproject.toml | 22 +++- uv.lock | 317 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 396 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cb24991..f409899 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,64 @@ uv run mypy src/ uv run ruff format src/ ``` +### Versioning + +This project uses [semantic versioning](https://semver.org/) (major.minor.patch) with [bump-my-version](https://github.com/callowayproject/bump-my-version) for automated version management. + +```bash +# Install dev dependencies (includes bump-my-version) +uv pip install -e ".[dev]" + +# Bump patch version (bug fixes): 0.1.0 → 0.1.1 +uv run bump-my-version bump patch + +# Bump minor version (new features): 0.1.0 → 0.2.0 +uv run bump-my-version bump minor + +# Bump major version (breaking changes): 0.1.0 → 1.0.0 +uv run bump-my-version bump major +``` + +Running `bump-my-version bump` automatically: +- Updates the version in `pyproject.toml` and `src/gitlab_pkg_upload/__init__.py` +- Creates a git commit with the version change +- Creates a git tag (format: `v1.2.3`) + +#### Release Workflow + +```bash +# 1. Bump version (e.g., patch for bug fix) +uv run bump-my-version bump patch + +# 2. Push changes and tags +git push && git push --tags + +# 3. Create GitHub release at https://github.com/your-org/gitlab-pkg-upload/releases/new +# 4. PyPI publication happens automatically via GitHub Actions +``` + +To create a GitHub release: +1. Navigate to the repository's Releases page +2. Click "Create a new release" +3. Select the version tag created by bump-my-version +4. Add release notes describing changes +5. Publish the release + +Publishing a GitHub release automatically triggers the `.github/workflows/publish.yml` workflow to publish to PyPI. + +#### Verification + +```bash +# Check that version numbers match in both files +grep -r "0.1.0" pyproject.toml src/gitlab_pkg_upload/__init__.py + +# Test bump-my-version dry run +uv run bump-my-version bump patch --dry-run --verbose + +# Verify git tags +git tag -l +``` + ## Project Structure ``` diff --git a/pyproject.toml b/pyproject.toml index 91ae71e..fdd54cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,9 +30,10 @@ dependencies = [ [project.optional-dependencies] dev = [ - "ruff", + "bump-my-version", "mypy", "pre-commit", + "ruff", ] test = [ "pytest", @@ -81,3 +82,22 @@ markers = [ "slow: Slow-running tests", "timeout: Timeout for test execution (provided by pytest-timeout)", ] + +[tool.bumpversion] +current_version = "0.1.0" +commit = true +tag = true +tag_name = "v{new_version}" +message = "Bump version: {current_version} → {new_version}" +parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" +serialize = ["{major}.{minor}.{patch}"] + +[[tool.bumpversion.files]] +filename = "pyproject.toml" +search = "version = \"{current_version}\"" +replace = "version = \"{new_version}\"" + +[[tool.bumpversion.files]] +filename = "src/gitlab_pkg_upload/__init__.py" +search = "__version__ = \"{current_version}\"" +replace = "__version__ = \"{new_version}\"" diff --git a/uv.lock b/uv.lock index 682ccef..d123026 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,28 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + [[package]] name = "argcomplete" version = "3.6.3" @@ -11,6 +33,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, ] +[[package]] +name = "bracex" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" }, +] + +[[package]] +name = "bump-my-version" +version = "1.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "questionary" }, + { name = "rich" }, + { name = "rich-click" }, + { name = "tomlkit" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/d3/43acec2ec4a477d6c6191faebe5f2e79facd80936ab3e93b6f9d18d11593/bump_my_version-1.2.6.tar.gz", hash = "sha256:1f2f0daa5d699904e9739be8efb51c4c945461bad83cd4da4c89d324d9a18343", size = 1195328, upload-time = "2025-12-29T11:59:30.389Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/8e/39de3356f72327dd0bf569540a858723f3fc4f11f3c5bfae85b3dadac5c3/bump_my_version-1.2.6-py3-none-any.whl", hash = "sha256:a2f567c10574a374b81a9bd6d2bd3cb2ca74befe5c24c3021123773635431659", size = 59791, upload-time = "2025-12-29T11:59:27.873Z" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -102,6 +153,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -164,6 +227,7 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "bump-my-version" }, { name = "mypy" }, { name = "pre-commit" }, { name = "ruff" }, @@ -179,6 +243,7 @@ test = [ [package.metadata] requires-dist = [ { name = "argcomplete", specifier = ">=3.0.0" }, + { name = "bump-my-version", marker = "extra == 'dev'" }, { name = "gitpython", specifier = ">=3.1.0" }, { name = "mypy", marker = "extra == 'dev'" }, { name = "pre-commit", marker = "extra == 'dev'" }, @@ -206,6 +271,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "identify" version = "2.6.15" @@ -426,6 +528,144 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -501,6 +741,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + [[package]] name = "python-gitlab" version = "7.1.0" @@ -569,6 +818,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -609,6 +870,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, ] +[[package]] +name = "rich-click" +version = "1.9.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/d1/b60ca6a8745e76800b50c7ee246fd73f08a3be5d8e0b551fc93c19fa1203/rich_click-1.9.5.tar.gz", hash = "sha256:48120531493f1533828da80e13e839d471979ec8d7d0ca7b35f86a1379cc74b6", size = 73927, upload-time = "2025-12-21T14:49:44.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/0a/d865895e1e5d88a60baee0fc3703eb111c502ee10c8c107516bc7623abf8/rich_click-1.9.5-py3-none-any.whl", hash = "sha256:9b195721a773b1acf0e16ff9ec68cef1e7d237e53471e6e3f7ade462f86c403a", size = 70580, upload-time = "2025-12-21T14:49:42.905Z" }, +] + [[package]] name = "ruff" version = "0.14.11" @@ -662,6 +937,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, ] +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -671,6 +955,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -693,3 +989,24 @@ sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544 wheels = [ { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, ] + +[[package]] +name = "wcmatch" +version = "10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, +] From 685b1a2d93d7bb38325fa578858afe2fbf915b57 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 14:59:47 -0600 Subject: [PATCH 25/36] Big refactor Signed-off-by: Javier Tia --- .github/workflows/lint.yml | 2 +- README.md | 61 +- pyproject.toml | 6 +- run_tests.py | 8 +- src/{gitlab_pkg_upload => glpkg}/__init__.py | 2 +- src/glpkg/cli/__init__.py | 10 + src/glpkg/cli/main.py | 321 +++++++ .../cli.py => glpkg/cli/upload.py} | 826 ++++++------------ .../duplicate_detector.py | 0 .../formatters.py | 0 src/{gitlab_pkg_upload => glpkg}/models.py | 2 +- src/{gitlab_pkg_upload => glpkg}/uploader.py | 0 .../validators.py | 8 +- tests/README.md | 6 +- tests/conftest.py | 6 +- tests/integration/conftest.py | 2 +- .../test_environment_validation.py | 2 +- tests/integration/test_helpers_module.py | 7 +- tests/unit/__init__.py | 2 +- tests/unit/test_cli.py | 619 +++---------- tests/unit/test_duplicate_detector.py | 8 +- tests/unit/test_formatters.py | 74 +- tests/unit/test_models.py | 2 +- tests/unit/test_uploader.py | 52 +- tests/unit/test_validators.py | 30 +- tests/utils/artifact_factory.py | 2 +- tests/utils/gitlab_helpers.py | 4 +- tests/utils/test_helpers.py | 12 +- uv.lock | 26 +- 29 files changed, 909 insertions(+), 1191 deletions(-) rename src/{gitlab_pkg_upload => glpkg}/__init__.py (94%) create mode 100644 src/glpkg/cli/__init__.py create mode 100644 src/glpkg/cli/main.py rename src/{gitlab_pkg_upload/cli.py => glpkg/cli/upload.py} (71%) rename src/{gitlab_pkg_upload => glpkg}/duplicate_detector.py (100%) rename src/{gitlab_pkg_upload => glpkg}/formatters.py (100%) rename src/{gitlab_pkg_upload => glpkg}/models.py (99%) rename src/{gitlab_pkg_upload => glpkg}/uploader.py (100%) rename src/{gitlab_pkg_upload => glpkg}/validators.py (99%) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 13c64f0..15f9b48 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -37,4 +37,4 @@ jobs: run: uv run ruff format --check src/ - name: Run Mypy type checking - run: uv run mypy src/gitlab_pkg_upload/ --strict + run: uv run mypy src/glpkg/ --strict diff --git a/README.md b/README.md index f409899..69813f6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# GitLab Package Upload +# glpkg A CLI tool for uploading files to GitLab's Generic Package Registry. @@ -11,7 +11,7 @@ A CLI tool for uploading files to GitLab's Generic Package Registry. uv pip install -e . # Or run directly without installing -uv run gitlab-pkg-upload --help +uv run glpkg --help ``` ### Using pip @@ -24,21 +24,27 @@ pip install -e . ```bash # Upload a single file -gitlab-pkg-upload file.tar.gz --package-name my-package --version 1.0.0 +glpkg upload --package-name my-package --package-version 1.0.0 --files file.tar.gz # Upload multiple files -gitlab-pkg-upload file1.tar.gz file2.zip --package-name my-package --version 1.0.0 +glpkg upload --package-name my-package --package-version 1.0.0 --files file1.tar.gz file2.zip # Upload with automatic project detection from git remote -gitlab-pkg-upload file.tar.gz --package-name my-package --version 1.0.0 +glpkg upload --package-name my-package --package-version 1.0.0 --files file.tar.gz # Specify project explicitly -gitlab-pkg-upload file.tar.gz --package-name my-package --version 1.0.0 \ - --project-path namespace/project +glpkg upload --package-name my-package --package-version 1.0.0 \ + --project-path namespace/project --files file.tar.gz # Handle duplicates (skip, replace, or error) -gitlab-pkg-upload file.tar.gz --package-name my-package --version 1.0.0 \ - --duplicate-policy replace +glpkg upload --package-name my-package --package-version 1.0.0 \ + --duplicate-policy replace --files file.tar.gz + +# Verbose output with global flags +glpkg --verbose upload --package-name my-package --package-version 1.0.0 --files file.tar.gz + +# JSON output for CI/CD pipelines +glpkg --json-output upload --package-name my-package --package-version 1.0.0 --files file.tar.gz ``` ## Configuration @@ -63,8 +69,8 @@ Your GitLab token requires: ```bash # Clone the repository -git clone https://gitlab.com/your-namespace/gitlab-pkg-upload.git -cd gitlab-pkg-upload +git clone https://gitlab.com/your-namespace/glpkg.git +cd glpkg # Install with development dependencies uv sync --all-extras @@ -161,7 +167,7 @@ uv run bump-my-version bump major ``` Running `bump-my-version bump` automatically: -- Updates the version in `pyproject.toml` and `src/gitlab_pkg_upload/__init__.py` +- Updates the version in `pyproject.toml` and `src/glpkg/__init__.py` - Creates a git commit with the version change - Creates a git tag (format: `v1.2.3`) @@ -174,7 +180,7 @@ uv run bump-my-version bump patch # 2. Push changes and tags git push && git push --tags -# 3. Create GitHub release at https://github.com/your-org/gitlab-pkg-upload/releases/new +# 3. Create GitHub release at https://github.com/your-org/glpkg/releases/new # 4. PyPI publication happens automatically via GitHub Actions ``` @@ -191,7 +197,7 @@ Publishing a GitHub release automatically triggers the `.github/workflows/publis ```bash # Check that version numbers match in both files -grep -r "0.1.0" pyproject.toml src/gitlab_pkg_upload/__init__.py +grep -r "0.1.0" pyproject.toml src/glpkg/__init__.py # Test bump-my-version dry run uv run bump-my-version bump patch --dry-run --verbose @@ -203,20 +209,25 @@ git tag -l ## Project Structure ``` -gitlab-pkg-upload/ +glpkg/ ├── src/ -│ └── gitlab_pkg_upload/ +│ └── glpkg/ │ ├── __init__.py -│ ├── cli.py # Command-line interface -│ ├── models.py # Data models -│ ├── uploader.py # Upload logic -│ └── validators.py # Input validation +│ ├── cli/ +│ │ ├── __init__.py +│ │ ├── main.py # Main CLI entry point with subcommand routing +│ │ └── upload.py # Upload subcommand implementation +│ ├── models.py # Data models +│ ├── uploader.py # Upload logic +│ ├── formatters.py # Output formatting +│ ├── duplicate_detector.py # Duplicate detection +│ └── validators.py # Input validation ├── tests/ -│ ├── unit/ # Unit tests -│ ├── integration/ # Integration tests -│ └── utils/ # Test utilities -├── pyproject.toml # Project configuration -└── README.md # This file +│ ├── unit/ # Unit tests +│ ├── integration/ # Integration tests +│ └── utils/ # Test utilities +├── pyproject.toml # Project configuration +└── README.md # This file ``` ## License diff --git a/pyproject.toml b/pyproject.toml index fdd54cc..7862ef3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "gitlab-pkg-upload" +name = "glpkg" version = "0.1.0" description = "A CLI tool for uploading files to GitLab's Generic Package Registry" authors = [{name = "Javier Tia"}] @@ -44,7 +44,7 @@ test = [ ] [project.scripts] -gitlab-pkg-upload = "gitlab_pkg_upload.cli:main" +glpkg = "glpkg.cli.main:main" [tool.setuptools] package-dir = {"" = "src"} @@ -98,6 +98,6 @@ search = "version = \"{current_version}\"" replace = "version = \"{new_version}\"" [[tool.bumpversion.files]] -filename = "src/gitlab_pkg_upload/__init__.py" +filename = "src/glpkg/__init__.py" search = "__version__ = \"{current_version}\"" replace = "__version__ = \"{new_version}\"" diff --git a/run_tests.py b/run_tests.py index 37125cd..de55a5f 100755 --- a/run_tests.py +++ b/run_tests.py @@ -76,20 +76,20 @@ def format_duration(seconds: float) -> str: def ensure_package_installed() -> bool: """ - Ensure the gitlab_pkg_upload package is installed in development mode. + Ensure the glpkg package is installed in development mode. Returns: True if package is available (already installed or successfully installed), False if installation failed. """ try: - import gitlab_pkg_upload # noqa: F401 + import glpkg # noqa: F401 return True except ImportError: pass - print("Installing gitlab_pkg_upload package in development mode...") + print("Installing glpkg package in development mode...") try: result = subprocess.run( ["uv", "pip", "install", "-e", "."], @@ -205,7 +205,7 @@ class Args: # Ensure the package is installed before running tests if not ensure_package_installed(): - print("\nFailed to install the gitlab_pkg_upload package.") + print("\nFailed to install the glpkg package.") print("Please install it manually with: uv pip install -e .") return 1 diff --git a/src/gitlab_pkg_upload/__init__.py b/src/glpkg/__init__.py similarity index 94% rename from src/gitlab_pkg_upload/__init__.py rename to src/glpkg/__init__.py index b646eb5..23b9f61 100644 --- a/src/gitlab_pkg_upload/__init__.py +++ b/src/glpkg/__init__.py @@ -1,4 +1,4 @@ -"""GitLab Generic Package Upload Tool.""" +"""glpkg - GitLab Generic Package Upload Tool.""" __version__ = "0.1.0" diff --git a/src/glpkg/cli/__init__.py b/src/glpkg/cli/__init__.py new file mode 100644 index 0000000..980e343 --- /dev/null +++ b/src/glpkg/cli/__init__.py @@ -0,0 +1,10 @@ +"""CLI package for glpkg. + +This package provides the command-line interface for glpkg, organized into: +- main: Global argument parsing, logging setup, and subcommand routing +- upload: Upload subcommand implementation +""" + +from glpkg.cli.main import main + +__all__ = ["main"] diff --git a/src/glpkg/cli/main.py b/src/glpkg/cli/main.py new file mode 100644 index 0000000..bae6849 --- /dev/null +++ b/src/glpkg/cli/main.py @@ -0,0 +1,321 @@ +"""Main CLI entry point with subcommand routing for glpkg. + +This module provides the command-line interface framework for glpkg, +including global argument parsing, logging configuration, and subcommand +routing via argparse subparsers. + +Supported global flags: + --verbose Enable verbose output + --quiet Suppress non-essential output + --debug Enable debug output + --token GitLab API token + --gitlab-url GitLab instance URL + --json-output Output results as JSON + --plain Force plain text output (no colors) + --version Display version number +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + +import argcomplete +from rich.console import Console +from rich.logging import RichHandler + +# Module-level logger +logger = logging.getLogger(__name__) + + +def get_version() -> str: + """Get the package version from pyproject.toml. + + Returns: + Version string from pyproject.toml, or 'unknown' if not found. + """ + try: + # Try to find pyproject.toml relative to this module + module_path = Path(__file__).parent + # Check in package location (installed) + pyproject_paths = [ + module_path.parent.parent.parent / "pyproject.toml", # Development layout + module_path.parent / "pyproject.toml", # Alternate location + ] + + for pyproject_path in pyproject_paths: + if pyproject_path.exists(): + content = pyproject_path.read_text() + # Simple parsing - look for version = "x.y.z" + for line in content.splitlines(): + line = line.strip() + if line.startswith("version") and "=" in line: + # Extract version value + _, _, value = line.partition("=") + value = value.strip().strip('"').strip("'") + return value + + # Fallback: try importlib.metadata (for installed packages) + try: + from importlib.metadata import version as get_pkg_version + + return get_pkg_version("glpkg") + except Exception: + pass + + return "unknown" + except Exception: + return "unknown" + + +def determine_verbosity(args: argparse.Namespace) -> str: + """Determine verbosity level from parsed arguments. + + Checks verbosity flags in priority order: debug > verbose > quiet > normal. + + Args: + args: Parsed argument namespace from argparse. + + Returns: + One of: 'debug', 'verbose', 'quiet', or 'normal'. + """ + if getattr(args, "debug", False): + return "debug" + elif getattr(args, "verbose", False): + return "verbose" + elif getattr(args, "quiet", False): + return "quiet" + else: + return "normal" + + +def setup_logging(args: argparse.Namespace) -> None: + """Configure logging based on verbosity flags. + + Sets up Python's root logger with RichHandler for enhanced console output. + When --json-output is enabled, logs go to stderr to keep stdout clean for JSON. + + Args: + args: Parsed argument namespace containing verbosity flags. + """ + verbosity = determine_verbosity(args) + + # Determine log level based on verbosity + log_levels = { + "debug": logging.DEBUG, + "verbose": logging.INFO, + "quiet": logging.WARNING, + "normal": logging.INFO, + } + level = log_levels.get(verbosity, logging.INFO) + + # Use stderr when json_output is enabled to keep stdout clean for JSON + stream = sys.stderr if getattr(args, "json_output", False) else sys.stdout + + # Configure RichHandler with appropriate settings + rich_handler = RichHandler( + console=Console(file=stream), + show_time=True, + show_path=False, + markup=True, + rich_tracebacks=True, + ) + + # Configure root logger + logging.basicConfig( + level=level, + format="%(message)s", + handlers=[rich_handler], + force=True, # Reconfigure if already configured + ) + + logger.debug( + f"Logging configured: level={verbosity}, stream={'stderr' if getattr(args, 'json_output', False) else 'stdout'}" + ) + + +def validate_global_flags(args: argparse.Namespace) -> None: + """Validate global flag combinations and detect conflicts. + + Checks for: + - Conflicting verbosity flags (--verbose, --quiet, --debug) + + Args: + args: Parsed argument namespace from argparse. + + Raises: + SystemExit: With exit code 3 (ConfigurationError) if conflicts are detected. + """ + errors: list[str] = [] + + # Check for conflicting verbosity flags + verbosity_flags = [] + if getattr(args, "verbose", False): + verbosity_flags.append("--verbose") + if getattr(args, "quiet", False): + verbosity_flags.append("--quiet") + if getattr(args, "debug", False): + verbosity_flags.append("--debug") + if len(verbosity_flags) > 1: + errors.append( + f"Cannot specify multiple verbosity flags: {', '.join(verbosity_flags)}. " + "Choose one of --verbose, --quiet, or --debug." + ) + + # Report all errors + if errors: + for error in errors: + print(f"Error: {error}", file=sys.stderr) + print( + "\nUse --help for usage information.", + file=sys.stderr, + ) + sys.exit(3) # ConfigurationError exit code + + +def create_argument_parser() -> argparse.ArgumentParser: + """Create and configure the main argument parser with subparsers. + + Returns: + Configured ArgumentParser instance with subparsers for subcommands. + """ + from glpkg.validators import DEFAULT_GITLAB_URL + + parser = argparse.ArgumentParser( + prog="glpkg", + description=( + "Upload files to GitLab's Generic Package Registry.\n\n" + "This tool uploads one or more files to a GitLab project's package registry, " + "with support for duplicate detection, retry handling, and various output formats." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""\ +Examples: + # Upload files to a package + %(prog)s upload --package-name myapp --package-version 1.0.0 --files dist/app.tar.gz + + # Upload with verbose output + %(prog)s --verbose upload --package-name myapp --package-version 1.0.0 --files file.bin + + # JSON output for CI/CD pipelines + %(prog)s --json-output upload --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz + +For subcommand help: + %(prog)s upload --help +""", + ) + + # Global arguments + global_group = parser.add_argument_group("global options") + global_group.add_argument( + "--gitlab-url", + type=str, + default=DEFAULT_GITLAB_URL, + metavar="URL", + help=f"GitLab instance URL (default: {DEFAULT_GITLAB_URL})", + ) + global_group.add_argument( + "--token", + type=str, + metavar="TOKEN", + help="GitLab API token (or set GITLAB_TOKEN environment variable)", + ) + + # Verbosity flags (mutual exclusion validated in validate_global_flags for exit code 3) + verbosity_group = parser.add_argument_group( + "verbosity", + "Control output verbosity (mutually exclusive)", + ) + verbosity_group.add_argument( + "--verbose", + action="store_true", + help="Enable verbose output with detailed progress information", + ) + verbosity_group.add_argument( + "--quiet", + action="store_true", + help="Suppress non-essential output (only show errors and final summary)", + ) + verbosity_group.add_argument( + "--debug", + action="store_true", + help="Enable debug output with full diagnostic information", + ) + + # Output format flags + output_group = parser.add_argument_group("output format") + output_group.add_argument( + "--json-output", + action="store_true", + help="Output results as JSON (useful for CI/CD pipelines and scripting)", + ) + output_group.add_argument( + "--plain", + action="store_true", + help="Force plain text output without colors or formatting", + ) + + # Version flag + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {get_version()}", + help="Display version number and exit", + ) + + # Create subparsers + subparsers = parser.add_subparsers( + title="commands", + description="Available subcommands", + dest="command", + metavar="", + ) + + # Register subcommands + from glpkg.cli.upload import register_upload_command + + register_upload_command(subparsers) + + return parser + + +def main(argv: list[str] | None = None) -> None: + """Main entry point for the glpkg CLI. + + Parses command-line arguments, validates configuration, and routes + to the appropriate subcommand handler. + + Args: + argv: Command-line arguments. If None, uses sys.argv[1:]. + """ + parser = create_argument_parser() + + # Enable shell completion via argcomplete + argcomplete.autocomplete(parser) + + # Parse arguments + args = parser.parse_args(argv) + + # If no subcommand is provided, show help and exit + if args.command is None: + parser.print_help() + sys.exit(0) + + # Validate global flag combinations + validate_global_flags(args) + + # Configure logging based on verbosity flags + setup_logging(args) + + # Execute the subcommand handler + if hasattr(args, "func"): + args.func(args) + else: + parser.print_help() + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/src/gitlab_pkg_upload/cli.py b/src/glpkg/cli/upload.py similarity index 71% rename from src/gitlab_pkg_upload/cli.py rename to src/glpkg/cli/upload.py index 7075167..d9f247b 100644 --- a/src/gitlab_pkg_upload/cli.py +++ b/src/glpkg/cli/upload.py @@ -1,10 +1,12 @@ -"""CLI entry point for gitlab-pkg-upload. +"""Upload subcommand for glpkg CLI. -This module provides the command-line interface for uploading files to GitLab's -Generic Package Registry. It handles argument parsing, validation, and -orchestrates the upload workflow. +This module provides the upload subcommand implementation, including: +- GitAutoDetector: Auto-detect GitLab project from Git repository +- ProjectResolver: Resolve GitLab project ID from project path +- UploadContextBuilder: Build upload context with all required components +- Upload command registration and execution logic -Supported flags: +Supported upload-specific flags: Required: --package-name Package name in the registry --package-version Package version @@ -16,14 +18,6 @@ Project specification: --project-url Full GitLab project URL --project-path Project path (namespace/project) - --gitlab-url GitLab instance URL (default: https://gitlab.com) - --token GitLab API token (or use GITLAB_TOKEN env var) - - Project resolution (auto-detected or manual): - Auto-detection: Searches for .git directory and extracts GitLab project - from git remotes (prioritizes 'origin' remote) - --project-url: Full GitLab project URL (e.g., https://gitlab.com/ns/proj) - --project-path: Project path with --gitlab-url (e.g., namespace/project) Duplicate handling: --duplicate-policy How to handle duplicates: skip, replace, error @@ -31,40 +25,10 @@ File mapping: --file-mapping Rename files during upload (source:target format) - Verbosity (mutually exclusive): - --verbose Enable verbose output - --quiet Suppress non-essential output - --debug Enable debug output - Operational: --dry-run Preview actions without executing --fail-fast Stop on first failure --retry Number of retry attempts - --json-output Output results as JSON - --plain Force plain text output (no colors) - --version Display version number - -Usage examples: - # Upload a single file - gitlab-pkg-upload --package-name myapp --package-version 1.0.0 --files dist/app.tar.gz - - # Upload multiple files - gitlab-pkg-upload --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz - - # Upload from directory - gitlab-pkg-upload --package-name myapp --package-version 1.0.0 --directory dist/ - - # With file renaming - gitlab-pkg-upload --package-name myapp --package-version 1.0.0 \\ - --files local.tar.gz --file-mapping local.tar.gz:remote.tar.gz - - # Dry run with verbose output - gitlab-pkg-upload --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ - --dry-run --verbose - - # JSON output for CI/CD pipelines - gitlab-pkg-upload --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ - --json-output """ from __future__ import annotations @@ -72,18 +36,16 @@ import argparse import logging import sys -from pathlib import Path from typing import TYPE_CHECKING, Optional -import argcomplete import git from gitlab import Gitlab from gitlab.exceptions import GitlabAuthenticationError, GitlabGetError -from rich.console import Console -from rich.logging import RichHandler -from gitlab_pkg_upload.duplicate_detector import DuplicateDetector -from gitlab_pkg_upload.models import ( +from glpkg.cli.main import determine_verbosity +from glpkg.duplicate_detector import DuplicateDetector +from glpkg.formatters import OutputFormatter +from glpkg.models import ( AuthenticationError, ConfigurationError, DuplicatePolicy, @@ -95,8 +57,10 @@ UploadContext, enhance_error_message, ) -from gitlab_pkg_upload.validators import ( +from glpkg.uploader import upload_files +from glpkg.validators import ( DEFAULT_GITLAB_URL, + collect_files, get_gitlab_token, normalize_gitlab_url, parse_git_url, @@ -105,17 +69,10 @@ if TYPE_CHECKING: pass -from gitlab_pkg_upload.formatters import OutputFormatter -from gitlab_pkg_upload.uploader import upload_files -from gitlab_pkg_upload.validators import collect_files - # Module-level logger logger = logging.getLogger(__name__) # Exception exit code mapping for standard Python exceptions -# Custom exceptions (GitLabUploadError subclasses) use their exit_code attribute. -# Standard Python exceptions use this mapping table. -# Unknown exceptions default to exit code 1. EXCEPTION_EXIT_CODE_MAP: dict[type, int] = { FileNotFoundError: 5, # File validation failure PermissionError: 5, # File validation failure @@ -125,448 +82,6 @@ } -def determine_verbosity(args: argparse.Namespace) -> str: - """Determine verbosity level from parsed arguments. - - Checks verbosity flags in priority order: debug > verbose > quiet > normal. - - Args: - args: Parsed argument namespace from argparse. - - Returns: - One of: 'debug', 'verbose', 'quiet', or 'normal'. - """ - if args.debug: - return "debug" - elif args.verbose: - return "verbose" - elif args.quiet: - return "quiet" - else: - return "normal" - - -def setup_logging(args: argparse.Namespace) -> None: - """Configure logging based on verbosity flags. - - Sets up Python's root logger with RichHandler for enhanced console output. - When --json-output is enabled, logs go to stderr to keep stdout clean for JSON. - - Args: - args: Parsed argument namespace containing verbosity flags. - """ - verbosity = determine_verbosity(args) - - # Determine log level based on verbosity - log_levels = { - "debug": logging.DEBUG, - "verbose": logging.INFO, - "quiet": logging.WARNING, - "normal": logging.INFO, - } - level = log_levels.get(verbosity, logging.INFO) - - # Use stderr when json_output is enabled to keep stdout clean for JSON - stream = sys.stderr if args.json_output else sys.stdout - - # Configure RichHandler with appropriate settings - rich_handler = RichHandler( - console=Console(file=stream), - show_time=True, - show_path=False, - markup=True, - rich_tracebacks=True, - ) - - # Configure root logger - logging.basicConfig( - level=level, - format="%(message)s", - handlers=[rich_handler], - force=True, # Reconfigure if already configured - ) - - logger.debug(f"Logging configured: level={verbosity}, stream={'stderr' if args.json_output else 'stdout'}") - - -def create_argument_parser() -> argparse.ArgumentParser: - """Create and configure the argument parser for gitlab-pkg-upload. - - Returns: - Configured ArgumentParser instance with all supported arguments. - """ - parser = argparse.ArgumentParser( - prog="gitlab-pkg-upload", - description=( - "Upload files to GitLab's Generic Package Registry.\n\n" - "This tool uploads one or more files to a GitLab project's package registry, " - "with support for duplicate detection, retry handling, and various output formats." - ), - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog="""\ -Examples: - # Upload a single file - %(prog)s --package-name myapp --package-version 1.0.0 --files dist/app.tar.gz - - # Upload multiple files - %(prog)s --package-name myapp --package-version 1.0.0 --files file1.bin file2.bin - - # Upload all files from a directory - %(prog)s --package-name myapp --package-version 1.0.0 --directory dist/ - - # With file renaming (source:target format) - %(prog)s --package-name myapp --package-version 1.0.0 \\ - --files local.tar.gz --file-mapping local.tar.gz:app-1.0.0.tar.gz - - # Skip duplicates (default behavior) - %(prog)s --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ - --duplicate-policy skip - - # Replace existing files - %(prog)s --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ - --duplicate-policy replace - - # Dry run with verbose output - %(prog)s --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ - --dry-run --verbose - - # JSON output for CI/CD pipelines - %(prog)s --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ - --json-output --quiet - - # Specify project explicitly - %(prog)s --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ - --project-url https://gitlab.com/mygroup/myproject - - # Use custom GitLab instance - %(prog)s --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ - --gitlab-url https://gitlab.example.com --project-path mygroup/myproject - -Environment variables: - GITLAB_TOKEN GitLab API token (alternative to --token) -""", - ) - - # Required arguments (validated in validate_flags to allow --version to work alone) - required_group = parser.add_argument_group("required arguments") - required_group.add_argument( - "--package-name", - type=str, - metavar="NAME", - help="Package name in the GitLab registry (e.g., 'myapp', 'my-library')", - ) - required_group.add_argument( - "--package-version", - type=str, - metavar="VERSION", - help="Package version (e.g., '1.0.0', '2.3.1-beta')", - ) - - # File input arguments (mutual exclusion validated in validate_flags for exit code 3) - file_input_group = parser.add_argument_group( - "file input (one required)", - "Specify files to upload using either --files or --directory", - ) - file_input_group.add_argument( - "--files", - nargs="+", - type=str, - metavar="FILE", - help="List of files to upload (e.g., --files file1.tar.gz file2.tar.gz)", - ) - file_input_group.add_argument( - "--directory", - type=str, - metavar="DIR", - help="Directory containing files to upload (uploads all top-level files)", - ) - - # Project specification arguments - project_group = parser.add_argument_group( - "project specification", - "Specify the target GitLab project (auto-detected from Git remote if not provided)", - ) - project_group.add_argument( - "--project-url", - type=str, - metavar="URL", - help="Full GitLab project URL (e.g., 'https://gitlab.com/namespace/project')", - ) - project_group.add_argument( - "--project-path", - type=str, - metavar="PATH", - help="Project path in namespace/project format (e.g., 'mygroup/myproject')", - ) - project_group.add_argument( - "--gitlab-url", - type=str, - default=DEFAULT_GITLAB_URL, - metavar="URL", - help=f"GitLab instance URL (default: {DEFAULT_GITLAB_URL})", - ) - project_group.add_argument( - "--token", - type=str, - metavar="TOKEN", - help="GitLab API token (or set GITLAB_TOKEN environment variable)", - ) - - # Duplicate handling - parser.add_argument( - "--duplicate-policy", - type=str, - choices=["skip", "replace", "error"], - default="skip", - metavar="POLICY", - help=( - "How to handle duplicate files: " - "'skip' (default) - skip uploading, " - "'replace' - delete existing and upload new, " - "'error' - fail with error" - ), - ) - - # File mapping - parser.add_argument( - "--file-mapping", - action="append", - type=str, - metavar="SOURCE:TARGET", - help=( - "Rename files during upload using source:target format. " - "Can be specified multiple times (e.g., --file-mapping local.bin:remote.bin). " - "Only valid with --files, not --directory." - ), - ) - - # Verbosity flags (mutual exclusion validated in validate_flags for exit code 3) - verbosity_group = parser.add_argument_group( - "verbosity", - "Control output verbosity (mutually exclusive)", - ) - verbosity_group.add_argument( - "--verbose", - action="store_true", - help="Enable verbose output with detailed progress information", - ) - verbosity_group.add_argument( - "--quiet", - action="store_true", - help="Suppress non-essential output (only show errors and final summary)", - ) - verbosity_group.add_argument( - "--debug", - action="store_true", - help="Enable debug output with full diagnostic information", - ) - - # Operational flags - operational_group = parser.add_argument_group("operational options") - operational_group.add_argument( - "--dry-run", - action="store_true", - help="Preview actions without executing uploads (shows what would be done)", - ) - operational_group.add_argument( - "--fail-fast", - action="store_true", - help="Stop immediately on first upload failure (default: continue with remaining files)", - ) - operational_group.add_argument( - "--retry", - type=int, - default=0, - metavar="N", - help="Number of retry attempts for failed uploads (default: 0)", - ) - - # Output format flags - output_group = parser.add_argument_group("output format") - output_group.add_argument( - "--json-output", - action="store_true", - help="Output results as JSON (useful for CI/CD pipelines and scripting)", - ) - output_group.add_argument( - "--plain", - action="store_true", - help="Force plain text output without colors or formatting", - ) - - # Version flag - handled early via action="version" to bypass other requirements - parser.add_argument( - "--version", - action="version", - version=f"%(prog)s {get_version()}", - help="Display version number and exit", - ) - - return parser - - -def validate_flags(args: argparse.Namespace) -> None: - """Validate flag combinations and detect conflicts. - - Checks for: - - Required arguments (--package-name and --package-version for upload runs) - - Conflicting file input (--files and --directory) - - Conflicting verbosity flags (--verbose, --quiet, --debug) - - Conflicting project specification (--project-url with --project-path) - - File input requirement (--files or --directory must be provided) - - File mapping constraint (--file-mapping only valid with --files) - - Args: - args: Parsed argument namespace from argparse. - - Raises: - SystemExit: With exit code 3 (ConfigurationError) if conflicts are detected. - """ - errors: list[str] = [] - - # Check required arguments for upload runs - if not args.package_name: - errors.append( - "--package-name is required. " - "Specify the package name in the GitLab registry." - ) - if not args.package_version: - errors.append( - "--package-version is required. " - "Specify the package version." - ) - - # Check for conflicting file input flags - if args.files and args.directory: - errors.append( - "Cannot specify both --files and --directory. " - "Use --files for explicit file list or --directory to upload all files from a directory." - ) - - # Check for conflicting verbosity flags - verbosity_flags = [] - if args.verbose: - verbosity_flags.append("--verbose") - if args.quiet: - verbosity_flags.append("--quiet") - if args.debug: - verbosity_flags.append("--debug") - if len(verbosity_flags) > 1: - errors.append( - f"Cannot specify multiple verbosity flags: {', '.join(verbosity_flags)}. " - "Choose one of --verbose, --quiet, or --debug." - ) - - # Check for conflicting project specification - if args.project_url and args.project_path: - errors.append( - "Cannot specify both --project-url and --project-path. " - "Use --project-url for full URLs or --project-path with --gitlab-url." - ) - - # Check that file input is provided - if not args.files and not args.directory: - errors.append( - "Either --files or --directory must be provided. " - "Use --files for explicit file list or --directory to upload all files from a directory." - ) - - # Check that file-mapping is only used with --files - if args.file_mapping and args.directory: - errors.append( - "--file-mapping can only be used with --files, not with --directory. " - "File mappings require explicit file specification." - ) - - # Check retry value is non-negative - if args.retry < 0: - errors.append( - f"--retry must be a non-negative integer, got {args.retry}." - ) - - # Report all errors - if errors: - for error in errors: - print(f"Error: {error}", file=sys.stderr) - print( - "\nUse --help for usage information.", - file=sys.stderr, - ) - sys.exit(3) # ConfigurationError exit code - - -def get_version() -> str: - """Get the package version from pyproject.toml. - - Returns: - Version string from pyproject.toml, or 'unknown' if not found. - """ - try: - # Try to find pyproject.toml relative to this module - module_path = Path(__file__).parent - # Check in package location (installed) - pyproject_paths = [ - module_path.parent.parent / "pyproject.toml", # Development layout - module_path / "pyproject.toml", # Alternate location - ] - - for pyproject_path in pyproject_paths: - if pyproject_path.exists(): - content = pyproject_path.read_text() - # Simple parsing - look for version = "x.y.z" - for line in content.splitlines(): - line = line.strip() - if line.startswith("version") and "=" in line: - # Extract version value - _, _, value = line.partition("=") - value = value.strip().strip('"').strip("'") - return value - - # Fallback: try importlib.metadata (for installed packages) - try: - from importlib.metadata import version as get_pkg_version - - return get_pkg_version("gitlab-pkg-upload") - except Exception: - pass - - return "unknown" - except Exception: - return "unknown" - - -def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace: - """Parse and validate command-line arguments. - - Creates the argument parser, integrates shell completion with argcomplete, - parses arguments, and validates flag combinations. - - Args: - argv: Command-line arguments to parse. If None, uses sys.argv[1:]. - - Returns: - Validated argument namespace. - - Raises: - SystemExit: If argument parsing fails or flag conflicts are detected. - """ - parser = create_argument_parser() - - # Enable shell completion via argcomplete - argcomplete.autocomplete(parser) - - # Parse arguments - args = parser.parse_args(argv) - - # Validate flag combinations - validate_flags(args) - - # Convert duplicate_policy string to enum - args.duplicate_policy = DuplicatePolicy(args.duplicate_policy) - - return args - - class GitAutoDetector: """Auto-detect GitLab project from Git repository. @@ -720,8 +235,8 @@ def parse_git_url(self, remote_url: str) -> Optional[tuple[str, str]]: f"Parse error: {e}\n\n" "SOLUTION:\n" "Supported Git URL formats:\n" - " • HTTPS: https://gitlab.com/namespace/project.git\n" - " • SSH: git@gitlab.com:namespace/project.git\n\n" + " - HTTPS: https://gitlab.com/namespace/project.git\n" + " - SSH: git@gitlab.com:namespace/project.git\n\n" "Use manual project specification:\n" " --project-url https://gitlab.com/namespace/project\n" " --project-path namespace/project" @@ -783,7 +298,7 @@ def get_gitlab_remotes(self, repo: git.Repo) -> list[GitRemoteInfo]: break # Only use first valid URL per remote if not gitlab_remotes: - remote_list = "\n".join(f" • {url}" for url in all_remote_urls) + remote_list = "\n".join(f" - {url}" for url in all_remote_urls) raise ProjectResolutionError( f"No GitLab remotes found in repository.\n\n" f"Found remotes:\n{remote_list}\n\n" @@ -794,8 +309,8 @@ def get_gitlab_remotes(self, repo: git.Repo) -> list[GitRemoteInfo]: " --project-url https://gitlab.com/namespace/project\n" " --project-path namespace/project\n\n" "Supported GitLab URL formats:\n" - " • HTTPS: https://gitlab.com/namespace/project.git\n" - " • SSH: git@gitlab.com:namespace/project.git" + " - HTTPS: https://gitlab.com/namespace/project.git\n" + " - SSH: git@gitlab.com:namespace/project.git" ) # Prioritize 'origin' remote @@ -851,8 +366,8 @@ def parse_project_url(self, url: str) -> ProjectInfo: "SOLUTION:\n" "Expected URL format: https://gitlab.com/namespace/project\n\n" "Examples:\n" - " • https://gitlab.com/mycompany/my-project\n" - " • https://gitlab.example.com/group/subgroup/project" + " - https://gitlab.com/mycompany/my-project\n" + " - https://gitlab.example.com/group/subgroup/project" ) # Split project_path into namespace and project_name @@ -987,8 +502,8 @@ def build( verbosity=verbosity, dry_run=args.dry_run, fail_fast=args.fail_fast, - json_output=args.json_output, - plain_output=args.plain, + json_output=getattr(args, "json_output", False), + plain_output=getattr(args, "plain", False), gitlab_url=gitlab_url, token=token, # Resolved token (from CLI or environment) ) @@ -1020,9 +535,9 @@ def build( raise ConfigurationError( f"Failed to build upload context: {e}\n\n" "SOLUTION:\n" - " • Verify all required arguments are provided\n" - " • Check that project ID is valid\n" - " • Ensure GitLab client is properly authenticated" + " - Verify all required arguments are provided\n" + " - Check that project ID is valid\n" + " - Ensure GitLab client is properly authenticated" ) @@ -1100,8 +615,8 @@ def resolve_project_manually( "SOLUTION:\n" "Expected URL format: https://gitlab.com/namespace/project\n\n" "Examples:\n" - " • https://gitlab.com/mycompany/my-project\n" - " • https://gitlab.example.com/group/subgroup/project" + " - https://gitlab.com/mycompany/my-project\n" + " - https://gitlab.example.com/group/subgroup/project" ) elif project_path: @@ -1114,9 +629,9 @@ def resolve_project_manually( "Project path must contain at least namespace/project.\n\n" "SOLUTION:\n" "Examples of valid project paths:\n" - " • mycompany/my-project\n" - " • group/subgroup/project-name\n" - " • username/personal-project" + " - mycompany/my-project\n" + " - group/subgroup/project-name\n" + " - username/personal-project" ) # Validate path components @@ -1127,8 +642,8 @@ def resolve_project_manually( "Path must contain at least namespace and project name.\n\n" "SOLUTION:\n" "Examples of valid project paths:\n" - " • mycompany/my-project\n" - " • group/subgroup/project-name" + " - mycompany/my-project\n" + " - group/subgroup/project-name" ) logger.info(f"Using project path: {path} at {gitlab_url}") @@ -1139,38 +654,277 @@ def resolve_project_manually( "No project specification provided.\n\n" "SOLUTION:\n" "Use one of the following:\n" - " • --project-url https://gitlab.com/namespace/project\n" - " • --project-path namespace/project --gitlab-url https://gitlab.com" + " --project-url https://gitlab.com/namespace/project\n" + " --project-path namespace/project --gitlab-url https://gitlab.com" + ) + + +def validate_upload_flags(args: argparse.Namespace) -> None: + """Validate upload-specific flag combinations and detect conflicts. + + Checks for: + - Required arguments (--package-name and --package-version) + - Conflicting file input (--files and --directory) + - Conflicting project specification (--project-url with --project-path) + - File input requirement (--files or --directory must be provided) + - File mapping constraint (--file-mapping only valid with --files) + + Args: + args: Parsed argument namespace from argparse. + + Raises: + SystemExit: With exit code 3 (ConfigurationError) if conflicts are detected. + """ + errors: list[str] = [] + + # Check required arguments for upload runs + if not args.package_name: + errors.append( + "--package-name is required. " + "Specify the package name in the GitLab registry." + ) + if not args.package_version: + errors.append( + "--package-version is required. " + "Specify the package version." + ) + + # Check for conflicting file input flags + if args.files and args.directory: + errors.append( + "Cannot specify both --files and --directory. " + "Use --files for explicit file list or --directory to upload all files from a directory." ) + # Check for conflicting project specification + if args.project_url and args.project_path: + errors.append( + "Cannot specify both --project-url and --project-path. " + "Use --project-url for full URLs or --project-path with --gitlab-url." + ) + + # Check that file input is provided + if not args.files and not args.directory: + errors.append( + "Either --files or --directory must be provided. " + "Use --files for explicit file list or --directory to upload all files from a directory." + ) + + # Check that file-mapping is only used with --files + if args.file_mapping and args.directory: + errors.append( + "--file-mapping can only be used with --files, not with --directory. " + "File mappings require explicit file specification." + ) + + # Check retry value is non-negative + if args.retry < 0: + errors.append( + f"--retry must be a non-negative integer, got {args.retry}." + ) + + # Report all errors + if errors: + for error in errors: + print(f"Error: {error}", file=sys.stderr) + print( + "\nUse 'glpkg upload --help' for usage information.", + file=sys.stderr, + ) + sys.exit(3) # ConfigurationError exit code + + +def register_upload_command(subparsers: argparse._SubParsersAction) -> None: + """Register the upload subcommand with the main argument parser. + + Args: + subparsers: Subparsers action from the main argument parser. + """ + upload_parser = subparsers.add_parser( + "upload", + help="Upload files to GitLab's Generic Package Registry", + description=( + "Upload one or more files to a GitLab project's package registry.\n\n" + "Supports automatic project detection from Git remotes, duplicate handling,\n" + "file renaming, and retry logic for reliability." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""\ +Examples: + # Upload a single file + glpkg upload --package-name myapp --package-version 1.0.0 --files dist/app.tar.gz + + # Upload multiple files + glpkg upload --package-name myapp --package-version 1.0.0 --files file1.bin file2.bin + + # Upload all files from a directory + glpkg upload --package-name myapp --package-version 1.0.0 --directory dist/ + + # With file renaming (source:target format) + glpkg upload --package-name myapp --package-version 1.0.0 \\ + --files local.tar.gz --file-mapping local.tar.gz:app-1.0.0.tar.gz + + # Skip duplicates (default behavior) + glpkg upload --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ + --duplicate-policy skip + + # Replace existing files + glpkg upload --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ + --duplicate-policy replace + + # Dry run with verbose output + glpkg --verbose upload --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ + --dry-run + + # Specify project explicitly + glpkg upload --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ + --project-url https://gitlab.com/mygroup/myproject + + # Use custom GitLab instance + glpkg upload --package-name myapp --package-version 1.0.0 --files dist/*.tar.gz \\ + --project-path mygroup/myproject + +Environment variables: + GITLAB_TOKEN GitLab API token (alternative to --token) +""", + ) + + # Required arguments + required_group = upload_parser.add_argument_group("required arguments") + required_group.add_argument( + "--package-name", + type=str, + metavar="NAME", + help="Package name in the GitLab registry (e.g., 'myapp', 'my-library')", + ) + required_group.add_argument( + "--package-version", + type=str, + metavar="VERSION", + help="Package version (e.g., '1.0.0', '2.3.1-beta')", + ) + + # File input arguments + file_input_group = upload_parser.add_argument_group( + "file input (one required)", + "Specify files to upload using either --files or --directory", + ) + file_input_group.add_argument( + "--files", + nargs="+", + type=str, + metavar="FILE", + help="List of files to upload (e.g., --files file1.tar.gz file2.tar.gz)", + ) + file_input_group.add_argument( + "--directory", + type=str, + metavar="DIR", + help="Directory containing files to upload (uploads all top-level files)", + ) + + # Project specification arguments + project_group = upload_parser.add_argument_group( + "project specification", + "Specify the target GitLab project (auto-detected from Git remote if not provided)", + ) + project_group.add_argument( + "--project-url", + type=str, + metavar="URL", + help="Full GitLab project URL (e.g., 'https://gitlab.com/namespace/project')", + ) + project_group.add_argument( + "--project-path", + type=str, + metavar="PATH", + help="Project path in namespace/project format (e.g., 'mygroup/myproject')", + ) + + # Duplicate handling + upload_parser.add_argument( + "--duplicate-policy", + type=str, + choices=["skip", "replace", "error"], + default="skip", + metavar="POLICY", + help=( + "How to handle duplicate files: " + "'skip' (default) - skip uploading, " + "'replace' - delete existing and upload new, " + "'error' - fail with error" + ), + ) -def main(argv: list[str] | None = None) -> None: - """Main entry point for the gitlab-pkg-upload CLI. + # File mapping + upload_parser.add_argument( + "--file-mapping", + action="append", + type=str, + metavar="SOURCE:TARGET", + help=( + "Rename files during upload using source:target format. " + "Can be specified multiple times (e.g., --file-mapping local.bin:remote.bin). " + "Only valid with --files, not --directory." + ), + ) + + # Operational flags + operational_group = upload_parser.add_argument_group("operational options") + operational_group.add_argument( + "--dry-run", + action="store_true", + help="Preview actions without executing uploads (shows what would be done)", + ) + operational_group.add_argument( + "--fail-fast", + action="store_true", + help="Stop immediately on first upload failure (default: continue with remaining files)", + ) + operational_group.add_argument( + "--retry", + type=int, + default=0, + metavar="N", + help="Number of retry attempts for failed uploads (default: 0)", + ) + + # Set the handler function + upload_parser.set_defaults(func=execute_upload) - Parses command-line arguments, validates configuration, and orchestrates - the upload workflow. + +def execute_upload(args: argparse.Namespace) -> None: + """Execute the upload subcommand. + + This is the main handler for the upload subcommand, orchestrating: + 1. Flag validation + 2. Project resolution (auto-detect or manual) + 3. GitLab authentication + 4. Context building + 5. File collection + 6. Upload execution + 7. Result formatting Args: - argv: Command-line arguments. If None, uses sys.argv[1:]. + args: Parsed argument namespace from argparse. """ - # Parse arguments - # Note: --version flag is handled automatically by argparse via action="version" - args = parse_arguments(argv) + # Validate upload-specific flags + validate_upload_flags(args) - # Configure logging based on verbosity flags - setup_logging(args) + # Convert duplicate_policy string to enum + args.duplicate_policy = DuplicatePolicy(args.duplicate_policy) # Project resolution try: if args.project_url or args.project_path: - # Manual specification (Flow 8) + # Manual specification gitlab_url, project_path = resolve_project_manually( project_url=args.project_url, project_path=args.project_path, gitlab_url=args.gitlab_url, ) else: - # Auto-detection (Flow 7) + # Auto-detection gitlab_url, project_path = auto_detect_project() # Authenticate with GitLab @@ -1192,7 +946,7 @@ def main(argv: list[str] | None = None) -> None: # Log success logger.info(f"Successfully resolved project: {project_path} (ID: {project_id})") - # Phase 3 - Context building + # Context building builder = UploadContextBuilder() context = builder.build( args=args, @@ -1235,7 +989,7 @@ def main(argv: list[str] | None = None) -> None: logger.error(f"Unexpected error during project resolution: {e}") sys.exit(1) - # Phase 4 - Upload orchestration + # Upload orchestration try: # Step 1: Collect files to upload files_to_upload, file_errors = collect_files( @@ -1300,7 +1054,3 @@ def main(argv: list[str] | None = None) -> None: except Exception as e: logger.error(f"Unexpected error during upload: {e}") sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/src/gitlab_pkg_upload/duplicate_detector.py b/src/glpkg/duplicate_detector.py similarity index 100% rename from src/gitlab_pkg_upload/duplicate_detector.py rename to src/glpkg/duplicate_detector.py diff --git a/src/gitlab_pkg_upload/formatters.py b/src/glpkg/formatters.py similarity index 100% rename from src/gitlab_pkg_upload/formatters.py rename to src/glpkg/formatters.py diff --git a/src/gitlab_pkg_upload/models.py b/src/glpkg/models.py similarity index 99% rename from src/gitlab_pkg_upload/models.py rename to src/glpkg/models.py index 8d847f3..285ab6f 100644 --- a/src/gitlab_pkg_upload/models.py +++ b/src/glpkg/models.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: from gitlab import Gitlab - from gitlab_pkg_upload.duplicate_detector import DuplicateDetector + from glpkg.duplicate_detector import DuplicateDetector # Enums diff --git a/src/gitlab_pkg_upload/uploader.py b/src/glpkg/uploader.py similarity index 100% rename from src/gitlab_pkg_upload/uploader.py rename to src/glpkg/uploader.py diff --git a/src/gitlab_pkg_upload/validators.py b/src/glpkg/validators.py similarity index 99% rename from src/gitlab_pkg_upload/validators.py rename to src/glpkg/validators.py index a751000..56c4894 100644 --- a/src/gitlab_pkg_upload/validators.py +++ b/src/glpkg/validators.py @@ -22,7 +22,7 @@ from typing import Optional from urllib.parse import urlparse -from gitlab_pkg_upload.models import ConfigurationError, FileValidationError, ProjectResolutionError +from glpkg.models import ConfigurationError, FileValidationError, ProjectResolutionError # Module-level logger logger = logging.getLogger(__name__) @@ -655,7 +655,7 @@ def validate_dependencies() -> None: " • Set local version: pyenv local 3.11\n\n" "3. Use uv to run with correct Python version:\n" " • Install uv: pip install uv\n" - " • Run command: uv run --python 3.11 gitlab-pkg-upload\n\n" + " • Run command: uv run --python 3.11 glpkg\n\n" "For more help, see: https://docs.python.org/3/installing/" ) @@ -684,8 +684,8 @@ def validate_dependencies() -> None: "\nSOLUTION:\n" "1. If using uv (recommended):\n" " • Install package: uv pip install -e .\n" - " • Run command: gitlab-pkg-upload\n" - " • Or run directly: uv run gitlab-pkg-upload\n\n" + " • Run command: glpkg\n" + " • Or run directly: uv run glpkg\n\n" "2. Manual installation with pip:\n" ) diff --git a/tests/README.md b/tests/README.md index 6132739..ee8045d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,6 +1,6 @@ # GitLab Package Upload Test Suite -This directory contains a comprehensive pytest-based test suite for the GitLab package upload functionality. The test suite validates the `gitlab-pkg-upload` command through both unit tests and end-to-end integration testing. +This directory contains a comprehensive pytest-based test suite for the GitLab package upload functionality. The test suite validates the `glpkg` command through both unit tests and end-to-end integration testing. ## Overview @@ -11,7 +11,7 @@ The test suite is organized into two categories: ## Quick Start -All test dependencies are automatically managed by uv. However, the `gitlab_pkg_upload` package must be installed in development mode before running tests. +All test dependencies are automatically managed by uv. However, the `glpkg` package must be installed in development mode before running tests. ```bash # Install the package in development mode (required before running tests) @@ -98,7 +98,7 @@ All test dependencies are automatically installed by uv when running `uv run pyt - pytest-sugar (progress visualization) - pytest-instafail (instant failure reporting) -**Important**: The `gitlab_pkg_upload` package itself must be installed in development mode before running tests: +**Important**: The `glpkg` package itself must be installed in development mode before running tests: ```bash uv pip install -e . diff --git a/tests/conftest.py b/tests/conftest.py index 034837c..afdac8f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,12 +22,12 @@ try: from gitlab import Gitlab - from gitlab_pkg_upload.cli import GitAutoDetector, ProjectResolver - from gitlab_pkg_upload.models import GitRemoteInfo, ProjectInfo + from glpkg.cli.upload import GitAutoDetector, ProjectResolver + from glpkg.models import GitRemoteInfo, ProjectInfo GITLAB_AVAILABLE = True except ImportError: - # Handle case where python-gitlab or gitlab_pkg_upload is not available + # Handle case where python-gitlab or glpkg is not available Gitlab = None ProjectResolver = None GitAutoDetector = None diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 4471329..e110043 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -45,7 +45,7 @@ import pytest -from gitlab_pkg_upload.cli import GitAutoDetector, ProjectResolutionError +from glpkg.cli.upload import GitAutoDetector, ProjectResolutionError def _validate_gitlab_repository() -> Tuple[bool, str, str]: diff --git a/tests/integration/test_environment_validation.py b/tests/integration/test_environment_validation.py index a69b209..0463fc2 100644 --- a/tests/integration/test_environment_validation.py +++ b/tests/integration/test_environment_validation.py @@ -16,7 +16,7 @@ import pytest -from gitlab_pkg_upload.cli import GitAutoDetector +from glpkg.cli.upload import GitAutoDetector # Test markers for categorization pytestmark = [ diff --git a/tests/integration/test_helpers_module.py b/tests/integration/test_helpers_module.py index 43fe1d6..a2567e5 100644 --- a/tests/integration/test_helpers_module.py +++ b/tests/integration/test_helpers_module.py @@ -23,7 +23,7 @@ pass # Import the main function from CLI module -from gitlab_pkg_upload.cli import main +from glpkg.cli.main import main @dataclass @@ -73,7 +73,7 @@ def __post_init__(self): class ModuleExecutor: """ - Handles execution of the gitlab-pkg-upload CLI via direct module invocation. + Handles execution of the glpkg CLI via direct module invocation. This class calls the main() function from the CLI module directly instead of spawning a subprocess. It captures stdout/stderr via context managers @@ -284,7 +284,8 @@ def build_argv( if not files and not directory: raise ValueError("Either files or directory must be provided") - argv = [] + # Start with the upload subcommand + argv = ["upload"] # Required arguments argv.extend(["--package-name", package_name]) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index 79a0f89..d4d411d 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -1 +1 @@ -# Unit tests for gitlab-pkg-upload +# Unit tests for glpkg diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 60e4105..144072b 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -43,25 +43,26 @@ from gitlab import Gitlab from gitlab.exceptions import GitlabAuthenticationError, GitlabGetError -from gitlab_pkg_upload.cli import ( +from glpkg.cli.upload import ( # Constants EXCEPTION_EXIT_CODE_MAP, # Functions - determine_verbosity, - setup_logging, - create_argument_parser, - validate_flags, - get_version, - parse_arguments, auto_detect_project, resolve_project_manually, - main, + validate_upload_flags, # Classes GitAutoDetector, ProjectResolver, UploadContextBuilder, ) -from gitlab_pkg_upload.models import ( +from glpkg.cli.main import ( + main, + create_argument_parser, + determine_verbosity, + get_version, + setup_logging, +) +from glpkg.models import ( DuplicatePolicy, GitRemoteInfo, ProjectInfo, @@ -182,9 +183,9 @@ class TestSetupLogging: """Tests for setup_logging function.""" @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.logging.basicConfig') - @patch('gitlab_pkg_upload.cli.RichHandler') - @patch('gitlab_pkg_upload.cli.Console') + @patch('glpkg.cli.main.logging.basicConfig') + @patch('glpkg.cli.main.RichHandler') + @patch('glpkg.cli.main.Console') def test_logging_setup_normal(self, mock_console, mock_rich_handler, mock_basic_config, mock_args): """Test logging setup with normal verbosity.""" setup_logging(mock_args) @@ -193,9 +194,9 @@ def test_logging_setup_normal(self, mock_console, mock_rich_handler, mock_basic_ assert call_kwargs['level'] == logging.INFO @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.logging.basicConfig') - @patch('gitlab_pkg_upload.cli.RichHandler') - @patch('gitlab_pkg_upload.cli.Console') + @patch('glpkg.cli.main.logging.basicConfig') + @patch('glpkg.cli.main.RichHandler') + @patch('glpkg.cli.main.Console') def test_logging_setup_debug(self, mock_console, mock_rich_handler, mock_basic_config, mock_args): """Test logging setup with debug verbosity.""" mock_args.debug = True @@ -204,9 +205,9 @@ def test_logging_setup_debug(self, mock_console, mock_rich_handler, mock_basic_c assert call_kwargs['level'] == logging.DEBUG @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.logging.basicConfig') - @patch('gitlab_pkg_upload.cli.RichHandler') - @patch('gitlab_pkg_upload.cli.Console') + @patch('glpkg.cli.main.logging.basicConfig') + @patch('glpkg.cli.main.RichHandler') + @patch('glpkg.cli.main.Console') def test_logging_setup_quiet(self, mock_console, mock_rich_handler, mock_basic_config, mock_args): """Test logging setup with quiet verbosity.""" mock_args.quiet = True @@ -215,9 +216,9 @@ def test_logging_setup_quiet(self, mock_console, mock_rich_handler, mock_basic_c assert call_kwargs['level'] == logging.WARNING @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.logging.basicConfig') - @patch('gitlab_pkg_upload.cli.RichHandler') - @patch('gitlab_pkg_upload.cli.Console') + @patch('glpkg.cli.main.logging.basicConfig') + @patch('glpkg.cli.main.RichHandler') + @patch('glpkg.cli.main.Console') def test_logging_setup_verbose(self, mock_console, mock_rich_handler, mock_basic_config, mock_args): """Test logging setup with verbose verbosity.""" mock_args.verbose = True @@ -226,9 +227,9 @@ def test_logging_setup_verbose(self, mock_console, mock_rich_handler, mock_basic assert call_kwargs['level'] == logging.INFO @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.logging.basicConfig') - @patch('gitlab_pkg_upload.cli.RichHandler') - @patch('gitlab_pkg_upload.cli.Console') + @patch('glpkg.cli.main.logging.basicConfig') + @patch('glpkg.cli.main.RichHandler') + @patch('glpkg.cli.main.Console') def test_logging_uses_stderr_for_json_output(self, mock_console, mock_rich_handler, mock_basic_config, mock_args): """Test logging uses stderr when json_output is enabled.""" mock_args.json_output = True @@ -246,29 +247,33 @@ def test_parser_creation(self): """Test argument parser is created successfully.""" parser = create_argument_parser() assert isinstance(parser, argparse.ArgumentParser) - assert parser.prog == "gitlab-pkg-upload" + assert parser.prog == "glpkg" @pytest.mark.timeout(60) - def test_parser_has_required_argument_groups(self): - """Test parser has expected argument groups and options.""" + def test_parser_has_global_options(self): + """Test parser has expected global options.""" parser = create_argument_parser() - # Parse with no args - parser itself won't fail, but validate_flags will + # Parse with no args - shows help and has command=None args = parser.parse_args([]) - # Verify that required argument attributes exist (even if None) - assert hasattr(args, 'package_name') - assert hasattr(args, 'package_version') - assert hasattr(args, 'files') - assert hasattr(args, 'directory') + # Verify that global argument attributes exist + assert hasattr(args, 'verbose') + assert hasattr(args, 'quiet') + assert hasattr(args, 'debug') + assert hasattr(args, 'json_output') + assert hasattr(args, 'command') + assert args.command is None # No subcommand provided @pytest.mark.timeout(60) - def test_parser_accepts_valid_arguments(self): - """Test parser accepts valid argument combinations.""" + def test_parser_accepts_upload_subcommand(self): + """Test parser accepts upload subcommand with valid arguments.""" parser = create_argument_parser() args = parser.parse_args([ + 'upload', '--package-name', 'test', '--package-version', '1.0.0', '--files', 'file.txt' ]) + assert args.command == 'upload' assert args.package_name == 'test' assert args.package_version == '1.0.0' assert args.files == ['file.txt'] @@ -279,6 +284,7 @@ def test_parser_duplicate_policy_choices(self): parser = create_argument_parser() for policy in ['skip', 'replace', 'error']: args = parser.parse_args([ + 'upload', '--package-name', 'test', '--package-version', '1.0.0', '--files', 'file.txt', @@ -292,6 +298,7 @@ def test_parser_invalid_duplicate_policy(self): parser = create_argument_parser() with pytest.raises(SystemExit): parser.parse_args([ + 'upload', '--package-name', 'test', '--package-version', '1.0.0', '--files', 'file.txt', @@ -303,6 +310,7 @@ def test_parser_multiple_files(self): """Test parser accepts multiple files.""" parser = create_argument_parser() args = parser.parse_args([ + 'upload', '--package-name', 'test', '--package-version', '1.0.0', '--files', 'file1.txt', 'file2.txt', 'file3.txt' @@ -314,6 +322,7 @@ def test_parser_default_values(self): """Test parser has correct default values.""" parser = create_argument_parser() args = parser.parse_args([ + 'upload', '--package-name', 'test', '--package-version', '1.0.0', '--files', 'file.txt' @@ -333,6 +342,7 @@ def test_parser_directory_option(self): """Test parser accepts directory option.""" parser = create_argument_parser() args = parser.parse_args([ + 'upload', '--package-name', 'test', '--package-version', '1.0.0', '--directory', '/path/to/dir' @@ -345,6 +355,7 @@ def test_parser_file_mapping_option(self): """Test parser accepts file mapping options.""" parser = create_argument_parser() args = parser.parse_args([ + 'upload', '--package-name', 'test', '--package-version', '1.0.0', '--files', 'file.txt', @@ -353,16 +364,30 @@ def test_parser_file_mapping_option(self): ]) assert args.file_mapping == ['file.txt:renamed.txt', 'other.bin:new.bin'] + @pytest.mark.timeout(60) + def test_parser_global_flags_before_subcommand(self): + """Test global flags can be placed before the subcommand.""" + parser = create_argument_parser() + args = parser.parse_args([ + '--verbose', + 'upload', + '--package-name', 'test', + '--package-version', '1.0.0', + '--files', 'file.txt' + ]) + assert args.verbose is True + assert args.command == 'upload' + -class TestValidateFlags: - """Tests for validate_flags function.""" +class TestValidateUploadFlags: + """Tests for validate_upload_flags function.""" @pytest.mark.timeout(60) def test_missing_package_name_raises_error(self, mock_args): """Test missing package name raises SystemExit.""" mock_args.package_name = None with pytest.raises(SystemExit) as exc_info: - validate_flags(mock_args) + validate_upload_flags(mock_args) assert exc_info.value.code == 3 @pytest.mark.timeout(60) @@ -370,7 +395,7 @@ def test_missing_package_version_raises_error(self, mock_args): """Test missing package version raises SystemExit.""" mock_args.package_version = None with pytest.raises(SystemExit) as exc_info: - validate_flags(mock_args) + validate_upload_flags(mock_args) assert exc_info.value.code == 3 @pytest.mark.timeout(60) @@ -379,16 +404,7 @@ def test_both_files_and_directory_raises_error(self, mock_args): mock_args.files = ["file.txt"] mock_args.directory = "/path/to/dir" with pytest.raises(SystemExit) as exc_info: - validate_flags(mock_args) - assert exc_info.value.code == 3 - - @pytest.mark.timeout(60) - def test_multiple_verbosity_flags_raises_error(self, mock_args): - """Test multiple verbosity flags raises error.""" - mock_args.verbose = True - mock_args.quiet = True - with pytest.raises(SystemExit) as exc_info: - validate_flags(mock_args) + validate_upload_flags(mock_args) assert exc_info.value.code == 3 @pytest.mark.timeout(60) @@ -397,7 +413,7 @@ def test_both_project_url_and_path_raises_error(self, mock_args): mock_args.project_url = "https://gitlab.com/group/project" mock_args.project_path = "group/project" with pytest.raises(SystemExit) as exc_info: - validate_flags(mock_args) + validate_upload_flags(mock_args) assert exc_info.value.code == 3 @pytest.mark.timeout(60) @@ -407,7 +423,7 @@ def test_file_mapping_with_directory_raises_error(self, mock_args): mock_args.directory = "/path/to/dir" mock_args.file_mapping = ["source:target"] with pytest.raises(SystemExit) as exc_info: - validate_flags(mock_args) + validate_upload_flags(mock_args) assert exc_info.value.code == 3 @pytest.mark.timeout(60) @@ -415,14 +431,14 @@ def test_negative_retry_raises_error(self, mock_args): """Test negative retry count raises error.""" mock_args.retry = -1 with pytest.raises(SystemExit) as exc_info: - validate_flags(mock_args) + validate_upload_flags(mock_args) assert exc_info.value.code == 3 @pytest.mark.timeout(60) def test_valid_flags_pass_validation(self, mock_args): """Test valid flag combination passes validation.""" # Should not raise - validate_flags(mock_args) + validate_upload_flags(mock_args) @pytest.mark.timeout(60) def test_no_file_input_raises_error(self, mock_args): @@ -430,26 +446,7 @@ def test_no_file_input_raises_error(self, mock_args): mock_args.files = None mock_args.directory = None with pytest.raises(SystemExit) as exc_info: - validate_flags(mock_args) - assert exc_info.value.code == 3 - - @pytest.mark.timeout(60) - def test_all_verbosity_flags_raises_error(self, mock_args): - """Test all three verbosity flags raises error.""" - mock_args.verbose = True - mock_args.quiet = True - mock_args.debug = True - with pytest.raises(SystemExit) as exc_info: - validate_flags(mock_args) - assert exc_info.value.code == 3 - - @pytest.mark.timeout(60) - def test_verbose_and_debug_raises_error(self, mock_args): - """Test verbose and debug flags raises error.""" - mock_args.verbose = True - mock_args.debug = True - with pytest.raises(SystemExit) as exc_info: - validate_flags(mock_args) + validate_upload_flags(mock_args) assert exc_info.value.code == 3 @pytest.mark.timeout(60) @@ -457,14 +454,14 @@ def test_zero_retry_is_valid(self, mock_args): """Test zero retry count is valid.""" mock_args.retry = 0 # Should not raise - validate_flags(mock_args) + validate_upload_flags(mock_args) @pytest.mark.timeout(60) def test_positive_retry_is_valid(self, mock_args): """Test positive retry count is valid.""" mock_args.retry = 5 # Should not raise - validate_flags(mock_args) + validate_upload_flags(mock_args) class TestGitAutoDetector: @@ -483,7 +480,7 @@ def test_initialization_with_custom_directory(self): assert detector.working_directory == "/custom/path" @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.git.Repo') + @patch('glpkg.cli.upload.git.Repo') def test_find_git_repository_success(self, mock_repo_class): """Test finding Git repository successfully.""" mock_repo = MagicMock() @@ -497,7 +494,7 @@ def test_find_git_repository_success(self, mock_repo_class): mock_repo_class.assert_called_once_with(".", search_parent_directories=True) @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.git.Repo') + @patch('glpkg.cli.upload.git.Repo') def test_find_git_repository_not_found(self, mock_repo_class): """Test Git repository not found returns None.""" mock_repo_class.side_effect = git.InvalidGitRepositoryError() @@ -508,7 +505,7 @@ def test_find_git_repository_not_found(self, mock_repo_class): assert repo is None @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.git.Repo') + @patch('glpkg.cli.upload.git.Repo') def test_find_git_repository_permission_error(self, mock_repo_class): """Test Git repository permission error raises ProjectResolutionError.""" mock_repo_class.side_effect = PermissionError("Access denied") @@ -519,7 +516,7 @@ def test_find_git_repository_permission_error(self, mock_repo_class): assert "Permission denied" in str(exc_info.value) @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.git.Repo') + @patch('glpkg.cli.upload.git.Repo') def test_find_git_repository_git_command_error(self, mock_repo_class): """Test Git command error raises ProjectResolutionError.""" mock_repo_class.side_effect = git.GitCommandError("git status", 128, stderr="fatal: error") @@ -530,7 +527,7 @@ def test_find_git_repository_git_command_error(self, mock_repo_class): assert "Git command error" in str(exc_info.value) @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.git.Repo') + @patch('glpkg.cli.upload.git.Repo') def test_find_git_repository_os_error(self, mock_repo_class): """Test OS error raises ProjectResolutionError.""" mock_repo_class.side_effect = OSError("Disk error") @@ -563,7 +560,7 @@ def test_is_known_non_gitlab_host(self): assert not detector._is_known_non_gitlab_host("gitlab.example.com") @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.parse_git_url') + @patch('glpkg.cli.upload.parse_git_url') def test_parse_git_url_success(self, mock_parse): """Test parsing Git URL successfully.""" mock_parse.return_value = ("https://gitlab.com", "group/project") @@ -574,7 +571,7 @@ def test_parse_git_url_success(self, mock_parse): assert result == ("https://gitlab.com", "group/project") @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.parse_git_url') + @patch('glpkg.cli.upload.parse_git_url') def test_parse_git_url_non_gitlab(self, mock_parse): """Test parsing non-GitLab URL returns None.""" mock_parse.return_value = ("https://github.com", "group/project") @@ -585,7 +582,7 @@ def test_parse_git_url_non_gitlab(self, mock_parse): assert result is None @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.parse_git_url') + @patch('glpkg.cli.upload.parse_git_url') def test_parse_git_url_unknown_host(self, mock_parse): """Test parsing URL from unknown host still returns it.""" mock_parse.return_value = ("https://git.example.com", "group/project") @@ -597,7 +594,7 @@ def test_parse_git_url_unknown_host(self, mock_parse): assert result == ("https://git.example.com", "group/project") @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.parse_git_url') + @patch('glpkg.cli.upload.parse_git_url') def test_parse_git_url_gitlab_like_error(self, mock_parse): """Test parsing GitLab-like URL that fails raises error.""" mock_parse.side_effect = Exception("Parse error") @@ -608,7 +605,7 @@ def test_parse_git_url_gitlab_like_error(self, mock_parse): assert "format is unrecognized" in str(exc_info.value) @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.parse_git_url') + @patch('glpkg.cli.upload.parse_git_url') def test_parse_git_url_non_gitlab_error_returns_none(self, mock_parse): """Test parsing non-GitLab URL that fails returns None.""" mock_parse.side_effect = Exception("Parse error") @@ -694,7 +691,7 @@ def test_initialization(self, mock_gitlab_client): assert isinstance(resolver.project_cache, dict) @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.normalize_gitlab_url') + @patch('glpkg.cli.upload.normalize_gitlab_url') def test_parse_project_url_success(self, mock_normalize, mock_gitlab_client): """Test parsing project URL successfully.""" mock_normalize.return_value = ("https://gitlab.com", "group/project") @@ -709,7 +706,7 @@ def test_parse_project_url_success(self, mock_normalize, mock_gitlab_client): assert result.project_name == "project" @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.normalize_gitlab_url') + @patch('glpkg.cli.upload.normalize_gitlab_url') def test_parse_project_url_nested_namespace(self, mock_normalize, mock_gitlab_client): """Test parsing project URL with nested namespace.""" mock_normalize.return_value = ("https://gitlab.com", "group/subgroup/project") @@ -721,7 +718,7 @@ def test_parse_project_url_nested_namespace(self, mock_normalize, mock_gitlab_cl assert result.project_name == "project" @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.normalize_gitlab_url') + @patch('glpkg.cli.upload.normalize_gitlab_url') def test_parse_project_url_invalid(self, mock_normalize, mock_gitlab_client): """Test parsing invalid project URL raises error.""" mock_normalize.side_effect = Exception("Invalid URL") @@ -813,7 +810,7 @@ def test_initialization(self): assert builder is not None @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.DuplicateDetector') + @patch('glpkg.cli.upload.DuplicateDetector') def test_build_context_success(self, mock_detector_class, mock_args, mock_gitlab_client): """Test building upload context successfully.""" mock_detector = MagicMock() @@ -839,7 +836,7 @@ def test_build_context_success(self, mock_detector_class, mock_args, mock_gitlab assert context.config.version == "1.0.0" @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.DuplicateDetector') + @patch('glpkg.cli.upload.DuplicateDetector') def test_build_context_with_verbosity(self, mock_detector_class, mock_args, mock_gitlab_client): """Test context building respects verbosity settings.""" mock_detector = MagicMock() @@ -860,7 +857,7 @@ def test_build_context_with_verbosity(self, mock_detector_class, mock_args, mock assert context.config.verbosity == "verbose" @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.DuplicateDetector') + @patch('glpkg.cli.upload.DuplicateDetector') def test_build_context_with_dry_run(self, mock_detector_class, mock_args, mock_gitlab_client): """Test context building with dry run enabled.""" mock_detector = MagicMock() @@ -881,7 +878,7 @@ def test_build_context_with_dry_run(self, mock_detector_class, mock_args, mock_g assert context.config.dry_run is True @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.DuplicateDetector') + @patch('glpkg.cli.upload.DuplicateDetector') def test_build_context_with_debug(self, mock_detector_class, mock_args, mock_gitlab_client): """Test context building with debug verbosity.""" mock_detector = MagicMock() @@ -902,7 +899,7 @@ def test_build_context_with_debug(self, mock_detector_class, mock_args, mock_git assert context.config.verbosity == "debug" @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.DuplicateDetector') + @patch('glpkg.cli.upload.DuplicateDetector') def test_build_context_with_replace_policy(self, mock_detector_class, mock_args, mock_gitlab_client): """Test context building with replace duplicate policy.""" mock_detector = MagicMock() @@ -922,7 +919,7 @@ def test_build_context_with_replace_policy(self, mock_detector_class, mock_args, assert context.config.duplicate_policy == DuplicatePolicy.REPLACE @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.DuplicateDetector') + @patch('glpkg.cli.upload.DuplicateDetector') def test_build_context_error_handling(self, mock_detector_class, mock_args, mock_gitlab_client): """Test context building raises ConfigurationError on failure.""" mock_detector_class.side_effect = Exception("Detector init failed") @@ -953,7 +950,7 @@ def test_get_version_returns_string(self): assert len(version) > 0 @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.GitAutoDetector') + @patch('glpkg.cli.upload.GitAutoDetector') def test_auto_detect_project_success(self, mock_detector_class): """Test auto-detecting project successfully.""" mock_detector = MagicMock() @@ -974,7 +971,7 @@ def test_auto_detect_project_success(self, mock_detector_class): assert project_path == "group/project" @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.GitAutoDetector') + @patch('glpkg.cli.upload.GitAutoDetector') def test_auto_detect_project_no_repo(self, mock_detector_class): """Test auto-detect fails when no Git repository found.""" mock_detector = MagicMock() @@ -986,7 +983,7 @@ def test_auto_detect_project_no_repo(self, mock_detector_class): assert "No Git repository found" in str(exc_info.value) @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.normalize_gitlab_url') + @patch('glpkg.cli.upload.normalize_gitlab_url') def test_resolve_project_manually_with_url(self, mock_normalize): """Test manual project resolution with URL.""" mock_normalize.return_value = ("https://gitlab.com", "group/project") @@ -1047,7 +1044,7 @@ def test_resolve_project_manually_no_specification(self): assert "No project specification provided" in str(exc_info.value) @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.normalize_gitlab_url') + @patch('glpkg.cli.upload.normalize_gitlab_url') def test_resolve_project_manually_url_parse_error(self, mock_normalize): """Test manual resolution with URL parse error.""" mock_normalize.side_effect = Exception("Invalid URL format") @@ -1083,422 +1080,50 @@ def test_resolve_project_manually_empty_path_parts(self): assert "Invalid project path" in str(exc_info.value) -class TestParseArguments: - """Tests for parse_arguments function.""" - - @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.argcomplete.autocomplete') - @patch('gitlab_pkg_upload.cli.validate_flags') - def test_parse_arguments_success(self, mock_validate, mock_autocomplete): - """Test parsing arguments successfully.""" - result = parse_arguments([ - '--package-name', 'test', - '--package-version', '1.0.0', - '--files', 'file.txt' - ]) - - assert result.package_name == "test" - assert result.package_version == "1.0.0" - mock_autocomplete.assert_called_once() - - @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.argcomplete.autocomplete') - @patch('gitlab_pkg_upload.cli.validate_flags') - def test_parse_arguments_converts_duplicate_policy(self, mock_validate, mock_autocomplete): - """Test duplicate policy is converted to enum.""" - result = parse_arguments([ - '--package-name', 'test', - '--package-version', '1.0.0', - '--files', 'file.txt', - '--duplicate-policy', 'replace' - ]) - - assert result.duplicate_policy == DuplicatePolicy.REPLACE - - @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.argcomplete.autocomplete') - @patch('gitlab_pkg_upload.cli.validate_flags') - def test_parse_arguments_all_policies(self, mock_validate, mock_autocomplete): - """Test all duplicate policies are converted correctly.""" - for policy_str, policy_enum in [ - ('skip', DuplicatePolicy.SKIP), - ('replace', DuplicatePolicy.REPLACE), - ('error', DuplicatePolicy.ERROR) - ]: - result = parse_arguments([ - '--package-name', 'test', - '--package-version', '1.0.0', - '--files', 'file.txt', - '--duplicate-policy', policy_str - ]) - assert result.duplicate_policy == policy_enum - - class TestMainFunction: """Tests for main function orchestration.""" @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.parse_arguments') - @patch('gitlab_pkg_upload.cli.setup_logging') - @patch('gitlab_pkg_upload.cli.auto_detect_project') - def test_main_authentication_error(self, mock_auto_detect, mock_setup_logging, - mock_parse_args, mock_args): - """Test main function handles authentication error.""" - mock_parse_args.return_value = mock_args - mock_auto_detect.side_effect = AuthenticationError("Auth failed") - + def test_main_no_subcommand_shows_help_and_exits_zero(self): + """Test main function with no subcommand exits with code 0.""" with pytest.raises(SystemExit) as exc_info: - main() + main([]) - assert exc_info.value.code == 2 + assert exc_info.value.code == 0 @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.parse_arguments') - @patch('gitlab_pkg_upload.cli.setup_logging') - @patch('gitlab_pkg_upload.cli.auto_detect_project') - def test_main_configuration_error(self, mock_auto_detect, mock_setup_logging, - mock_parse_args, mock_args): - """Test main function handles configuration error.""" - mock_parse_args.return_value = mock_args - mock_auto_detect.side_effect = ConfigurationError("Config error") - + def test_main_help_flag(self): + """Test main function with --help exits with code 0.""" with pytest.raises(SystemExit) as exc_info: - main() - - assert exc_info.value.code == 3 - - @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.parse_arguments') - @patch('gitlab_pkg_upload.cli.setup_logging') - @patch('gitlab_pkg_upload.cli.auto_detect_project') - def test_main_project_resolution_error(self, mock_auto_detect, mock_setup_logging, - mock_parse_args, mock_args): - """Test main function handles project resolution error.""" - mock_parse_args.return_value = mock_args - mock_auto_detect.side_effect = ProjectResolutionError("Project not found") + main(['--help']) - with pytest.raises(SystemExit) as exc_info: - main() - - assert exc_info.value.code == 4 + assert exc_info.value.code == 0 @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.parse_arguments') - @patch('gitlab_pkg_upload.cli.setup_logging') - @patch('gitlab_pkg_upload.cli.auto_detect_project') - def test_main_file_not_found_error(self, mock_auto_detect, mock_setup_logging, - mock_parse_args, mock_args): - """Test main function handles FileNotFoundError.""" - mock_parse_args.return_value = mock_args - mock_auto_detect.side_effect = FileNotFoundError("File missing") - + def test_main_version_flag(self): + """Test main function with --version exits with code 0.""" with pytest.raises(SystemExit) as exc_info: - main() + main(['--version']) - assert exc_info.value.code == 5 + assert exc_info.value.code == 0 @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.parse_arguments') - @patch('gitlab_pkg_upload.cli.setup_logging') - @patch('gitlab_pkg_upload.cli.auto_detect_project') - def test_main_permission_error(self, mock_auto_detect, mock_setup_logging, - mock_parse_args, mock_args): - """Test main function handles PermissionError.""" - mock_parse_args.return_value = mock_args - mock_auto_detect.side_effect = PermissionError("Access denied") - + def test_main_upload_help_flag(self): + """Test main function with upload --help exits with code 0.""" with pytest.raises(SystemExit) as exc_info: - main() + main(['upload', '--help']) - assert exc_info.value.code == 5 + assert exc_info.value.code == 0 @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.parse_arguments') - @patch('gitlab_pkg_upload.cli.setup_logging') - @patch('gitlab_pkg_upload.cli.auto_detect_project') - def test_main_value_error(self, mock_auto_detect, mock_setup_logging, - mock_parse_args, mock_args): - """Test main function handles ValueError.""" - mock_parse_args.return_value = mock_args - mock_auto_detect.side_effect = ValueError("Invalid value") - + def test_main_conflicting_verbosity_flags(self): + """Test main function detects conflicting verbosity flags.""" with pytest.raises(SystemExit) as exc_info: - main() + main(['--verbose', '--quiet', 'upload', '--package-name', 'test', + '--package-version', '1.0.0', '--files', 'file.txt']) assert exc_info.value.code == 3 - @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.parse_arguments') - @patch('gitlab_pkg_upload.cli.setup_logging') - @patch('gitlab_pkg_upload.cli.auto_detect_project') - def test_main_connection_error(self, mock_auto_detect, mock_setup_logging, - mock_parse_args, mock_args): - """Test main function handles ConnectionError.""" - mock_parse_args.return_value = mock_args - mock_auto_detect.side_effect = ConnectionError("Network error") - - with pytest.raises(SystemExit) as exc_info: - main() - - assert exc_info.value.code == 6 - - @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.parse_arguments') - @patch('gitlab_pkg_upload.cli.setup_logging') - @patch('gitlab_pkg_upload.cli.auto_detect_project') - def test_main_timeout_error(self, mock_auto_detect, mock_setup_logging, - mock_parse_args, mock_args): - """Test main function handles TimeoutError.""" - mock_parse_args.return_value = mock_args - mock_auto_detect.side_effect = TimeoutError("Timed out") - - with pytest.raises(SystemExit) as exc_info: - main() - - assert exc_info.value.code == 6 - - @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.parse_arguments') - @patch('gitlab_pkg_upload.cli.setup_logging') - @patch('gitlab_pkg_upload.cli.auto_detect_project') - def test_main_unexpected_error(self, mock_auto_detect, mock_setup_logging, - mock_parse_args, mock_args): - """Test main function handles unexpected errors.""" - mock_parse_args.return_value = mock_args - mock_auto_detect.side_effect = RuntimeError("Unexpected error") - - with pytest.raises(SystemExit) as exc_info: - main() - - assert exc_info.value.code == 1 - - @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.parse_arguments') - @patch('gitlab_pkg_upload.cli.setup_logging') - @patch('gitlab_pkg_upload.cli.resolve_project_manually') - @patch('gitlab_pkg_upload.cli.get_gitlab_token') - @patch('gitlab_pkg_upload.cli.Gitlab') - @patch('gitlab_pkg_upload.cli.ProjectResolver') - @patch('gitlab_pkg_upload.cli.UploadContextBuilder') - @patch('gitlab_pkg_upload.cli.collect_files') - @patch('gitlab_pkg_upload.cli.upload_files') - @patch('gitlab_pkg_upload.cli.OutputFormatter') - def test_main_success_flow(self, mock_formatter_class, mock_upload, - mock_collect, mock_builder_class, - mock_resolver_class, mock_gitlab_class, - mock_get_token, mock_resolve_manual, - mock_setup_logging, mock_parse_args, mock_args): - """Test main function success flow.""" - # Setup args for manual project specification - mock_args.project_url = "https://gitlab.com/group/project" - mock_args.duplicate_policy = DuplicatePolicy.SKIP - mock_parse_args.return_value = mock_args - mock_resolve_manual.return_value = ("https://gitlab.com", "group/project") - mock_get_token.return_value = "test-token" - - mock_gl = MagicMock() - mock_gitlab_class.return_value = mock_gl - - mock_resolver = MagicMock() - mock_resolver.resolve_project_id.return_value = 12345 - mock_resolver.validate_project_access.return_value = True - mock_resolver_class.return_value = mock_resolver - - mock_builder = MagicMock() - mock_context = MagicMock() - mock_context.config = MagicMock() - mock_context.config.package_name = "test-package" - mock_context.config.version = "1.0.0" - mock_builder.build.return_value = mock_context - mock_builder_class.return_value = mock_builder - - mock_collect.return_value = ([MagicMock()], []) - mock_result = MagicMock() - mock_result.success = True - mock_upload.return_value = [mock_result] - - mock_formatter = MagicMock() - mock_formatter_class.return_value = mock_formatter - - with pytest.raises(SystemExit) as exc_info: - main() - - assert exc_info.value.code == 0 - mock_setup_logging.assert_called_once() - mock_gl.auth.assert_called_once() - - @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.parse_arguments') - @patch('gitlab_pkg_upload.cli.setup_logging') - @patch('gitlab_pkg_upload.cli.resolve_project_manually') - @patch('gitlab_pkg_upload.cli.get_gitlab_token') - @patch('gitlab_pkg_upload.cli.Gitlab') - @patch('gitlab_pkg_upload.cli.ProjectResolver') - def test_main_project_access_validation_fails(self, mock_resolver_class, - mock_gitlab_class, mock_get_token, - mock_resolve_manual, mock_setup_logging, - mock_parse_args, mock_args): - """Test main function when project access validation fails.""" - mock_args.project_url = "https://gitlab.com/group/project" - mock_args.duplicate_policy = DuplicatePolicy.SKIP - mock_parse_args.return_value = mock_args - mock_resolve_manual.return_value = ("https://gitlab.com", "group/project") - mock_get_token.return_value = "test-token" - - mock_gl = MagicMock() - mock_gitlab_class.return_value = mock_gl - - mock_resolver = MagicMock() - mock_resolver.resolve_project_id.return_value = 12345 - mock_resolver.validate_project_access.return_value = False - mock_resolver_class.return_value = mock_resolver - - with pytest.raises(SystemExit) as exc_info: - main() - - assert exc_info.value.code == 4 - - @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.parse_arguments') - @patch('gitlab_pkg_upload.cli.setup_logging') - @patch('gitlab_pkg_upload.cli.resolve_project_manually') - @patch('gitlab_pkg_upload.cli.get_gitlab_token') - @patch('gitlab_pkg_upload.cli.Gitlab') - @patch('gitlab_pkg_upload.cli.ProjectResolver') - @patch('gitlab_pkg_upload.cli.UploadContextBuilder') - @patch('gitlab_pkg_upload.cli.collect_files') - def test_main_no_valid_files(self, mock_collect, mock_builder_class, - mock_resolver_class, mock_gitlab_class, - mock_get_token, mock_resolve_manual, - mock_setup_logging, mock_parse_args, mock_args): - """Test main function with no valid files to upload.""" - mock_args.project_url = "https://gitlab.com/group/project" - mock_args.duplicate_policy = DuplicatePolicy.SKIP - mock_parse_args.return_value = mock_args - mock_resolve_manual.return_value = ("https://gitlab.com", "group/project") - mock_get_token.return_value = "test-token" - - mock_gl = MagicMock() - mock_gitlab_class.return_value = mock_gl - - mock_resolver = MagicMock() - mock_resolver.resolve_project_id.return_value = 12345 - mock_resolver.validate_project_access.return_value = True - mock_resolver_class.return_value = mock_resolver - - mock_builder = MagicMock() - mock_context = MagicMock() - mock_builder.build.return_value = mock_context - mock_builder_class.return_value = mock_builder - - # No valid files - mock_collect.return_value = ([], []) - - with pytest.raises(SystemExit) as exc_info: - main() - - assert exc_info.value.code == 5 - - @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.parse_arguments') - @patch('gitlab_pkg_upload.cli.setup_logging') - @patch('gitlab_pkg_upload.cli.resolve_project_manually') - @patch('gitlab_pkg_upload.cli.get_gitlab_token') - @patch('gitlab_pkg_upload.cli.Gitlab') - @patch('gitlab_pkg_upload.cli.ProjectResolver') - @patch('gitlab_pkg_upload.cli.UploadContextBuilder') - @patch('gitlab_pkg_upload.cli.collect_files') - @patch('gitlab_pkg_upload.cli.upload_files') - @patch('gitlab_pkg_upload.cli.OutputFormatter') - def test_main_partial_upload_failure(self, mock_formatter_class, mock_upload, - mock_collect, mock_builder_class, - mock_resolver_class, mock_gitlab_class, - mock_get_token, mock_resolve_manual, - mock_setup_logging, mock_parse_args, mock_args): - """Test main function with partial upload failures.""" - mock_args.project_url = "https://gitlab.com/group/project" - mock_args.duplicate_policy = DuplicatePolicy.SKIP - mock_parse_args.return_value = mock_args - mock_resolve_manual.return_value = ("https://gitlab.com", "group/project") - mock_get_token.return_value = "test-token" - - mock_gl = MagicMock() - mock_gitlab_class.return_value = mock_gl - - mock_resolver = MagicMock() - mock_resolver.resolve_project_id.return_value = 12345 - mock_resolver.validate_project_access.return_value = True - mock_resolver_class.return_value = mock_resolver - - mock_builder = MagicMock() - mock_context = MagicMock() - mock_context.config = MagicMock() - mock_context.config.package_name = "test-package" - mock_context.config.version = "1.0.0" - mock_builder.build.return_value = mock_context - mock_builder_class.return_value = mock_builder - - mock_collect.return_value = ([MagicMock(), MagicMock()], []) - # One success, one failure - mock_result1 = MagicMock() - mock_result1.success = True - mock_result2 = MagicMock() - mock_result2.success = False - mock_upload.return_value = [mock_result1, mock_result2] - - mock_formatter = MagicMock() - mock_formatter_class.return_value = mock_formatter - - with pytest.raises(SystemExit) as exc_info: - main() - - assert exc_info.value.code == 1 - - @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.parse_arguments') - @patch('gitlab_pkg_upload.cli.setup_logging') - @patch('gitlab_pkg_upload.cli.resolve_project_manually') - @patch('gitlab_pkg_upload.cli.get_gitlab_token') - @patch('gitlab_pkg_upload.cli.Gitlab') - @patch('gitlab_pkg_upload.cli.ProjectResolver') - @patch('gitlab_pkg_upload.cli.UploadContextBuilder') - @patch('gitlab_pkg_upload.cli.collect_files') - def test_main_file_errors_with_fail_fast(self, mock_collect, mock_builder_class, - mock_resolver_class, mock_gitlab_class, - mock_get_token, mock_resolve_manual, - mock_setup_logging, mock_parse_args, mock_args): - """Test main function with file errors and fail_fast enabled.""" - mock_args.project_url = "https://gitlab.com/group/project" - mock_args.duplicate_policy = DuplicatePolicy.SKIP - mock_args.fail_fast = True - mock_parse_args.return_value = mock_args - mock_resolve_manual.return_value = ("https://gitlab.com", "group/project") - mock_get_token.return_value = "test-token" - - mock_gl = MagicMock() - mock_gitlab_class.return_value = mock_gl - - mock_resolver = MagicMock() - mock_resolver.resolve_project_id.return_value = 12345 - mock_resolver.validate_project_access.return_value = True - mock_resolver_class.return_value = mock_resolver - - mock_builder = MagicMock() - mock_context = MagicMock() - mock_builder.build.return_value = mock_context - mock_builder_class.return_value = mock_builder - - # Files with errors - mock_collect.return_value = ( - [MagicMock()], - [{'source_path': 'bad.txt', 'error_message': 'Invalid file'}] - ) - - with pytest.raises(SystemExit) as exc_info: - main() - - assert exc_info.value.code == 5 - class TestExceptionExitCodeMapping: """Tests for exception exit code mapping.""" @@ -1594,7 +1219,7 @@ def test_git_auto_detector_multiple_urls_per_remote(self, mock_git_repo): assert len(remotes) == 1 @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.normalize_gitlab_url') + @patch('glpkg.cli.upload.normalize_gitlab_url') def test_project_resolver_deeply_nested_namespace(self, mock_normalize, mock_gitlab_client): """Test parsing URL with deeply nested namespace.""" mock_normalize.return_value = ( @@ -1611,7 +1236,7 @@ def test_project_resolver_deeply_nested_namespace(self, mock_normalize, mock_git assert result.project_name == "project" @pytest.mark.timeout(60) - def test_validate_flags_multiple_errors_all_reported(self, mock_args, capsys): + def test_validate_upload_flags_multiple_errors_all_reported(self, mock_args, capsys): """Test that multiple validation errors are all reported.""" mock_args.package_name = None mock_args.package_version = None @@ -1619,7 +1244,7 @@ def test_validate_flags_multiple_errors_all_reported(self, mock_args, capsys): mock_args.directory = None with pytest.raises(SystemExit) as exc_info: - validate_flags(mock_args) + validate_upload_flags(mock_args) assert exc_info.value.code == 3 captured = capsys.readouterr() @@ -1652,7 +1277,7 @@ def test_determine_verbosity_only_quiet(self, mock_args): assert determine_verbosity(mock_args) == "quiet" @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.DuplicateDetector') + @patch('glpkg.cli.upload.DuplicateDetector') def test_build_context_with_json_output(self, mock_detector_class, mock_args, mock_gitlab_client): """Test context building with JSON output enabled.""" mock_detector = MagicMock() @@ -1673,7 +1298,7 @@ def test_build_context_with_json_output(self, mock_detector_class, mock_args, mo assert context.config.json_output is True @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.DuplicateDetector') + @patch('glpkg.cli.upload.DuplicateDetector') def test_build_context_with_plain_output(self, mock_detector_class, mock_args, mock_gitlab_client): """Test context building with plain output enabled.""" mock_detector = MagicMock() @@ -1694,7 +1319,7 @@ def test_build_context_with_plain_output(self, mock_detector_class, mock_args, m assert context.config.plain_output is True @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.DuplicateDetector') + @patch('glpkg.cli.upload.DuplicateDetector') def test_build_context_with_fail_fast(self, mock_detector_class, mock_args, mock_gitlab_client): """Test context building with fail_fast enabled.""" mock_detector = MagicMock() @@ -1715,7 +1340,7 @@ def test_build_context_with_fail_fast(self, mock_detector_class, mock_args, mock assert context.config.fail_fast is True @pytest.mark.timeout(60) - @patch('gitlab_pkg_upload.cli.DuplicateDetector') + @patch('glpkg.cli.upload.DuplicateDetector') def test_build_context_with_retry_count(self, mock_detector_class, mock_args, mock_gitlab_client): """Test context building with custom retry count.""" mock_detector = MagicMock() diff --git a/tests/unit/test_duplicate_detector.py b/tests/unit/test_duplicate_detector.py index 4ccc78e..9150a68 100644 --- a/tests/unit/test_duplicate_detector.py +++ b/tests/unit/test_duplicate_detector.py @@ -15,12 +15,12 @@ import pytest -from gitlab_pkg_upload.duplicate_detector import ( +from glpkg.duplicate_detector import ( DuplicateDetector, calculate_sha256, handle_network_error_with_retry, ) -from gitlab_pkg_upload.models import FileFingerprint, RemoteFile +from glpkg.models import FileFingerprint, RemoteFile # Mark these as fast unit tests pytestmark = [pytest.mark.unit, pytest.mark.fast] @@ -395,7 +395,7 @@ def test_session_duplicate_checksum_calculation(self, mock_gitlab_client, tmp_pa detector.register_file(file1, "target.txt", checksum) with patch( - "gitlab_pkg_upload.duplicate_detector.calculate_sha256", + "glpkg.duplicate_detector.calculate_sha256", return_value=checksum, ) as mock_calc: detector.check_session_duplicate(file2, "target.txt") @@ -818,7 +818,7 @@ def test_register_file_uses_current_timestamp(self, mock_gitlab_client, tmp_path mock_time = 1704067200.0 # Fixed timestamp - with patch("gitlab_pkg_upload.duplicate_detector.time.time", return_value=mock_time): + with patch("glpkg.duplicate_detector.time.time", return_value=mock_time): detector.register_file(test_file, "target.txt", "a" * 64) fingerprint = detector.session_registry["target.txt"] diff --git a/tests/unit/test_formatters.py b/tests/unit/test_formatters.py index 0b4fa77..9d24955 100644 --- a/tests/unit/test_formatters.py +++ b/tests/unit/test_formatters.py @@ -22,7 +22,7 @@ import pytest -from src.gitlab_pkg_upload.formatters import ( +from glpkg.formatters import ( OutputFormatter, detect_color_support, detect_tty, @@ -31,7 +31,7 @@ format_error, get_formatter, ) -from src.gitlab_pkg_upload.models import ( +from glpkg.models import ( DuplicatePolicy, GitLabUploadError, UploadConfig, @@ -106,7 +106,7 @@ def update(self, message): def mock_rich_console(): """Fixture that patches rich.console.Console with MockConsole.""" with patch("rich.console.Console", MockConsole): - with patch("src.gitlab_pkg_upload.formatters.Console", MockConsole): + with patch("glpkg.formatters.Console", MockConsole): yield MockConsole @@ -114,7 +114,7 @@ def mock_rich_console(): def mock_rich_status(): """Fixture that patches rich.status.Status with MockStatus.""" with patch("rich.status.Status", MockStatus): - with patch("src.gitlab_pkg_upload.formatters.Status", MockStatus): + with patch("glpkg.formatters.Status", MockStatus): yield MockStatus @@ -336,7 +336,7 @@ def test_detect_color_support_with_no_color_env(self, clean_env): """Test detect_color_support returns False when NO_COLOR is set.""" os.environ["NO_COLOR"] = "1" - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): assert detect_color_support() is False @pytest.mark.timeout(60) @@ -344,7 +344,7 @@ def test_detect_color_support_with_force_color_env(self, clean_env): """Test detect_color_support returns True when FORCE_COLOR is set.""" os.environ["FORCE_COLOR"] = "1" - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): assert detect_color_support() is True @pytest.mark.timeout(60) @@ -352,7 +352,7 @@ def test_detect_color_support_with_colorterm_env(self, clean_env): """Test detect_color_support returns True when COLORTERM is set.""" os.environ["COLORTERM"] = "truecolor" - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): assert detect_color_support() is True @pytest.mark.timeout(60) @@ -360,13 +360,13 @@ def test_detect_color_support_with_term_color(self, clean_env): """Test detect_color_support returns True when TERM contains color.""" os.environ["TERM"] = "xterm-256color" - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): assert detect_color_support() is True @pytest.mark.timeout(60) def test_detect_color_support_without_tty(self, clean_env): """Test detect_color_support returns False when not in a TTY.""" - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=False): + with patch("glpkg.formatters.detect_tty", return_value=False): assert detect_color_support() is False @pytest.mark.timeout(60) @@ -374,7 +374,7 @@ def test_detect_color_support_windows_wt_session(self, clean_env): """Test detect_color_support returns True on Windows with WT_SESSION.""" os.environ["WT_SESSION"] = "some-session-id" - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): with patch.object(sys, "platform", "win32"): assert detect_color_support() is True @@ -383,7 +383,7 @@ def test_detect_color_support_windows_ansicon(self, clean_env): """Test detect_color_support returns True on Windows with ANSICON.""" os.environ["ANSICON"] = "1" - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): with patch.object(sys, "platform", "win32"): assert detect_color_support() is True @@ -393,7 +393,7 @@ def test_detect_color_support_precedence(self, clean_env): os.environ["NO_COLOR"] = "1" os.environ["FORCE_COLOR"] = "1" - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): assert detect_color_support() is False @pytest.mark.timeout(60) @@ -431,7 +431,7 @@ def test_detect_unicode_support_with_lc_all_utf8(self, clean_env): @pytest.mark.timeout(60) def test_detect_unicode_support_without_tty(self, clean_env): """Test detect_unicode_support returns False when not in a TTY.""" - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=False): + with patch("glpkg.formatters.detect_tty", return_value=False): assert detect_unicode_support() is False @pytest.mark.timeout(60) @@ -463,9 +463,9 @@ def test_init_without_plain_output_flag(self, mock_rich_console): """Test OutputFormatter detects terminal capabilities when plain_output=False.""" config = create_upload_config(plain_output=False) - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): - with patch("src.gitlab_pkg_upload.formatters.detect_color_support", return_value=True): - with patch("src.gitlab_pkg_upload.formatters.detect_unicode_support", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_color_support", return_value=True): + with patch("glpkg.formatters.detect_unicode_support", return_value=True): formatter = OutputFormatter(config) assert formatter.is_tty is True @@ -487,7 +487,7 @@ def test_init_with_json_output_flag(self, mock_rich_console): """Test OutputFormatter initializes correctly with json_output=True.""" config = create_upload_config(json_output=True) - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): formatter = OutputFormatter(config) assert formatter.config.json_output is True @@ -509,9 +509,9 @@ def test_format_rich_output_successful_uploads(self, mock_rich_console): """Test rich output displays successful uploads correctly.""" config = create_upload_config(plain_output=False) - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): - with patch("src.gitlab_pkg_upload.formatters.detect_color_support", return_value=True): - with patch("src.gitlab_pkg_upload.formatters.detect_unicode_support", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_color_support", return_value=True): + with patch("glpkg.formatters.detect_unicode_support", return_value=True): formatter = OutputFormatter(config) results = [ @@ -538,7 +538,7 @@ def test_format_rich_output_skipped_duplicates(self, mock_rich_console): """Test rich output displays skipped duplicates correctly.""" config = create_upload_config(plain_output=False) - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): formatter = OutputFormatter(config) results = [ @@ -566,7 +566,7 @@ def test_format_rich_output_failed_uploads(self, mock_rich_console): """Test rich output displays failed uploads correctly.""" config = create_upload_config(plain_output=False) - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): formatter = OutputFormatter(config) results = [ @@ -592,7 +592,7 @@ def test_format_rich_output_replaced_duplicates(self, mock_rich_console): """Test rich output displays replaced duplicates correctly.""" config = create_upload_config(plain_output=False) - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): formatter = OutputFormatter(config) results = [ @@ -621,7 +621,7 @@ def test_format_rich_output_statistics(self, mock_upload_results, mock_rich_cons """Test rich output displays statistics correctly.""" config = create_upload_config(plain_output=False) - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): formatter = OutputFormatter(config) captured = io.StringIO() @@ -642,7 +642,7 @@ def test_format_rich_output_empty_results(self, mock_rich_console): """Test rich output handles empty results list.""" config = create_upload_config(plain_output=False) - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): formatter = OutputFormatter(config) captured = io.StringIO() @@ -662,7 +662,7 @@ def test_format_rich_output_all_successful(self, mock_rich_console): """Test rich output shows success message when all uploads succeed.""" config = create_upload_config(plain_output=False) - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): formatter = OutputFormatter(config) results = [ @@ -1143,7 +1143,7 @@ def test_format_error_uses_enhance_error_message(self): "gitlab_url": "https://gitlab.com", } - with patch("src.gitlab_pkg_upload.formatters.enhance_error_message") as mock_enhance: + with patch("glpkg.formatters.enhance_error_message") as mock_enhance: mock_enhance.return_value = "Enhanced error message" result = format_error(error, context) @@ -1172,7 +1172,7 @@ def test_create_progress_spinner_returns_status(self, mock_rich_console, mock_ri """Test create_progress_spinner returns a Status-like object.""" config = create_upload_config() - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): formatter = OutputFormatter(config) spinner = formatter.create_progress_spinner("Loading...") @@ -1186,7 +1186,7 @@ def test_create_progress_spinner_with_message(self, mock_rich_console, mock_rich """Test create_progress_spinner accepts custom message.""" config = create_upload_config() - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): formatter = OutputFormatter(config) message = "Uploading files..." @@ -1210,7 +1210,7 @@ def test_create_progress_spinner_non_tty(self, mock_rich_console, mock_rich_stat """Test spinner creation when is_tty is False.""" config = create_upload_config(plain_output=False) - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=False): + with patch("glpkg.formatters.detect_tty", return_value=False): formatter = OutputFormatter(config) spinner = formatter.create_progress_spinner("Loading...") @@ -1234,7 +1234,7 @@ def test_display_progress_function(self, mock_rich_console, mock_rich_status): """Test standalone display_progress function delegates to formatter.""" config = create_upload_config() - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): formatter = OutputFormatter(config) spinner = display_progress(formatter, "Processing...") @@ -1285,7 +1285,7 @@ def test_format_output_selects_plain_when_not_tty(self, mock_rich_console): """Test format_output calls plain formatter when not in TTY.""" config = create_upload_config(plain_output=False, json_output=False) - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=False): + with patch("glpkg.formatters.detect_tty", return_value=False): formatter = OutputFormatter(config) results = [create_upload_result()] @@ -1304,8 +1304,8 @@ def test_format_output_selects_rich_when_tty(self, mock_rich_console): """Test format_output calls rich formatter when in TTY.""" config = create_upload_config(plain_output=False, json_output=False) - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): - with patch("src.gitlab_pkg_upload.formatters.detect_color_support", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_color_support", return_value=True): formatter = OutputFormatter(config) results = [create_upload_result()] @@ -1343,8 +1343,8 @@ def test_full_workflow_rich_output(self, mock_upload_results, mock_rich_console) """Test complete rich output workflow.""" config = create_upload_config(plain_output=False) - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): - with patch("src.gitlab_pkg_upload.formatters.detect_color_support", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_color_support", return_value=True): formatter = OutputFormatter(config) captured = io.StringIO() @@ -1595,7 +1595,7 @@ def test_console_file_output_isolation(self, mock_rich_console): """Test that console output doesn't interfere with stdout capture.""" config = create_upload_config(plain_output=False) - with patch("src.gitlab_pkg_upload.formatters.detect_tty", return_value=True): + with patch("glpkg.formatters.detect_tty", return_value=True): formatter = OutputFormatter(config) # Redirect console to a separate buffer using MockConsole diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index fb9c66a..0ae355d 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -15,7 +15,7 @@ import pytest -from gitlab_pkg_upload.models import ( +from glpkg.models import ( # Dataclasses FileFingerprint, GitRemoteInfo, diff --git a/tests/unit/test_uploader.py b/tests/unit/test_uploader.py index 33780d3..58f7a52 100644 --- a/tests/unit/test_uploader.py +++ b/tests/unit/test_uploader.py @@ -15,7 +15,7 @@ import pytest from gitlab.exceptions import GitlabError -from gitlab_pkg_upload.models import ( +from glpkg.models import ( ChecksumValidationError, DuplicatePolicy, RemoteFile, @@ -23,7 +23,7 @@ UploadContext, UploadResult, ) -from gitlab_pkg_upload.uploader import ( +from glpkg.uploader import ( delete_file_from_registry, handle_duplicate, is_transient_error, @@ -591,7 +591,7 @@ def test_handle_duplicate_replace_calls_delete( mock_project.packages.list.return_value = [] with patch( - "gitlab_pkg_upload.uploader.delete_file_from_registry" + "glpkg.uploader.delete_file_from_registry" ) as mock_delete: handle_duplicate(mock_upload_context, mock_file_path, sample_remote_file) mock_delete.assert_called_once_with( @@ -801,7 +801,7 @@ def test_upload_files_single_file_success( mock_project.packages.get.return_value = mock_package_obj with patch( - "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + "glpkg.uploader.calculate_sha256", return_value="a" * 64 ): results = upload_files(mock_upload_context, [(mock_file_path, "target.bin")]) @@ -830,10 +830,10 @@ def test_upload_files_multiple_files_success( mock_project.packages.get.return_value = mock_package_obj with patch( - "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + "glpkg.uploader.calculate_sha256", return_value="a" * 64 ): with patch( - "gitlab_pkg_upload.uploader.validate_upload", return_value=True + "glpkg.uploader.validate_upload", return_value=True ): results = upload_files( mock_upload_context, @@ -873,7 +873,7 @@ def test_upload_files_remote_duplicate_skip_policy( ) with patch( - "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + "glpkg.uploader.calculate_sha256", return_value="a" * 64 ): results = upload_files( mock_upload_context, [(mock_file_path, "target.bin")] @@ -907,7 +907,7 @@ def test_upload_files_remote_duplicate_replace_policy( mock_project.packages.get.return_value = mock_package_obj with patch( - "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + "glpkg.uploader.calculate_sha256", return_value="a" * 64 ): results = upload_files( mock_upload_context, [(mock_file_path, "target.bin")] @@ -929,7 +929,7 @@ def test_upload_files_remote_duplicate_error_policy( ) with patch( - "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + "glpkg.uploader.calculate_sha256", return_value="a" * 64 ): results = upload_files( mock_upload_context, [(mock_file_path, "target.bin")] @@ -955,7 +955,7 @@ def test_upload_files_fail_fast_enabled( mock_upload_context.gl.projects.get.side_effect = Exception("Upload failed") with patch( - "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + "glpkg.uploader.calculate_sha256", return_value="a" * 64 ): results = upload_files( mock_upload_context, @@ -1000,7 +1000,7 @@ def mock_get_project(*args, **kwargs): mock_project.packages.get.return_value = mock_package_obj with patch( - "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + "glpkg.uploader.calculate_sha256", return_value="a" * 64 ): results = upload_files( mock_upload_context, @@ -1030,7 +1030,7 @@ def test_upload_files_checksum_calculation( mock_project.packages.get.return_value = mock_package_obj with patch( - "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + "glpkg.uploader.calculate_sha256", return_value="a" * 64 ) as mock_sha: upload_files(mock_upload_context, [(mock_file_path, "target.bin")]) mock_sha.assert_called_once_with(mock_file_path) @@ -1043,10 +1043,10 @@ def test_upload_files_validation_called( mock_upload_context.gl.projects.get.return_value = mock_project with patch( - "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + "glpkg.uploader.calculate_sha256", return_value="a" * 64 ): with patch( - "gitlab_pkg_upload.uploader.validate_upload", return_value=True + "glpkg.uploader.validate_upload", return_value=True ) as mock_validate: upload_files(mock_upload_context, [(mock_file_path, "target.bin")]) mock_validate.assert_called_once() @@ -1059,10 +1059,10 @@ def test_upload_files_registration_called( mock_upload_context.gl.projects.get.return_value = mock_project with patch( - "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + "glpkg.uploader.calculate_sha256", return_value="a" * 64 ): with patch( - "gitlab_pkg_upload.uploader.validate_upload", return_value=True + "glpkg.uploader.validate_upload", return_value=True ): upload_files(mock_upload_context, [(mock_file_path, "target.bin")]) @@ -1076,7 +1076,7 @@ def test_upload_files_upload_exception_handled( mock_upload_context.gl.projects.get.side_effect = Exception("Network error") with patch( - "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + "glpkg.uploader.calculate_sha256", return_value="a" * 64 ): results = upload_files( mock_upload_context, [(mock_file_path, "target.bin")] @@ -1108,10 +1108,10 @@ def test_upload_files_replace_policy_deletes_different_checksum( mock_project.packages.get.return_value = mock_package_obj with patch( - "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + "glpkg.uploader.calculate_sha256", return_value="a" * 64 ): with patch( - "gitlab_pkg_upload.uploader.delete_file_from_registry", return_value=1 + "glpkg.uploader.delete_file_from_registry", return_value=1 ) as mock_delete: upload_files(mock_upload_context, [(mock_file_path, "target.bin")]) mock_delete.assert_called() @@ -1127,7 +1127,7 @@ def test_upload_files_result_includes_duplicate_metadata( ) with patch( - "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + "glpkg.uploader.calculate_sha256", return_value="a" * 64 ): results = upload_files( mock_upload_context, [(mock_file_path, "target.bin")] @@ -1181,10 +1181,10 @@ def test_workflow_no_duplicates_all_succeed( mock_project.packages.get.return_value = mock_package_obj with patch( - "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + "glpkg.uploader.calculate_sha256", return_value="a" * 64 ): with patch( - "gitlab_pkg_upload.uploader.validate_upload", return_value=True + "glpkg.uploader.validate_upload", return_value=True ): results = upload_files(mock_upload_context, files) @@ -1232,10 +1232,10 @@ def mock_upload_side_effect(context, file, target): mock_project.packages.get.return_value = mock_package_obj with patch( - "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + "glpkg.uploader.calculate_sha256", return_value="a" * 64 ): with patch( - "gitlab_pkg_upload.uploader.upload_single_file", + "glpkg.uploader.upload_single_file", side_effect=mock_upload_side_effect, ): results = upload_files( @@ -1266,7 +1266,7 @@ def test_workflow_all_duplicates_skip_policy( ) with patch( - "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + "glpkg.uploader.calculate_sha256", return_value="a" * 64 ): results = upload_files(mock_upload_context, files) @@ -1307,7 +1307,7 @@ def test_workflow_duplicate_then_new_file( mock_project.packages.get.return_value = mock_package_obj with patch( - "gitlab_pkg_upload.uploader.calculate_sha256", return_value="a" * 64 + "glpkg.uploader.calculate_sha256", return_value="a" * 64 ): results = upload_files( mock_upload_context, diff --git a/tests/unit/test_validators.py b/tests/unit/test_validators.py index b472458..48438ff 100644 --- a/tests/unit/test_validators.py +++ b/tests/unit/test_validators.py @@ -17,12 +17,12 @@ import pytest -from gitlab_pkg_upload.models import ( +from glpkg.models import ( ConfigurationError, FileValidationError, ProjectResolutionError, ) -from gitlab_pkg_upload.validators import ( +from glpkg.validators import ( DEFAULT_GITLAB_URL, calculate_sha256, collect_files, @@ -1013,8 +1013,8 @@ def test_successful_validation(self, monkeypatch): """Test successful configuration validation.""" monkeypatch.setenv("GITLAB_TOKEN", "x" * 26) - with patch("gitlab_pkg_upload.validators.validate_dependencies"): - with patch("gitlab_pkg_upload.validators.validate_git_installation"): + with patch("glpkg.validators.validate_dependencies"): + with patch("glpkg.validators.validate_git_installation"): # Should not raise validate_configuration(token="x" * 26, require_git=False) @@ -1023,9 +1023,9 @@ def test_validation_with_require_git(self, monkeypatch): """Test validation with Git requirement.""" monkeypatch.setenv("GITLAB_TOKEN", "x" * 26) - with patch("gitlab_pkg_upload.validators.validate_dependencies"): - with patch("gitlab_pkg_upload.validators.validate_git_installation"): - with patch("gitlab_pkg_upload.validators.validate_git_repository"): + with patch("glpkg.validators.validate_dependencies"): + with patch("glpkg.validators.validate_git_installation"): + with patch("glpkg.validators.validate_git_repository"): # Should not raise validate_configuration( token="x" * 26, @@ -1036,7 +1036,7 @@ def test_validation_with_require_git(self, monkeypatch): def test_dependencies_failure_propagates(self): """Test dependencies validation failure propagates.""" with patch( - "gitlab_pkg_upload.validators.validate_dependencies", + "glpkg.validators.validate_dependencies", side_effect=ConfigurationError("Missing dependency"), ): with pytest.raises(ConfigurationError) as exc_info: @@ -1046,7 +1046,7 @@ def test_dependencies_failure_propagates(self): @pytest.mark.timeout(60) def test_token_validation_failure_propagates(self): """Test token validation failure propagates.""" - with patch("gitlab_pkg_upload.validators.validate_dependencies"): + with patch("glpkg.validators.validate_dependencies"): with pytest.raises(ConfigurationError): validate_configuration(token="short") @@ -1055,9 +1055,9 @@ def test_git_failure_ignored_when_not_required(self, monkeypatch): """Test Git validation failure is ignored when not required.""" monkeypatch.setenv("GITLAB_TOKEN", "x" * 26) - with patch("gitlab_pkg_upload.validators.validate_dependencies"): + with patch("glpkg.validators.validate_dependencies"): with patch( - "gitlab_pkg_upload.validators.validate_git_installation", + "glpkg.validators.validate_git_installation", side_effect=ConfigurationError("Git not found"), ): # Should not raise - Git is not required @@ -1071,9 +1071,9 @@ def test_git_failure_propagates_when_required(self, monkeypatch): """Test Git validation failure propagates when required.""" monkeypatch.setenv("GITLAB_TOKEN", "x" * 26) - with patch("gitlab_pkg_upload.validators.validate_dependencies"): + with patch("glpkg.validators.validate_dependencies"): with patch( - "gitlab_pkg_upload.validators.validate_git_installation", + "glpkg.validators.validate_git_installation", side_effect=ConfigurationError("Git not found"), ): with pytest.raises(ConfigurationError) as exc_info: @@ -1088,7 +1088,7 @@ def test_token_from_environment(self, monkeypatch): """Test token is retrieved from environment when not provided.""" monkeypatch.setenv("GITLAB_TOKEN", "x" * 26) - with patch("gitlab_pkg_upload.validators.validate_dependencies"): - with patch("gitlab_pkg_upload.validators.validate_git_installation"): + with patch("glpkg.validators.validate_dependencies"): + with patch("glpkg.validators.validate_git_installation"): # Should not raise - token from environment validate_configuration(token=None, require_git=False) diff --git a/tests/utils/artifact_factory.py b/tests/utils/artifact_factory.py index b30f9da..8ee3840 100644 --- a/tests/utils/artifact_factory.py +++ b/tests/utils/artifact_factory.py @@ -13,7 +13,7 @@ class in the monolithic test file. It provides utilities for generating test dat from pathlib import Path from typing import Dict, List, Optional -from gitlab_pkg_upload.validators import calculate_sha256 +from glpkg.validators import calculate_sha256 @dataclass diff --git a/tests/utils/gitlab_helpers.py b/tests/utils/gitlab_helpers.py index 6af4d54..9e60a9e 100644 --- a/tests/utils/gitlab_helpers.py +++ b/tests/utils/gitlab_helpers.py @@ -25,7 +25,7 @@ class in the monolithic test file. It provides utilities for interacting with th # Import exception models from the new modular structure try: - from gitlab_pkg_upload.models import ( + from glpkg.models import ( AuthenticationError, GitLabUploadError, NetworkError, @@ -34,7 +34,7 @@ class in the monolithic test file. It provides utilities for interacting with th EXCEPTION_MODELS_AVAILABLE = True except ImportError: - # Fall back to basic exceptions when gitlab_pkg_upload is not available + # Fall back to basic exceptions when glpkg is not available EXCEPTION_MODELS_AVAILABLE = False GitLabUploadError = Exception AuthenticationError = Exception diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py index b6b1248..7766135 100644 --- a/tests/utils/test_helpers.py +++ b/tests/utils/test_helpers.py @@ -28,8 +28,8 @@ class in the monolithic test file. It provides utilities for running the # Import from the new modular structure try: - from gitlab_pkg_upload.cli import main as cli_main - from gitlab_pkg_upload.models import ( + from glpkg.cli.main import main as cli_main + from glpkg.models import ( AuthenticationError, ConfigurationError, DuplicatePolicy, @@ -130,7 +130,7 @@ def __post_init__(self): class ScriptExecutor: """ - Handles execution of the gitlab-pkg-upload CLI. + Handles execution of the glpkg CLI. Extracted from the monolithic test file's UploadScriptInterface class. This class manages execution of the upload CLI via direct module invocation @@ -146,14 +146,14 @@ def __init__(self, script_path: Optional[Path] = None): Args: script_path: Deprecated parameter, kept for backward compatibility. - Direct module invocation is always used via gitlab_pkg_upload.cli. + Direct module invocation is always used via glpkg.cli. """ self.script_path = script_path self._use_direct_invocation = CLI_AVAILABLE if not self._use_direct_invocation: raise ImportError( - "gitlab_pkg_upload module is not available. " + "glpkg module is not available. " "Install the package with: uv pip install -e ." ) @@ -470,7 +470,7 @@ def build_command(self, use_json_output: bool = False, **kwargs) -> List[str]: # Use program name for direct invocation, script path for subprocess fallback if self._use_direct_invocation: - command = ["gitlab-pkg-upload"] + command = ["glpkg", "upload"] else: command = [str(self.script_path)] diff --git a/uv.lock b/uv.lock index d123026..de27540 100644 --- a/uv.lock +++ b/uv.lock @@ -214,7 +214,19 @@ wheels = [ ] [[package]] -name = "gitlab-pkg-upload" +name = "gitpython" +version = "3.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, +] + +[[package]] +name = "glpkg" version = "0.1.0" source = { editable = "." } dependencies = [ @@ -259,18 +271,6 @@ requires-dist = [ ] provides-extras = ["dev", "test"] -[[package]] -name = "gitpython" -version = "3.1.46" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "gitdb" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, -] - [[package]] name = "h11" version = "0.16.0" From 8baf77905ddac9b26e4336693173fe3a82d28b85 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 15:11:25 -0600 Subject: [PATCH 26/36] ci: Add test coverage reporting and integration test opt-in The test suite previously had no coverage tracking, making it difficult to identify untested code paths and ensure adequate test coverage as the project evolves. Additionally, integration tests could run unexpectedly in CI environments where GitLab credentials were misconfigured, leading to confusing failures. Integrate pytest-cov with dual coverage thresholds: a 90% failure threshold that blocks merges with insufficient coverage, and a 95% warning threshold that surfaces coverage regressions early without blocking development. Require explicit RUN_INTEGRATION_TESTS=1 opt-in for integration tests to prevent accidental execution and make the test environment requirements explicit. This separation ensures unit tests run quickly in all environments while integration tests only execute when deliberately enabled with proper credentials. Signed-off-by: Javier Tia --- .github/workflows/test.yml | 37 +++++++++++++++-- pyproject.toml | 21 ++++++++++ tests/README.md | 78 +++++++++++++++++++++++++++++++---- tests/integration/conftest.py | 13 ++++++ 4 files changed, 137 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6ba6ce0..a779608 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,15 +37,46 @@ jobs: - name: Install test dependencies run: uv pip install -e ".[test]" --system - - name: Run unit tests - run: uv run pytest tests/unit/ -m unit -v -n auto + - name: Run unit tests with coverage + run: uv run pytest tests/unit/ -m unit -v -n auto --cov=glpkg --cov-report=xml --cov-report=term + + - name: Upload coverage reports + if: matrix.python-version == '3.11' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: | + coverage.xml + htmlcov/ + retention-days: 7 + + - name: Check coverage threshold + if: matrix.python-version == '3.11' + run: | + # Extract coverage percentage from XML + COVERAGE=$(python -c " + import xml.etree.ElementTree as ET + tree = ET.parse('coverage.xml') + root = tree.getroot() + line_rate = float(root.get('line-rate', 0)) + print(f'{line_rate * 100:.2f}') + ") + echo "Coverage: ${COVERAGE}%" + + # Check if below 95% warning threshold + if (( $(echo "$COVERAGE < 95" | bc -l) )); then + echo "::warning::Coverage is below 95% target: ${COVERAGE}%" + fi + + # The --cov-fail-under=90 in pytest already handles the failure threshold - name: Run integration tests if: ${{ secrets.GITLAB_TOKEN != '' && secrets.GITLAB_REPO != '' }} env: GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} GITLAB_PROJECT_PATH: ${{ secrets.GITLAB_REPO }} - run: uv run pytest tests/integration/ -m integration -v -n auto + RUN_INTEGRATION_TESTS: "1" + run: uv run pytest tests/integration/ -m integration -v -n auto --no-cov - name: Upload test results uses: actions/upload-artifact@v4 diff --git a/pyproject.toml b/pyproject.toml index 7862ef3..eff937a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ test = [ "pytest-timeout", "pytest-sugar", "pytest-instafail", + "pytest-cov", ] [project.scripts] @@ -74,6 +75,12 @@ addopts = [ "-v", "--tb=short", "--strict-markers", + "--cov=glpkg", + "--cov-report=term-missing", + "--cov-report=html", + "--cov-report=xml", + "--cov-fail-under=90", + "--no-cov-on-fail", ] markers = [ "unit: Unit tests that don't require external dependencies", @@ -82,6 +89,20 @@ markers = [ "slow: Slow-running tests", "timeout: Timeout for test execution (provided by pytest-timeout)", ] +# Note: Target coverage is 95% (warning threshold in CI), fail threshold is 90% + +[tool.coverage.run] +source = ["src/glpkg"] +omit = ["tests/*", "*/tests/*"] + +[tool.coverage.report] +precision = 2 +show_missing = true +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", +] [tool.bumpversion] current_version = "0.1.0" diff --git a/tests/README.md b/tests/README.md index ee8045d..2a1bbbc 100644 --- a/tests/README.md +++ b/tests/README.md @@ -23,7 +23,8 @@ uv run pytest tests/ # Run only unit tests (fast, no external dependencies) uv run pytest tests/unit/ -# Run integration tests (requires GITLAB_TOKEN) +# Run integration tests (requires RUN_INTEGRATION_TESTS=1 and GITLAB_TOKEN) +export RUN_INTEGRATION_TESTS=1 export GITLAB_TOKEN="your-token" uv run pytest tests/integration/ -m integration @@ -97,6 +98,7 @@ All test dependencies are automatically installed by uv when running `uv run pyt - pytest-timeout (timeout management) - pytest-sugar (progress visualization) - pytest-instafail (instant failure reporting) +- pytest-cov (code coverage reporting) **Important**: The `glpkg` package itself must be installed in development mode before running tests: @@ -108,12 +110,20 @@ Alternatively, you can use `pip install -e .` if not using uv. ### GitLab Configuration (Integration Tests Only) -Integration tests require a GitLab API token: +Integration tests require explicit opt-in and a GitLab API token: ```bash +# Required: Opt-in to run integration tests +export RUN_INTEGRATION_TESTS=1 + +# Required: GitLab API token export GITLAB_TOKEN="your-gitlab-token" -export GITLAB_URL="https://gitlab.example.com" # Optional, defaults to GitLab.com -export GITLAB_PROJECT_PATH="group/project" # Optional, can auto-detect from git + +# Optional: Custom GitLab URL (defaults to GitLab.com) +export GITLAB_URL="https://gitlab.example.com" + +# Optional: Project path (can auto-detect from git) +export GITLAB_PROJECT_PATH="group/project" ``` ### Required Permissions @@ -193,21 +203,69 @@ uv run pytest tests/ -m api ./run_tests.py --durations=5 tests/ ``` +## Code Coverage + +The test suite includes code coverage reporting with dual thresholds: + +- **Warning threshold**: 95% (CI adds a warning annotation when below) +- **Failure threshold**: 90% (tests fail if coverage drops below) + +### Running Tests with Coverage + +```bash +# Run unit tests with coverage (default configuration) +uv run pytest tests/unit/ -m unit --cov=glpkg --cov-report=term + +# Generate HTML coverage report +uv run pytest tests/unit/ -m unit --cov=glpkg --cov-report=html + +# View HTML report +open htmlcov/index.html # macOS +xdg-open htmlcov/index.html # Linux + +# Generate XML coverage report (for CI) +uv run pytest tests/unit/ -m unit --cov=glpkg --cov-report=xml +``` + +### Coverage Configuration + +Coverage is configured in `pyproject.toml`: + +```toml +[tool.coverage.run] +source = ["src/glpkg"] +omit = ["tests/*", "*/tests/*"] + +[tool.coverage.report] +precision = 2 +show_missing = true +``` + +### Interpreting Coverage Reports + +- **Term-missing output**: Shows which lines are not covered directly in the terminal +- **HTML report**: Provides an interactive view in `htmlcov/index.html` +- **XML report**: Machine-readable format for CI integration in `coverage.xml` + ## Integration Test Requirements -Integration tests automatically validate their environment before running. +Integration tests require explicit opt-in and automatically validate their environment before running. ### Automatic Environment Validation When you run integration tests, the test suite checks: -1. **GITLAB_TOKEN environment variable** - Must be set with a valid GitLab API token -2. **Git repository** - Must run from within a Git repository -3. **GitLab remotes** - Repository must have at least one remote pointing to a GitLab instance +1. **RUN_INTEGRATION_TESTS environment variable** - Must be set to `1` to opt-in +2. **GITLAB_TOKEN environment variable** - Must be set with a valid GitLab API token +3. **Git repository** - Must run from within a Git repository +4. **GitLab remotes** - Repository must have at least one remote pointing to a GitLab instance ### Verifying Your Setup ```bash +# Check if integration tests are enabled +echo $RUN_INTEGRATION_TESTS + # Check if in Git repository git remote -v @@ -217,7 +275,8 @@ git remote -v | grep gitlab # Check token is set echo $GITLAB_TOKEN -# Verify token is not empty +# Verify full setup +[ "$RUN_INTEGRATION_TESTS" = "1" ] && echo "Integration tests enabled" || echo "Integration tests NOT enabled" [ -n "$GITLAB_TOKEN" ] && echo "Token is set" || echo "Token is NOT set" ``` @@ -225,6 +284,7 @@ echo $GITLAB_TOKEN If integration tests are skipped, the error message explains what's missing: +- **Integration tests disabled**: Set `export RUN_INTEGRATION_TESTS=1` to opt-in - **Missing GITLAB_TOKEN**: Set the environment variable with `export GITLAB_TOKEN='your-token'` - **No Git repository**: Navigate to a Git repository or initialize one - **No GitLab remotes**: Add a GitLab remote with `git remote add origin https://gitlab.com/namespace/project.git` diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index e110043..81482b0 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -147,6 +147,7 @@ def validate_integration_environment(): This session-scoped fixture runs once before any integration tests execute. It validates: + 0. RUN_INTEGRATION_TESTS environment variable is set to "1" 1. GITLAB_TOKEN environment variable is set 2. Current directory is within a Git repository 3. Git repository has at least one GitLab remote @@ -157,6 +158,18 @@ def validate_integration_environment(): This fixture is marked autouse=True so it runs automatically for all integration tests without needing to be explicitly requested. """ + # Check 0: RUN_INTEGRATION_TESTS environment variable (opt-in mechanism) + run_integration = os.environ.get("RUN_INTEGRATION_TESTS") + if run_integration != "1": + pytest.skip( + "Integration tests disabled. Set RUN_INTEGRATION_TESTS=1 to enable.\n\n" + "SOLUTION:\n" + " export RUN_INTEGRATION_TESTS=1\n\n" + "Or run with:\n" + " RUN_INTEGRATION_TESTS=1 pytest tests/integration/ -m integration", + allow_module_level=True, + ) + # Check 1: GITLAB_TOKEN environment variable token = os.environ.get("GITLAB_TOKEN") if not token: From 35a00bb23dceeab8bb0ca0acd4e682f45d2d5c9f Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 15:14:44 -0600 Subject: [PATCH 27/36] glpkg/cli: Add shell completion installation for bash and zsh Users currently have no convenient way to enable tab completion for glpkg commands. Setting up shell completions manually requires understanding argcomplete internals and knowing the correct paths and configuration for each shell, creating unnecessary friction for new users. Add an --install-completion flag that generates and installs the appropriate completion script for bash or zsh. This leverages argcomplete to generate shell-specific scripts and places them in conventional locations (~/.bash_completion.d/ or ~/.zsh/completion/), then displays the necessary shell configuration commands. The approach follows established patterns used by other CLI tools and supports the two most common shells without requiring global argcomplete activation. Signed-off-by: Javier Tia --- src/glpkg/cli/__init__.py | 1 + src/glpkg/cli/completion.py | 139 ++++++++++++ src/glpkg/cli/main.py | 27 +++ tests/unit/test_completion.py | 403 ++++++++++++++++++++++++++++++++++ 4 files changed, 570 insertions(+) create mode 100644 src/glpkg/cli/completion.py create mode 100644 tests/unit/test_completion.py diff --git a/src/glpkg/cli/__init__.py b/src/glpkg/cli/__init__.py index 980e343..c364c7e 100644 --- a/src/glpkg/cli/__init__.py +++ b/src/glpkg/cli/__init__.py @@ -3,6 +3,7 @@ This package provides the command-line interface for glpkg, organized into: - main: Global argument parsing, logging setup, and subcommand routing - upload: Upload subcommand implementation +- completion: Shell completion installation for bash and zsh """ from glpkg.cli.main import main diff --git a/src/glpkg/cli/completion.py b/src/glpkg/cli/completion.py new file mode 100644 index 0000000..1bf365e --- /dev/null +++ b/src/glpkg/cli/completion.py @@ -0,0 +1,139 @@ +"""Shell completion installation for glpkg. + +This module provides functionality to generate and install shell completion +scripts for bash and zsh using argcomplete. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import argcomplete + +logger = logging.getLogger(__name__) + +SUPPORTED_SHELLS = ["bash", "zsh"] + +COMPLETION_PATHS = { + "bash": "~/.bash_completion.d/", + "zsh": "~/.zsh/completion/", +} + +COMPLETION_FILENAMES = { + "bash": "glpkg", + "zsh": "_glpkg", +} + + +def generate_completion_script(shell: str) -> str: + """Generate shell completion script for the specified shell. + + Args: + shell: The shell to generate completion for ('bash' or 'zsh'). + + Returns: + The generated completion script as a string. + + Raises: + ValueError: If the shell is not supported. + """ + if shell not in SUPPORTED_SHELLS: + raise ValueError( + f"Unsupported shell: {shell}. Supported shells: {', '.join(SUPPORTED_SHELLS)}" + ) + + return argcomplete.shellcode(["glpkg"], shell=shell) + + +def get_completion_path(shell: str) -> Path: + """Get the completion directory path for the specified shell. + + Args: + shell: The shell to get the completion path for ('bash' or 'zsh'). + + Returns: + The expanded absolute path to the completion directory. + + Raises: + ValueError: If the shell is not supported. + """ + if shell not in SUPPORTED_SHELLS: + raise ValueError( + f"Unsupported shell: {shell}. Supported shells: {', '.join(SUPPORTED_SHELLS)}" + ) + + return Path(COMPLETION_PATHS[shell]).expanduser() + + +def install_completion(shell: str) -> None: + """Install shell completion for the specified shell. + + Generates the completion script and writes it to the appropriate + completion directory. Creates the directory if it doesn't exist. + + Args: + shell: The shell to install completion for ('bash' or 'zsh'). + + Raises: + ValueError: If the shell is not supported. + PermissionError: If there are insufficient permissions to write the file. + OSError: If there are other file system errors. + """ + if shell not in SUPPORTED_SHELLS: + raise ValueError( + f"Unsupported shell: {shell}. Supported shells: {', '.join(SUPPORTED_SHELLS)}" + ) + + script = generate_completion_script(shell) + completion_dir = get_completion_path(shell) + filename = COMPLETION_FILENAMES[shell] + completion_file = completion_dir / filename + + try: + completion_dir.mkdir(parents=True, exist_ok=True) + except PermissionError as e: + raise PermissionError( + f"Cannot create directory {completion_dir}: Permission denied. " + f"Try running with appropriate permissions or create the directory manually." + ) from e + except OSError as e: + raise OSError( + f"Cannot create directory {completion_dir}: {e}. " + f"Please check the path and try again." + ) from e + + try: + completion_file.write_text(script) + completion_file.chmod(0o644) + except PermissionError as e: + raise PermissionError( + f"Cannot write to {completion_file}: Permission denied. " + f"Try running with appropriate permissions." + ) from e + except OSError as e: + raise OSError( + f"Cannot write to {completion_file}: {e}. " + f"Please check the path and try again." + ) from e + + logger.info(f"Installed {shell} completion to {completion_file}") + + # Print activation instructions + print(f"Shell completion for {shell} installed to: {completion_file}") + print() + if shell == "bash": + print("To activate completion, add the following to your ~/.bashrc:") + print(f" source {completion_file}") + print() + print("Then restart your shell or run:") + print(" source ~/.bashrc") + elif shell == "zsh": + print("To activate completion, ensure ~/.zsh/completion is in your fpath.") + print("Add the following to your ~/.zshrc (before compinit):") + print(" fpath=(~/.zsh/completion $fpath)") + print() + print("Then run:") + print(" autoload -Uz compinit && compinit") + print() + print("Or restart your shell.") diff --git a/src/glpkg/cli/main.py b/src/glpkg/cli/main.py index bae6849..9b99334 100644 --- a/src/glpkg/cli/main.py +++ b/src/glpkg/cli/main.py @@ -1,3 +1,4 @@ +# PYTHON_ARGCOMPLETE_OK """Main CLI entry point with subcommand routing for glpkg. This module provides the command-line interface framework for glpkg, @@ -265,6 +266,15 @@ def create_argument_parser() -> argparse.ArgumentParser: help="Display version number and exit", ) + # Shell completion installation + parser.add_argument( + "--install-completion", + type=str, + choices=["bash", "zsh"], + metavar="SHELL", + help="Install shell completion for the specified shell (bash or zsh)", + ) + # Create subparsers subparsers = parser.add_subparsers( title="commands", @@ -298,6 +308,23 @@ def main(argv: list[str] | None = None) -> None: # Parse arguments args = parser.parse_args(argv) + # Handle --install-completion before checking for subcommand + if args.install_completion: + from glpkg.cli.completion import install_completion + + try: + install_completion(args.install_completion) + sys.exit(0) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(3) # ConfigurationError + except PermissionError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(5) # FileValidationError + except OSError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(5) # FileValidationError + # If no subcommand is provided, show help and exit if args.command is None: parser.print_help() diff --git a/tests/unit/test_completion.py b/tests/unit/test_completion.py new file mode 100644 index 0000000..38712c1 --- /dev/null +++ b/tests/unit/test_completion.py @@ -0,0 +1,403 @@ +""" +Unit tests for the shell completion module. + +Tests cover completion script generation, path resolution, +installation functionality, and integration with the main CLI. + +Test Structure: + - TestGenerateCompletionScript: Tests for script generation + - TestGetCompletionPath: Tests for completion directory paths + - TestInstallCompletion: Tests for installation functionality + - TestMainIntegration: Tests for CLI integration + +Running Tests: + # Run all completion tests + pytest tests/unit/test_completion.py -v + + # Run specific test class + pytest tests/unit/test_completion.py::TestGenerateCompletionScript -v +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch, call + +import pytest + +from glpkg.cli.completion import ( + SUPPORTED_SHELLS, + COMPLETION_PATHS, + COMPLETION_FILENAMES, + generate_completion_script, + get_completion_path, + install_completion, +) +from glpkg.cli.main import create_argument_parser, main + +# Mark all tests as fast unit tests +pytestmark = [pytest.mark.unit, pytest.mark.fast] + + +# ============================================================================= +# Test Classes +# ============================================================================= + + +class TestGenerateCompletionScript: + """Tests for generate_completion_script function.""" + + @pytest.mark.timeout(60) + def test_bash_script_generation(self): + """Test bash completion script generation returns non-empty string.""" + script = generate_completion_script("bash") + assert isinstance(script, str) + assert len(script) > 0 + assert "glpkg" in script + + @pytest.mark.timeout(60) + def test_zsh_script_generation(self): + """Test zsh completion script generation returns non-empty string.""" + script = generate_completion_script("zsh") + assert isinstance(script, str) + assert len(script) > 0 + assert "glpkg" in script + + @pytest.mark.timeout(60) + def test_unsupported_shell_raises_error(self): + """Test unsupported shell raises ValueError.""" + with pytest.raises(ValueError) as exc_info: + generate_completion_script("fish") + assert "Unsupported shell" in str(exc_info.value) + assert "fish" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_empty_shell_raises_error(self): + """Test empty shell string raises ValueError.""" + with pytest.raises(ValueError) as exc_info: + generate_completion_script("") + assert "Unsupported shell" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.argcomplete.shellcode') + def test_shellcode_called_with_correct_args_bash(self, mock_shellcode): + """Test argcomplete.shellcode is called with correct arguments for bash.""" + mock_shellcode.return_value = "# bash completion script" + + generate_completion_script("bash") + + mock_shellcode.assert_called_once_with(["glpkg"], shell="bash") + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.argcomplete.shellcode') + def test_shellcode_called_with_correct_args_zsh(self, mock_shellcode): + """Test argcomplete.shellcode is called with correct arguments for zsh.""" + mock_shellcode.return_value = "# zsh completion script" + + generate_completion_script("zsh") + + mock_shellcode.assert_called_once_with(["glpkg"], shell="zsh") + + +class TestGetCompletionPath: + """Tests for get_completion_path function.""" + + @pytest.mark.timeout(60) + def test_bash_returns_correct_path(self): + """Test bash returns ~/.bash_completion.d/ expanded to absolute path.""" + path = get_completion_path("bash") + assert isinstance(path, Path) + assert path.is_absolute() + assert ".bash_completion.d" in str(path) + + @pytest.mark.timeout(60) + def test_zsh_returns_correct_path(self): + """Test zsh returns ~/.zsh/completion/ expanded to absolute path.""" + path = get_completion_path("zsh") + assert isinstance(path, Path) + assert path.is_absolute() + assert ".zsh" in str(path) + assert "completion" in str(path) + + @pytest.mark.timeout(60) + def test_unsupported_shell_raises_error(self): + """Test unsupported shell raises ValueError.""" + with pytest.raises(ValueError) as exc_info: + get_completion_path("fish") + assert "Unsupported shell" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_paths_are_path_objects(self): + """Test returned paths are Path objects.""" + for shell in SUPPORTED_SHELLS: + path = get_completion_path(shell) + assert isinstance(path, Path) + + @pytest.mark.timeout(60) + def test_paths_match_defined_constants(self): + """Test paths match the defined COMPLETION_PATHS constants.""" + for shell in SUPPORTED_SHELLS: + path = get_completion_path(shell) + expected = Path(COMPLETION_PATHS[shell]).expanduser() + assert path == expected + + +class TestInstallCompletion: + """Tests for install_completion function.""" + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.Path.chmod') + @patch('glpkg.cli.completion.Path.write_text') + @patch('glpkg.cli.completion.Path.mkdir') + @patch('glpkg.cli.completion.generate_completion_script') + def test_successful_installation_bash( + self, mock_generate, mock_mkdir, mock_write, mock_chmod, capsys + ): + """Test successful installation for bash.""" + mock_generate.return_value = "# bash completion script" + + install_completion("bash") + + mock_generate.assert_called_once_with("bash") + mock_mkdir.assert_called_once_with(parents=True, exist_ok=True) + mock_write.assert_called_once_with("# bash completion script") + mock_chmod.assert_called_once_with(0o644) + + captured = capsys.readouterr() + assert "bash" in captured.out + assert "installed" in captured.out.lower() + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.Path.chmod') + @patch('glpkg.cli.completion.Path.write_text') + @patch('glpkg.cli.completion.Path.mkdir') + @patch('glpkg.cli.completion.generate_completion_script') + def test_successful_installation_zsh( + self, mock_generate, mock_mkdir, mock_write, mock_chmod, capsys + ): + """Test successful installation for zsh.""" + mock_generate.return_value = "# zsh completion script" + + install_completion("zsh") + + mock_generate.assert_called_once_with("zsh") + mock_mkdir.assert_called_once_with(parents=True, exist_ok=True) + mock_write.assert_called_once_with("# zsh completion script") + mock_chmod.assert_called_once_with(0o644) + + captured = capsys.readouterr() + assert "zsh" in captured.out + assert "installed" in captured.out.lower() + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.Path.mkdir') + @patch('glpkg.cli.completion.generate_completion_script') + def test_directory_creation_permission_error(self, mock_generate, mock_mkdir): + """Test permission error during directory creation.""" + mock_generate.return_value = "# script" + mock_mkdir.side_effect = PermissionError("Access denied") + + with pytest.raises(PermissionError) as exc_info: + install_completion("bash") + assert "Permission denied" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.Path.mkdir') + @patch('glpkg.cli.completion.generate_completion_script') + def test_directory_creation_os_error(self, mock_generate, mock_mkdir): + """Test OS error during directory creation.""" + mock_generate.return_value = "# script" + mock_mkdir.side_effect = OSError("Disk error") + + with pytest.raises(OSError) as exc_info: + install_completion("bash") + assert "Disk error" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.Path.write_text') + @patch('glpkg.cli.completion.Path.mkdir') + @patch('glpkg.cli.completion.generate_completion_script') + def test_file_write_permission_error(self, mock_generate, mock_mkdir, mock_write): + """Test permission error during file write.""" + mock_generate.return_value = "# script" + mock_write.side_effect = PermissionError("Cannot write") + + with pytest.raises(PermissionError) as exc_info: + install_completion("bash") + assert "Permission denied" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.Path.write_text') + @patch('glpkg.cli.completion.Path.mkdir') + @patch('glpkg.cli.completion.generate_completion_script') + def test_file_write_os_error(self, mock_generate, mock_mkdir, mock_write): + """Test OS error during file write.""" + mock_generate.return_value = "# script" + mock_write.side_effect = OSError("No space left") + + with pytest.raises(OSError) as exc_info: + install_completion("bash") + assert "No space left" in str(exc_info.value) + + @pytest.mark.timeout(60) + def test_unsupported_shell_raises_error(self): + """Test unsupported shell raises ValueError.""" + with pytest.raises(ValueError) as exc_info: + install_completion("fish") + assert "Unsupported shell" in str(exc_info.value) + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.Path.chmod') + @patch('glpkg.cli.completion.Path.write_text') + @patch('glpkg.cli.completion.Path.mkdir') + @patch('glpkg.cli.completion.generate_completion_script') + def test_activation_instructions_bash( + self, mock_generate, mock_mkdir, mock_write, mock_chmod, capsys + ): + """Test activation instructions are printed for bash.""" + mock_generate.return_value = "# bash script" + + install_completion("bash") + + captured = capsys.readouterr() + assert "bashrc" in captured.out.lower() + assert "source" in captured.out + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.Path.chmod') + @patch('glpkg.cli.completion.Path.write_text') + @patch('glpkg.cli.completion.Path.mkdir') + @patch('glpkg.cli.completion.generate_completion_script') + def test_activation_instructions_zsh( + self, mock_generate, mock_mkdir, mock_write, mock_chmod, capsys + ): + """Test activation instructions are printed for zsh.""" + mock_generate.return_value = "# zsh script" + + install_completion("zsh") + + captured = capsys.readouterr() + assert "fpath" in captured.out + assert "compinit" in captured.out + + +class TestMainIntegration: + """Tests for integration with main CLI parser.""" + + @pytest.mark.timeout(60) + def test_parser_has_install_completion_argument(self): + """Test argument parser has --install-completion option.""" + parser = create_argument_parser() + args = parser.parse_args([]) + assert hasattr(args, 'install_completion') + + @pytest.mark.timeout(60) + def test_install_completion_bash_parsing(self): + """Test --install-completion bash argument parsing.""" + parser = create_argument_parser() + args = parser.parse_args(['--install-completion', 'bash']) + assert args.install_completion == 'bash' + + @pytest.mark.timeout(60) + def test_install_completion_zsh_parsing(self): + """Test --install-completion zsh argument parsing.""" + parser = create_argument_parser() + args = parser.parse_args(['--install-completion', 'zsh']) + assert args.install_completion == 'zsh' + + @pytest.mark.timeout(60) + def test_invalid_shell_shows_error(self): + """Test invalid shell value shows error.""" + parser = create_argument_parser() + with pytest.raises(SystemExit): + parser.parse_args(['--install-completion', 'fish']) + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.install_completion') + def test_main_calls_install_completion(self, mock_install): + """Test main function calls install_completion for --install-completion.""" + with pytest.raises(SystemExit) as exc_info: + main(['--install-completion', 'bash']) + + mock_install.assert_called_once_with('bash') + assert exc_info.value.code == 0 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.install_completion') + def test_main_handles_value_error(self, mock_install): + """Test main function handles ValueError with exit code 3.""" + mock_install.side_effect = ValueError("Unsupported shell") + + with pytest.raises(SystemExit) as exc_info: + main(['--install-completion', 'bash']) + + assert exc_info.value.code == 3 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.install_completion') + def test_main_handles_permission_error(self, mock_install): + """Test main function handles PermissionError with exit code 5.""" + mock_install.side_effect = PermissionError("Access denied") + + with pytest.raises(SystemExit) as exc_info: + main(['--install-completion', 'bash']) + + assert exc_info.value.code == 5 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.completion.install_completion') + def test_main_handles_os_error(self, mock_install): + """Test main function handles OSError with exit code 5.""" + mock_install.side_effect = OSError("Disk error") + + with pytest.raises(SystemExit) as exc_info: + main(['--install-completion', 'bash']) + + assert exc_info.value.code == 5 + + @pytest.mark.timeout(60) + def test_install_completion_before_subcommand(self): + """Test --install-completion is processed before subcommand requirement.""" + parser = create_argument_parser() + # Should not require a subcommand when --install-completion is used + args = parser.parse_args(['--install-completion', 'bash']) + assert args.install_completion == 'bash' + assert args.command is None + + +class TestConstants: + """Tests for module constants.""" + + @pytest.mark.timeout(60) + def test_supported_shells_contains_bash(self): + """Test SUPPORTED_SHELLS contains bash.""" + assert "bash" in SUPPORTED_SHELLS + + @pytest.mark.timeout(60) + def test_supported_shells_contains_zsh(self): + """Test SUPPORTED_SHELLS contains zsh.""" + assert "zsh" in SUPPORTED_SHELLS + + @pytest.mark.timeout(60) + def test_completion_paths_defined_for_all_shells(self): + """Test COMPLETION_PATHS has entries for all supported shells.""" + for shell in SUPPORTED_SHELLS: + assert shell in COMPLETION_PATHS + + @pytest.mark.timeout(60) + def test_completion_filenames_defined_for_all_shells(self): + """Test COMPLETION_FILENAMES has entries for all supported shells.""" + for shell in SUPPORTED_SHELLS: + assert shell in COMPLETION_FILENAMES + + @pytest.mark.timeout(60) + def test_bash_filename_is_glpkg(self): + """Test bash completion filename is 'glpkg'.""" + assert COMPLETION_FILENAMES["bash"] == "glpkg" + + @pytest.mark.timeout(60) + def test_zsh_filename_is_underscored(self): + """Test zsh completion filename follows convention with underscore.""" + assert COMPLETION_FILENAMES["zsh"] == "_glpkg" From 7cc38763f20270104572426754c9e2efebf61301 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 15:18:54 -0600 Subject: [PATCH 28/36] glpkg: Add universal binary (.pyz) build and release support Currently the package can only be distributed through PyPI as a wheel or source distribution. Users who want a single-file executable or who prefer not to use pip must either install from source or build manually, which complicates deployment in restricted environments and CI pipelines. Introduce a build script that generates self-contained .pyz archives using Shiv or PEX. These zipapp binaries bundle the package and all dependencies into a single executable file that runs on any system with a compatible Python interpreter. The CI workflow now builds and uploads this artifact alongside standard distributions during releases, giving users a portable installation option that requires no package manager. Signed-off-by: Javier Tia --- .github/workflows/publish.yml | 13 +++ .gitignore | 4 + pyproject.toml | 2 + scripts/build_pyz.sh | 183 ++++++++++++++++++++++++++++++ uv.lock | 203 ++++++++++++++++++++++++++++++++++ 5 files changed, 405 insertions(+) create mode 100755 scripts/build_pyz.sh diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cd82eee..6ac9f83 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,12 +23,25 @@ jobs: - name: Install build dependencies run: uv pip install build --system + - name: Install build tools for .pyz + run: uv pip install shiv --system + - name: Build package distributions run: python -m build + - name: Build .pyz universal binary + run: bash scripts/build_pyz.sh --tool shiv --output-dir dist + - name: List built artifacts run: ls -la dist/ + - name: Upload .pyz to release + if: github.event_name == 'release' + uses: softprops/action-gh-release@v1 + with: + files: dist/glpkg.pyz + fail_on_unmatched_files: true + - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: diff --git a/.gitignore b/.gitignore index a0b8d4f..56c1f27 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,10 @@ share/python-wheels/ *.egg MANIFEST +# Shiv/PEX universal binaries +*.pyz +*.pex + # PyInstaller *.manifest *.spec diff --git a/pyproject.toml b/pyproject.toml index eff937a..e79e9f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,8 +32,10 @@ dependencies = [ dev = [ "bump-my-version", "mypy", + "pex>=2.0.0", "pre-commit", "ruff", + "shiv>=1.0.0", ] test = [ "pytest", diff --git a/scripts/build_pyz.sh b/scripts/build_pyz.sh new file mode 100755 index 0000000..4cd7de6 --- /dev/null +++ b/scripts/build_pyz.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# +# Build script for creating .pyz universal binaries using Shiv or PEX. +# +# Usage: +# ./scripts/build_pyz.sh [OPTIONS] +# +# Options: +# --tool [shiv|pex|both] Build tool to use (default: shiv) +# --output-dir DIR Output directory (default: dist) +# --help Show this help message +# +# Examples: +# ./scripts/build_pyz.sh # Build with Shiv to dist/ +# ./scripts/build_pyz.sh --tool pex # Build with PEX +# ./scripts/build_pyz.sh --tool both # Build with both tools +# ./scripts/build_pyz.sh --output-dir build # Output to build/ +# +# Platform Compatibility Notes: +# - .pyz files are platform-independent for pure Python packages +# - Dependencies with C extensions (e.g., some cryptography libraries) +# may require platform-specific builds +# - Tested on Linux, should work on macOS and Windows with Python 3.11+ +# + +set -euo pipefail + +# Default values +TOOL="shiv" +OUTPUT_DIR="dist" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +usage() { + head -30 "$0" | tail -28 | sed 's/^# //' | sed 's/^#//' + exit 0 +} + +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --tool) + TOOL="$2" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="$2" + shift 2 + ;; + --help|-h) + usage + ;; + *) + log_error "Unknown option: $1" + usage + ;; + esac +done + +# Validate tool option +if [[ ! "$TOOL" =~ ^(shiv|pex|both)$ ]]; then + log_error "Invalid tool: $TOOL. Must be 'shiv', 'pex', or 'both'." + exit 1 +fi + +# Create output directory +mkdir -p "${PROJECT_ROOT}/${OUTPUT_DIR}" + +build_with_shiv() { + log_info "Building .pyz with Shiv..." + + # Determine how to run shiv (uv run for local dev, direct for CI) + local shiv_cmd="shiv" + if command -v uv &> /dev/null && [[ -f "${PROJECT_ROOT}/uv.lock" ]]; then + # Check if shiv is available directly first (CI environment) + if ! command -v shiv &> /dev/null; then + shiv_cmd="uv run shiv" + fi + elif ! command -v shiv &> /dev/null; then + log_error "shiv is not installed. Install with: uv pip install shiv" + exit 1 + fi + + local output_file="${PROJECT_ROOT}/${OUTPUT_DIR}/glpkg.pyz" + local temp_dir + temp_dir=$(mktemp -d) + + # Cleanup on exit + trap "rm -rf ${temp_dir}" EXIT + + log_info "Installing package to temporary directory..." + uv pip install "${PROJECT_ROOT}" --target "${temp_dir}" --quiet + + log_info "Creating .pyz archive..." + ${shiv_cmd} \ + --site-packages "${temp_dir}" \ + --compressed \ + --console-script glpkg \ + --output-file "${output_file}" \ + --python "/usr/bin/env python3" + + chmod +x "${output_file}" + + local size + size=$(du -h "${output_file}" | cut -f1) + log_info "Successfully built: ${output_file} (${size})" +} + +build_with_pex() { + log_info "Building .pex with PEX..." + + # Determine how to run pex (uv run for local dev, direct for CI) + local pex_cmd="pex" + if command -v uv &> /dev/null && [[ -f "${PROJECT_ROOT}/uv.lock" ]]; then + # Check if pex is available directly first (CI environment) + if ! command -v pex &> /dev/null; then + pex_cmd="uv run pex" + fi + elif ! command -v pex &> /dev/null; then + log_error "pex is not installed. Install with: uv pip install pex" + exit 1 + fi + + local output_file="${PROJECT_ROOT}/${OUTPUT_DIR}/glpkg.pex" + + log_info "Creating .pex archive..." + ${pex_cmd} \ + "${PROJECT_ROOT}" \ + --console-script glpkg \ + --output-file "${output_file}" + + chmod +x "${output_file}" + + local size + size=$(du -h "${output_file}" | cut -f1) + log_info "Successfully built: ${output_file} (${size})" +} + +# Main execution +cd "${PROJECT_ROOT}" + +log_info "Project root: ${PROJECT_ROOT}" +log_info "Output directory: ${OUTPUT_DIR}" +log_info "Build tool: ${TOOL}" + +case $TOOL in + shiv) + build_with_shiv + ;; + pex) + build_with_pex + ;; + both) + build_with_shiv + # Reset trap for second build + trap - EXIT + build_with_pex + ;; +esac + +log_info "Build complete!" +echo "" +echo "To test the built binary:" +echo " python ${OUTPUT_DIR}/glpkg.pyz --version" +echo " python ${OUTPUT_DIR}/glpkg.pyz --help" diff --git a/uv.lock b/uv.lock index de27540..55cd8d6 100644 --- a/uv.lock +++ b/uv.lock @@ -174,6 +174,98 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" }, + { url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" }, + { url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" }, + { url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" }, + { url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" }, + { url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, + { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, + { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, + { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -241,11 +333,14 @@ dependencies = [ dev = [ { name = "bump-my-version" }, { name = "mypy" }, + { name = "pex" }, { name = "pre-commit" }, { name = "ruff" }, + { name = "shiv" }, ] test = [ { name = "pytest" }, + { name = "pytest-cov" }, { name = "pytest-instafail" }, { name = "pytest-sugar" }, { name = "pytest-timeout" }, @@ -258,8 +353,10 @@ requires-dist = [ { name = "bump-my-version", marker = "extra == 'dev'" }, { name = "gitpython", specifier = ">=3.1.0" }, { name = "mypy", marker = "extra == 'dev'" }, + { name = "pex", marker = "extra == 'dev'", specifier = ">=2.0.0" }, { name = "pre-commit", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'test'" }, + { name = "pytest-cov", marker = "extra == 'test'" }, { name = "pytest-instafail", marker = "extra == 'test'" }, { name = "pytest-sugar", marker = "extra == 'test'" }, { name = "pytest-timeout", marker = "extra == 'test'" }, @@ -267,6 +364,7 @@ requires-dist = [ { name = "python-gitlab", specifier = ">=4.0.0" }, { name = "rich", specifier = ">=13.0.0" }, { name = "ruff", marker = "extra == 'dev'" }, + { name = "shiv", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "tenacity", specifier = ">=8.0.0" }, ] provides-extras = ["dev", "test"] @@ -494,6 +592,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" }, ] +[[package]] +name = "pex" +version = "2.77.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/48/02dc102a955fd46b5098fb3a7854a8ce2b1d3612d7c4354eddc50f75bb43/pex-2.77.3.tar.gz", hash = "sha256:f4e0d6f561570d27949bb0c9a268545129025a0544b6bba6335a45b9e272dc4d", size = 5224006, upload-time = "2026-01-09T14:32:13.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/b8/dddaef21fabf62e109025f3122003ff7a3360d612bcbc623b40e4b1ac0de/pex-2.77.3-py2.py35.py36.py37.py38.py39.py310.py311-none-any.whl", hash = "sha256:de77352f03ef95b776e973926e4ce0c9f549c35a6a22bf2b6bf9158c0f9504a7", size = 3930903, upload-time = "2026-01-09T14:32:08.84Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7f/03a8e20c247647db23cd25c04207a3518fe485376043bc24ba549f0be32a/pex-2.77.3-py3.py312-none-any.whl", hash = "sha256:0e2654cf9d27e9489270c71dbc1963cd86cfa4bcee756283d7670171b7a64b41", size = 1731309, upload-time = "2026-01-09T14:32:11.166Z" }, +] + +[[package]] +name = "pip" +version = "25.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/6e/74a3f0179a4a73a53d66ce57fdb4de0080a8baa1de0063de206d6167acc2/pip-25.3.tar.gz", hash = "sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343", size = 1803014, upload-time = "2025-10-25T00:55:41.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl", hash = "sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd", size = 1778622, upload-time = "2025-10-25T00:55:39.247Z" }, +] + [[package]] name = "platformdirs" version = "4.5.1" @@ -691,6 +808,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + [[package]] name = "pytest-instafail" version = "0.5.0" @@ -910,6 +1041,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/1c/1dbe51782c0e1e9cfce1d1004752672d2d4629ea46945d19d731ad772b3b/ruff-0.14.11-py3-none-win_arm64.whl", hash = "sha256:649fb6c9edd7f751db276ef42df1f3df41c38d67d199570ae2a7bd6cbc3590f0", size = 12938644, upload-time = "2026-01-08T19:11:50.027Z" }, ] +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[package]] +name = "shiv" +version = "1.0.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "pip" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/85/004e7123b4821c64be6d9bfed27f63147b4dde929a1cd848f137befeada4/shiv-1.0.8.tar.gz", hash = "sha256:2a68d69e98ce81cb5b8fdafbfc1e27efa93e6d89ca14bfae33482e4176f561d6", size = 32806, upload-time = "2024-11-01T19:47:46.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ec/afbb46f7c1ab071a50d92424daf149420ca1f0e02dc51239485747151d6c/shiv-1.0.8-py2.py3-none-any.whl", hash = "sha256:a60e4b05a2d2f8b820d567b1d89ee59af731759771c32c282d03c4ceae6aba24", size = 20516, upload-time = "2024-11-01T19:47:45.061Z" }, +] + [[package]] name = "smmap" version = "5.0.2" @@ -937,6 +1091,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, ] +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + [[package]] name = "tomlkit" version = "0.13.3" From 3a7bb5c6889eb0acf1203e9935f2ad8a7af1db06 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 15:23:08 -0600 Subject: [PATCH 29/36] ci: Expand documentation workflow to cover new docs The documentation CI workflow only validated README.md and tests/README.md, leaving newly added documentation files unchecked. This creates a gap where broken links or markdown issues in contribution guides, release procedures, and shell completion documentation would go undetected until users encounter them. Extend the markdown linting and link checking steps to include CONTRIBUTING.md, docs/RELEASING.md, and docs/SHELL_COMPLETION.md. This ensures all user-facing documentation maintains consistent quality and link validity through automated verification. Signed-off-by: Javier Tia --- .github/workflows/docs.yml | 14 ++- CONTRIBUTING.md | 149 ++++++++++++++++++++++++++++++ README.md | 182 ++++++++++--------------------------- docs/RELEASING.md | 161 ++++++++++++++++++++++++++++++++ docs/SHELL_COMPLETION.md | 172 +++++++++++++++++++++++++++++++++++ 5 files changed, 542 insertions(+), 136 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/RELEASING.md create mode 100644 docs/SHELL_COMPLETION.md diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 49dcfd5..1b0fa00 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -29,13 +29,25 @@ jobs: run: npm install -g markdown-link-check - name: Lint markdown files - run: markdownlint-cli2 "README.md" "tests/README.md" + run: markdownlint-cli2 "README.md" "CONTRIBUTING.md" "tests/README.md" "docs/**/*.md" continue-on-error: false - name: Check links in README.md run: markdown-link-check README.md continue-on-error: false + - name: Check links in CONTRIBUTING.md + run: markdown-link-check CONTRIBUTING.md + continue-on-error: false + - name: Check links in tests/README.md run: markdown-link-check tests/README.md continue-on-error: false + + - name: Check links in docs/RELEASING.md + run: markdown-link-check docs/RELEASING.md + continue-on-error: false + + - name: Check links in docs/SHELL_COMPLETION.md + run: markdown-link-check docs/SHELL_COMPLETION.md + continue-on-error: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..64c8d39 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,149 @@ +# Contributing to glpkg + +Thank you for your interest in contributing to glpkg! This document provides guidelines and instructions for development. + +## Getting Started + +### Prerequisites + +- Python 3.11 or higher +- uv installed (`curl -LsSf https://astral.sh/uv/install.sh | sh`) + +### Development Setup + +```bash +# Clone the repository +git clone https://github.com/your-org/glpkg.git +cd glpkg + +# Install all dependencies (including dev and test extras) +uv sync --all-extras +``` + +## Development Workflow + +1. Install pre-commit hooks: + + ```bash + uv run pre-commit install + ``` + +2. Create a feature branch: + + ```bash + git checkout -b feature/your-feature-name + ``` + +3. Make your changes +4. Run tests locally before committing +5. Commit your changes (pre-commit hooks run automatically) +6. Push and create a pull request + +## Running Tests + +### Unit Tests + +Fast tests that don't require external dependencies: + +```bash +uv run pytest tests/unit/ +``` + +### Integration Tests + +Require a GitLab token and opt-in via environment variable: + +```bash +export RUN_INTEGRATION_TESTS=1 +export GITLAB_TOKEN="your-token" +uv run pytest tests/integration/ -m integration +``` + +### All Tests with Coverage + +```bash +uv run pytest tests/ +``` + +### Parallel Execution + +Speed up test runs with parallel execution: + +```bash +uv run pytest tests/ -n auto +``` + +For detailed testing documentation, see [tests/README.md](tests/README.md). + +## Code Quality Checks + +Pre-commit hooks run automatically on every commit. To run checks manually: + +### Linting + +```bash +uv run ruff check src/ +``` + +### Type Checking + +```bash +uv run mypy src/ +``` + +### Formatting + +```bash +uv run ruff format src/ +``` + +### Run All Pre-commit Hooks + +```bash +uv run pre-commit run --all-files +``` + +## Coverage Requirements + +- **Target coverage**: 95% (warning threshold in CI) +- **Minimum coverage**: 90% (tests fail below this threshold) + +View coverage report locally: + +```bash +uv run pytest tests/unit/ --cov=glpkg --cov-report=html +open htmlcov/index.html # or xdg-open on Linux +``` + +## Pull Request Process + +1. Create a feature branch from `main` +2. Make changes with clear, descriptive commits +3. Ensure all tests pass and coverage meets requirements +4. Ensure pre-commit hooks pass +5. Push your branch and create a pull request +6. Address review feedback +7. Squash commits if requested by maintainers + +## Code Style Guidelines + +- Follow PEP 8 conventions (enforced by ruff) +- Use type hints (checked by mypy in strict mode) +- Write descriptive docstrings for public APIs +- Keep functions focused and testable +- Avoid over-engineering; keep solutions simple + +## Adding New Features + +When adding new features: + +1. Add unit tests in `tests/unit/` +2. Add integration tests in `tests/integration/` if the feature interacts with external services +3. Update documentation in README.md or relevant docs +4. Add appropriate pytest markers: + - `@pytest.mark.unit` for unit tests + - `@pytest.mark.integration` for integration tests + +## Questions? + +If you have questions about contributing, please open an issue on GitHub. diff --git a/README.md b/README.md index 69813f6..d765581 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,51 @@ # glpkg +![Coverage](https://img.shields.io/badge/coverage-95%25-brightgreen) + A CLI tool for uploading files to GitLab's Generic Package Registry. ## Installation -### Using uv (Recommended) +### From PyPI (Recommended) ```bash -# Install in development mode -uv pip install -e . +# Using uv (recommended) +uv pip install glpkg -# Or run directly without installing -uv run glpkg --help +# Or using pip +pip install glpkg +``` + +### Universal Binary (.pyz) + +Download the pre-built universal binary from GitHub releases: + +```bash +# Download the latest release +curl -L -o glpkg.pyz https://github.com/your-org/glpkg/releases/latest/download/glpkg.pyz + +# Make it executable +chmod +x glpkg.pyz + +# Run directly +./glpkg.pyz --help + +# Or run with Python +python glpkg.pyz --help ``` -### Using pip +### Development Installation ```bash -pip install -e . +# Clone the repository +git clone https://github.com/your-org/glpkg.git +cd glpkg + +# Install in development mode with uv +uv pip install -e . + +# Or run directly without installing +uv run glpkg --help ``` ## Usage @@ -60,151 +88,35 @@ glpkg --json-output upload --package-name my-package --package-version 1.0.0 --f ### Token Permissions Your GitLab token requires: + - `api` scope for full API access - Write access to the target project's Package Registry ## Development -### Setup +For detailed contribution guidelines, see [CONTRIBUTING.md](CONTRIBUTING.md). + +### Quick Start ```bash -# Clone the repository -git clone https://gitlab.com/your-namespace/glpkg.git +# Clone and install dependencies +git clone https://github.com/your-org/glpkg.git cd glpkg - -# Install with development dependencies uv sync --all-extras -# Or using pip -pip install -e ".[dev,test]" -``` - -### Pre-commit Hooks - -Pre-commit hooks automate code quality checks locally before each commit, ensuring consistency with the CI/CD pipeline. - -```bash # Install pre-commit hooks uv run pre-commit install -# Or using pip -pre-commit install -``` - -Once installed, hooks run automatically on `git commit`. You can also run them manually: - -```bash -# Run all hooks on all files -uv run pre-commit run --all-files - -# Update hook versions -uv run pre-commit autoupdate -``` - -The configured hooks include: -- **Ruff**: Linting and code formatting -- **Mypy**: Static type checking with strict mode -- **File maintenance**: Trailing whitespace removal, end-of-file fixes, YAML/TOML validation - -### Running Tests - -```bash -# Install the package in development mode first -uv pip install -e . - -# Run all tests -uv run pytest tests/ - -# Run only unit tests (fast, no external dependencies) +# Run tests uv run pytest tests/unit/ - -# Run integration tests (requires GITLAB_TOKEN) -export GITLAB_TOKEN="your-token" -uv run pytest tests/integration/ -m integration - -# Run with parallel execution -uv run pytest tests/ -n auto - -# Run with verbose output -uv run pytest tests/ -v -``` - -See [tests/README.md](tests/README.md) for detailed testing documentation. - -### Code Quality - -Pre-commit hooks automate these checks on every commit. See [Pre-commit Hooks](#pre-commit-hooks) for setup. - -To run checks manually: - -```bash -# Run linter -uv run ruff check src/ - -# Run type checker -uv run mypy src/ - -# Format code -uv run ruff format src/ -``` - -### Versioning - -This project uses [semantic versioning](https://semver.org/) (major.minor.patch) with [bump-my-version](https://github.com/callowayproject/bump-my-version) for automated version management. - -```bash -# Install dev dependencies (includes bump-my-version) -uv pip install -e ".[dev]" - -# Bump patch version (bug fixes): 0.1.0 → 0.1.1 -uv run bump-my-version bump patch - -# Bump minor version (new features): 0.1.0 → 0.2.0 -uv run bump-my-version bump minor - -# Bump major version (breaking changes): 0.1.0 → 1.0.0 -uv run bump-my-version bump major ``` -Running `bump-my-version bump` automatically: -- Updates the version in `pyproject.toml` and `src/glpkg/__init__.py` -- Creates a git commit with the version change -- Creates a git tag (format: `v1.2.3`) - -#### Release Workflow - -```bash -# 1. Bump version (e.g., patch for bug fix) -uv run bump-my-version bump patch - -# 2. Push changes and tags -git push && git push --tags +### Documentation -# 3. Create GitHub release at https://github.com/your-org/glpkg/releases/new -# 4. PyPI publication happens automatically via GitHub Actions -``` - -To create a GitHub release: -1. Navigate to the repository's Releases page -2. Click "Create a new release" -3. Select the version tag created by bump-my-version -4. Add release notes describing changes -5. Publish the release - -Publishing a GitHub release automatically triggers the `.github/workflows/publish.yml` workflow to publish to PyPI. - -#### Verification - -```bash -# Check that version numbers match in both files -grep -r "0.1.0" pyproject.toml src/glpkg/__init__.py - -# Test bump-my-version dry run -uv run bump-my-version bump patch --dry-run --verbose - -# Verify git tags -git tag -l -``` +- [CONTRIBUTING.md](CONTRIBUTING.md) - Development setup and contribution guidelines +- [docs/SHELL_COMPLETION.md](docs/SHELL_COMPLETION.md) - Shell completion setup for bash and zsh +- [docs/RELEASING.md](docs/RELEASING.md) - Release procedures and publishing workflow +- [tests/README.md](tests/README.md) - Detailed testing documentation ## Project Structure diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..71958f2 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,161 @@ +# Release Procedures + +This document describes the release workflow for glpkg. + +## Overview + +- Project uses [semantic versioning](https://semver.org/) (major.minor.patch) +- Automated version management with [bump-my-version](https://github.com/callowayproject/bump-my-version) +- PyPI publishing via GitHub Actions on release creation +- Universal .pyz binaries built and attached to GitHub releases + +## Creating a Release + +### 1. Bump Version + +Use `bump-my-version` based on the type of changes: + +```bash +# Bug fixes: 0.1.0 → 0.1.1 +uv run bump-my-version bump patch + +# New features: 0.1.0 → 0.2.0 +uv run bump-my-version bump minor + +# Breaking changes: 0.1.0 → 1.0.0 +uv run bump-my-version bump major +``` + +### 2. Verify Changes + +Confirm the version was updated correctly: + +```bash +# Check version in both files +grep -r "version" pyproject.toml src/glpkg/__init__.py | head -5 + +# Verify git commit and tag were created +git log -1 --oneline +git tag -l | tail -3 +``` + +### 3. Push Changes + +Push the commit and tag to GitHub: + +```bash +git push && git push --tags +``` + +### 4. Create GitHub Release + +1. Navigate to the repository's Releases page +2. Click "Create a new release" +3. Select the version tag (e.g., `v0.2.0`) +4. Add release notes describing the changes +5. Click "Publish release" + +### 5. Automated Publishing + +The GitHub Actions workflow (`.github/workflows/publish.yml`) automatically: + +- Builds and publishes the package to PyPI +- Builds the .pyz universal binary +- Attaches the binary to the GitHub release + +## Building .pyz Locally + +For testing or local distribution, you can build the universal binary locally: + +```bash +# Build with Shiv (recommended) +./scripts/build_pyz.sh --tool shiv + +# Build with PEX +./scripts/build_pyz.sh --tool pex + +# Build with both tools +./scripts/build_pyz.sh --tool both + +# Test the binary +python dist/glpkg.pyz --version +``` + +See `scripts/build_pyz.sh` for build script details. + +## Verification Steps + +After publishing a release: + +1. **Check PyPI**: Visit `https://pypi.org/project/glpkg/` + +2. **Test PyPI installation**: + + ```bash + uv pip install glpkg== + glpkg --version + ``` + +3. **Test .pyz binary**: + + ```bash + # Download from GitHub release + curl -L -o glpkg.pyz https://github.com/your-org/glpkg/releases/download/v/glpkg.pyz + python glpkg.pyz --version + ``` + +## Troubleshooting + +### PyPI Publish Fails + +- Verify `PYPI_API_TOKEN` secret is configured in GitHub repository settings +- Check that trusted publishing is configured on PyPI for this repository +- Review the GitHub Actions logs for specific error messages + +### .pyz Build Fails + +- Ensure `shiv` or `pex` is installed: `uv pip install shiv pex` +- Check build logs in GitHub Actions for dependency issues +- Try building locally to reproduce the issue + +### Version Mismatch + +Preview changes before bumping: + +```bash +uv run bump-my-version bump --dry-run --verbose patch +``` + +### Tag Already Exists + +If you need to recreate a tag: + +```bash +# Delete local tag +git tag -d v + +# Delete remote tag +git push origin :refs/tags/v + +# Re-run bump-my-version or create tag manually +git tag v +git push --tags +``` + +## Release Checklist + +Before creating a release, verify: + +- [ ] All tests passing on main branch +- [ ] Coverage meets 90% minimum threshold +- [ ] CHANGELOG or release notes prepared +- [ ] Version bumped with bump-my-version +- [ ] Changes and tags pushed to GitHub +- [ ] GitHub release created with release notes + +After release: + +- [ ] PyPI package published successfully +- [ ] .pyz binary attached to release +- [ ] Installation verified from PyPI +- [ ] .pyz binary verified diff --git a/docs/SHELL_COMPLETION.md b/docs/SHELL_COMPLETION.md new file mode 100644 index 0000000..40f35b9 --- /dev/null +++ b/docs/SHELL_COMPLETION.md @@ -0,0 +1,172 @@ +# Shell Completion Setup + +glpkg supports shell completion for bash and zsh, providing tab-completion for commands, options, and arguments. + +## Overview + +- Completion powered by [argcomplete](https://github.com/kislyuk/argcomplete) +- Supports bash and zsh shells +- Provides tab-completion for subcommands, flags, and option values + +## Installation + +### Automatic Installation (Recommended) + +Use the built-in completion installer: + +```bash +# For Bash +glpkg --install-completion bash + +# For Zsh +glpkg --install-completion zsh +``` + +Follow the activation instructions printed after installation. + +### Manual Installation + +#### Bash + +1. Generate the completion script: + + ```bash + glpkg --install-completion bash + ``` + + Or manually create the file at `~/.bash_completion.d/glpkg` + +2. Add to your `~/.bashrc`: + + ```bash + source ~/.bash_completion.d/glpkg + ``` + +3. Reload your shell: + + ```bash + source ~/.bashrc + ``` + + Or restart your terminal. + +#### Zsh + +1. Generate the completion script: + + ```bash + glpkg --install-completion zsh + ``` + + Or manually create the file at `~/.zsh/completion/_glpkg` + +2. Add to your `~/.zshrc` (before `compinit`): + + ```zsh + fpath=(~/.zsh/completion $fpath) + ``` + +3. Reload completions: + + ```zsh + autoload -Uz compinit && compinit + ``` + + Or restart your terminal. + +## Usage + +Once installed, use Tab to complete commands and options: + +```bash +# Show available commands +glpkg + +# Show options for upload command +glpkg upload -- + +# Complete long option names +glpkg upload --pack # completes to --package-name +``` + +## Verification + +Test that completion is working: + +1. Open a new terminal or reload your shell +2. Type `glpkg ` and press Tab twice +3. You should see available commands and options + +If completion works, you'll see suggestions like: + +``` +upload --help --verbose --json-output +``` + +## Troubleshooting + +### Completion Not Working + +1. **Verify installation**: Check that the completion script exists + + ```bash + # Bash + cat ~/.bash_completion.d/glpkg + + # Zsh + cat ~/.zsh/completion/_glpkg + ``` + +2. **Verify shell configuration**: Ensure your shell config sources the completion + + ```bash + # Check bashrc + grep -n "bash_completion" ~/.bashrc + + # Check zshrc + grep -n "completion" ~/.zshrc + ``` + +3. **Restart shell**: Close and reopen your terminal, or source your config file + +### Permission Denied + +If you encounter permission errors during installation: + +```bash +# Create directory with appropriate permissions +mkdir -p ~/.bash_completion.d +chmod 755 ~/.bash_completion.d + +# Or for zsh +mkdir -p ~/.zsh/completion +chmod 755 ~/.zsh/completion +``` + +Then re-run the installation command. + +### Zsh Completion Not Loading + +Ensure `fpath` is updated before `compinit` in your `.zshrc`: + +```zsh +# This must come BEFORE compinit +fpath=(~/.zsh/completion $fpath) + +# Then initialize completions +autoload -Uz compinit && compinit +``` + +If you've modified your `.zshrc`, rebuild the completion cache: + +```zsh +rm -f ~/.zcompdump +compinit +``` + +## Technical Details + +- **Implementation**: `src/glpkg/cli/completion.py` +- **Bash completion path**: `~/.bash_completion.d/glpkg` +- **Zsh completion path**: `~/.zsh/completion/_glpkg` +- **Library**: argcomplete for completion generation From 40a4c26b70d3d1ddec874f1885de415331128829 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 16:19:14 -0600 Subject: [PATCH 30/36] glpkg: Rename package to glpkg-cli for PyPI availability The original package name "glpkg" may conflict with existing packages or reserved names on PyPI, preventing publication to the public registry. A unique, descriptive name ensures the package can be published and discovered by users searching for GitLab package registry tools. Rename the distribution package to "glpkg-cli" while preserving the "glpkg" command name for end users. This follows the common convention of using a "-cli" suffix for command-line tool distributions, avoiding namespace conflicts while maintaining a clean user experience. The documentation is updated to reflect the new installation commands and includes additional guidance for manual releases and binary installation. Signed-off-by: Javier Tia --- README.md | 31 +++++++++++++-- docs/RELEASING.md | 97 ++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 5 +-- uv.lock | 2 +- 4 files changed, 126 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d765581..f28e06f 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,25 @@ A CLI tool for uploading files to GitLab's Generic Package Registry. ```bash # Using uv (recommended) -uv pip install glpkg +uv pip install glpkg-cli # Or using pip -pip install glpkg +pip install glpkg-cli +``` + +After installation, the `glpkg` command is available in your PATH: + +```bash +# Verify installation +glpkg --version + +# View available commands +glpkg --help ``` ### Universal Binary (.pyz) -Download the pre-built universal binary from GitHub releases: +Download the pre-built universal binary from GitHub releases. This is a self-contained executable that requires no installation - just Python 3.11+. ```bash # Download the latest release @@ -34,6 +44,21 @@ chmod +x glpkg.pyz python glpkg.pyz --help ``` +Optionally, install the binary to a location in your PATH for easier access: + +```bash +# Install to ~/.local/bin (user-local) +mv glpkg.pyz ~/.local/bin/glpkg +chmod +x ~/.local/bin/glpkg + +# Or install system-wide (requires sudo) +sudo mv glpkg.pyz /usr/local/bin/glpkg +sudo chmod +x /usr/local/bin/glpkg + +# Now use it like a regular command +glpkg --help +``` + ### Development Installation ```bash diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 71958f2..089f659 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -83,16 +83,109 @@ python dist/glpkg.pyz --version See `scripts/build_pyz.sh` for build script details. +## Manual Release (Without GitHub Actions) + +If you need to publish a release manually without relying on GitHub Actions: + +### 1. Get the Current Version + +```bash +# Extract version from pyproject.toml +VERSION=$(grep -m1 'version = ' pyproject.toml | cut -d'"' -f2) +echo "Version: ${VERSION}" + +# Or from Python +VERSION=$(uv run python -c "import glpkg; print(glpkg.__version__)") +echo "Version: ${VERSION}" +``` + +### 2. Build the Package + +```bash +# Install build tool if needed +uv pip install build + +# Build source distribution and wheel +uv run python -m build + +# Verify build artifacts +ls dist/ +# Should show: glpkg_cli-${VERSION}.tar.gz and glpkg_cli-${VERSION}-py3-none-any.whl +``` + +### 3. Publish to PyPI + +PyPI requires API token authentication (username/password is no longer supported). + +**Get an API token:** + +1. Log in to [PyPI](https://pypi.org/manage/account/) +2. Go to Account Settings → API tokens +3. Create a new token (scope: "Entire account" or project-specific) +4. Copy the token (starts with `pypi-`) + +**Upload with the token:** + +```bash +# Install twine if not already installed +uv pip install twine + +# Upload to PyPI using API token +TWINE_USERNAME=__token__ TWINE_PASSWORD=pypi- uv run twine upload dist/glpkg_cli-${VERSION}* +``` + +Alternatively, configure credentials in `~/.pypirc`: + +```ini +[pypi] +username = __token__ +password = pypi- +``` + +Then upload without environment variables: + +```bash +uv run twine upload dist/glpkg_cli-${VERSION}* +``` + +For more information, see: + +- [API Tokens](https://pypi.org/help/#apitoken) - Create a token for manual uploads +- [Trusted Publishers](https://pypi.org/help/#trusted-publishers) - Configure GitHub Actions for automated publishing + +### 4. Build and Upload .pyz Binary + +```bash +# Build the .pyz binary +./scripts/build_pyz.sh --tool shiv + +# Verify the binary works +python dist/glpkg.pyz --version +``` + +Upload the .pyz binary to the GitHub release: + +```bash +# Using GitHub CLI (gh) +gh release upload v${VERSION} dist/glpkg.pyz + +# Or manually via GitHub web interface: +# 1. Go to https://github.com/your-org/glpkg/releases/tag/v${VERSION} +# 2. Click "Edit release" +# 3. Drag and drop dist/glpkg.pyz into the "Attach binaries" area +# 4. Click "Update release" +``` + ## Verification Steps After publishing a release: -1. **Check PyPI**: Visit `https://pypi.org/project/glpkg/` +1. **Check PyPI**: Visit `https://pypi.org/project/glpkg-cli/` 2. **Test PyPI installation**: ```bash - uv pip install glpkg== + uv pip install glpkg-cli== glpkg --version ``` diff --git a/pyproject.toml b/pyproject.toml index e79e9f2..ede4806 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,18 +3,17 @@ requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "glpkg" +name = "glpkg-cli" version = "0.1.0" description = "A CLI tool for uploading files to GitLab's Generic Package Registry" authors = [{name = "Javier Tia"}] -license = {text = "MIT"} +license = "MIT" requires-python = ">=3.11" readme = "README.md" keywords = ["gitlab", "package", "upload", "cli", "generic-package-registry"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", diff --git a/uv.lock b/uv.lock index 55cd8d6..d16c3fe 100644 --- a/uv.lock +++ b/uv.lock @@ -318,7 +318,7 @@ wheels = [ ] [[package]] -name = "glpkg" +name = "glpkg-cli" version = "0.1.0" source = { editable = "." } dependencies = [ From af288be6caa6ae529d1cef11ccd15161fee9689f Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 16:21:59 -0600 Subject: [PATCH 31/36] docs: Add GitHub Actions workflow documentation The project lacks centralized documentation for its CI/CD pipeline, requiring developers to read through individual workflow YAML files to understand the build and deployment process. This makes onboarding harder and increases the risk of misconfiguring secrets or triggering workflows incorrectly. Add comprehensive workflow documentation covering all four GitHub Actions workflows (test, lint, publish, docs), including their triggers, required secrets, debugging tips, and future improvement recommendations. Include status badges in the README to provide at-a-glance pipeline health visibility, and add the new documentation file to the docs workflow link checker for validation. Signed-off-by: Javier Tia --- .github/workflows/docs.yml | 4 + README.md | 5 + docs/WORKFLOWS.md | 225 +++++++++++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 docs/WORKFLOWS.md diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1b0fa00..e74aea4 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -51,3 +51,7 @@ jobs: - name: Check links in docs/SHELL_COMPLETION.md run: markdown-link-check docs/SHELL_COMPLETION.md continue-on-error: false + + - name: Check links in docs/WORKFLOWS.md + run: markdown-link-check docs/WORKFLOWS.md + continue-on-error: false diff --git a/README.md b/README.md index f28e06f..68fd23f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # glpkg +![Tests](https://github.com/OWNER/REPO/actions/workflows/test.yml/badge.svg) +![Lint](https://github.com/OWNER/REPO/actions/workflows/lint.yml/badge.svg) +![Publish](https://github.com/OWNER/REPO/actions/workflows/publish.yml/badge.svg) +![Docs](https://github.com/OWNER/REPO/actions/workflows/docs.yml/badge.svg) ![Coverage](https://img.shields.io/badge/coverage-95%25-brightgreen) A CLI tool for uploading files to GitLab's Generic Package Registry. @@ -141,6 +145,7 @@ uv run pytest tests/unit/ - [CONTRIBUTING.md](CONTRIBUTING.md) - Development setup and contribution guidelines - [docs/SHELL_COMPLETION.md](docs/SHELL_COMPLETION.md) - Shell completion setup for bash and zsh - [docs/RELEASING.md](docs/RELEASING.md) - Release procedures and publishing workflow +- [docs/WORKFLOWS.md](docs/WORKFLOWS.md) - GitHub Actions workflows and CI/CD pipeline - [tests/README.md](tests/README.md) - Detailed testing documentation ## Project Structure diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md new file mode 100644 index 0000000..cee3718 --- /dev/null +++ b/docs/WORKFLOWS.md @@ -0,0 +1,225 @@ +# GitHub Workflows Documentation + +This document provides comprehensive documentation for all GitHub Actions workflows used in the glpkg project. These workflows form the CI/CD pipeline that ensures code quality, runs tests, validates documentation, and handles package publishing. + +## Table of Contents + +- [Test Workflow](#test-workflow) +- [Lint Workflow](#lint-workflow) +- [Publish Workflow](#publish-workflow) +- [Documentation Workflow](#documentation-workflow) +- [Workflow Status Badges](#workflow-status-badges) +- [Common Debugging Steps](#common-debugging-steps) +- [Future Improvements](#future-improvements) + +## Test Workflow + +**File:** `.github/workflows/test.yml` + +### Purpose + +Runs unit and integration tests across Python 3.11-3.13 to ensure code correctness and maintain test coverage standards. + +### Triggers + +- Push to any branch +- Pull requests to `main` or `master` +- Manual dispatch via `workflow_dispatch` + +### Key Features + +- **Matrix testing** across Python versions 3.11, 3.12, and 3.13 +- **Unit tests** with pytest-cov providing coverage reporting +- **Coverage thresholds**: 95% warning level, 90% fail threshold +- **Integration tests** run conditionally when `GITLAB_TOKEN` and `GITLAB_REPO` secrets are configured +- **Coverage report upload** for Python 3.11 runs +- Uses `uv` for fast, reliable dependency management + +### Required Secrets + +| Secret | Required | Description | +|--------|----------|-------------| +| `GITLAB_TOKEN` | Optional | GitLab API token for integration tests | +| `GITLAB_REPO` | Optional | GitLab repository path for integration tests | + +Integration tests are skipped if these secrets are not configured. + +### Artifacts + +- `coverage.xml` - XML coverage report for CI integrations +- `htmlcov/` - HTML coverage report for detailed inspection +- `pytest-results` - Test result files + +### Debugging Tips + +- **Coverage threshold failures**: Check the "Check coverage threshold" step output for specific coverage percentages +- **Integration tests skipped**: Verify `GITLAB_TOKEN` and `GITLAB_REPO` secrets are configured in repository settings +- **Manual test runs**: Use `workflow_dispatch` from the Actions tab for on-demand testing +- **Detailed coverage**: Download the `htmlcov` artifact to see line-by-line coverage +- **Local reproduction**: Run `uv run pytest tests/unit/ --cov=src/glpkg --cov-report=term-missing` + +## Lint Workflow + +**File:** `.github/workflows/lint.yml` + +### Purpose + +Performs code quality checks using ruff for linting/formatting and mypy for type checking to maintain consistent code standards. + +### Triggers + +- Push to any branch +- Pull requests to `main` or `master` +- Manual dispatch via `workflow_dispatch` + +### Key Features + +- **Ruff linting**: `ruff check src/` for code style and error detection +- **Ruff formatting**: `ruff format --check src/` for consistent code formatting +- **Mypy type checking**: Strict mode (`--strict`) for comprehensive type safety +- Uses Python 3.11 and `uv` for consistency + +### Required Secrets + +None + +### Debugging Tips + +- **Ruff lint errors**: Run locally with `uv run ruff check src/ --fix` to auto-fix issues +- **Ruff format errors**: Run `uv run ruff format src/` to auto-format code +- **Mypy errors**: Run `uv run mypy src/glpkg/ --strict` locally to see detailed type errors +- **Configuration**: Check `pyproject.toml` for ruff and mypy configuration options +- **Ignore patterns**: Add inline `# noqa` comments or configure exclusions in `pyproject.toml` + +## Publish Workflow + +**File:** `.github/workflows/publish.yml` + +### Purpose + +Builds and publishes the package to PyPI and creates GitHub release assets including the universal `.pyz` binary. + +### Triggers + +- GitHub release published +- Manual dispatch via `workflow_dispatch` + +### Key Features + +- **Package building**: Creates wheel and sdist with `python -m build` +- **Universal binary**: Builds `.pyz` file using shiv for standalone execution +- **GitHub release assets**: Uploads `.pyz` binary to release assets +- **PyPI publishing**: Publishes to PyPI using token authentication +- **Package name**: Published as `glpkg-cli` on PyPI + +### Required Secrets + +| Secret | Required | Description | +|--------|----------|-------------| +| `PYPI_API_TOKEN` | Yes | PyPI API token with upload permissions | + +### Artifacts + +- `dist/` directory containing: + - `*.whl` - Wheel package + - `*.tar.gz` - Source distribution + - `*.pyz` - Universal binary + +### Debugging Tips + +- **Test .pyz build locally**: `bash scripts/build_pyz.sh --tool shiv --output-dir dist` +- **Verify package builds**: `uv pip install build --system && python -m build` +- **PyPI token issues**: Ensure token has "Upload packages" permission for the `glpkg-cli` project +- **Build script issues**: Review `scripts/build_pyz.sh` for shiv configuration +- **Version conflicts**: Check that the version in `pyproject.toml` doesn't already exist on PyPI +- **Local testing**: Install the built wheel with `pip install dist/*.whl` before publishing + +## Documentation Workflow + +**File:** `.github/workflows/docs.yml` + +### Purpose + +Validates markdown files for proper formatting and checks that all links are functional. + +### Triggers + +- Push to any branch +- Pull requests to `main` or `master` +- Manual dispatch via `workflow_dispatch` + +### Key Features + +- **Markdown linting** with markdownlint-cli2 for consistent formatting +- **Link checking** with markdown-link-check for broken URL detection +- **Validated files**: + - `README.md` + - `CONTRIBUTING.md` + - `tests/README.md` + - `docs/RELEASING.md` + - `docs/SHELL_COMPLETION.md` + - `docs/WORKFLOWS.md` + +### Required Secrets + +None + +### Debugging Tips + +- **Markdown lint errors**: Install markdownlint-cli2 locally (`npm install -g markdownlint-cli2`) and run on specific files +- **Broken links**: Verify URLs are accessible; some may be rate-limited or require authentication +- **Custom rules**: Add `.markdownlint.json` to configure or disable specific linting rules +- **Link check config**: Add `.markdown-link-check.json` for custom link checking behavior +- **False positives**: Some internal links may fail in CI but work in the repository + +## Workflow Status Badges + +Add these badges to your README.md to display workflow status: + +```markdown +![Tests](https://github.com/OWNER/REPO/actions/workflows/test.yml/badge.svg) +![Lint](https://github.com/OWNER/REPO/actions/workflows/lint.yml/badge.svg) +![Publish](https://github.com/OWNER/REPO/actions/workflows/publish.yml/badge.svg) +![Docs](https://github.com/OWNER/REPO/actions/workflows/docs.yml/badge.svg) +``` + +Replace `OWNER/REPO` with your actual GitHub repository path (e.g., `your-org/glpkg`). + +## Common Debugging Steps + +1. **Check workflow logs**: Navigate to the Actions tab in GitHub and select the failed workflow run +2. **Manual testing**: Use `workflow_dispatch` to trigger workflows manually for debugging +3. **Local reproduction**: Run the same commands locally using `uv` before pushing +4. **Review configuration**: Check `pyproject.toml` for tool-specific settings +5. **Secret verification**: Ensure required secrets are configured in repository Settings > Secrets and variables > Actions +6. **Branch protection**: Verify branch protection rules aren't blocking workflow execution +7. **Permissions**: Check that the `GITHUB_TOKEN` has necessary permissions for the workflow + +## Future Improvements + +### Integration Test Secret Checking + +The current condition `${{ secrets.GITLAB_TOKEN != '' }}` may not work as expected since GitHub Actions doesn't expose secret values in expressions. Consider these alternatives: + +- Use a dedicated job with conditional execution based on repository context +- Use environment-based checks with separate environments for integration testing +- Restrict integration tests to specific branches: `if: github.ref == 'refs/heads/main'` + +### PyPI Trusted Publishing + +The current workflow uses token-based authentication (`PYPI_API_TOKEN`). Consider migrating to PyPI trusted publishing (OIDC) for enhanced security: + +**Benefits:** + +- Eliminates need for long-lived API tokens +- No secret rotation required +- Stronger authentication through GitHub's OIDC provider + +**Migration steps:** + +1. Configure the PyPI project for trusted publishing in PyPI settings +2. Add the GitHub repository as a trusted publisher +3. Update the workflow to use `pypa/gh-action-pypi-publish` with OIDC +4. Remove the `PYPI_API_TOKEN` secret after verification + +See [PyPI Trusted Publishing documentation](https://docs.pypi.org/trusted-publishers/) for details. From 196df7a6241f1f0646f121211ccb1c176e4af464 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 16:26:01 -0600 Subject: [PATCH 32/36] docs: Reformat markdown for 72-character line wrapping Long lines in documentation files make diffs harder to review and conflict resolution more tedious when multiple contributors edit the same sections. Markdown files also display inconsistently across different editors and terminals when lines exceed standard widths. Apply consistent line wrapping at approximately 72 characters across all markdown documentation. This follows the conventional commit message and prose formatting standard, improving readability in terminals, simplifying three-way merges, and ensuring predictable rendering across tools. Tables are reformatted for column alignment and code blocks are adjusted to fit within the standard width. Signed-off-by: Javier Tia --- CONTRIBUTING.md | 6 +- README.md | 44 +++++---- docs/RELEASING.md | 11 ++- docs/SHELL_COMPLETION.md | 7 +- docs/WORKFLOWS.md | 191 ++++++++++++++++++++------------------- tests/README.md | 72 +++++++++------ 6 files changed, 186 insertions(+), 145 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64c8d39..0a89783 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,7 @@ # Contributing to glpkg -Thank you for your interest in contributing to glpkg! This document provides guidelines and instructions for development. +Thank you for your interest in contributing to glpkg! This document provides +guidelines and instructions for development. ## Getting Started @@ -138,7 +139,8 @@ open htmlcov/index.html # or xdg-open on Linux When adding new features: 1. Add unit tests in `tests/unit/` -2. Add integration tests in `tests/integration/` if the feature interacts with external services +2. Add integration tests in `tests/integration/` if the feature interacts + with external services 3. Update documentation in README.md or relevant docs 4. Add appropriate pytest markers: - `@pytest.mark.unit` for unit tests diff --git a/README.md b/README.md index 68fd23f..72e5895 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,14 @@ glpkg --help ### Universal Binary (.pyz) -Download the pre-built universal binary from GitHub releases. This is a self-contained executable that requires no installation - just Python 3.11+. +Download the pre-built universal binary from GitHub releases. +This is a self-contained executable that requires no installation - +just Python 3.11+. ```bash # Download the latest release -curl -L -o glpkg.pyz https://github.com/your-org/glpkg/releases/latest/download/glpkg.pyz +curl -L -o glpkg.pyz \ + https://github.com/your-org/glpkg/releases/latest/download/glpkg.pyz # Make it executable chmod +x glpkg.pyz @@ -81,13 +84,16 @@ uv run glpkg --help ```bash # Upload a single file -glpkg upload --package-name my-package --package-version 1.0.0 --files file.tar.gz +glpkg upload --package-name my-package --package-version 1.0.0 \ + --files file.tar.gz # Upload multiple files -glpkg upload --package-name my-package --package-version 1.0.0 --files file1.tar.gz file2.zip +glpkg upload --package-name my-package --package-version 1.0.0 \ + --files file1.tar.gz file2.zip # Upload with automatic project detection from git remote -glpkg upload --package-name my-package --package-version 1.0.0 --files file.tar.gz +glpkg upload --package-name my-package --package-version 1.0.0 \ + --files file.tar.gz # Specify project explicitly glpkg upload --package-name my-package --package-version 1.0.0 \ @@ -98,21 +104,23 @@ glpkg upload --package-name my-package --package-version 1.0.0 \ --duplicate-policy replace --files file.tar.gz # Verbose output with global flags -glpkg --verbose upload --package-name my-package --package-version 1.0.0 --files file.tar.gz +glpkg --verbose upload --package-name my-package \ + --package-version 1.0.0 --files file.tar.gz # JSON output for CI/CD pipelines -glpkg --json-output upload --package-name my-package --package-version 1.0.0 --files file.tar.gz +glpkg --json-output upload --package-name my-package \ + --package-version 1.0.0 --files file.tar.gz ``` ## Configuration ### Environment Variables -| Variable | Description | Required | -|----------|-------------|----------| -| `GITLAB_TOKEN` | GitLab personal access token with `api` scope | Yes | -| `GITLAB_URL` | GitLab instance URL | No (defaults to https://gitlab.com) | -| `GITLAB_PROJECT_PATH` | Project path (e.g., `namespace/project`) | No (auto-detected from git) | +| Variable | Description | Required | +| --------------------- | -------------------------------------- | -------- | +| `GITLAB_TOKEN` | GitLab access token with api scope | Yes | +| `GITLAB_URL` | GitLab URL (default: gitlab.com) | No | +| `GITLAB_PROJECT_PATH` | Project path (e.g., `group/project`) | No | ### Token Permissions @@ -142,22 +150,22 @@ uv run pytest tests/unit/ ### Documentation -- [CONTRIBUTING.md](CONTRIBUTING.md) - Development setup and contribution guidelines -- [docs/SHELL_COMPLETION.md](docs/SHELL_COMPLETION.md) - Shell completion setup for bash and zsh -- [docs/RELEASING.md](docs/RELEASING.md) - Release procedures and publishing workflow -- [docs/WORKFLOWS.md](docs/WORKFLOWS.md) - GitHub Actions workflows and CI/CD pipeline +- [CONTRIBUTING.md](CONTRIBUTING.md) - Development setup and guidelines +- [docs/SHELL_COMPLETION.md](docs/SHELL_COMPLETION.md) - Shell completion +- [docs/RELEASING.md](docs/RELEASING.md) - Release procedures +- [docs/WORKFLOWS.md](docs/WORKFLOWS.md) - GitHub Actions workflows - [tests/README.md](tests/README.md) - Detailed testing documentation ## Project Structure -``` +```text glpkg/ ├── src/ │ └── glpkg/ │ ├── __init__.py │ ├── cli/ │ │ ├── __init__.py -│ │ ├── main.py # Main CLI entry point with subcommand routing +│ │ ├── main.py # Main CLI entry point │ │ └── upload.py # Upload subcommand implementation │ ├── models.py # Data models │ ├── uploader.py # Upload logic diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 089f659..b00dae0 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -131,7 +131,9 @@ PyPI requires API token authentication (username/password is no longer supported uv pip install twine # Upload to PyPI using API token -TWINE_USERNAME=__token__ TWINE_PASSWORD=pypi- uv run twine upload dist/glpkg_cli-${VERSION}* +TWINE_USERNAME=__token__ \ +TWINE_PASSWORD=pypi- \ +uv run twine upload dist/glpkg_cli-${VERSION}* ``` Alternatively, configure credentials in `~/.pypirc`: @@ -150,8 +152,11 @@ uv run twine upload dist/glpkg_cli-${VERSION}* For more information, see: -- [API Tokens](https://pypi.org/help/#apitoken) - Create a token for manual uploads -- [Trusted Publishers](https://pypi.org/help/#trusted-publishers) - Configure GitHub Actions for automated publishing +- [API Tokens][pypi-tokens] - Create a token for manual uploads +- [Trusted Publishers][pypi-trusted] - Configure GitHub Actions + +[pypi-tokens]: https://pypi.org/help/#apitoken +[pypi-trusted]: https://pypi.org/help/#trusted-publishers ### 4. Build and Upload .pyz Binary diff --git a/docs/SHELL_COMPLETION.md b/docs/SHELL_COMPLETION.md index 40f35b9..91ae170 100644 --- a/docs/SHELL_COMPLETION.md +++ b/docs/SHELL_COMPLETION.md @@ -1,6 +1,7 @@ # Shell Completion Setup -glpkg supports shell completion for bash and zsh, providing tab-completion for commands, options, and arguments. +glpkg supports shell completion for bash and zsh, providing tab-completion +for commands, options, and arguments. ## Overview @@ -94,12 +95,12 @@ glpkg upload --pack # completes to --package-name Test that completion is working: 1. Open a new terminal or reload your shell -2. Type `glpkg ` and press Tab twice +2. Type `glpkg` followed by a space and press Tab twice 3. You should see available commands and options If completion works, you'll see suggestions like: -``` +```text upload --help --verbose --json-output ``` diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index cee3718..ada3491 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -1,6 +1,9 @@ # GitHub Workflows Documentation -This document provides comprehensive documentation for all GitHub Actions workflows used in the glpkg project. These workflows form the CI/CD pipeline that ensures code quality, runs tests, validates documentation, and handles package publishing. +This document provides comprehensive documentation for all GitHub Actions +workflows used in the glpkg project. These workflows form the CI/CD pipeline +that ensures code quality, runs tests, validates documentation, and handles +package publishing. ## Table of Contents @@ -16,143 +19,142 @@ This document provides comprehensive documentation for all GitHub Actions workfl **File:** `.github/workflows/test.yml` -### Purpose +Runs unit and integration tests across Python 3.11-3.13 to ensure code +correctness and maintain test coverage standards. -Runs unit and integration tests across Python 3.11-3.13 to ensure code correctness and maintain test coverage standards. - -### Triggers +**Triggers:** - Push to any branch - Pull requests to `main` or `master` - Manual dispatch via `workflow_dispatch` -### Key Features +**Key Features:** -- **Matrix testing** across Python versions 3.11, 3.12, and 3.13 -- **Unit tests** with pytest-cov providing coverage reporting -- **Coverage thresholds**: 95% warning level, 90% fail threshold -- **Integration tests** run conditionally when `GITLAB_TOKEN` and `GITLAB_REPO` secrets are configured -- **Coverage report upload** for Python 3.11 runs +- Matrix testing across Python versions 3.11, 3.12, and 3.13 +- Unit tests with pytest-cov providing coverage reporting +- Coverage thresholds: 95% warning level, 90% fail threshold +- Integration tests run conditionally when secrets are configured +- Coverage report upload for Python 3.11 runs - Uses `uv` for fast, reliable dependency management -### Required Secrets +**Secrets:** -| Secret | Required | Description | -|--------|----------|-------------| -| `GITLAB_TOKEN` | Optional | GitLab API token for integration tests | -| `GITLAB_REPO` | Optional | GitLab repository path for integration tests | +| Secret | Required | Description | +| -------------- | -------- | ---------------------------------------- | +| `GITLAB_TOKEN` | Optional | GitLab API token for integration tests | +| `GITLAB_REPO` | Optional | GitLab repository path for integration | Integration tests are skipped if these secrets are not configured. -### Artifacts +**Artifacts:** - `coverage.xml` - XML coverage report for CI integrations - `htmlcov/` - HTML coverage report for detailed inspection - `pytest-results` - Test result files -### Debugging Tips +**Debugging:** -- **Coverage threshold failures**: Check the "Check coverage threshold" step output for specific coverage percentages -- **Integration tests skipped**: Verify `GITLAB_TOKEN` and `GITLAB_REPO` secrets are configured in repository settings -- **Manual test runs**: Use `workflow_dispatch` from the Actions tab for on-demand testing -- **Detailed coverage**: Download the `htmlcov` artifact to see line-by-line coverage -- **Local reproduction**: Run `uv run pytest tests/unit/ --cov=src/glpkg --cov-report=term-missing` +- Coverage threshold failures: Check the "Check coverage threshold" step + output for specific coverage percentages +- Integration tests skipped: Verify `GITLAB_TOKEN` and `GITLAB_REPO` + secrets are configured in repository settings +- Manual test runs: Use `workflow_dispatch` from Actions tab +- Detailed coverage: Download the `htmlcov` artifact +- Local reproduction: + `uv run pytest tests/unit/ --cov=src/glpkg --cov-report=term-missing` ## Lint Workflow **File:** `.github/workflows/lint.yml` -### Purpose - -Performs code quality checks using ruff for linting/formatting and mypy for type checking to maintain consistent code standards. +Performs code quality checks using ruff for linting/formatting and mypy for +type checking to maintain consistent code standards. -### Triggers +**Triggers:** - Push to any branch - Pull requests to `main` or `master` - Manual dispatch via `workflow_dispatch` -### Key Features +**Key Features:** -- **Ruff linting**: `ruff check src/` for code style and error detection -- **Ruff formatting**: `ruff format --check src/` for consistent code formatting -- **Mypy type checking**: Strict mode (`--strict`) for comprehensive type safety +- Ruff linting: `ruff check src/` for code style and error detection +- Ruff formatting: `ruff format --check src/` for consistent formatting +- Mypy type checking: Strict mode (`--strict`) for comprehensive type safety - Uses Python 3.11 and `uv` for consistency -### Required Secrets +**Secrets:** None required. -None +**Debugging:** -### Debugging Tips - -- **Ruff lint errors**: Run locally with `uv run ruff check src/ --fix` to auto-fix issues -- **Ruff format errors**: Run `uv run ruff format src/` to auto-format code -- **Mypy errors**: Run `uv run mypy src/glpkg/ --strict` locally to see detailed type errors -- **Configuration**: Check `pyproject.toml` for ruff and mypy configuration options -- **Ignore patterns**: Add inline `# noqa` comments or configure exclusions in `pyproject.toml` +- Ruff lint errors: Run `uv run ruff check src/ --fix` to auto-fix issues +- Ruff format errors: Run `uv run ruff format src/` to auto-format code +- Mypy errors: Run `uv run mypy src/glpkg/ --strict` locally +- Configuration: Check `pyproject.toml` for ruff and mypy settings +- Ignore patterns: Add `# noqa` comments or configure in `pyproject.toml` ## Publish Workflow **File:** `.github/workflows/publish.yml` -### Purpose - -Builds and publishes the package to PyPI and creates GitHub release assets including the universal `.pyz` binary. +Builds and publishes the package to PyPI and creates GitHub release assets +including the universal `.pyz` binary. -### Triggers +**Triggers:** - GitHub release published - Manual dispatch via `workflow_dispatch` -### Key Features +**Key Features:** -- **Package building**: Creates wheel and sdist with `python -m build` -- **Universal binary**: Builds `.pyz` file using shiv for standalone execution -- **GitHub release assets**: Uploads `.pyz` binary to release assets -- **PyPI publishing**: Publishes to PyPI using token authentication -- **Package name**: Published as `glpkg-cli` on PyPI +- Package building: Creates wheel and sdist with `python -m build` +- Universal binary: Builds `.pyz` file using shiv for standalone execution +- GitHub release assets: Uploads `.pyz` binary to release assets +- PyPI publishing: Publishes to PyPI using token authentication +- Package name: Published as `glpkg-cli` on PyPI -### Required Secrets +**Secrets:** -| Secret | Required | Description | -|--------|----------|-------------| -| `PYPI_API_TOKEN` | Yes | PyPI API token with upload permissions | +| Secret | Required | Description | +| ---------------- | -------- | ------------------------------------- | +| `PYPI_API_TOKEN` | Yes | PyPI API token with upload permission | -### Artifacts +**Artifacts:** - `dist/` directory containing: - `*.whl` - Wheel package - `*.tar.gz` - Source distribution - `*.pyz` - Universal binary -### Debugging Tips +**Debugging:** -- **Test .pyz build locally**: `bash scripts/build_pyz.sh --tool shiv --output-dir dist` -- **Verify package builds**: `uv pip install build --system && python -m build` -- **PyPI token issues**: Ensure token has "Upload packages" permission for the `glpkg-cli` project -- **Build script issues**: Review `scripts/build_pyz.sh` for shiv configuration -- **Version conflicts**: Check that the version in `pyproject.toml` doesn't already exist on PyPI -- **Local testing**: Install the built wheel with `pip install dist/*.whl` before publishing +- Test .pyz build locally: + `bash scripts/build_pyz.sh --tool shiv --output-dir dist` +- Verify package builds: + `uv pip install build --system && python -m build` +- PyPI token issues: Ensure token has "Upload packages" permission +- Build script issues: Review `scripts/build_pyz.sh` for shiv configuration +- Version conflicts: Check version in `pyproject.toml` doesn't exist on PyPI +- Local testing: Install built wheel with `pip install dist/*.whl` ## Documentation Workflow **File:** `.github/workflows/docs.yml` -### Purpose +Validates markdown files for proper formatting and checks that all links +are functional. -Validates markdown files for proper formatting and checks that all links are functional. - -### Triggers +**Triggers:** - Push to any branch - Pull requests to `main` or `master` - Manual dispatch via `workflow_dispatch` -### Key Features +**Key Features:** -- **Markdown linting** with markdownlint-cli2 for consistent formatting -- **Link checking** with markdown-link-check for broken URL detection -- **Validated files**: +- Markdown linting with markdownlint-cli2 for consistent formatting +- Link checking with markdown-link-check for broken URL detection +- Validated files: - `README.md` - `CONTRIBUTING.md` - `tests/README.md` @@ -160,17 +162,15 @@ Validates markdown files for proper formatting and checks that all links are fun - `docs/SHELL_COMPLETION.md` - `docs/WORKFLOWS.md` -### Required Secrets - -None +**Secrets:** None required. -### Debugging Tips +**Debugging:** -- **Markdown lint errors**: Install markdownlint-cli2 locally (`npm install -g markdownlint-cli2`) and run on specific files -- **Broken links**: Verify URLs are accessible; some may be rate-limited or require authentication -- **Custom rules**: Add `.markdownlint.json` to configure or disable specific linting rules -- **Link check config**: Add `.markdown-link-check.json` for custom link checking behavior -- **False positives**: Some internal links may fail in CI but work in the repository +- Markdown lint errors: Install markdownlint-cli2 locally and run on files +- Broken links: Verify URLs are accessible +- Custom rules: Add `.markdownlint.json` to configure linting rules +- Link check config: Add `.markdown-link-check.json` for custom behavior +- False positives: Some internal links may fail in CI but work locally ## Workflow Status Badges @@ -183,31 +183,34 @@ Add these badges to your README.md to display workflow status: ![Docs](https://github.com/OWNER/REPO/actions/workflows/docs.yml/badge.svg) ``` -Replace `OWNER/REPO` with your actual GitHub repository path (e.g., `your-org/glpkg`). +Replace `OWNER/REPO` with your actual GitHub repository path. ## Common Debugging Steps -1. **Check workflow logs**: Navigate to the Actions tab in GitHub and select the failed workflow run -2. **Manual testing**: Use `workflow_dispatch` to trigger workflows manually for debugging -3. **Local reproduction**: Run the same commands locally using `uv` before pushing -4. **Review configuration**: Check `pyproject.toml` for tool-specific settings -5. **Secret verification**: Ensure required secrets are configured in repository Settings > Secrets and variables > Actions -6. **Branch protection**: Verify branch protection rules aren't blocking workflow execution -7. **Permissions**: Check that the `GITHUB_TOKEN` has necessary permissions for the workflow +1. **Check workflow logs**: Navigate to Actions tab and select failed run +2. **Manual testing**: Use `workflow_dispatch` to trigger manually +3. **Local reproduction**: Run same commands locally using `uv` +4. **Review configuration**: Check `pyproject.toml` for tool settings +5. **Secret verification**: Ensure secrets are configured in Settings +6. **Branch protection**: Verify rules aren't blocking execution +7. **Permissions**: Check `GITHUB_TOKEN` has necessary permissions ## Future Improvements ### Integration Test Secret Checking -The current condition `${{ secrets.GITLAB_TOKEN != '' }}` may not work as expected since GitHub Actions doesn't expose secret values in expressions. Consider these alternatives: +The current condition `${{ secrets.GITLAB_TOKEN != '' }}` may not work as +expected since GitHub Actions doesn't expose secret values in expressions. +Consider these alternatives: - Use a dedicated job with conditional execution based on repository context -- Use environment-based checks with separate environments for integration testing -- Restrict integration tests to specific branches: `if: github.ref == 'refs/heads/main'` +- Use environment-based checks with separate environments +- Restrict integration tests to specific branches ### PyPI Trusted Publishing -The current workflow uses token-based authentication (`PYPI_API_TOKEN`). Consider migrating to PyPI trusted publishing (OIDC) for enhanced security: +The current workflow uses token-based authentication (`PYPI_API_TOKEN`). +Consider migrating to PyPI trusted publishing (OIDC) for enhanced security. **Benefits:** @@ -219,7 +222,9 @@ The current workflow uses token-based authentication (`PYPI_API_TOKEN`). Conside 1. Configure the PyPI project for trusted publishing in PyPI settings 2. Add the GitHub repository as a trusted publisher -3. Update the workflow to use `pypa/gh-action-pypi-publish` with OIDC +3. Update workflow to use `pypa/gh-action-pypi-publish` with OIDC 4. Remove the `PYPI_API_TOKEN` secret after verification -See [PyPI Trusted Publishing documentation](https://docs.pypi.org/trusted-publishers/) for details. +See [PyPI Trusted Publishing documentation][pypi-trusted] for details. + +[pypi-trusted]: https://docs.pypi.org/trusted-publishers/ diff --git a/tests/README.md b/tests/README.md index 2a1bbbc..fb0b234 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,17 +1,23 @@ # GitLab Package Upload Test Suite -This directory contains a comprehensive pytest-based test suite for the GitLab package upload functionality. The test suite validates the `glpkg` command through both unit tests and end-to-end integration testing. +This directory contains a comprehensive pytest-based test suite for the GitLab +package upload functionality. The test suite validates the `glpkg` command +through both unit tests and end-to-end integration testing. ## Overview The test suite is organized into two categories: -- **Unit tests** (`tests/unit/`): Fast tests that validate individual components without external dependencies -- **Integration tests** (`tests/integration/`): End-to-end tests that execute the actual upload script and verify results against the GitLab Package Registry +- **Unit tests** (`tests/unit/`): Fast tests that validate individual + components without external dependencies +- **Integration tests** (`tests/integration/`): End-to-end tests that execute + the actual upload script and verify results against the GitLab Package + Registry ## Quick Start -All test dependencies are automatically managed by uv. However, the `glpkg` package must be installed in development mode before running tests. +All test dependencies are automatically managed by uv. However, the `glpkg` +package must be installed in development mode before running tests. ```bash # Install the package in development mode (required before running tests) @@ -35,9 +41,10 @@ uv run pytest tests/ -n auto uv run pytest tests/ -v ``` -### Using the Convenience Wrapper +### Convenience Wrapper -The `run_tests.py` script provides convenience commands that delegate to `uv run pytest`: +The `run_tests.py` script provides convenience commands that delegate to +`uv run pytest`: ```bash # Run unit tests @@ -55,7 +62,7 @@ The `run_tests.py` script provides convenience commands that delegate to `uv run ## Test Structure -``` +```text tests/ ├── conftest.py # Shared fixtures and configuration ├── unit/ # Unit tests (no external dependencies) @@ -70,7 +77,7 @@ tests/ │ ├── test_single_file_upload.py # Single file upload tests │ ├── test_multiple_files_upload.py # Multiple files upload tests │ ├── test_duplicate_handling.py # Skip, replace, error policies -│ ├── test_project_resolution.py # Auto-detection and manual specification +│ ├── test_project_resolution.py # Auto-detection and manual spec │ ├── test_error_scenarios.py # Network failures, auth errors │ └── test_end_to_end.py # Comprehensive end-to-end scenarios ├── utils/ @@ -91,7 +98,9 @@ tests/ ### Dependency Management -All test dependencies are automatically installed by uv when running `uv run pytest`. The dependencies are defined in `pyproject.toml` under `[project.optional-dependencies]`: +All test dependencies are automatically installed by uv when running +`uv run pytest`. The dependencies are defined in `pyproject.toml` under +`[project.optional-dependencies]`: - pytest - pytest-xdist (parallel execution) @@ -100,7 +109,8 @@ All test dependencies are automatically installed by uv when running `uv run pyt - pytest-instafail (instant failure reporting) - pytest-cov (code coverage reporting) -**Important**: The `glpkg` package itself must be installed in development mode before running tests: +**Important**: The `glpkg` package itself must be installed in development +mode before running tests: ```bash uv pip install -e . @@ -129,6 +139,7 @@ export GITLAB_PROJECT_PATH="group/project" ### Required Permissions Your GitLab token needs the following permissions: + - `api` scope for full API access - Write access to the target project's Package Registry - Ability to create and delete packages in the registry @@ -185,7 +196,7 @@ uv run pytest tests/ -m "not slow" uv run pytest tests/ -m api ``` -### Using the Convenience Wrapper +### Wrapper Script Usage ```bash # Run unit tests @@ -243,22 +254,23 @@ show_missing = true ### Interpreting Coverage Reports -- **Term-missing output**: Shows which lines are not covered directly in the terminal +- **Term-missing output**: Shows which lines are not covered in terminal - **HTML report**: Provides an interactive view in `htmlcov/index.html` - **XML report**: Machine-readable format for CI integration in `coverage.xml` ## Integration Test Requirements -Integration tests require explicit opt-in and automatically validate their environment before running. +Integration tests require explicit opt-in and automatically validate their +environment before running. ### Automatic Environment Validation When you run integration tests, the test suite checks: -1. **RUN_INTEGRATION_TESTS environment variable** - Must be set to `1` to opt-in -2. **GITLAB_TOKEN environment variable** - Must be set with a valid GitLab API token +1. **RUN_INTEGRATION_TESTS environment variable** - Must be set to `1` +2. **GITLAB_TOKEN environment variable** - Must be set with a valid token 3. **Git repository** - Must run from within a Git repository -4. **GitLab remotes** - Repository must have at least one remote pointing to a GitLab instance +4. **GitLab remotes** - Repository must have at least one GitLab remote ### Verifying Your Setup @@ -276,18 +288,22 @@ git remote -v | grep gitlab echo $GITLAB_TOKEN # Verify full setup -[ "$RUN_INTEGRATION_TESTS" = "1" ] && echo "Integration tests enabled" || echo "Integration tests NOT enabled" -[ -n "$GITLAB_TOKEN" ] && echo "Token is set" || echo "Token is NOT set" +[ "$RUN_INTEGRATION_TESTS" = "1" ] && \ + echo "Integration tests enabled" || \ + echo "Integration tests NOT enabled" +[ -n "$GITLAB_TOKEN" ] && \ + echo "Token is set" || \ + echo "Token is NOT set" ``` ### When Validation Fails If integration tests are skipped, the error message explains what's missing: -- **Integration tests disabled**: Set `export RUN_INTEGRATION_TESTS=1` to opt-in -- **Missing GITLAB_TOKEN**: Set the environment variable with `export GITLAB_TOKEN='your-token'` +- **Integration tests disabled**: Set `export RUN_INTEGRATION_TESTS=1` +- **Missing GITLAB_TOKEN**: Set with `export GITLAB_TOKEN='your-token'` - **No Git repository**: Navigate to a Git repository or initialize one -- **No GitLab remotes**: Add a GitLab remote with `git remote add origin https://gitlab.com/namespace/project.git` +- **No GitLab remotes**: Add a GitLab remote with `git remote add origin` ## Debugging Test Failures @@ -319,16 +335,19 @@ uv run pytest tests/ -v --log-cli-level=DEBUG ### Common Issues #### Authentication Errors + - Verify `GITLAB_TOKEN` is set and valid - Check token has required permissions (`api` scope) - Test with unit tests: `uv run pytest tests/unit/` (doesn't require token) #### Timeout Errors + - Check network connectivity to GitLab - Run sequentially: `uv run pytest tests/integration/` (without `-n auto`) - Increase timeout: `uv run pytest tests/ --timeout=1200` #### Parallel Execution Issues + - Run sequentially for debugging: `uv run pytest tests/` (without `-n auto`) - Check rate limiter is working properly @@ -378,11 +397,11 @@ End-to-end tests requiring GitLab API access: ## Test Execution Time -| Test Category | Duration | Requirements | -|---------------|----------|--------------| -| Unit tests | 10-30 seconds | None | -| Integration tests | 10-15 minutes | GITLAB_TOKEN | -| All tests (parallel) | 5-10 minutes | GITLAB_TOKEN | +| Test Category | Duration | Requirements | +| --------------------- | -------------- | -------------- | +| Unit tests | 10-30 seconds | None | +| Integration tests | 10-15 minutes | GITLAB_TOKEN | +| All tests (parallel) | 5-10 minutes | GITLAB_TOKEN | ## Adding New Tests @@ -394,6 +413,7 @@ End-to-end tests requiring GitLab API access: 4. Add appropriate markers for categorization Example: + ```python import pytest From 3c7b3a15dc14e0d841077b359eace5847d04b13d Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 16:26:17 -0600 Subject: [PATCH 33/36] glpkg: Fix type annotations and improve code formatting Static analysis tools report multiple type safety issues throughout the codebase, including untyped function signatures, missing generic type parameters, and improperly typed callables. Additionally, several strings exceed the recommended line length limits, making the code harder to read and causing linter warnings. Add explicit type annotations to function parameters and return types, particularly for generic containers and callable signatures. This enables mypy to perform proper type checking and catch potential bugs at development time rather than runtime. Reformat long strings by splitting them across multiple lines or extracting variables to improve readability while maintaining the same runtime behavior. Signed-off-by: Javier Tia --- src/glpkg/__init__.py | 20 ++--- src/glpkg/cli/completion.py | 12 +-- src/glpkg/cli/main.py | 5 +- src/glpkg/cli/upload.py | 32 ++++---- src/glpkg/duplicate_detector.py | 76 ++++++++----------- src/glpkg/formatters.py | 129 +++++++++++++++++++------------- src/glpkg/models.py | 22 ++---- src/glpkg/uploader.py | 53 ++++--------- src/glpkg/validators.py | 44 +++++------ 9 files changed, 183 insertions(+), 210 deletions(-) diff --git a/src/glpkg/__init__.py b/src/glpkg/__init__.py index 23b9f61..0094553 100644 --- a/src/glpkg/__init__.py +++ b/src/glpkg/__init__.py @@ -5,22 +5,22 @@ # Export key models and exceptions for convenience from .duplicate_detector import DuplicateDetector from .models import ( + AuthenticationError, + ChecksumValidationError, + ConfigurationError, DuplicatePolicy, FileFingerprint, - RemoteFile, - UploadResult, + FileValidationError, + GitLabUploadError, + GitRemoteInfo, + NetworkError, ProjectInfo, + ProjectResolutionError, ProjectResolutionResult, - GitRemoteInfo, + RemoteFile, UploadConfig, UploadContext, - GitLabUploadError, - AuthenticationError, - ConfigurationError, - ProjectResolutionError, - FileValidationError, - NetworkError, - ChecksumValidationError, + UploadResult, ) __all__ = [ diff --git a/src/glpkg/cli/completion.py b/src/glpkg/cli/completion.py index 1bf365e..e3f43aa 100644 --- a/src/glpkg/cli/completion.py +++ b/src/glpkg/cli/completion.py @@ -43,7 +43,11 @@ def generate_completion_script(shell: str) -> str: f"Unsupported shell: {shell}. Supported shells: {', '.join(SUPPORTED_SHELLS)}" ) - return argcomplete.shellcode(["glpkg"], shell=shell) + # argcomplete.shellcode is not typed properly + result: str = argcomplete.shellcode( # type: ignore[attr-defined,no-untyped-call] + ["glpkg"], shell=shell + ) + return result def get_completion_path(shell: str) -> Path: @@ -99,8 +103,7 @@ def install_completion(shell: str) -> None: ) from e except OSError as e: raise OSError( - f"Cannot create directory {completion_dir}: {e}. " - f"Please check the path and try again." + f"Cannot create directory {completion_dir}: {e}. Please check the path and try again." ) from e try: @@ -113,8 +116,7 @@ def install_completion(shell: str) -> None: ) from e except OSError as e: raise OSError( - f"Cannot write to {completion_file}: {e}. " - f"Please check the path and try again." + f"Cannot write to {completion_file}: {e}. Please check the path and try again." ) from e logger.info(f"Installed {shell} completion to {completion_file}") diff --git a/src/glpkg/cli/main.py b/src/glpkg/cli/main.py index 9b99334..43d4732 100644 --- a/src/glpkg/cli/main.py +++ b/src/glpkg/cli/main.py @@ -132,9 +132,8 @@ def setup_logging(args: argparse.Namespace) -> None: force=True, # Reconfigure if already configured ) - logger.debug( - f"Logging configured: level={verbosity}, stream={'stderr' if getattr(args, 'json_output', False) else 'stdout'}" - ) + stream = "stderr" if getattr(args, "json_output", False) else "stdout" + logger.debug(f"Logging configured: level={verbosity}, stream={stream}") def validate_global_flags(args: argparse.Namespace) -> None: diff --git a/src/glpkg/cli/upload.py b/src/glpkg/cli/upload.py index d9f247b..d2497c8 100644 --- a/src/glpkg/cli/upload.py +++ b/src/glpkg/cli/upload.py @@ -59,7 +59,6 @@ ) from glpkg.uploader import upload_files from glpkg.validators import ( - DEFAULT_GITLAB_URL, collect_files, get_gitlab_token, normalize_gitlab_url, @@ -414,7 +413,7 @@ def resolve_project_id(self, gitlab_url: str, project_path: str) -> int: try: logger.debug(f"Resolving project ID for: {project_path}") project = self.gl.projects.get(project_path) - project_id = project.id + project_id: int = project.id # Cache the result self.project_cache[cache_key] = project_id @@ -508,7 +507,9 @@ def build( token=token, # Resolved token (from CLI or environment) ) - logger.debug(f"Created UploadConfig: package={config.package_name}, version={config.version}") + logger.debug( + f"Created UploadConfig: package={config.package_name}, version={config.version}" + ) # Initialize DuplicateDetector detector = DuplicateDetector(gl, project_id) @@ -680,20 +681,17 @@ def validate_upload_flags(args: argparse.Namespace) -> None: # Check required arguments for upload runs if not args.package_name: errors.append( - "--package-name is required. " - "Specify the package name in the GitLab registry." + "--package-name is required. Specify the package name in the GitLab registry." ) if not args.package_version: - errors.append( - "--package-version is required. " - "Specify the package version." - ) + errors.append("--package-version is required. Specify the package version.") # Check for conflicting file input flags if args.files and args.directory: errors.append( "Cannot specify both --files and --directory. " - "Use --files for explicit file list or --directory to upload all files from a directory." + "Use --files for explicit file list or --directory to upload " + "all files from a directory." ) # Check for conflicting project specification @@ -707,7 +705,8 @@ def validate_upload_flags(args: argparse.Namespace) -> None: if not args.files and not args.directory: errors.append( "Either --files or --directory must be provided. " - "Use --files for explicit file list or --directory to upload all files from a directory." + "Use --files for explicit file list or --directory to upload " + "all files from a directory." ) # Check that file-mapping is only used with --files @@ -719,9 +718,7 @@ def validate_upload_flags(args: argparse.Namespace) -> None: # Check retry value is non-negative if args.retry < 0: - errors.append( - f"--retry must be a non-negative integer, got {args.retry}." - ) + errors.append(f"--retry must be a non-negative integer, got {args.retry}.") # Report all errors if errors: @@ -734,7 +731,9 @@ def validate_upload_flags(args: argparse.Namespace) -> None: sys.exit(3) # ConfigurationError exit code -def register_upload_command(subparsers: argparse._SubParsersAction) -> None: +def register_upload_command( + subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", +) -> None: """Register the upload subcommand with the main argument parser. Args: @@ -939,8 +938,7 @@ def execute_upload(args: argparse.Namespace) -> None: # Validate access if not resolver.validate_project_access(project_id): raise ProjectResolutionError( - f"Cannot access project {project_path}. " - f"Verify you have appropriate permissions." + f"Cannot access project {project_path}. Verify you have appropriate permissions." ) # Log success diff --git a/src/glpkg/duplicate_detector.py b/src/glpkg/duplicate_detector.py index ca3b315..3100b36 100644 --- a/src/glpkg/duplicate_detector.py +++ b/src/glpkg/duplicate_detector.py @@ -5,12 +5,14 @@ import logging import time from pathlib import Path -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Callable, Optional, TypeVar + +from .models import FileFingerprint, RemoteFile if TYPE_CHECKING: from gitlab import Gitlab -from .models import FileFingerprint, RemoteFile +T = TypeVar("T") logger = logging.getLogger(__name__) @@ -36,10 +38,10 @@ def calculate_sha256(file_path: Path) -> str: def handle_network_error_with_retry( operation_name: str, - operation_func, + operation_func: Callable[[], T], max_retries: int = 3, retry_delays: list[int] | None = None, -): +) -> T: """ Execute an operation with retry logic for network errors. @@ -74,11 +76,12 @@ def handle_network_error_with_retry( else: logger.error(f"{operation_name} failed after {max_retries + 1} attempts: {e}") + assert last_exception is not None raise last_exception class DuplicateDetector: - """Core component responsible for detecting duplicates both locally (within session) and remotely (in GitLab registry).""" + """Detect duplicates locally (within session) and remotely (in GitLab).""" def __init__(self, gitlab_client: Gitlab, project_id: int): """ @@ -121,7 +124,8 @@ def check_session_duplicate( f"Session duplicate detected: {target_filename} (checksum: {current_checksum})" ) logger.info( - f"Original source: {existing_fingerprint.source_path}, Current source: {file_path}" + f"Original source: {existing_fingerprint.source_path}, " + f"Current source: {file_path}" ) return existing_fingerprint else: @@ -129,7 +133,8 @@ def check_session_duplicate( f"Same target filename {target_filename} but different content detected" ) logger.warning( - f"Existing checksum: {existing_fingerprint.sha256_checksum}, Current checksum: {current_checksum}" + f"Existing checksum: {existing_fingerprint.sha256_checksum}, " + f"Current checksum: {current_checksum}" ) else: logger.debug(f"No session duplicate found for {target_filename}") @@ -151,12 +156,10 @@ def check_remote_duplicate( Returns: RemoteFile if duplicate found, None otherwise """ - logger.info( - f"Starting remote duplicate check for {filename} in {package_name} v{version}" - ) + logger.info(f"Starting remote duplicate check for {filename} in {package_name} v{version}") logger.debug(f"Local checksum to compare: {checksum}") - def _check_remote_duplicate(): + def _check_remote_duplicate() -> Optional[RemoteFile]: """Internal function to check remote duplicate.""" project = self.gl.projects.get(self.project_id) packages = project.packages.list(package_name=package_name, get_all=True) @@ -165,14 +168,10 @@ def _check_remote_duplicate(): target_package = next((p for p in packages if p.version == version), None) if not target_package: - logger.debug( - f"Package {package_name} v{version} not found - no remote duplicate" - ) + logger.debug(f"Package {package_name} v{version} not found - no remote duplicate") return None - logger.debug( - f"Found package {package_name} v{version} (ID: {target_package.id})" - ) + logger.debug(f"Found package {package_name} v{version} (ID: {target_package.id})") # Get package files package_obj = project.packages.get(target_package.id) @@ -184,14 +183,10 @@ def _check_remote_duplicate(): matching_files = [f for f in package_files if f.file_name == filename] if not matching_files: - logger.debug( - f"No files named {filename} found in remote package - no duplicate" - ) + logger.debug(f"No files named {filename} found in remote package - no duplicate") return None - logger.debug( - f"Found {len(matching_files)} file(s) with matching filename {filename}" - ) + logger.debug(f"Found {len(matching_files)} file(s) with matching filename {filename}") # Check for checksum matches for pkg_file in matching_files: @@ -202,17 +197,15 @@ def _check_remote_duplicate(): f"Comparing checksums - Remote: {remote_sha256}, Local: {checksum}" ) if remote_sha256.lower() == checksum.lower(): - logger.info( - f"Remote duplicate detected: {filename} (checksum: {checksum})" - ) - logger.info( - f"Remote file ID: {pkg_file.id}, Size: {getattr(pkg_file, 'size', 'unknown')}" - ) + logger.info(f"Remote duplicate detected: {filename} (checksum: {checksum})") + file_size = getattr(pkg_file, "size", "unknown") + logger.info(f"Remote file ID: {pkg_file.id}, Size: {file_size}") # Generate download URL + base_url = self.gl.api_url.replace("/api/v4", "") download_url = ( - f"{self.gl.api_url.replace('/api/v4', '')}/api/v4/projects/{self.project_id}/packages/generic/" - f"{package_name}/{version}/{filename}" + f"{base_url}/api/v4/projects/{self.project_id}" + f"/packages/generic/{package_name}/{version}/{filename}" ) return RemoteFile( @@ -226,20 +219,17 @@ def _check_remote_duplicate(): ) else: logger.debug( - f"File {filename} exists but checksum differs (remote: {remote_sha256}, local: {checksum})" + f"File {filename} exists but checksum differs " + f"(remote: {remote_sha256}, local: {checksum})" ) else: # Handle incomplete metadata gracefully - use file size as fallback logger.warning( f"Remote checksum not available for {filename}, using file size comparison" ) - logger.debug( - f"Cannot verify duplicate without checksum for {filename}" - ) + logger.debug(f"Cannot verify duplicate without checksum for {filename}") - logger.debug( - f"No matching checksums found for {filename} - no remote duplicate" - ) + logger.debug(f"No matching checksums found for {filename} - no remote duplicate") return None try: @@ -252,7 +242,7 @@ def _check_remote_duplicate(): logger.warning(f"Proceeding without duplicate detection for {filename}") return None - def register_file(self, file_path: Path, target_filename: str, checksum: str): + def register_file(self, file_path: Path, target_filename: str, checksum: str) -> None: """ Register file as processed in current session. @@ -272,9 +262,5 @@ def register_file(self, file_path: Path, target_filename: str, checksum: str): ) self.session_registry[target_filename] = fingerprint - logger.info( - f"Registered file in session: {target_filename} (checksum: {checksum})" - ) - logger.debug( - f"Session registry now contains {len(self.session_registry)} file(s)" - ) + logger.info(f"Registered file in session: {target_filename} (checksum: {checksum})") + logger.debug(f"Session registry now contains {len(self.session_registry)} file(s)") diff --git a/src/glpkg/formatters.py b/src/glpkg/formatters.py index ae4b210..da74e37 100644 --- a/src/glpkg/formatters.py +++ b/src/glpkg/formatters.py @@ -14,11 +14,9 @@ from rich.console import Console from rich.status import Status -from rich.table import Table from .models import GitLabUploadError, UploadConfig, UploadResult, enhance_error_message - # Terminal Detection Functions @@ -189,9 +187,7 @@ def __init__(self, config: UploadConfig) -> None: legacy_windows=False, ) - def format_output( - self, results: List[UploadResult], package_name: str, version: str - ) -> None: + def format_output(self, results: List[UploadResult], package_name: str, version: str) -> None: """Format and output upload results based on configuration. Determines the appropriate output format based on config.json_output @@ -247,9 +243,13 @@ def _format_rich_output( self.console.print(f"[cyan]Target Filename:[/cyan] {result.target_filename}") self.console.print(f"[cyan]Download URL:[/cyan] [blue]{result.result}[/blue]") if result.was_duplicate and result.duplicate_action == "replaced": - self.console.print("[cyan]Action:[/cyan] [yellow]Replaced existing duplicate[/yellow]") + self.console.print( + "[cyan]Action:[/cyan] [yellow]Replaced existing duplicate[/yellow]" + ) if result.existing_url is not None: - self.console.print(f"[cyan]Previous URL:[/cyan] [dim]{result.existing_url}[/dim]") + self.console.print( + f"[cyan]Previous URL:[/cyan] [dim]{result.existing_url}[/dim]" + ) self.console.print() # Display Skipped Duplicates Section @@ -258,7 +258,8 @@ def _format_rich_output( for result in skipped_duplicates: self.console.print(f"[cyan]Source File:[/cyan] {result.source_path}") self.console.print(f"[cyan]Target Filename:[/cyan] {result.target_filename}") - self.console.print(f"[cyan]Existing URL:[/cyan] [blue]{result.existing_url or result.result}[/blue]") + existing_url = result.existing_url or result.result + self.console.print(f"[cyan]Existing URL:[/cyan] [blue]{existing_url}[/blue]") self.console.print(f"[cyan]Reason:[/cyan] {result.result}") self.console.print() @@ -272,12 +273,16 @@ def _format_rich_output( if result.was_duplicate: self.console.print(f"[cyan]Duplicate Action:[/cyan] {result.duplicate_action}") if result.existing_url is not None: - self.console.print(f"[cyan]Existing URL:[/cyan] [blue]{result.existing_url}[/blue]") + self.console.print( + f"[cyan]Existing URL:[/cyan] [blue]{result.existing_url}[/blue]" + ) self.console.print() # Calculate and Display Statistics total_processed = len(successful_uploads) + len(skipped_duplicates) + len(failed_uploads) - replaced_count = sum(1 for r in successful_uploads if r.was_duplicate and r.duplicate_action == "replaced") + replaced_count = sum( + 1 for r in successful_uploads if r.was_duplicate and r.duplicate_action == "replaced" + ) new_uploads_count = len(successful_uploads) - replaced_count self.console.print("\n[bold]Duplicate Detection Statistics:[/bold]") @@ -288,10 +293,20 @@ def _format_rich_output( self.console.print(f"• Total processed: {total_processed}") # Display Final Results Summary - self.console.print(f"\n[bold]Final Results:[/bold] {len(successful_uploads)} uploaded ({new_uploads_count} new, {replaced_count} replaced), {len(skipped_duplicates)} skipped duplicates, {len(failed_uploads)} failed out of {total_processed} total") + self.console.print( + f"\n[bold]Final Results:[/bold] {len(successful_uploads)} uploaded " + f"({new_uploads_count} new, {replaced_count} replaced), " + f"{len(skipped_duplicates)} skipped duplicates, " + f"{len(failed_uploads)} failed out of {total_processed} total" + ) if not failed_uploads: - self.console.print(f"\n[bold green]✓[/bold green] All files processed successfully for {package_name} v{version}: {new_uploads_count} new uploads, {replaced_count} replaced duplicates, {len(skipped_duplicates)} skipped duplicates") + self.console.print( + f"\n[bold green]✓[/bold green] All files processed successfully " + f"for {package_name} v{version}: {new_uploads_count} new uploads, " + f"{replaced_count} replaced duplicates, " + f"{len(skipped_duplicates)} skipped duplicates" + ) def _format_json_output( self, results: List[UploadResult], package_name: str, version: str @@ -322,48 +337,53 @@ def _format_json_output( # Build upload result objects for each category successful_uploads_data = [] for result in successful_uploads: - successful_uploads_data.append({ - "source_path": result.source_path, - "target_filename": result.target_filename, - "download_url": result.result, - "checksum": None, # Reserved for future use - "was_duplicate": result.was_duplicate, - "duplicate_action": result.duplicate_action, - "existing_url": result.existing_url, - "error_message": None, - }) + successful_uploads_data.append( + { + "source_path": result.source_path, + "target_filename": result.target_filename, + "download_url": result.result, + "checksum": None, # Reserved for future use + "was_duplicate": result.was_duplicate, + "duplicate_action": result.duplicate_action, + "existing_url": result.existing_url, + "error_message": None, + } + ) skipped_duplicates_data = [] for result in skipped_duplicates: - skipped_duplicates_data.append({ - "source_path": result.source_path, - "target_filename": result.target_filename, - "download_url": result.existing_url or result.result, - "checksum": None, - "was_duplicate": result.was_duplicate, - "duplicate_action": result.duplicate_action, - "existing_url": result.existing_url, - "error_message": None, - }) + skipped_duplicates_data.append( + { + "source_path": result.source_path, + "target_filename": result.target_filename, + "download_url": result.existing_url or result.result, + "checksum": None, + "was_duplicate": result.was_duplicate, + "duplicate_action": result.duplicate_action, + "existing_url": result.existing_url, + "error_message": None, + } + ) failed_uploads_data = [] for result in failed_uploads: - failed_uploads_data.append({ - "source_path": result.source_path, - "target_filename": result.target_filename, - "download_url": None, - "checksum": None, - "was_duplicate": result.was_duplicate, - "duplicate_action": result.duplicate_action, - "existing_url": result.existing_url, - "error_message": result.result, - }) + failed_uploads_data.append( + { + "source_path": result.source_path, + "target_filename": result.target_filename, + "download_url": None, + "checksum": None, + "was_duplicate": result.was_duplicate, + "duplicate_action": result.duplicate_action, + "existing_url": result.existing_url, + "error_message": result.result, + } + ) # Calculate statistics total_processed = len(successful_uploads) + len(skipped_duplicates) + len(failed_uploads) replaced_count = sum( - 1 for r in successful_uploads - if r.was_duplicate and r.duplicate_action == "replaced" + 1 for r in successful_uploads if r.was_duplicate and r.duplicate_action == "replaced" ) new_uploads_count = len(successful_uploads) - replaced_count @@ -467,8 +487,7 @@ def _format_plain_output( # Calculate statistics total_processed = len(successful_uploads) + len(skipped_duplicates) + len(failed_uploads) replaced_count = sum( - 1 for r in successful_uploads - if r.was_duplicate and r.duplicate_action == "replaced" + 1 for r in successful_uploads if r.was_duplicate and r.duplicate_action == "replaced" ) new_uploads_count = len(successful_uploads) - replaced_count @@ -481,10 +500,20 @@ def _format_plain_output( print(f"* Total processed: {total_processed}") # Display Final Results - print(f"\nFinal Results: {len(successful_uploads)} uploaded ({new_uploads_count} new, {replaced_count} replaced), {len(skipped_duplicates)} skipped duplicates, {len(failed_uploads)} failed out of {total_processed} total") + print( + f"\nFinal Results: {len(successful_uploads)} uploaded " + f"({new_uploads_count} new, {replaced_count} replaced), " + f"{len(skipped_duplicates)} skipped duplicates, " + f"{len(failed_uploads)} failed out of {total_processed} total" + ) if not failed_uploads: - print(f"\n[OK] All files processed successfully for {package_name} v{version}: {new_uploads_count} new uploads, {replaced_count} replaced duplicates, {len(skipped_duplicates)} skipped duplicates") + print( + f"\n[OK] All files processed successfully " + f"for {package_name} v{version}: {new_uploads_count} new uploads, " + f"{replaced_count} replaced duplicates, " + f"{len(skipped_duplicates)} skipped duplicates" + ) def create_progress_spinner(self, message: str) -> Status: """Create a progress spinner for long-running operations. @@ -508,9 +537,7 @@ def create_progress_spinner(self, message: str) -> Status: # Error Formatting Function -def format_error( - error: Exception, context: Optional[Dict[str, Any]] = None -) -> str: +def format_error(error: Exception, context: Optional[Dict[str, Any]] = None) -> str: """Format error messages with context for better debugging. Uses enhanced error messages from models.py when context is available. diff --git a/src/glpkg/models.py b/src/glpkg/models.py index 285ab6f..ad75134 100644 --- a/src/glpkg/models.py +++ b/src/glpkg/models.py @@ -177,9 +177,7 @@ class ChecksumValidationError(GitLabUploadError): # Error Enhancement Functions -def handle_project_not_found_error( - project_path: str, gitlab_url: str, original_error: str -) -> str: +def handle_project_not_found_error(project_path: str, gitlab_url: str, original_error: str) -> str: """ Generate helpful error message for project not found errors. @@ -209,9 +207,7 @@ def handle_project_not_found_error( ) -def handle_authentication_error( - project_path: str, gitlab_url: str, original_error: str -) -> str: +def handle_authentication_error(project_path: str, gitlab_url: str, original_error: str) -> str: """ Generate helpful error message for authentication failures. @@ -298,7 +294,8 @@ def handle_network_connectivity_error(gitlab_url: str, original_error: str) -> s f" 1. Test connectivity: curl -I {gitlab_url}\n" f" 2. Check GitLab status page (if available)\n" f" 3. Try accessing {gitlab_url} in a web browser\n" - f" 4. Verify DNS resolution: nslookup {gitlab_url.replace('https://', '').replace('http://', '')}\n\n" + f" 4. Verify DNS resolution: nslookup " + f"{gitlab_url.replace('https://', '').replace('http://', '')}\n\n" f"If using a corporate network:\n" f" • Check proxy settings\n" f" • Verify SSL certificate trust\n" @@ -307,7 +304,7 @@ def handle_network_connectivity_error(gitlab_url: str, original_error: str) -> s ) -def enhance_error_message(error: Exception, context: dict) -> str: +def enhance_error_message(error: Exception, context: dict[str, str]) -> str: """ Enhance error messages with context and helpful suggestions. @@ -329,14 +326,9 @@ def enhance_error_message(error: Exception, context: dict) -> str: if "404" in error_msg or "not found" in error_msg: return handle_project_not_found_error(project_path, gitlab_url, original_error) - elif any( - keyword in error_msg - for keyword in ["401", "403", "authentication", "unauthorized"] - ): + elif any(keyword in error_msg for keyword in ["401", "403", "authentication", "unauthorized"]): if "permission" in error_msg or "forbidden" in error_msg: - return handle_permission_error( - project_path, gitlab_url, operation, original_error - ) + return handle_permission_error(project_path, gitlab_url, operation, original_error) else: return handle_authentication_error(project_path, gitlab_url, original_error) diff --git a/src/glpkg/uploader.py b/src/glpkg/uploader.py index 75ea8e5..d509059 100644 --- a/src/glpkg/uploader.py +++ b/src/glpkg/uploader.py @@ -5,9 +5,7 @@ import logging import time from pathlib import Path -from typing import Optional -from gitlab import Gitlab from gitlab.exceptions import GitlabError from tenacity import ( retry, @@ -20,7 +18,6 @@ from .models import ( ChecksumValidationError, DuplicatePolicy, - NetworkError, RemoteFile, UploadContext, UploadResult, @@ -29,7 +26,7 @@ logger = logging.getLogger(__name__) -def is_transient_error(exception: Exception) -> bool: +def is_transient_error(exception: BaseException) -> bool: """ Determine if an exception represents a transient error that should be retried. @@ -154,9 +151,7 @@ def upload_single_file(context: UploadContext, file: Path, target_filename: str) ) elapsed_time = time.time() - start_time - logger.info( - f"Uploaded {target_filename} ({file_size_mb:.2f} MB) in {elapsed_time:.2f}s" - ) + logger.info(f"Uploaded {target_filename} ({file_size_mb:.2f} MB) in {elapsed_time:.2f}s") # Construct download URL download_url = ( @@ -167,9 +162,7 @@ def upload_single_file(context: UploadContext, file: Path, target_filename: str) return download_url -def validate_upload( - context: UploadContext, filename: str, expected_sha256: str -) -> bool: +def validate_upload(context: UploadContext, filename: str, expected_sha256: str) -> bool: """ Validate that an uploaded file has the correct checksum in the registry. @@ -193,18 +186,15 @@ def validate_upload( logger.debug(f"Expected SHA256: {expected_sha256}") project = context.gl.projects.get(context.project_id) - packages = project.packages.list( - package_name=context.config.package_name, get_all=True - ) + packages = project.packages.list(package_name=context.config.package_name, get_all=True) # Find the target package version - target_package = next( - (p for p in packages if p.version == context.config.version), None - ) + target_package = next((p for p in packages if p.version == context.config.version), None) if not target_package: logger.error( - f"Package {context.config.package_name} v{context.config.version} not found during validation" + f"Package {context.config.package_name} v{context.config.version} " + "not found during validation" ) return False @@ -244,17 +234,12 @@ def validate_upload( return True # Checksum mismatch - this is an error - error_msg = ( - f"Checksum mismatch for {filename}: " - f"expected {expected_sha256}, got {remote_sha256}" - ) + error_msg = f"Checksum mismatch for {filename}: expected {expected_sha256}, got {remote_sha256}" logger.error(error_msg) raise ChecksumValidationError(error_msg) -def handle_duplicate( - context: UploadContext, file: Path, remote: RemoteFile -) -> tuple[str, str]: +def handle_duplicate(context: UploadContext, file: Path, remote: RemoteFile) -> tuple[str, str]: """ Handle a detected duplicate file based on the configured policy. @@ -320,14 +305,10 @@ def delete_file_from_registry(context: UploadContext, filename: str) -> int: logger.debug(f"Deleting {filename} from registry") project = context.gl.projects.get(context.project_id) - packages = project.packages.list( - package_name=context.config.package_name, get_all=True - ) + packages = project.packages.list(package_name=context.config.package_name, get_all=True) # Find the target package version - target_package = next( - (p for p in packages if p.version == context.config.version), None - ) + target_package = next((p for p in packages if p.version == context.config.version), None) if not target_package: logger.warning( @@ -358,9 +339,7 @@ def delete_file_from_registry(context: UploadContext, filename: str) -> int: return deleted_count -def upload_files( - context: UploadContext, files: list[tuple[Path, str]] -) -> list[UploadResult]: +def upload_files(context: UploadContext, files: list[tuple[Path, str]]) -> list[UploadResult]: """ Upload multiple files to the GitLab generic package registry. @@ -386,9 +365,7 @@ def upload_files( ) if session_duplicate: - logger.info( - f"Session duplicate detected for {target_filename}, skipping" - ) + logger.info(f"Session duplicate detected for {target_filename}, skipping") # Construct URL from session duplicate info existing_url = ( f"{context.config.gitlab_url}/api/v4/projects/{context.project_id}" @@ -421,9 +398,7 @@ def upload_files( if remote_duplicate: try: - action, result_value = handle_duplicate( - context, source_path, remote_duplicate - ) + action, result_value = handle_duplicate(context, source_path, remote_duplicate) if action == "skipped": results.append( diff --git a/src/glpkg/validators.py b/src/glpkg/validators.py index 56c4894..2bc1b7e 100644 --- a/src/glpkg/validators.py +++ b/src/glpkg/validators.py @@ -33,7 +33,7 @@ def validate_filename(filename: str) -> None: """ - Validate filename contains only ASCII characters and allowed patterns for GitLab Generic Package Registry. + Validate filename for GitLab Generic Package Registry compatibility. GitLab's API restricts filenames to ASCII-safe characters only. This function checks if the provided filename complies with these restrictions. @@ -82,7 +82,8 @@ def validate_file_exists(file_path: Path) -> None: Examples: Valid: Path("package.tar.gz") (existing readable file) - Invalid: Path("nonexistent.bin"), Path("/some/directory"), Path("unreadable.txt") (no permissions) + Invalid: Path("nonexistent.bin"), Path("/some/directory"), + Path("unreadable.txt") (no permissions) """ # Check if path exists if not file_path.exists(): @@ -97,9 +98,7 @@ def validate_file_exists(file_path: Path) -> None: with open(file_path, "rb"): pass except (PermissionError, OSError): - raise FileValidationError( - f"File is not readable: {file_path}. Check file permissions." - ) + raise FileValidationError(f"File is not readable: {file_path}. Check file permissions.") def calculate_sha256(file_path: Path) -> str: @@ -131,7 +130,9 @@ def calculate_sha256(file_path: Path) -> str: for chunk in iter(lambda: f.read(8192), b""): sha256_hash.update(chunk) except (IOError, OSError) as e: - raise FileValidationError(f"Failed to read file for checksum calculation: {file_path}. Error: {e}") + raise FileValidationError( + f"Failed to read file for checksum calculation: {file_path}. Error: {e}" + ) return sha256_hash.hexdigest() @@ -173,8 +174,7 @@ def parse_file_mapping(mappings: list[str], files: list[str]) -> dict[str, str]: for mapping in mappings: if mapping.count(":") != 1: raise ConfigurationError( - f"Invalid file mapping format '{mapping}'. " - "Expected format: 'local.bin:remote.bin'" + f"Invalid file mapping format '{mapping}'. Expected format: 'local.bin:remote.bin'" ) local_name, remote_name = mapping.split(":", 1) file_mappings[local_name] = remote_name @@ -195,7 +195,7 @@ def collect_files( files: list[str] | None = None, directory: str | None = None, file_mappings: dict[str, str] | list[str] | None = None, -) -> tuple[list[tuple[Path, str]], list[dict]]: +) -> tuple[list[tuple[Path, str]], list[dict[str, str]]]: """ Collect files to upload based on input mode (files list or directory). @@ -237,7 +237,7 @@ def collect_files( >>> files_to_upload, errors = collect_files(directory="/path/to/uploads") """ files_to_upload: list[tuple[Path, str]] = [] - file_errors: list[dict] = [] + file_errors: list[dict[str, str]] = [] # Validate mutually exclusive inputs if files and directory: @@ -245,9 +245,7 @@ def collect_files( "Cannot specify both 'files' and 'directory'. They are mutually exclusive." ) if not files and not directory: - raise ConfigurationError( - "Either 'files' or 'directory' must be provided." - ) + raise ConfigurationError("Either 'files' or 'directory' must be provided.") # Handle file_mappings type conversion if file_mappings is None: @@ -613,8 +611,7 @@ def get_gitlab_token(cli_token: str | None = None) -> str: # No token found raise ConfigurationError( - "No GitLab token provided. " - "Set GITLAB_TOKEN environment variable or use --token argument" + "No GitLab token provided. Set GITLAB_TOKEN environment variable or use --token argument" ) @@ -703,7 +700,8 @@ def validate_dependencies() -> None: "• Check Python version: python --version\n" "• Check pip version: pip --version\n" "• Update pip: pip install --upgrade pip\n" - "• For corporate networks: pip install --trusted-host pypi.org --trusted-host pypi.python.org\n\n" + "• For corporate networks: pip install --trusted-host pypi.org " + "--trusted-host pypi.python.org\n\n" "For more help: https://packaging.python.org/tutorials/installing-packages/" ) @@ -769,7 +767,8 @@ def validate_gitlab_token(token: str, gitlab_url: str = DEFAULT_GITLAB_URL) -> N "TROUBLESHOOTING:\n" "• Check token format: should be 20+ characters\n" "• Verify token hasn't expired\n" - f"• Test token manually: curl -H 'PRIVATE-TOKEN: your-token' {gitlab_url}/api/v4/user\n\n" + f"• Test token manually: " + f"curl -H 'PRIVATE-TOKEN: your-token' {gitlab_url}/api/v4/user\n\n" "For more help: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html" ) @@ -836,9 +835,7 @@ def validate_git_installation() -> None: logger.debug("Validating Git installation...") try: - result = subprocess.run( - ["git", "--version"], capture_output=True, text=True, timeout=10 - ) + result = subprocess.run(["git", "--version"], capture_output=True, text=True, timeout=10) if result.returncode != 0: raise ConfigurationError( @@ -1190,8 +1187,7 @@ def validate_project_specification( else: raise ProjectResolutionError( - f"Unknown specification type: '{spec_type}'.\n" - "Expected 'url', 'path', or 'auto'." + f"Unknown specification type: '{spec_type}'.\nExpected 'url', 'path', or 'auto'." ) @@ -1270,9 +1266,7 @@ def validate_configuration( logger.error("Git installation validation failed") raise else: - logger.warning( - "Git installation validation failed (not required for this operation)" - ) + logger.warning("Git installation validation failed (not required for this operation)") logger.debug(f"Git validation error: {e}") # 4. Validate Git repository access (only if Git operations are required) From 27f812a5b38a90590319f5b0e87986eb1ec5c32e Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 16:33:33 -0600 Subject: [PATCH 34/36] tests: Add comprehensive unit tests for CLI, formatters, and uploader The existing test suite lacks coverage for several important code paths including error handling in execute_upload, edge cases in terminal detection, duplicate handling scenarios, and transient error classification. This gaps make it difficult to verify correct behavior during refactoring and could allow regressions to slip through. Extend the test suite with targeted tests for previously uncovered functionality. The execute_upload tests validate the complete flow from project resolution through file upload, including all exception types mapped in EXCEPTION_EXIT_CODE_MAP. The formatter tests cover ConEmu color detection on Windows and duplicate metadata display in both rich and plain output modes. The uploader tests verify GitlabError response code classification for transient vs permanent errors and the fail_fast behavior with remote duplicates. Signed-off-by: Javier Tia --- tests/unit/test_cli.py | 500 ++++++++++++++++++++++++++++++++++ tests/unit/test_formatters.py | 91 +++++++ tests/unit/test_uploader.py | 97 +++++++ 3 files changed, 688 insertions(+) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 144072b..06480d2 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -48,6 +48,7 @@ EXCEPTION_EXIT_CODE_MAP, # Functions auto_detect_project, + execute_upload, resolve_project_manually, validate_upload_flags, # Classes @@ -1171,6 +1172,70 @@ def test_timeout_error_exit_code(self): assert EXCEPTION_EXIT_CODE_MAP[TimeoutError] == 6 +class TestProjectResolverExceptionHandling: + """Tests for ProjectResolver exception handling.""" + + @pytest.mark.timeout(60) + def test_resolve_project_id_generic_exception(self, mock_gitlab_client): + """Test generic Exception in resolve_project_id raises ProjectResolutionError.""" + mock_gitlab_client.projects.get.side_effect = RuntimeError("Unexpected error") + + resolver = ProjectResolver(mock_gitlab_client) + with pytest.raises(ProjectResolutionError) as exc_info: + resolver.resolve_project_id("https://gitlab.com", "group/project") + + # The error should be wrapped in ProjectResolutionError + assert "Unexpected error" in str(exc_info.value) or exc_info.value is not None + + +class TestResolveProjectManuallyEdgeCases: + """Tests for edge cases in resolve_project_manually.""" + + @pytest.mark.timeout(60) + def test_resolve_project_manually_path_empty_components(self): + """Test manual resolution with path that has empty components after split.""" + with pytest.raises(ProjectResolutionError) as exc_info: + resolve_project_manually( + project_url=None, + project_path="//project", # Empty namespace component + gitlab_url="https://gitlab.com" + ) + assert "Invalid project path" in str(exc_info.value) + + +class TestGetVersionFallbacks: + """Tests for get_version function fallback behavior.""" + + @pytest.mark.timeout(60) + @patch('builtins.open', side_effect=FileNotFoundError("pyproject.toml not found")) + def test_get_version_file_not_found_fallback(self, mock_open): + """Test get_version falls back when pyproject.toml not found.""" + with patch('importlib.metadata.version', side_effect=Exception("Not installed")): + version = get_version() + # Should return "unknown" when all methods fail + assert version == "unknown" or isinstance(version, str) + + @pytest.mark.timeout(60) + @patch('builtins.open', side_effect=Exception("Read error")) + def test_get_version_read_error_fallback(self, mock_open): + """Test get_version handles exceptions gracefully.""" + version = get_version() + # Should return "unknown" or actual version + assert isinstance(version, str) + + +class TestMainFunctionEdgeCases: + """Tests for main function edge cases.""" + + @pytest.mark.timeout(60) + def test_main_with_only_debug_flag_no_subcommand(self): + """Test main with only debug flag and no subcommand.""" + with pytest.raises(SystemExit) as exc_info: + main(['--debug']) + # Should exit with 0 (show help) + assert exc_info.value.code == 0 + + class TestEdgeCases: """Tests for edge cases and error scenarios.""" @@ -1359,3 +1424,438 @@ def test_build_context_with_retry_count(self, mock_detector_class, mock_args, mo ) assert context.config.retry_count == 5 + + +class TestExecuteUpload: + """Tests for execute_upload function.""" + + @pytest.fixture + def upload_args(self, mock_args, tmp_path): + """Create args for execute_upload testing.""" + test_file = tmp_path / "test.bin" + test_file.write_bytes(b"test content") + mock_args.files = [str(test_file)] + mock_args.project_url = "https://gitlab.com/mygroup/myproject" + mock_args.project_path = None + return mock_args + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.Gitlab') + @patch('glpkg.cli.upload.ProjectResolver') + @patch('glpkg.cli.upload.UploadContextBuilder') + @patch('glpkg.cli.upload.upload_files') + @patch('glpkg.cli.upload.OutputFormatter') + @patch('glpkg.cli.upload.collect_files') + def test_execute_upload_success( + self, + mock_collect, + mock_formatter_class, + mock_upload_files, + mock_builder_class, + mock_resolver_class, + mock_gitlab_class, + mock_get_token, + upload_args, + tmp_path + ): + """Test successful execute_upload flow.""" + test_file = tmp_path / "test.bin" + + # Setup mocks + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gitlab_class.return_value = mock_gl + + mock_resolver = MagicMock() + mock_resolver.resolve_project_id.return_value = 12345 + mock_resolver.validate_project_access.return_value = True + mock_resolver_class.return_value = mock_resolver + + mock_builder = MagicMock() + mock_context = MagicMock() + mock_context.config.package_name = "test-package" + mock_context.config.version = "1.0.0" + mock_builder.build.return_value = mock_context + mock_builder_class.return_value = mock_builder + + mock_collect.return_value = ([(test_file, "test.bin")], []) + + mock_result = MagicMock() + mock_result.success = True + mock_upload_files.return_value = [mock_result] + + mock_formatter = MagicMock() + mock_formatter_class.return_value = mock_formatter + + with pytest.raises(SystemExit) as exc_info: + execute_upload(upload_args) + + # Should exit with 0 for success + assert exc_info.value.code == 0 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.auto_detect_project') + def test_execute_upload_auto_detect_project_error(self, mock_auto_detect, mock_args): + """Test execute_upload handles ProjectResolutionError during auto-detect.""" + mock_args.project_url = None + mock_args.project_path = None + mock_args.files = ["test.txt"] + mock_args.directory = None + mock_args.file_mapping = None + + mock_auto_detect.side_effect = ProjectResolutionError("No Git repository found") + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + # Should exit with the error's exit code + assert exc_info.value.code > 0 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_manual_resolution_error(self, mock_resolve, mock_args): + """Test execute_upload handles errors during manual project resolution.""" + mock_args.project_url = "https://gitlab.com/invalid" + mock_args.project_path = None + mock_args.files = ["test.txt"] + mock_args.directory = None + mock_args.file_mapping = None + + mock_resolve.side_effect = ProjectResolutionError("Invalid project URL") + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + assert exc_info.value.code > 0 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_authentication_error( + self, mock_resolve, mock_get_token, mock_args + ): + """Test execute_upload handles AuthenticationError.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = None + mock_args.files = ["test.txt"] + mock_args.directory = None + mock_args.file_mapping = None + + mock_resolve.return_value = ("https://gitlab.com", "group/project") + mock_get_token.side_effect = AuthenticationError("No token found") + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + assert exc_info.value.code > 0 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.Gitlab') + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_connection_error( + self, mock_resolve, mock_gitlab_class, mock_get_token, mock_args + ): + """Test execute_upload handles ConnectionError.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = None + mock_args.files = ["test.txt"] + mock_args.directory = None + mock_args.file_mapping = None + + mock_resolve.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gl.auth.side_effect = ConnectionError("Network error") + mock_gitlab_class.return_value = mock_gl + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + assert exc_info.value.code == 6 # Connection error exit code + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.Gitlab') + @patch('glpkg.cli.upload.ProjectResolver') + @patch('glpkg.cli.upload.UploadContextBuilder') + @patch('glpkg.cli.upload.collect_files') + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_no_valid_files( + self, + mock_resolve, + mock_collect, + mock_builder_class, + mock_resolver_class, + mock_gitlab_class, + mock_get_token, + mock_args, + ): + """Test execute_upload exits when no valid files to upload.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = None + mock_args.files = ["nonexistent.txt"] + mock_args.directory = None + mock_args.file_mapping = None + + mock_resolve.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gitlab_class.return_value = mock_gl + + mock_resolver = MagicMock() + mock_resolver.resolve_project_id.return_value = 12345 + mock_resolver.validate_project_access.return_value = True + mock_resolver_class.return_value = mock_resolver + + mock_builder = MagicMock() + mock_context = MagicMock() + mock_builder.build.return_value = mock_context + mock_builder_class.return_value = mock_builder + + # No valid files, only errors + mock_collect.return_value = ([], [{"source_path": "nonexistent.txt", "error_message": "Not found"}]) + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + assert exc_info.value.code == 5 # File validation error exit code + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.Gitlab') + @patch('glpkg.cli.upload.ProjectResolver') + @patch('glpkg.cli.upload.UploadContextBuilder') + @patch('glpkg.cli.upload.upload_files') + @patch('glpkg.cli.upload.OutputFormatter') + @patch('glpkg.cli.upload.collect_files') + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_with_failed_uploads( + self, + mock_resolve, + mock_collect, + mock_formatter_class, + mock_upload_files, + mock_builder_class, + mock_resolver_class, + mock_gitlab_class, + mock_get_token, + mock_args, + tmp_path, + ): + """Test execute_upload exits with 1 when some uploads fail.""" + test_file = tmp_path / "test.bin" + test_file.write_bytes(b"test content") + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = None + mock_args.files = [str(test_file)] + mock_args.directory = None + mock_args.file_mapping = None + + mock_resolve.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gitlab_class.return_value = mock_gl + + mock_resolver = MagicMock() + mock_resolver.resolve_project_id.return_value = 12345 + mock_resolver.validate_project_access.return_value = True + mock_resolver_class.return_value = mock_resolver + + mock_builder = MagicMock() + mock_context = MagicMock() + mock_context.config.package_name = "test-package" + mock_context.config.version = "1.0.0" + mock_builder.build.return_value = mock_context + mock_builder_class.return_value = mock_builder + + mock_collect.return_value = ([(test_file, "test.bin")], []) + + # One failed upload + mock_result = MagicMock() + mock_result.success = False + mock_upload_files.return_value = [mock_result] + + mock_formatter = MagicMock() + mock_formatter_class.return_value = mock_formatter + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + # Should exit with 1 for failed uploads + assert exc_info.value.code == 1 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.Gitlab') + @patch('glpkg.cli.upload.ProjectResolver') + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_project_access_denied( + self, + mock_resolve, + mock_resolver_class, + mock_gitlab_class, + mock_get_token, + mock_args, + ): + """Test execute_upload handles project access validation failure.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = None + mock_args.files = ["test.txt"] + mock_args.directory = None + mock_args.file_mapping = None + + mock_resolve.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gitlab_class.return_value = mock_gl + + mock_resolver = MagicMock() + mock_resolver.resolve_project_id.return_value = 12345 + mock_resolver.validate_project_access.return_value = False # Access denied + mock_resolver_class.return_value = mock_resolver + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + assert exc_info.value.code > 0 + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.Gitlab') + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_timeout_error( + self, mock_resolve, mock_gitlab_class, mock_get_token, mock_args + ): + """Test execute_upload handles TimeoutError.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = None + mock_args.files = ["test.txt"] + mock_args.directory = None + mock_args.file_mapping = None + + mock_resolve.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gl.auth.side_effect = TimeoutError("Connection timed out") + mock_gitlab_class.return_value = mock_gl + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + assert exc_info.value.code == 6 # Timeout error exit code + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.Gitlab') + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_value_error( + self, mock_resolve, mock_gitlab_class, mock_get_token, mock_args + ): + """Test execute_upload handles ValueError.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = None + mock_args.files = ["test.txt"] + mock_args.directory = None + mock_args.file_mapping = None + + mock_resolve.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gl.auth.side_effect = ValueError("Invalid value") + mock_gitlab_class.return_value = mock_gl + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + assert exc_info.value.code == 3 # Value error exit code + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.Gitlab') + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_unexpected_error( + self, mock_resolve, mock_gitlab_class, mock_get_token, mock_args + ): + """Test execute_upload handles unexpected errors.""" + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = None + mock_args.files = ["test.txt"] + mock_args.directory = None + mock_args.file_mapping = None + + mock_resolve.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gl.auth.side_effect = RuntimeError("Unexpected error") + mock_gitlab_class.return_value = mock_gl + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + assert exc_info.value.code == 1 # Generic error exit code + + @pytest.mark.timeout(60) + @patch('glpkg.cli.upload.get_gitlab_token') + @patch('glpkg.cli.upload.Gitlab') + @patch('glpkg.cli.upload.ProjectResolver') + @patch('glpkg.cli.upload.UploadContextBuilder') + @patch('glpkg.cli.upload.collect_files') + @patch('glpkg.cli.upload.resolve_project_manually') + def test_execute_upload_file_errors_fail_fast( + self, + mock_resolve, + mock_collect, + mock_builder_class, + mock_resolver_class, + mock_gitlab_class, + mock_get_token, + mock_args, + tmp_path, + ): + """Test execute_upload with file errors and fail_fast enabled.""" + test_file = tmp_path / "test.bin" + test_file.write_bytes(b"test content") + mock_args.project_url = "https://gitlab.com/group/project" + mock_args.project_path = None + mock_args.files = [str(test_file)] + mock_args.directory = None + mock_args.file_mapping = None + mock_args.fail_fast = True + + mock_resolve.return_value = ("https://gitlab.com", "group/project") + mock_get_token.return_value = "test-token" + + mock_gl = MagicMock() + mock_gitlab_class.return_value = mock_gl + + mock_resolver = MagicMock() + mock_resolver.resolve_project_id.return_value = 12345 + mock_resolver.validate_project_access.return_value = True + mock_resolver_class.return_value = mock_resolver + + mock_builder = MagicMock() + mock_context = MagicMock() + mock_builder.build.return_value = mock_context + mock_builder_class.return_value = mock_builder + + # Some valid files, some errors + mock_collect.return_value = ( + [(test_file, "test.bin")], + [{"source_path": "bad.txt", "error_message": "Not found"}] + ) + + with pytest.raises(SystemExit) as exc_info: + execute_upload(mock_args) + + # Should exit with 5 (file validation error) due to fail_fast + assert exc_info.value.code == 5 diff --git a/tests/unit/test_formatters.py b/tests/unit/test_formatters.py index 9d24955..6067ece 100644 --- a/tests/unit/test_formatters.py +++ b/tests/unit/test_formatters.py @@ -387,6 +387,25 @@ def test_detect_color_support_windows_ansicon(self, clean_env): with patch.object(sys, "platform", "win32"): assert detect_color_support() is True + @pytest.mark.timeout(60) + def test_detect_color_support_windows_conemu_on(self, clean_env): + """Test detect_color_support returns True on Windows with ConEmuANSI=ON.""" + os.environ["ConEmuANSI"] = "ON" + + with patch("glpkg.formatters.detect_tty", return_value=True): + with patch.object(sys, "platform", "win32"): + assert detect_color_support() is True + + @pytest.mark.timeout(60) + def test_detect_color_support_windows_conemu_off(self, clean_env): + """Test detect_color_support returns False on Windows with ConEmuANSI not ON.""" + os.environ["ConEmuANSI"] = "OFF" + + with patch("glpkg.formatters.detect_tty", return_value=True): + with patch.object(sys, "platform", "win32"): + # Should return False as only "ON" enables color support + assert detect_color_support() is False + @pytest.mark.timeout(60) def test_detect_color_support_precedence(self, clean_env): """Test that NO_COLOR takes precedence over FORCE_COLOR.""" @@ -444,6 +463,20 @@ def test_detect_unicode_support_with_ascii_encoding(self, clean_env): with patch.object(sys, "stdout", mock_stdout): assert detect_unicode_support() is False + @pytest.mark.timeout(60) + def test_detect_unicode_support_encoding_raises_exception(self, clean_env): + """Test detect_unicode_support handles exception when accessing encoding.""" + mock_stdout = Mock() + # Make encoding access raise an exception + type(mock_stdout).encoding = property(lambda self: (_ for _ in ()).throw(AttributeError("No encoding"))) + mock_stdout.isatty.return_value = True + + with patch.object(sys, "stdout", mock_stdout): + with patch("glpkg.formatters.detect_tty", return_value=True): + # Should return False as encoding check failed + result = detect_unicode_support() + assert result is False + class TestOutputFormatterInit: """Tests for OutputFormatter initialization.""" @@ -637,6 +670,36 @@ def test_format_rich_output_statistics(self, mock_upload_results, mock_rich_cons assert "Failed uploads:" in output assert "Total processed:" in output + @pytest.mark.timeout(60) + def test_format_rich_output_failed_with_duplicate_info(self, mock_rich_console): + """Test rich output displays failed uploads with duplicate metadata.""" + config = create_upload_config(plain_output=False) + + with patch("glpkg.formatters.detect_tty", return_value=True): + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=False, + result="Duplicate file detected", + was_duplicate=True, + duplicate_action="error", + existing_url="https://gitlab.com/existing/file.txt", + ) + ] + + captured = io.StringIO() + formatter.console = MockConsole(file=captured, force_terminal=True) + + formatter._format_rich_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "Failed Uploads" in output + assert "Duplicate Action:" in output or "error" in output + assert "Existing URL:" in output or "existing" in output.lower() + @pytest.mark.timeout(60) def test_format_rich_output_empty_results(self, mock_rich_console): """Test rich output handles empty results list.""" @@ -1088,6 +1151,34 @@ def test_format_plain_output_replaced_duplicates(self, mock_rich_console): assert "Action: Replaced existing duplicate" in output assert "Previous URL:" in output + @pytest.mark.timeout(60) + def test_format_plain_output_failed_with_duplicate_info(self, mock_rich_console): + """Test plain output displays failed uploads with duplicate metadata.""" + config = create_upload_config(plain_output=True) + formatter = OutputFormatter(config) + + results = [ + create_upload_result( + source_path="/path/to/file.txt", + target_filename="file.txt", + success=False, + result="Duplicate file detected", + was_duplicate=True, + duplicate_action="error", + existing_url="https://gitlab.com/existing/file.txt", + ) + ] + + with capture_stdout() as captured: + formatter._format_plain_output(results, "test-package", "1.0.0") + + output = captured.getvalue() + assert "[FAIL] Failed Uploads" in output + assert "Duplicate Action:" in output + assert "error" in output + assert "Existing URL:" in output + assert "existing" in output + class TestErrorFormatting: """Tests for error formatting functions.""" diff --git a/tests/unit/test_uploader.py b/tests/unit/test_uploader.py index 58f7a52..8b123f8 100644 --- a/tests/unit/test_uploader.py +++ b/tests/unit/test_uploader.py @@ -156,6 +156,62 @@ def test_connection_error_is_transient(self): error = ConnectionError("Connection refused") assert is_transient_error(error) is True + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_502(self): + """Test GitlabError with response_code=502 returns True.""" + error = GitlabError("Bad Gateway") + error.response_code = 502 + assert is_transient_error(error) is True + + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_503(self): + """Test GitlabError with response_code=503 returns True.""" + error = GitlabError("Service Unavailable") + error.response_code = 503 + assert is_transient_error(error) is True + + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_408(self): + """Test GitlabError with response_code=408 (Request Timeout) returns True.""" + error = GitlabError("Request Timeout") + error.response_code = 408 + assert is_transient_error(error) is True + + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_429(self): + """Test GitlabError with response_code=429 (Rate Limited) returns True.""" + error = GitlabError("Too Many Requests") + error.response_code = 429 + assert is_transient_error(error) is True + + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_403(self): + """Test GitlabError with response_code=403 returns False.""" + error = GitlabError("Forbidden") + error.response_code = 403 + assert is_transient_error(error) is False + + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_404(self): + """Test GitlabError with response_code=404 returns False.""" + error = GitlabError("Not Found") + error.response_code = 404 + assert is_transient_error(error) is False + + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_400(self): + """Test GitlabError with response_code=400 returns False.""" + error = GitlabError("Bad Request") + error.response_code = 400 + assert is_transient_error(error) is False + + @pytest.mark.timeout(60) + def test_gitlab_error_with_response_code_422(self): + """Test GitlabError with response_code=422 (Unprocessable Entity) returns False.""" + error = GitlabError("Unprocessable Entity") + error.response_code = 422 + assert is_transient_error(error) is False + @pytest.mark.timeout(60) def test_timeout_error_is_transient(self): """Test TimeoutError returns True.""" @@ -524,6 +580,19 @@ def test_validate_filename_with_path_variations( class TestHandleDuplicate: """Test duplicate handling based on policy.""" + @pytest.mark.timeout(60) + def test_handle_duplicate_unknown_policy_raises_error( + self, mock_upload_context, mock_file_path, sample_remote_file + ): + """Test unknown duplicate policy raises ValueError.""" + # Set an invalid policy by bypassing the enum + mock_upload_context.config.duplicate_policy = "invalid_policy" + + with pytest.raises(ValueError) as exc_info: + handle_duplicate(mock_upload_context, mock_file_path, sample_remote_file) + + assert "Unknown duplicate policy" in str(exc_info.value) + @pytest.mark.timeout(60) def test_handle_duplicate_skip_policy( self, mock_upload_context, mock_file_path, sample_remote_file @@ -940,6 +1009,34 @@ def test_upload_files_remote_duplicate_error_policy( assert results[0].was_duplicate is True assert results[0].duplicate_action == "error" + @pytest.mark.timeout(60) + def test_upload_files_remote_duplicate_error_policy_fail_fast( + self, mock_upload_context, mock_file_path, sample_remote_file, tmp_path + ): + """Test remote duplicate with ERROR policy and fail_fast enabled stops early.""" + mock_upload_context.config.duplicate_policy = DuplicatePolicy.ERROR + mock_upload_context.config.fail_fast = True + mock_upload_context.detector.check_remote_duplicate.return_value = ( + sample_remote_file + ) + + file2 = tmp_path / "file2.bin" + file2.write_bytes(b"content2") + + with patch( + "glpkg.uploader.calculate_sha256", return_value="a" * 64 + ): + results = upload_files( + mock_upload_context, + [(mock_file_path, "target1.bin"), (file2, "target2.bin")] + ) + + # With fail_fast, should stop after first error + assert len(results) == 1 + assert results[0].success is False + assert results[0].was_duplicate is True + assert results[0].duplicate_action == "error" + @pytest.mark.timeout(60) def test_upload_files_fail_fast_enabled( self, mock_upload_context, tmp_path, mock_project From 89a549d35ae4644f9dbbd90b2ce6fc64c47d00ec Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 16:40:17 -0600 Subject: [PATCH 35/36] readme: Update workflow badge URLs to correct repository The README workflow status badges reference placeholder repository paths that do not correspond to an actual GitHub repository. This causes broken badge images and prevents users from seeing the real CI status at a glance. Point all badge URLs to the actual jetm/glpkg repository where the workflows are defined and executed. This ensures badge images resolve correctly and accurately reflect the current build status. Signed-off-by: Javier Tia --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 72e5895..3ac0abb 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # glpkg -![Tests](https://github.com/OWNER/REPO/actions/workflows/test.yml/badge.svg) -![Lint](https://github.com/OWNER/REPO/actions/workflows/lint.yml/badge.svg) -![Publish](https://github.com/OWNER/REPO/actions/workflows/publish.yml/badge.svg) -![Docs](https://github.com/OWNER/REPO/actions/workflows/docs.yml/badge.svg) +![Tests](https://github.com/jetm/glpkg/actions/workflows/test.yml/badge.svg) +![Lint](https://github.com/jetm/glpkg/actions/workflows/lint.yml/badge.svg) +![Publish](https://github.com/jetm/glpkg/actions/workflows/publish.yml/badge.svg) +![Docs](https://github.com/jetm/glpkg/actions/workflows/docs.yml/badge.svg) ![Coverage](https://img.shields.io/badge/coverage-95%25-brightgreen) A CLI tool for uploading files to GitLab's Generic Package Registry. From 8578c0fca89c852917ac0609e0257fe8a1870165 Mon Sep 17 00:00:00 2001 From: Javier Tia Date: Sat, 10 Jan 2026 16:42:32 -0600 Subject: [PATCH 36/36] github/workflows: Switch dependency caching from pip to uv The workflows currently use pip's built-in caching through setup-python, but dependencies are installed via uv. This mismatch means the cache never benefits actual installs since uv maintains its own separate cache directory, resulting in unnecessary network fetches on every CI run. Enable uv's native caching mechanism in setup-uv instead. This ensures the cache aligns with the package manager actually performing installs, reducing CI execution time and network overhead. Signed-off-by: Javier Tia --- .github/workflows/lint.yml | 3 ++- .github/workflows/test.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 15f9b48..3c11ae5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -20,12 +20,13 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v4 + with: + enable-cache: true - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.11' - cache: 'pip' - name: Install dependencies run: uv pip install -e ".[dev]" --system diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a779608..85cc9e4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,12 +24,13 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v4 + with: + enable-cache: true - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - cache: pip - name: Install package in editable mode run: uv pip install -e . --system