diff --git a/README.md b/README.md index 17a15b9..5932e34 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ [![Coverage](https://codecov.io/gh/rahulkaushal04/depkeeper/branch/main/graph/badge.svg)](https://codecov.io/gh/rahulkaushal04/depkeeper) [![PyPI version](https://badge.fury.io/py/depkeeper.svg)](https://badge.fury.io/py/depkeeper) [![Python versions](https://img.shields.io/pypi/pyversions/depkeeper.svg)](https://pypi.org/project/depkeeper/) +[![Downloads](https://static.pepy.tech/badge/depkeeper)](https://pepy.tech/project/depkeeper) +[![Downloads per month](https://static.pepy.tech/badge/depkeeper/month)](https://pepy.tech/project/depkeeper) [![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) Modern, intelligent Python dependency management for `requirements.txt` files. diff --git a/depkeeper/__version__.py b/depkeeper/__version__.py index 510076d..88a2ac6 100644 --- a/depkeeper/__version__.py +++ b/depkeeper/__version__.py @@ -9,4 +9,4 @@ from __future__ import annotations #: Current depkeeper version (PEP 440 compliant). -__version__: str = "0.1.0.dev3" +__version__: str = "0.1.0" diff --git a/depkeeper/cli.py b/depkeeper/cli.py index 3adecf9..2c23add 100644 --- a/depkeeper/cli.py +++ b/depkeeper/cli.py @@ -1,8 +1,8 @@ """ Command-line interface for depkeeper. -This module defines the main Click entry point, global options, command -registration, and top-level error handling for the depkeeper CLI. +This module provides the main CLI entry point and handles global options, +configuration loading, and command registration. """ from __future__ import annotations @@ -15,11 +15,12 @@ import click +from depkeeper.config import load_config from depkeeper.__version__ import __version__ from depkeeper.context import DepKeeperContext -from depkeeper.exceptions import DepKeeperError -from depkeeper.utils.console import print_error, print_warning +from depkeeper.exceptions import ConfigError, DepKeeperError from depkeeper.utils.logger import get_logger, setup_logging +from depkeeper.utils.console import print_error, print_warning logger = get_logger("cli") @@ -73,10 +74,19 @@ def cli( """ _configure_logging(verbose) + try: + loaded_config = load_config(config) + except ConfigError as exc: + print_error(str(exc)) + raise SystemExit(1) from exc + depkeeper_ctx = DepKeeperContext() - depkeeper_ctx.config_path = config - depkeeper_ctx.verbose = verbose + depkeeper_ctx.config_path = config or ( + loaded_config.source_path if loaded_config.source_path else None + ) depkeeper_ctx.color = color + depkeeper_ctx.verbose = verbose + depkeeper_ctx.config = loaded_config ctx.obj = depkeeper_ctx # Respect NO_COLOR for downstream libraries @@ -86,7 +96,9 @@ def cli( os.environ["NO_COLOR"] = "1" logger.debug("depkeeper v%s", __version__) - logger.debug("Config path: %s", config) + logger.debug("Config path: %s", depkeeper_ctx.config_path) + if loaded_config.source_path: + logger.debug("Loaded configuration: %s", loaded_config.to_log_dict()) logger.debug("Verbosity: %s | Color: %s", verbose, color) diff --git a/depkeeper/commands/check.py b/depkeeper/commands/check.py index 9afa885..7310e38 100644 --- a/depkeeper/commands/check.py +++ b/depkeeper/commands/check.py @@ -1,33 +1,7 @@ """Check command implementation for depkeeper. -Analyzes a ``requirements.txt`` file to identify packages with available -updates, dependency conflicts, and Python version compatibility issues. - -The command orchestrates three core components: - -1. **RequirementsParser** — parses the requirements file into structured - :class:`Requirement` objects. -2. **VersionChecker** — queries PyPI concurrently to fetch latest versions - and compute recommendations. -3. **DependencyAnalyzer** — cross-validates all recommended versions and - resolves conflicts through iterative downgrading/constraining. - -All components share a single :class:`PyPIDataStore` instance to guarantee -that each package's metadata is fetched at most once per invocation. - -Typical usage:: - - # Show all packages with available updates - $ depkeeper check requirements.txt --outdated-only - - # Machine-readable JSON output - $ depkeeper check --format json > report.json - - # Enable conflict resolution (adjusts recommendations to be mutually compatible) - $ depkeeper check --check-conflicts - - # Strict mode: only use pinned versions, don't infer from constraints - $ depkeeper check --strict-version-matching +Analyzes requirements files to identify available updates, dependency +conflicts, and Python version compatibility issues. """ from __future__ import annotations @@ -37,7 +11,7 @@ import click import asyncio from pathlib import Path -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from depkeeper.models import Package from depkeeper.exceptions import DepKeeperError, ParseError @@ -84,12 +58,12 @@ @click.option( "--strict-version-matching", is_flag=True, + default=None, help="Only use exact version pins, don't infer from constraints.", ) @click.option( - "--check-conflicts", - is_flag=True, - default=True, + "--check-conflicts/--no-check-conflicts", + default=None, help="Check for dependency conflicts between packages.", ) @pass_context @@ -98,8 +72,8 @@ def check( file: Path, outdated_only: bool, format: str, - strict_version_matching: bool, - check_conflicts: bool, + strict_version_matching: Optional[bool], + check_conflicts: Optional[bool], ) -> None: """Check requirements file for available updates. @@ -108,16 +82,18 @@ def check( Optionally performs dependency conflict analysis to ensure recommended updates are compatible with each other. - When ``--check-conflicts`` is enabled, the command: + \b + When --check-conflicts is enabled (the default), the command: + 1. Fetches initial recommendations for every package. + 2. Cross-validates all recommendations to detect conflicts. + 3. Iteratively adjusts versions until a conflict-free set is found. + 4. Displays the final resolved versions along with any unresolved + conflicts. - 1. Fetches initial recommendations for every package. - 2. Cross-validates all recommendations to detect conflicts (package A's - dependency on B is incompatible with B's recommended version). - 3. Iteratively adjusts versions (downgrading sources or constraining - targets) until a conflict-free set is found or the iteration limit - is reached. - 4. Displays the final resolved versions along with any unresolved - conflicts. + Options not explicitly provided on the command line fall back to values + from the configuration file (depkeeper.toml or pyproject.toml), then + to built-in defaults. + \f Args: ctx: Depkeeper context with configuration and verbosity settings. @@ -126,19 +102,22 @@ def check( format: Output format (``table``, ``simple``, or ``json``). strict_version_matching: Don't infer current versions from constraints like ``>=2.0``; only use exact pins (``==``). - check_conflicts: Enable cross-package conflict resolution. + Falls back to the ``strict_version_matching`` config option. + check_conflicts: Enable cross-package conflict resolution. Falls + back to the ``check_conflicts`` config option. Exits: - 0 if all packages are up-to-date, 1 if updates are available or an - error occurred. - - Example:: - - >>> # CLI invocation - $ depkeeper check requirements.txt --check-conflicts --format table + 0 if the command completed successfully (whether or not updates + are available), 1 if an error occurred. """ + cfg = ctx.config + if strict_version_matching is None: + strict_version_matching = cfg.strict_version_matching if cfg else False + if check_conflicts is None: + check_conflicts = cfg.check_conflicts if cfg else True + try: - has_updates = asyncio.run( + asyncio.run( _check_async( ctx, file, @@ -148,7 +127,7 @@ def check( check_conflicts=check_conflicts, ) ) - sys.exit(1 if has_updates else 0) + sys.exit(0) except DepKeeperError as e: print_error(f"{e}") @@ -195,7 +174,9 @@ async def _check_async( Returns: ``True`` if any package has updates or unresolved conflicts, - ``False`` if everything is up-to-date. + ``False`` if everything is up-to-date. The return value is + used only for informational logging; it does not affect the + exit code (which is always 0 on success). Raises: DepKeeperError: Requirements file cannot be parsed or is malformed. @@ -365,12 +346,18 @@ def _display_table(packages: List[Package]) -> None: Example:: - ┏━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━┓ - ┃ Status ┃ Package ┃ Current ┃ Latest ┃ Update Type ┃ - ┡━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━┩ - │ ✓ OK │ requests │ 2.31.0 │ 2.31.0 │ - │ - │ ⬆ OUTDATED │ flask │ 2.0.0 │ 3.0.0 │ major │ - └────────────┴─────────────┴───────────┴───────────┴──────────────┘ + Dependency Status + + Status Package Current Latest Recommended Update Type Conflicts Python Support + + ✓ OK django 3.2.0 5.0.2 - - - Current: >=3.8 + Latest: >=3.10 + + ⬆ OUTDATED requests 2.28.0 2.32.0 2.32.0 minor - Current: >=3.7 + Latest: >=3.8 + + ⬆ OUTDATED flask 2.0.0 3.0.1 2.3.3 patch - Current: >=3.7 + Latest: >=3.8 """ data = [_create_table_row(pkg) for pkg in packages] @@ -536,10 +523,12 @@ def _display_simple(packages: List[Package]) -> None: Example:: - [OUTDATED] flask 2.0.0 → 3.0.0 - ⚠ Conflict: werkzeug requires >=2.0,<3 + requests 2.28.0 → 2.32.0 (recommended: 2.32.0) Python: installed: >=3.7, latest: >=3.8 - [OK] requests 2.31.0 → 2.31.0 + flask 2.0.0 → 3.0.1 (recommended: 2.3.3) + Python: installed: >=3.7, latest: >=3.8, recommended: >=3.7 + celery 5.3.0 → 5.3.6 + Python: installed: >=3.8, latest: >=3.8 """ console = get_raw_console() @@ -594,12 +583,19 @@ def _display_json(packages: List[Package]) -> None: [ { - "name": "flask", - "current_version": "2.0.0", - "latest_version": "3.0.0", - "recommended_version": "2.3.3", - "conflicts": [...], - "metadata": {...} + "name": "requests", + "status": "outdated", + "versions": { + "current": "2.28.0", + "latest": "2.32.0", + "recommended": "2.32.0" + }, + "update_type": "minor", + "python_requirements": { + "current": ">=3.7", + "latest": ">=3.8", + "recommended": ">=3.8" + } }, ... ] diff --git a/depkeeper/commands/update.py b/depkeeper/commands/update.py index cecc4bc..228afc2 100644 --- a/depkeeper/commands/update.py +++ b/depkeeper/commands/update.py @@ -1,39 +1,7 @@ """Update command implementation for depkeeper. -Updates packages in a ``requirements.txt`` file to safe upgrade versions -while maintaining major version boundaries and Python compatibility. - -The command orchestrates three core components: - -1. **RequirementsParser** — parses the requirements file into structured - :class:`Requirement` objects. -2. **VersionChecker** — queries PyPI concurrently to fetch latest versions - and compute recommendations. -3. **DependencyAnalyzer** — cross-validates all recommended versions and - resolves conflicts through iterative downgrading/constraining. - -All components share a single :class:`PyPIDataStore` instance to guarantee -that each package's metadata is fetched at most once per invocation. - -Recommended versions **never** cross major version boundaries, ensuring -that updates avoid breaking changes from major version upgrades. - -Typical usage:: - - # Update all packages to safe versions - $ depkeeper update requirements.txt - - # Preview changes without applying - $ depkeeper update --dry-run - - # Update only specific packages - $ depkeeper update -p flask -p click - - # Create backup and skip confirmation - $ depkeeper update --backup -y - - # Disable conflict resolution (faster, but may create conflicts) - $ depkeeper update --no-check-conflicts +Updates packages in requirements files to safe upgrade versions while +maintaining major version boundaries and Python compatibility. """ from __future__ import annotations @@ -43,7 +11,7 @@ import shutil import asyncio from pathlib import Path -from typing import Dict, List, Tuple +from typing import Dict, List, Optional, Tuple from depkeeper.models import Package, Requirement from depkeeper.exceptions import DepKeeperError, ParseError @@ -102,12 +70,12 @@ @click.option( "--strict-version-matching", is_flag=True, + default=None, help="Only use exact version pins, don't infer from constraints.", ) @click.option( - "--check-conflicts", - is_flag=True, - default=True, + "--check-conflicts/--no-check-conflicts", + default=None, help="Check for dependency conflicts and adjust versions accordingly.", ) @pass_context @@ -118,28 +86,26 @@ def update( yes: bool, backup: bool, packages: Tuple[str, ...], - strict_version_matching: bool, - check_conflicts: bool, + strict_version_matching: Optional[bool], + check_conflicts: Optional[bool], ) -> None: """Update packages to safe upgrade versions. - Updates packages to their recommended versions — the maximum version + Updates packages to their recommended versions -- the maximum version within the same major version that is compatible with your Python version. This avoids breaking changes from major version upgrades. - When ``--strict-version-matching`` is disabled (default), the command - can infer the current version from range constraints like ``>=2.0``. - When enabled, only exact pins (``==``) are treated as current versions. - - When ``--check-conflicts`` is enabled (default), the command: + \b + When --check-conflicts is enabled (the default), the command: + 1. Fetches initial recommendations for every package. + 2. Cross-validates all recommendations to detect conflicts. + 3. Iteratively adjusts versions until a conflict-free set is found. + 4. Applies the final resolved versions to the requirements file. - 1. Fetches initial recommendations for every package. - 2. Cross-validates all recommendations to detect conflicts (package A's - dependency on B is incompatible with B's recommended version). - 3. Iteratively adjusts versions (downgrading sources or constraining - targets) until a conflict-free set is found or the iteration limit - is reached. - 4. Applies the final resolved versions to the requirements file. + Options not explicitly provided on the command line fall back to values + from the configuration file (depkeeper.toml or pyproject.toml), then + to built-in defaults. + \f Args: ctx: Depkeeper context with configuration and verbosity settings. @@ -149,20 +115,21 @@ def update( backup: Create a timestamped backup before modifying the file. packages: Only update these packages (empty = update all). strict_version_matching: Don't infer current versions from - constraints; only use exact pins (``==``). - check_conflicts: Enable dependency conflict resolution. When enabled, - the command will adjust recommended versions to avoid conflicts. + constraints; only use exact pins (``==``). Falls back to the + ``strict_version_matching`` config option. + check_conflicts: Enable dependency conflict resolution. Falls + back to the ``check_conflicts`` config option. Exits: 0 if updates were applied successfully or no updates needed, 1 if an error occurred. - - Example:: - - >>> # CLI invocation - $ depkeeper update requirements.txt --dry-run - $ depkeeper update -p flask -p click --backup -y """ + cfg = ctx.config + if strict_version_matching is None: + strict_version_matching = cfg.strict_version_matching if cfg else False + if check_conflicts is None: + check_conflicts = cfg.check_conflicts if cfg else True + try: asyncio.run( _update_async( diff --git a/depkeeper/config.py b/depkeeper/config.py new file mode 100644 index 0000000..27855bf --- /dev/null +++ b/depkeeper/config.py @@ -0,0 +1,289 @@ +"""Configuration file loader for depkeeper. + +Handles discovery, loading, parsing, and validation of configuration files. +Supports two formats: + +- ``depkeeper.toml`` — settings under ``[depkeeper]`` table +- ``pyproject.toml`` — settings under ``[tool.depkeeper]`` table + +Discovery order: + +1. Explicit path from ``--config`` or ``DEPKEEPER_CONFIG`` +2. ``depkeeper.toml`` in current directory +3. ``pyproject.toml`` with ``[tool.depkeeper]`` section + +Configuration precedence: defaults < config file < environment < CLI args. + +Typical usage:: + + config = load_config() # Auto-discover + config = load_config(Path("custom.toml")) # Explicit path + +Example (``depkeeper.toml``):: + + [depkeeper] + check_conflicts = true + strict_version_matching = false +""" + +from __future__ import annotations + + +import tomli as tomllib +from pathlib import Path +from typing import Any, Dict, Optional +from dataclasses import dataclass, field + +from depkeeper.exceptions import ConfigError +from depkeeper.utils.logger import get_logger +from depkeeper.constants import ( + DEFAULT_CHECK_CONFLICTS, + DEFAULT_STRICT_VERSION_MATCHING, +) + +logger = get_logger("config") + + +@dataclass +class DepKeeperConfig: + """Parsed and validated depkeeper configuration. + + Contains settings from ``depkeeper.toml`` or ``pyproject.toml``. + All fields have defaults, so empty config files are valid. + + Attributes: + check_conflicts: Enable dependency conflict resolution. When ``True``, + analyzes transitive dependencies to avoid conflicts. + strict_version_matching: Only consider exact pins (``==``) as current + versions. Ignores range constraints like ``>=2.0``. + source_path: Path to loaded config file, or ``None`` if using defaults. + """ + + check_conflicts: bool = DEFAULT_CHECK_CONFLICTS + strict_version_matching: bool = DEFAULT_STRICT_VERSION_MATCHING + + # Metadata (not a user-facing option) + source_path: Optional[Path] = field(default=None, repr=False) + + def to_log_dict(self) -> Dict[str, Any]: + """Return configuration as dictionary for debug logging. + + Excludes ``source_path`` metadata. + + Returns: + Dictionary of configuration option names to values. + """ + return { + "check_conflicts": self.check_conflicts, + "strict_version_matching": self.strict_version_matching, + } + + +def discover_config_file(explicit_path: Optional[Path] = None) -> Optional[Path]: + """Find the configuration file to load. + + Search order: + + 1. ``explicit_path`` (from ``--config`` or ``DEPKEEPER_CONFIG``) + 2. ``depkeeper.toml`` in current directory + 3. ``pyproject.toml`` with ``[tool.depkeeper]`` section in current directory + + Validates ``pyproject.toml`` contains depkeeper section before using it. + + Args: + explicit_path: Explicit config path. If provided, must exist. + + Returns: + Resolved path to config file, or ``None`` if not found. + + Raises: + ConfigError: Explicit path provided but does not exist. + """ + # 1. Explicit path takes priority + if explicit_path is not None: + resolved = explicit_path.resolve() + if not resolved.is_file(): + raise ConfigError( + f"Configuration file not found: {explicit_path}", + config_path=str(explicit_path), + ) + logger.debug("Using explicit config: %s", resolved) + return resolved + + cwd = Path.cwd() + + # 2. depkeeper.toml in current directory + depkeeper_toml = cwd / "depkeeper.toml" + if depkeeper_toml.is_file(): + logger.debug("Found depkeeper.toml: %s", depkeeper_toml) + return depkeeper_toml + + # 3. pyproject.toml with [tool.depkeeper] section + pyproject_toml = cwd / "pyproject.toml" + if pyproject_toml.is_file(): + if _pyproject_has_depkeeper_section(pyproject_toml): + logger.debug("Found [tool.depkeeper] in pyproject.toml: %s", pyproject_toml) + return pyproject_toml + + logger.debug("No configuration file found") + return None + + +def _pyproject_has_depkeeper_section(path: Path) -> bool: + """Check if pyproject.toml contains [tool.depkeeper] section. + + Quick parse to avoid loading pyproject.toml without depkeeper config. + Parse errors are silently ignored for graceful fallback. + + Args: + path: Path to pyproject.toml file. + + Returns: + ``True`` if ``[tool.depkeeper]`` exists, ``False`` otherwise. + """ + try: + raw = _read_toml(path) + return "depkeeper" in raw.get("tool", {}) + except Exception: + return False + + +def load_config(config_path: Optional[Path] = None) -> DepKeeperConfig: + """Load and validate depkeeper configuration. + + Discovers config file (or uses provided path), parses and validates it. + Returns config with defaults if no file found. + + Handles both ``depkeeper.toml`` and ``pyproject.toml`` formats. + + Args: + config_path: Explicit path to config file. If ``None``, uses + auto-discovery (see :func:`discover_config_file`). + + Returns: + Validated :class:`DepKeeperConfig` with values from file or defaults. + + Raises: + ConfigError: File cannot be parsed, has unknown keys, or invalid values. + """ + resolved = discover_config_file(config_path) + + if resolved is None: + logger.debug("No config file found, using defaults") + return DepKeeperConfig() + + logger.info("Loading configuration from %s", resolved) + raw = _read_toml(resolved) + + # Extract the depkeeper-specific section + if resolved.name == "pyproject.toml": + section = raw.get("tool", {}).get("depkeeper", {}) + else: + # depkeeper.toml — settings live under [depkeeper] + section = raw.get("depkeeper", {}) + + if not section: + logger.debug("Config file found but no depkeeper section — using defaults") + return DepKeeperConfig(source_path=resolved) + + config = _parse_section(section, config_path=str(resolved)) + config.source_path = resolved + + logger.debug("Loaded configuration: %s", config.to_log_dict()) + return config + + +def _read_toml(path: Path) -> Dict[str, Any]: + """Read and parse a TOML file. + + Uses ``tomli`` if available, otherwise ``tomllib`` (Python 3.11+). + + Args: + path: Path to TOML file. + + Returns: + Parsed TOML as nested dictionary. + + Raises: + ConfigError: File cannot be read, invalid TOML, or no parser available. + """ + if tomllib is None: + raise ConfigError( + "TOML support requires Python 3.11+ or the 'tomli' package. " + "Install it with: pip install tomli", + config_path=str(path), + ) + + try: + with open(path, "rb") as fh: + return tomllib.load(fh) + except tomllib.TOMLDecodeError as exc: + raise ConfigError( + f"Invalid TOML in {path.name}: {exc}", + config_path=str(path), + ) from exc + except OSError as exc: + raise ConfigError( + f"Cannot read configuration file {path}: {exc}", + config_path=str(path), + ) from exc + + +def _parse_section( + section: Dict[str, Any], + *, + config_path: str, +) -> DepKeeperConfig: + """Parse and validate depkeeper configuration section. + + Validates ``[depkeeper]`` or ``[tool.depkeeper]`` table from TOML. + Rejects unknown keys and type mismatches. + + Args: + section: Raw config dictionary from TOML file. + config_path: Path string for error messages. + + Returns: + Validated :class:`DepKeeperConfig` with values from section and defaults. + + Raises: + ConfigError: Unknown keys or incorrect types (e.g., string for boolean). + """ + config = DepKeeperConfig() + + # Known depkeeper configuration options + known_top = { + "check_conflicts", + "strict_version_matching", + } + + # Validate that no unknown keys are present + unknown_top = set(section.keys()) - known_top + if unknown_top: + raise ConfigError( + f"Unknown configuration keys: {', '.join(sorted(unknown_top))}", + config_path=config_path, + ) + + # Parse and validate each option + if "check_conflicts" in section: + val = section["check_conflicts"] + if not isinstance(val, bool): + raise ConfigError( + f"check_conflicts must be a boolean, got {type(val).__name__}", + config_path=config_path, + option="check_conflicts", + ) + config.check_conflicts = val + + if "strict_version_matching" in section: + val = section["strict_version_matching"] + if not isinstance(val, bool): + raise ConfigError( + f"strict_version_matching must be a boolean, got {type(val).__name__}", + config_path=config_path, + option="strict_version_matching", + ) + config.strict_version_matching = val + + return config diff --git a/depkeeper/constants.py b/depkeeper/constants.py index 9634768..f085e89 100644 --- a/depkeeper/constants.py +++ b/depkeeper/constants.py @@ -92,3 +92,13 @@ #: Verbose log format including timestamp and logger name. LOG_VERBOSE_FORMAT: Final[str] = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + +# --------------------------------------------------------------------------- +# Configuration defaults +# --------------------------------------------------------------------------- + +#: Default setting for dependency conflict checking. +DEFAULT_CHECK_CONFLICTS: Final[bool] = True + +#: Default setting for strict version matching (only exact pins). +DEFAULT_STRICT_VERSION_MATCHING: Final[bool] = False diff --git a/depkeeper/context.py b/depkeeper/context.py index 04d9d3f..29d3b4f 100644 --- a/depkeeper/context.py +++ b/depkeeper/context.py @@ -1,16 +1,17 @@ """ Shared context object for depkeeper CLI commands. -This module defines the global Click context used to share configuration -and runtime options across CLI subcommands. +This module defines the global Click context used to share configuration, +loaded file-based settings, and runtime options across CLI subcommands. """ from __future__ import annotations +import click from pathlib import Path from typing import Optional -import click +from depkeeper.config import DepKeeperConfig class DepKeeperContext: @@ -23,14 +24,18 @@ class DepKeeperContext: config_path: Path to the depkeeper configuration file, if provided. verbose: Verbosity level (0=WARNING, 1=INFO, 2+=DEBUG). color: Whether colored terminal output is enabled. + config: Loaded and validated configuration from the configuration + file. ``None`` until :func:`~depkeeper.config.load_config` + is called during CLI initialization. """ - __slots__ = ("config_path", "verbose", "color") + __slots__ = ("config_path", "verbose", "color", "config") def __init__(self) -> None: self.config_path: Optional[Path] = None self.verbose: int = 0 self.color: bool = True + self.config: Optional["DepKeeperConfig"] = None #: Click decorator for injecting :class:`DepKeeperContext` into commands. diff --git a/depkeeper/exceptions.py b/depkeeper/exceptions.py index d0e6618..f77fddf 100644 --- a/depkeeper/exceptions.py +++ b/depkeeper/exceptions.py @@ -187,3 +187,32 @@ def __init__( self.file_path = file_path self.operation = operation self.original_error = original_error + + +class ConfigError(DepKeeperError): + """Raised when a configuration file is invalid or cannot be loaded. + + Args: + message: Human-readable description of the configuration problem. + config_path: Path to the configuration file that caused the error. + option: The specific configuration option that is invalid, if + applicable. + """ + + __slots__ = ("config_path", "option") + + def __init__( + self, + message: str, + *, + config_path: Optional[str] = None, + option: Optional[str] = None, + ) -> None: + details: MutableMapping[str, Any] = {} + _add_if(details, "config_path", config_path) + _add_if(details, "option", option) + + super().__init__(message, details) + + self.config_path = config_path + self.option = option diff --git a/depkeeper/models/requirement.py b/depkeeper/models/requirement.py index 98cab05..a616227 100644 --- a/depkeeper/models/requirement.py +++ b/depkeeper/models/requirement.py @@ -99,7 +99,7 @@ def update_version( """ Return a requirement string updated to the given version. - All existing version specifiers are replaced with ``>=new_version``. + All existing version specifiers are replaced with ``==new_version``. Hashes are omitted in the updated output. Args: @@ -111,7 +111,7 @@ def update_version( """ updated = Requirement( name=self.name, - specs=[(">=", new_version)], + specs=[("==", new_version)], extras=list(self.extras), markers=self.markers, url=self.url, diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index 0bd36df..7731071 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -674,16 +674,16 @@ Test CLI behavior using Click's test runner: assert result.exit_code == 0 assert "requests" in result.output - def test_check_returns_exit_code_on_outdated(self, runner): - """Check returns non-zero exit when updates available.""" + def test_check_returns_success_with_outdated(self, runner): + """Check returns exit code 0 even when updates are available.""" with runner.isolated_filesystem(): with open("requirements.txt", "w") as f: f.write("requests==2.28.0\n") - result = runner.invoke(cli, ["check", "--exit-code"]) + result = runner.invoke(cli, ["check"]) - # Exit code 1 indicates updates available - assert result.exit_code in (0, 1) + # Exit code 0 indicates successful execution + assert result.exit_code == 0 def test_check_missing_file_shows_error(self, runner): """Check command shows helpful error for missing file.""" diff --git a/docs/getting-started/basic-usage.md b/docs/getting-started/basic-usage.md index f27fc47..b2b0733 100644 --- a/docs/getting-started/basic-usage.md +++ b/docs/getting-started/basic-usage.md @@ -65,10 +65,20 @@ depkeeper check path/to/requirements.txt ``` ``` - Package Current Latest Recommended Status - ───────────────────────────────────────────────────────── - requests 2.28.0 2.32.0 2.32.0 Outdated (minor) - flask 2.0.0 3.0.1 2.3.3 Outdated (patch) + Dependency Status + + Status Package Current Latest Recommended Update Type Conflicts Python Support + + ✓ OK django 3.2.0 5.0.2 - - - Current: >=3.8 + Latest: >=3.10 + + ⬆ OUTDATED requests 2.28.0 2.32.0 2.32.0 minor - Current: >=3.7 + Latest: >=3.8 + + ⬆ OUTDATED flask 2.0.0 3.0.1 2.3.3 patch - Current: >=3.7 + Latest: >=3.8 + + [WARNING] 2 package(s) have updates available ``` === "Simple" @@ -78,10 +88,16 @@ depkeeper check path/to/requirements.txt ``` ``` - requests: 2.28.0 -> 2.32.0 (minor) - flask: 2.0.0 -> 2.3.3 (patch) + requests 2.28.0 → 2.32.0 (recommended: 2.32.0) + Python: installed: >=3.7, latest: >=3.8 + flask 2.0.0 → 3.0.1 (recommended: 2.3.3) + Python: installed: >=3.7, latest: >=3.8, recommended: >=3.7 + celery 5.3.0 → 5.3.6 + Python: installed: >=3.8, latest: >=3.8 ``` + Each line shows the package name, installed version, latest version, and a recommended version when it differs from the latest. The indented Python line shows the required Python version for each relevant release. + === "JSON" ```bash @@ -92,15 +108,52 @@ depkeeper check path/to/requirements.txt [ { "name": "requests", - "current_version": "2.28.0", - "latest_version": "2.32.0", - "recommended_version": "2.32.0", + "status": "latest", + "versions": { + "current": "2.32.5", + "latest": "2.32.5", + "recommended": "2.32.5" + }, + "python_requirements": { + "current": ">=3.9", + "latest": ">=3.9", + "recommended": ">=3.9" + } + }, + { + "name": "polars", + "status": "outdated", + "versions": { + "current": "1.37.1", + "latest": "1.38.1", + "recommended": "1.38.1" + }, "update_type": "minor", - "has_conflicts": false + "python_requirements": { + "current": ">=3.10", + "latest": ">=3.10", + "recommended": ">=3.10" + } + }, + { + "name": "setuptools", + "status": "latest", + "versions": { + "current": "80.10.2", + "latest": "82.0.0", + "recommended": "80.10.2" + }, + "python_requirements": { + "current": ">=3.9", + "latest": ">=3.9", + "recommended": ">=3.9" + } } ] ``` + Each object includes the package `name`, its `status` (`latest` or `outdated`), a `versions` block with `current`, `latest`, and `recommended` versions, and a `python_requirements` block showing the required Python version for each release. Outdated packages also include an `update_type` field (`patch`, `minor`, or `major`). + ### Filter to Outdated Only Show only packages that need updates: @@ -217,14 +270,21 @@ When checking or updating, depkeeper: ### Example ``` -Package Current Recommended Status -─────────────────────────────────────────────── -requests 2.28.0 2.31.0 Outdated -urllib3 1.26.0 1.26.18 Constrained + Dependency Status + + Status Package Current Latest Recommended Update Type Conflicts Python Support -ℹ urllib3 constrained by requests (requires urllib3<2.0) + ⬆ OUTDATED pytest-asyncio 0.3.0 1.3.0 0.23.8 minor - Latest: >=3.10 + Recommended: >=3.8 + + ⬆ OUTDATED pytest 7.0.2 9.0.2 7.4.4 minor pytest-asyncio needs >= 7.0.0,<9 Latest: >=3.10 + Recommended: >=3.7 + +[WARNING] 2 package(s) have updates available ``` +In this example, `pytest` is constrained by `pytest-asyncio` which requires `pytest>=8.2,<9`. depkeeper detects this conflict and adjusts the recommended version of `pytest` to stay within safe boundaries. + ### Disabling Conflict Checking For faster checks without resolution: diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index faf45e6..b65129b 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -180,17 +180,18 @@ If `depkeeper` is not found after installation: 2. **Ensure pip's bin directory is in PATH**: - === "Linux/macOS" + === "Linux/macOS" - ```bash - export PATH="$HOME/.local/bin:$PATH" - ``` + ```bash + export PATH="$HOME/.local/bin:$PATH" + ``` - === "Windows" + === "Windows" - Add `%USERPROFILE%\AppData\Local\Programs\Python\Python3X\Scripts` to your PATH. + Add `%USERPROFILE%\AppData\Local\Programs\Python\Python3X\Scripts` to your PATH. 3. **Try running as a module**: + ```bash python -m depkeeper --version ``` diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index ca6a51d..5d23f32 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -41,26 +41,44 @@ depkeeper check You'll see output like: ``` -Checking requirements.txt... -Found 5 package(s) +Resolution Summary: +================================================== +Total packages: 5 +Packages with conflicts: 0 +Packages changed: 0 +Converged: Yes (1 iterations) -Package Current Latest Recommended Status -───────────────────────────────────────────────────────── -requests 2.28.0 2.32.0 2.32.0 Outdated (minor) -flask 2.0.0 3.0.1 2.3.3 Outdated (patch) -click 8.0.0 8.1.7 8.1.7 Outdated (minor) -django 3.2.0 5.0.2 3.2.24 Outdated (patch) -pytest 7.4.0 8.0.0 7.4.4 Outdated (patch) + Dependency Status -✓ Found 5 packages with available updates + Status Package Current Latest Recommended Update Type Conflicts Python Support + + ✓ OK django 3.2.0 5.0.2 - - - Current: >=3.8 + Latest: >=3.10 + + ⬆ OUTDATED requests 2.28.0 2.32.0 2.32.0 minor - Current: >=3.7 + Latest: >=3.8 + + ⬆ OUTDATED flask 2.0.0 3.0.1 2.3.3 patch - Current: >=3.7 + Latest: >=3.8 + + ⬆ OUTDATED click 8.0.0 8.1.7 8.1.7 minor - Current: >=3.7 + Latest: >=3.7 + + ⬆ OUTDATED pytest 7.4.0 8.0.0 7.4.4 patch - Current: >=3.7 + Latest: >=3.8 + +[WARNING] 4 package(s) have updates available ``` !!! info "Understanding the output" + - **Status**: Whether the package is up to date (`✓ OK`) or has updates (`⬆ OUTDATED`) - **Current**: Your pinned/installed version - - **Latest**: Newest version on PyPI + - **Latest**: Newest version available on PyPI - **Recommended**: Safe upgrade that respects major version boundaries - - **Status**: Update type (major/minor/patch) + - **Update Type**: Severity of the update (major/minor/patch) + - **Conflicts**: Any dependency conflicts detected + - **Python Support**: Python version requirements for current, latest, and recommended versions --- @@ -75,18 +93,15 @@ depkeeper update --dry-run Output: ``` -Checking requirements.txt... -Found 5 package(s) + Update Plan (Dry Run) -Updates available: + Package Current New Version Change Python Requires -Package Current → Recommended Type -───────────────────────────────────────────── -requests 2.28.0 → 2.32.0 minor -flask 2.0.0 → 2.3.3 patch -click 8.0.0 → 8.1.7 minor + requests 2.28.0 2.32.0 minor >=3.8 + flask 2.0.0 2.3.3 patch >=3.8 + click 8.0.0 8.1.7 minor >=3.7 -ℹ 3 packages would be updated (dry run - no changes made) +[WARNING] Dry run mode - no changes applied ``` --- @@ -99,12 +114,20 @@ When you're ready to update: depkeeper update ``` -You'll be asked to confirm: +You'll see the update plan and be asked to confirm: ``` -Apply 3 updates? [y/N]: y + Update Plan -✓ Successfully updated 3 packages + Package Current New Version Change Python Requires + + requests 2.28.0 2.32.0 minor >=3.8 + flask 2.0.0 2.3.3 patch >=3.8 + click 8.0.0 8.1.7 minor >=3.7 + +Update 3 packages? (y, n) [y]: y + +[OK] ✓ Successfully updated 3 package(s) ``` To skip the confirmation prompt: @@ -158,16 +181,18 @@ depkeeper -vv check # Debug level ## Quick Reference -| Command | Description | -|---|---| -| `depkeeper check` | Check for available updates | -| `depkeeper check --outdated-only` | Show only outdated packages | -| `depkeeper check -f json` | Output as JSON | -| `depkeeper update` | Update all packages | -| `depkeeper update --dry-run` | Preview updates | -| `depkeeper update -y` | Update without confirmation | -| `depkeeper update --backup` | Create backup before updating | -| `depkeeper update -p PKG` | Update specific package(s) | +``` +Command Description +───────────────────────────────────────────────────────────────── +depkeeper check Check for available updates +depkeeper check --outdated-only Show only outdated packages +depkeeper check -f json Output as JSON +depkeeper update Update all packages +depkeeper update --dry-run Preview updates without changes +depkeeper update -y Update without confirmation +depkeeper update --backup Create backup before updating +depkeeper update -p PKG Update specific package(s) +``` --- diff --git a/docs/guides/checking-updates.md b/docs/guides/checking-updates.md index d591d32..0098755 100644 --- a/docs/guides/checking-updates.md +++ b/docs/guides/checking-updates.md @@ -41,29 +41,34 @@ depkeeper check --format table ``` ``` -Checking requirements.txt... -Found 5 package(s) - -Package Current Latest Recommended Status -───────────────────────────────────────────────────────── -requests 2.28.0 2.32.0 2.32.0 Outdated (minor) -flask 2.0.0 3.0.1 2.3.3 Outdated (patch) -click 8.0.0 8.1.7 8.1.7 Outdated (minor) -django 3.2.0 5.0.2 3.2.24 Outdated (patch) -pytest 7.4.0 8.0.0 7.4.4 Outdated (patch) - -✓ Found 5 packages with available updates + Dependency Status + + Status Package Current Latest Recommended Update Type Conflicts Python Support + + ✓ OK django 3.2.0 5.0.2 - - - Current: >=3.8 + Latest: >=3.10 + + ⬆ OUTDATED requests 2.28.0 2.32.0 2.32.0 minor - Current: >=3.7 + Latest: >=3.8 + + ⬆ OUTDATED flask 2.0.0 3.0.1 2.3.3 patch - Current: >=3.7 + Latest: >=3.8 + +[WARNING] 2 package(s) have updates available ``` **Columns explained:** | Column | Description | |---|---| +| **Status** | Whether the package is up to date (`OK`) or `OUTDATED` | | **Package** | Normalized package name | | **Current** | Version from your requirements file | | **Latest** | Newest version on PyPI | -| **Recommended** | Safe upgrade (within major version) | -| **Status** | Update type and any issues | +| **Recommended** | Safe upgrade (within major version), or `-` if already up to date | +| **Update Type** | Severity of the update (`patch`, `minor`, or `major`) | +| **Conflicts** | Any dependency conflicts detected | +| **Python Support** | Required Python version for the current and latest releases | ### Simple Format @@ -74,13 +79,16 @@ depkeeper check --format simple ``` ``` -requests: 2.28.0 -> 2.32.0 (minor) -flask: 2.0.0 -> 2.3.3 (patch) -click: 8.0.0 -> 8.1.7 (minor) -django: 3.2.0 -> 3.2.24 (patch) -pytest: 7.4.0 -> 7.4.4 (patch) + requests 2.28.0 → 2.32.0 (recommended: 2.32.0) + Python: installed: >=3.7, latest: >=3.8 + flask 2.0.0 → 3.0.1 (recommended: 2.3.3) + Python: installed: >=3.7, latest: >=3.8, recommended: >=3.7 + celery 5.3.0 → 5.3.6 + Python: installed: >=3.8, latest: >=3.8 ``` +Each line shows the package name, installed version, latest version, and a recommended version when it differs from the latest. The indented Python line shows the required Python version for each relevant release. + ### JSON Format Machine-readable output for CI/CD: @@ -93,27 +101,52 @@ depkeeper check --format json [ { "name": "requests", - "current_version": "2.28.0", - "latest_version": "2.32.0", - "recommended_version": "2.32.0", + "status": "latest", + "versions": { + "current": "2.32.5", + "latest": "2.32.5", + "recommended": "2.32.5" + }, + "python_requirements": { + "current": ">=3.9", + "latest": ">=3.9", + "recommended": ">=3.9" + } + }, + { + "name": "polars", + "status": "outdated", + "versions": { + "current": "1.37.1", + "latest": "1.38.1", + "recommended": "1.38.1" + }, "update_type": "minor", - "has_conflicts": false, - "conflicts": [], - "python_compatible": true + "python_requirements": { + "current": ">=3.10", + "latest": ">=3.10", + "recommended": ">=3.10" + } }, { - "name": "flask", - "current_version": "2.0.0", - "latest_version": "3.0.1", - "recommended_version": "2.3.3", - "update_type": "patch", - "has_conflicts": false, - "conflicts": [], - "python_compatible": true + "name": "setuptools", + "status": "latest", + "versions": { + "current": "80.10.2", + "latest": "82.0.0", + "recommended": "80.10.2" + }, + "python_requirements": { + "current": ">=3.9", + "latest": ">=3.9", + "recommended": ">=3.9" + } } ] ``` +Each object includes the package `name`, its `status` (`latest` or `outdated`), a `versions` block with `current`, `latest`, and `recommended` versions, and a `python_requirements` block showing the required Python version for each release. Outdated packages also include an `update_type` field (`patch`, `minor`, or `major`). + --- ## Filtering Results @@ -139,15 +172,21 @@ By default, depkeeper checks for dependency conflicts during version resolution. A conflict occurs when packages have incompatible version requirements: ``` -Package Current Recommended Status -─────────────────────────────────────────────── -requests 2.28.0 2.31.0 Outdated (minor) -urllib3 1.26.0 1.26.18 Constrained + Dependency Status + + Status Package Current Latest Recommended Update Type Conflicts Python Support -⚠ Conflicts detected: - urllib3: constrained by requests (requires urllib3>=1.21.1,<2) + ⬆ OUTDATED pytest-asyncio 0.3.0 1.3.0 0.23.8 minor - Latest: >=3.10 + Recommended: >=3.8 + + ⬆ OUTDATED pytest 7.0.2 9.0.2 7.4.4 minor pytest-asyncio needs >= 7.0.0,<9 Latest: >=3.10 + Recommended: >=3.7 + +[WARNING] 2 package(s) have updates available ``` +In this example, `pytest` is constrained by `pytest-asyncio` which requires `pytest>=8.2,<9`. depkeeper detects this conflict and adjusts the recommended version of `pytest` to stay within safe boundaries. + ### How It Works 1. **Metadata Fetch**: depkeeper fetches dependency metadata from PyPI diff --git a/docs/guides/ci-cd-integration.md b/docs/guides/ci-cd-integration.md index 75e6903..adf1f70 100644 --- a/docs/guides/ci-cd-integration.md +++ b/docs/guides/ci-cd-integration.md @@ -20,6 +20,11 @@ depkeeper fits into CI/CD workflows for: --- +!!! important + The examples use `src/requirements.txt` -- replace with your actual requirements file path. + +--- + ## GitHub Actions ### Check for Outdated Dependencies @@ -50,26 +55,17 @@ jobs: - name: Install depkeeper run: pip install depkeeper - - name: Check dependencies - run: depkeeper check --format json > deps-report.json - - - name: Check for outdated - id: outdated - run: | - OUTDATED=$(depkeeper check --outdated-only --format simple | wc -l) - echo "count=$OUTDATED" >> $GITHUB_OUTPUT - - - name: Report status - if: steps.outdated.outputs.count > 0 + - name: Check dependencies and report run: | - echo "⚠️ Found ${{ steps.outdated.outputs.count }} outdated dependencies" - depkeeper check --outdated-only --format table - - - name: Upload report - uses: actions/upload-artifact@v4 - with: - name: dependency-report - path: deps-report.json + echo "Checking for outdated dependencies:" + depkeeper check src/requirements.txt --outdated-only --format table + + # Fail if outdated dependencies are found + if depkeeper check src/requirements.txt --outdated-only --format json 2>/dev/null | grep -q '"status": "outdated"'; then + echo "" + echo "❌ Build failed: Outdated dependencies detected. Please update them." + exit 1 + fi ``` ### Automated Dependency Updates @@ -90,8 +86,6 @@ jobs: steps: - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Python uses: actions/setup-python@v5 @@ -100,54 +94,77 @@ jobs: - name: Install dependencies run: | + sudo apt-get update && sudo apt-get install -y jq pip install depkeeper - pip install -r requirements.txt + pip install -r src/requirements.txt - name: Check for updates id: check run: | - depkeeper check --outdated-only --format json > outdated.json - UPDATES=$(cat outdated.json | jq length) - echo "count=$UPDATES" >> $GITHUB_OUTPUT + depkeeper check src/requirements.txt --outdated-only --format json > outdated.json + echo "count=$(cat outdated.json | jq length)" >> $GITHUB_OUTPUT - name: Update dependencies if: steps.check.outputs.count > 0 - run: depkeeper update --backup -y + run: depkeeper update src/requirements.txt -y - name: Run tests if: steps.check.outputs.count > 0 - run: pytest + run: | + cd src + pytest - - name: Create Pull Request + - name: Generate update report if: steps.check.outputs.count > 0 - uses: peter-evans/create-pull-request@v6 - with: - token: ${{ secrets.GITHUB_TOKEN }} - commit-message: 'chore(deps): update dependencies' - title: '⬆️ Update dependencies' - body: | - Automated dependency updates by depkeeper. + run: | + echo "UPDATES_LIST<> $GITHUB_ENV + cat outdated.json | jq -r '.[] | "- **\(.name)**: \(.versions.current) → \(.versions.recommended)"' + echo "EOF" >> $GITHUB_ENV - ## Updated packages - $(depkeeper check --format simple) - branch: deps/automated-updates - delete-branch: true -``` + - name: Commit and push changes + if: steps.check.outputs.count > 0 + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b deps/automated-updates + git add src/requirements.txt + git commit -m "chore(deps): update dependencies" + git push -f origin deps/automated-updates -### Fail on Outdated (Strict Mode) + - name: Create Pull Request + if: steps.check.outputs.count > 0 + uses: actions/github-script@v7 + with: + script: | + const { data: pulls } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + head: `${context.repo.owner}:deps/automated-updates`, + state: 'open' + }); -For strict dependency policies: + const prBody = `Automated dependency updates by depkeeper. -```yaml -- name: Check dependencies (strict) - run: | - OUTDATED=$(depkeeper check --outdated-only --format json | jq length) - if [ "$OUTDATED" -gt 0 ]; then - echo "❌ $OUTDATED outdated dependencies found!" - depkeeper check --outdated-only - exit 1 - fi - echo "✅ All dependencies up to date" + ## Updated packages + ${process.env.UPDATES_LIST}`; + + if (pulls.length === 0) { + await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: '⬆️ Update dependencies', + head: 'deps/automated-updates', + base: 'master', + body: prBody + }); + } else { + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pulls[0].number, + body: prBody + }); + } ``` --- @@ -169,11 +186,9 @@ dependency-check: image: python:${PYTHON_VERSION} script: - pip install depkeeper - - depkeeper check --format json > deps-report.json - - depkeeper check --outdated-only + - depkeeper check src/requirements.txt --outdated-only --format json > deps-report.json || true + - depkeeper check src/requirements.txt --outdated-only --format table || true artifacts: - reports: - dotenv: deps-report.json paths: - deps-report.json rules: @@ -182,19 +197,28 @@ dependency-check: dependency-update: stage: update + dependencies: [dependency-check] image: python:${PYTHON_VERSION} script: - - pip install depkeeper - - depkeeper update --backup -y - - pip install -r requirements.txt - - pytest + - pip install depkeeper pytest + - | + COUNT=$(python -c "import json,sys; data=open('deps-report.json').read().strip(); print(len(json.loads(data)) if data else 0)" 2>/dev/null || echo "0") + if [ "$COUNT" -eq 0 ]; then + echo "No outdated dependencies. Skipping update." + exit 0 + fi + - depkeeper update src/requirements.txt --backup -y + - pip install -r src/requirements.txt + - cd src && pytest artifacts: paths: - - requirements.txt - - requirements.txt.backup.* + - src/requirements.txt + - src/requirements.txt.backup.* rules: - if: $CI_PIPELINE_SOURCE == "schedule" when: manual + - if: $CI_PIPELINE_SOURCE == "web" + when: manual ``` --- @@ -224,15 +248,19 @@ steps: - script: pip install depkeeper displayName: Install depkeeper - - script: depkeeper check --format json > $(Build.ArtifactStagingDirectory)/deps.json - displayName: Check dependencies + - script: | + depkeeper check src/requirements.txt --outdated-only --format json \ + > $(Build.ArtifactStagingDirectory)/deps.json || true + displayName: Check dependencies (JSON report) - - script: depkeeper check --outdated-only --format table + - script: | + depkeeper check src/requirements.txt --outdated-only --format table || true displayName: Show outdated packages - task: PublishBuildArtifacts@1 + condition: always() inputs: - pathToPublish: $(Build.ArtifactStagingDirectory)/deps.json + pathToPublish: '$(Build.ArtifactStagingDirectory)' artifactName: dependency-report ``` @@ -257,20 +285,24 @@ pipeline { stages { stage('Setup') { steps { + sh 'apt-get update && apt-get install -y jq' sh 'pip install depkeeper' } } stage('Check Dependencies') { steps { - sh 'depkeeper check --format json > deps-report.json' - sh 'depkeeper check --outdated-only' + sh ''' + depkeeper check src/requirements.txt --outdated-only --format json \ + > deps-report.json || echo "[]" > deps-report.json + ''' + sh 'depkeeper check src/requirements.txt --outdated-only --format table || true' } } stage('Archive Report') { steps { - archiveArtifacts artifacts: 'deps-report.json' + archiveArtifacts allowEmptyArchive: true, artifacts: 'deps-report.json' } } } @@ -278,13 +310,17 @@ pipeline { post { always { script { - def outdated = sh( - script: 'depkeeper check --outdated-only --format simple | wc -l', - returnStdout: true - ).trim() - - if (outdated.toInteger() > 0) { - currentBuild.description = "⚠️ ${outdated} outdated dependencies" + if (fileExists('deps-report.json')) { + def outdated = sh( + script: 'jq length deps-report.json || echo 0', + returnStdout: true + ).trim() + + if (outdated.toInteger() > 0) { + currentBuild.description = "⚠️ ${outdated} outdated dependencies" + } + } else { + echo "deps-report.json not found — skipping outdated count." } } } @@ -311,12 +347,14 @@ jobs: name: Install depkeeper command: pip install depkeeper - run: - name: Check dependencies - command: | - depkeeper check --format json > deps-report.json - depkeeper check --outdated-only + name: Export outdated deps as JSON + command: depkeeper check src/requirements.txt --outdated-only --format json > deps-report.json || true + - run: + name: Show outdated deps as table + command: depkeeper check src/requirements.txt --outdated-only --format table || true - store_artifacts: path: deps-report.json + destination: deps-report.json workflows: weekly-check: @@ -343,10 +381,10 @@ repos: hooks: - id: depkeeper-check name: Check dependencies - entry: depkeeper check --outdated-only --format simple + entry: depkeeper check src/requirements.txt --outdated-only --format table language: system pass_filenames: false - files: requirements.*\.txt$ + files: requirements\.txt$ ``` --- @@ -375,7 +413,10 @@ Always run your test suite after automated updates: ```yaml - name: Update - run: depkeeper update -y + run: depkeeper update src/requirements.txt -y + +- name: Install updated packages + run: pip install -r src/requirements.txt - name: Test run: pytest @@ -386,29 +427,36 @@ Always run your test suite after automated updates: Don't push directly to main. Create PRs for review: ```yaml -- uses: peter-evans/create-pull-request@v6 - with: - branch: deps/updates -``` - -### 5. Use JSON for Processing - -Use `--format json` when you need to process the output: - -```bash -depkeeper check --format json | jq '.[] | select(.update_type == "patch")' -``` - -### 6. Notifications - -Send notifications for outdated dependencies: - -```yaml -- name: Notify Slack - if: steps.check.outputs.count > 0 - uses: slackapi/slack-github-action@v1 +- name: Commit and push changes + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b deps/automated-updates + git add src/requirements.txt + git commit -m "chore(deps): update dependencies" + git push -f origin deps/automated-updates + +- name: Create Pull Request + uses: actions/github-script@v7 with: - slack-message: "⚠️ ${{ steps.check.outputs.count }} dependencies need updates" + script: | + const { data: pulls } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + head: `${context.repo.owner}:deps/automated-updates`, + state: 'open' + }); + + if (pulls.length === 0) { + await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: '⬆️ Update dependencies', + head: 'deps/automated-updates', + base: 'master', + body: 'Automated dependency updates by depkeeper.' + }); + } ``` --- @@ -423,12 +471,6 @@ Use exit codes for CI logic: | `1` | Error | Fail build | | `2` | Usage error | Fail build | -Example: - -```bash -depkeeper check || echo "Check failed with code $?" -``` - --- ## Next Steps diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 09528c3..ac31de8 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -1,11 +1,11 @@ --- title: Configuration -description: Configure depkeeper behavior via CLI options and environment variables +description: Configure depkeeper behavior via CLI options, environment variables, and configuration files --- # Configuration -depkeeper can be configured through CLI options, environment variables, and configuration files. +depkeeper can be configured through CLI options, environment variables, and configuration files. When a CLI flag is not explicitly provided, depkeeper reads the value from the configuration file. If no configuration file is found, built-in defaults apply. --- @@ -48,8 +48,6 @@ All environment variables are prefixed with `DEPKEEPER_`: |---|---|---| | `DEPKEEPER_CONFIG` | Path to configuration file | `/path/to/config.toml` | | `DEPKEEPER_COLOR` | Enable/disable colors | `true`, `false` | -| `DEPKEEPER_CACHE_DIR` | Cache directory path | `~/.cache/depkeeper` | -| `DEPKEEPER_LOG_LEVEL` | Logging level | `DEBUG`, `INFO`, `WARNING` | ### Examples @@ -57,14 +55,6 @@ All environment variables are prefixed with `DEPKEEPER_`: # Disable colors export DEPKEEPER_COLOR=false depkeeper check - -# Set custom cache directory -export DEPKEEPER_CACHE_DIR=/tmp/depkeeper-cache -depkeeper check - -# Enable debug logging -export DEPKEEPER_LOG_LEVEL=DEBUG -depkeeper check ``` ### In CI/CD @@ -72,7 +62,6 @@ depkeeper check ```yaml env: DEPKEEPER_COLOR: false - DEPKEEPER_LOG_LEVEL: INFO steps: - run: depkeeper check @@ -96,39 +85,11 @@ depkeeper looks for configuration in: # depkeeper.toml [depkeeper] -# Default update strategy -update_strategy = "minor" - # Enable conflict checking by default check_conflicts = true -# Cache settings -cache_ttl = 3600 # seconds - -# Number of concurrent PyPI requests -concurrent_requests = 10 - -[depkeeper.filters] -# Packages to exclude from updates -exclude = [ - "django", # Pin major version manually - "numpy", # Requires specific testing -] - -# Include pre-release versions -include_pre_release = false - -[depkeeper.pypi] -# Custom PyPI index -index_url = "https://pypi.org/simple" - -# Additional indexes -extra_index_urls = [ - "https://private.pypi.example.com/simple" -] - -# Request timeout in seconds -timeout = 30 +# Only consider exact version pins (==) +strict_version_matching = false ``` ### pyproject.toml Format @@ -137,101 +98,28 @@ timeout = 30 # pyproject.toml [tool.depkeeper] -update_strategy = "minor" check_conflicts = true - -[tool.depkeeper.filters] -exclude = ["django", "numpy"] -include_pre_release = false +strict_version_matching = false ``` --- ## Configuration Options Reference -### General Options - | Option | Type | Default | Description | |---|---|---|---| -| `update_strategy` | string | `"minor"` | Default update strategy | -| `check_conflicts` | bool | `true` | Enable dependency resolution | -| `strict_version_matching` | bool | `false` | Only consider exact pins | -| `cache_ttl` | int | `3600` | Cache TTL in seconds | -| `concurrent_requests` | int | `10` | Max concurrent PyPI requests | - -### Filter Options - -| Option | Type | Default | Description | -|---|---|---|---| -| `exclude` | list | `[]` | Packages to skip | -| `include_pre_release` | bool | `false` | Include alpha/beta versions | - -### PyPI Options - -| Option | Type | Default | Description | -|---|---|---|---| -| `index_url` | string | PyPI URL | Primary package index | -| `extra_index_urls` | list | `[]` | Additional indexes | -| `timeout` | int | `30` | Request timeout (seconds) | - ---- - -## Update Strategies - -Configure how depkeeper recommends updates: - -| Strategy | Description | Risk Level | -|---|---|---| -| `patch` | Only patch updates (x.x.PATCH) | Lowest | -| `minor` | Minor + patch updates (x.MINOR.x) | Low | -| `major` | All updates including major | Higher | - -```toml -[depkeeper] -update_strategy = "minor" # Default: safe updates only -``` - -!!! note "Major Version Boundary" - Even with `update_strategy = "major"`, depkeeper respects major version boundaries for safety. To cross a major version, update your requirements manually. +| `check_conflicts` | bool | `true` | Enable dependency conflict resolution | +| `strict_version_matching` | bool | `false` | Only consider exact version pins (`==`) | --- ## Excluding Packages -Skip specific packages from updates: - -```toml -[depkeeper.filters] -exclude = [ - "django", # Pin manually - "tensorflow", # Requires GPU testing - "numpy", # Version-sensitive -] -``` - -Or via CLI: +Use the `--packages` / `-p` CLI option to update only specific packages: ```bash -# Update all except django -depkeeper update -p requests -p flask # Only update specified packages -``` - ---- - -## Private Package Indexes - -Configure custom PyPI indexes: - -```toml -[depkeeper.pypi] -# Replace the default index -index_url = "https://private.pypi.example.com/simple" - -# Or add additional indexes -extra_index_urls = [ - "https://private.pypi.example.com/simple", - "https://another.index.com/simple", -] +# Update only specific packages +depkeeper update -p requests -p flask ``` --- @@ -244,17 +132,8 @@ extra_index_urls = [ # depkeeper.toml - Production-safe settings [depkeeper] -update_strategy = "patch" check_conflicts = true strict_version_matching = true - -[depkeeper.filters] -exclude = [ - "django", - "celery", - "redis", -] -include_pre_release = false ``` ### Active Development @@ -263,25 +142,8 @@ include_pre_release = false # depkeeper.toml - Development settings [depkeeper] -update_strategy = "minor" check_conflicts = true - -[depkeeper.filters] -include_pre_release = false -``` - -### CI/CD Pipeline - -```toml -# depkeeper.toml - CI/CD optimized - -[depkeeper] -update_strategy = "minor" -check_conflicts = true -concurrent_requests = 20 # Faster in CI - -[depkeeper.pypi] -timeout = 60 # Longer timeout for reliability +strict_version_matching = false ``` --- @@ -323,7 +185,7 @@ depkeeper -vv check 2>&1 | grep -i config ``` DEBUG: Config path: /project/depkeeper.toml -DEBUG: Loaded configuration: {'update_strategy': 'minor', ...} +DEBUG: Loaded configuration: {'check_conflicts': True, 'strict_version_matching': False} DEBUG: Effective check_conflicts: True ``` diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index cb2dd87..90d81be 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -83,20 +83,15 @@ Get-Content requirements.txt | Set-Content -Encoding UTF8 requirements_utf8.txt ### Connection timeout -**Error**: `ConnectionError: Connection to pypi.org timed out` +**Error**: `NetworkError: Connection to pypi.org timed out` **Solutions**: ```bash -# Configure timeout via depkeeper.toml -# [depkeeper.pypi] -# timeout = 60 - -# Use a mirror via configuration file -# [depkeeper.pypi] -# index_url = "https://pypi.tuna.tsinghua.edu.cn/simple" - # Check network connectivity ping pypi.org + +# If behind a firewall, ensure pypi.org is accessible +curl -I https://pypi.org ``` ### SSL certificate errors @@ -130,7 +125,9 @@ pip config set global.proxy http://proxy.example.com:8080 ### Conflict detected -**Error**: `ConflictError: package-a requires package-b<2.0, but package-c requires package-b>=2.0` +**Warning**: Dependency conflicts shown in check output + +**Example**: `package-a requires package-b<2.0, but package-c requires package-b>=2.0` **Solutions:** @@ -179,9 +176,9 @@ pip install package-a ## Update Issues -### Update fails with rollback +### Update fails with error -**Error**: `UpdateError: Failed to update requirements, changes rolled back` +**Error**: `FileOperationError: Cannot write to requirements.txt` **Common causes:** @@ -195,7 +192,7 @@ pip install package-a ls -la requirements.txt # Close editors that might lock the file -# Try with elevated permissions if needed +# Try with elevated permissions if needed (Unix/macOS) sudo depkeeper update ``` @@ -203,12 +200,14 @@ sudo depkeeper update **Issue**: Unwanted alpha/beta versions suggested -depkeeper excludes pre-releases by default. To explicitly configure this, add the following to your configuration file: +depkeeper automatically excludes pre-release versions (alpha, beta, rc, dev) by default. If you're seeing pre-releases, this may indicate: + +- The package only has pre-release versions available +- The package's versioning scheme doesn't follow PEP 440 -```toml -# depkeeper.toml -[depkeeper.filters] -include_pre_release = false +```bash +# Check available versions on PyPI directly +pip index versions package-name ``` --- @@ -240,41 +239,6 @@ depkeeper check --format json 2>/dev/null --- -## Cache Issues - -### Stale data - -**Issue**: depkeeper showing old versions - -**Solution**: -```bash -# Remove cache directory manually -# Unix/macOS: -rm -rf ~/.cache/depkeeper - -# Windows (PowerShell): -Remove-Item -Recurse -Force "$env:LOCALAPPDATA\depkeeper\cache" - -# Or set a custom cache directory -export DEPKEEPER_CACHE_DIR=/tmp/depkeeper-cache -``` - -### Cache corruption - -**Error**: `CacheError: Failed to read cache` - -**Solution**: -```bash -# Remove cache directory -# Unix/macOS: -rm -rf ~/.cache/depkeeper - -# Windows: -rmdir /s /q %LOCALAPPDATA%\depkeeper\cache -``` - ---- - ## Getting Help If you're still having issues: diff --git a/docs/guides/updating-dependencies.md b/docs/guides/updating-dependencies.md index d6e368c..d751560 100644 --- a/docs/guides/updating-dependencies.md +++ b/docs/guides/updating-dependencies.md @@ -34,19 +34,25 @@ depkeeper update --dry-run ``` ``` -Checking requirements.txt... +Update Plan (Dry Run) -Updates available: + Package Current New Version Change Python Requires -Package Current → Recommended Type -───────────────────────────────────────────── -requests 2.28.0 → 2.32.0 minor -flask 2.0.0 → 2.3.3 patch -click 8.0.0 → 8.1.7 minor - -ℹ 3 packages would be updated (dry run - no changes made) + requests 2.28.0 2.32.0 minor >=3.8 + flask 2.0.0 2.3.3 patch >=3.7 + click 8.0.0 8.1.7 minor >=3.7 ``` +**Columns explained:** + +| Column | Description | +|---|---| +| **Package** | Normalized package name | +| **Current** | Version from your requirements file | +| **New Version** | The safe recommended version to update to | +| **Change** | Severity of the update (`patch`, `minor`, or `major`) | +| **Python Requires** | Required Python version for the new version | + !!! tip "Best Practice" Always run `--dry-run` first to review changes before applying them. @@ -140,21 +146,19 @@ This prevents unexpected breaking changes. ## Conflict Resolution -When updating, depkeeper automatically resolves conflicts: +When updating, depkeeper automatically resolves conflicts. Constrained packages show the dependency that restricts them in the check output, and the update plan reflects the safe resolved version: ``` -Checking requirements.txt... - -Updates available: +Update Plan (Dry Run) -Package Current → Recommended Type -───────────────────────────────────────────── -requests 2.28.0 → 2.31.0 minor -urllib3 1.26.0 → 1.26.18 constrained + Package Current New Version Change Python Requires -ℹ urllib3 version constrained by requests dependency + pytest-asyncio 0.3.0 0.23.8 minor >=3.8 + pytest 7.0.2 7.4.4 minor >=3.7 ``` +In this example, `pytest` is constrained by `pytest-asyncio` and depkeeper adjusts both recommendations to stay within compatible boundaries. + ### Disable Conflict Checking For faster updates without resolution: @@ -256,12 +260,12 @@ flask==2.0.0 click>=8.0.0 # After -requests>=2.32.0 -flask>=2.3.3 -click>=8.1.7 +requests==2.32.0 +flask==2.3.3 +click==8.1.7 ``` -depkeeper updates the version specifier to `>=new_version`. +depkeeper updates the version specifier to `==new_version`. ### Preserved Elements diff --git a/docs/reference/cli-commands.md b/docs/reference/cli-commands.md index 1542f99..ddaf585 100644 --- a/docs/reference/cli-commands.md +++ b/docs/reference/cli-commands.md @@ -88,6 +88,10 @@ depkeeper check [OPTIONS] [FILE] | `--strict-version-matching` | | Only consider exact version pins (`==`) | `False` | | `--check-conflicts / --no-check-conflicts` | | Enable/disable dependency conflict resolution | `True` | +!!! tip "Configuration File Fallback" + + `--strict-version-matching` and `--check-conflicts` fall back to values from your `depkeeper.toml` or `pyproject.toml` when not provided on the command line. See [Configuration](../guides/configuration.md) for details. + ### How It Works 1. **Parse** -- Read and parse the requirements file (PEP 440/508 compliant) @@ -200,6 +204,10 @@ depkeeper update [OPTIONS] [FILE] | `--strict-version-matching` | | Only consider exact version pins | `False` | | `--check-conflicts / --no-check-conflicts` | | Enable/disable conflict resolution | `True` | +!!! tip "Configuration File Fallback" + + `--strict-version-matching` and `--check-conflicts` fall back to values from your `depkeeper.toml` or `pyproject.toml` when not provided on the command line. See [Configuration](../guides/configuration.md) for details. + ### Update Process 1. **Parse** -- Read the requirements file @@ -286,8 +294,6 @@ Commands respect these environment variables: | `DEPKEEPER_CONFIG` | `--config` option | | `DEPKEEPER_COLOR` | `--color` option | | `NO_COLOR` | Disables colors ([standard](https://no-color.org/)) | -| `DEPKEEPER_LOG_LEVEL` | Logging level (`WARNING`, `INFO`, `DEBUG`) | -| `DEPKEEPER_TIMEOUT` | HTTP request timeout in seconds | --- diff --git a/docs/reference/configuration-options.md b/docs/reference/configuration-options.md index 43ed3bc..ddea6f4 100644 --- a/docs/reference/configuration-options.md +++ b/docs/reference/configuration-options.md @@ -5,7 +5,7 @@ description: Complete configuration reference for depkeeper # Configuration Options -Complete reference for all depkeeper configuration options. depkeeper supports CLI arguments, environment variables, and configuration files with a clear precedence hierarchy. +Complete reference for all depkeeper configuration options. depkeeper supports CLI arguments, environment variables, and configuration files with a clear precedence hierarchy. Configuration file values serve as defaults that CLI arguments override. --- @@ -64,9 +64,6 @@ All environment variables use the `DEPKEEPER_` prefix: |---|---|---|---| | `DEPKEEPER_CONFIG` | Path | - | Configuration file path | | `DEPKEEPER_COLOR` | Boolean | `true` | Enable/disable colors | -| `DEPKEEPER_CACHE_DIR` | Path | OS default | Cache directory | -| `DEPKEEPER_LOG_LEVEL` | String | `WARNING` | Logging level | -| `DEPKEEPER_TIMEOUT` | Integer | `30` | HTTP timeout (seconds) | ### Boolean Values @@ -84,15 +81,6 @@ depkeeper also respects the `NO_COLOR` environment variable as defined by the [n ```bash # Disable colors export DEPKEEPER_COLOR=false - -# Set custom cache directory -export DEPKEEPER_CACHE_DIR=/tmp/depkeeper - -# Enable debug logging -export DEPKEEPER_LOG_LEVEL=DEBUG - -# Increase timeout -export DEPKEEPER_TIMEOUT=60 ``` --- @@ -113,25 +101,8 @@ depkeeper searches for configuration in this order: # depkeeper.toml [depkeeper] -# Update behavior -update_strategy = "minor" check_conflicts = true strict_version_matching = false - -# Performance -cache_ttl = 3600 -concurrent_requests = 10 - -# Filters -[depkeeper.filters] -exclude = ["django", "numpy"] -include_pre_release = false - -# PyPI configuration -[depkeeper.pypi] -index_url = "https://pypi.org/simple" -extra_index_urls = [] -timeout = 30 ``` ### pyproject.toml @@ -140,52 +111,20 @@ timeout = 30 # pyproject.toml [tool.depkeeper] -update_strategy = "minor" check_conflicts = true - -[tool.depkeeper.filters] -exclude = ["django"] +strict_version_matching = false ``` --- -## Configuration Reference +## Configuration File Reference -### General Options +These options can be set in the ``[depkeeper]`` table of ``depkeeper.toml`` or the ``[tool.depkeeper]`` table of ``pyproject.toml``. | Option | Type | Default | Description | |---|---|---|---| -| `update_strategy` | String | `"minor"` | Default update strategy | -| `check_conflicts` | Boolean | `true` | Enable dependency resolution | -| `strict_version_matching` | Boolean | `false` | Only use exact version pins | -| `cache_ttl` | Integer | `3600` | Cache TTL in seconds | -| `concurrent_requests` | Integer | `10` | Max concurrent HTTP requests | - -### Update Strategies - -| Value | Description | Example | -|---|---|---| -| `"patch"` | Bug fixes only | `2.28.0` to `2.28.1` | -| `"minor"` | Features and fixes | `2.28.0` to `2.29.0` | -| `"major"` | All updates | `2.28.0` to `3.0.0` | - -!!! note - Regardless of the strategy, depkeeper respects major version boundaries by default. Recommendations never cross major versions unless the strategy explicitly allows it. - -### Filter Options - -| Option | Type | Default | Description | -|---|---|---|---| -| `exclude` | List[String] | `[]` | Packages to skip | -| `include_pre_release` | Boolean | `false` | Include alpha/beta versions | - -### PyPI Options - -| Option | Type | Default | Description | -|---|---|---|---| -| `index_url` | String | `https://pypi.org/simple` | Primary package index | -| `extra_index_urls` | List[String] | `[]` | Additional indexes | -| `timeout` | Integer | `30` | Request timeout (seconds) | +| `check_conflicts` | Boolean | `true` | Enable dependency conflict resolution | +| `strict_version_matching` | Boolean | `false` | Only use exact version pins (`==`) | --- @@ -218,7 +157,6 @@ depkeeper check --no-check-conflicts |---|---|---| | `check_conflicts` | `false` | CLI wins | | `color` | `false` | From environment | -| `update_strategy` | `"minor"` | Built-in default | --- @@ -230,12 +168,7 @@ depkeeper check --no-check-conflicts # depkeeper.toml [depkeeper] -update_strategy = "minor" check_conflicts = true -cache_ttl = 3600 - -[depkeeper.filters] -include_pre_release = false ``` ### Production / Conservative @@ -244,43 +177,8 @@ include_pre_release = false # depkeeper.toml [depkeeper] -update_strategy = "patch" check_conflicts = true strict_version_matching = true - -[depkeeper.filters] -exclude = [ - "django", # Manual major updates - "celery", # Requires testing - "sqlalchemy", # Version sensitive -] -``` - -### CI/CD Pipeline - -```toml -# depkeeper.toml - -[depkeeper] -update_strategy = "minor" -check_conflicts = true -concurrent_requests = 20 - -[depkeeper.pypi] -timeout = 60 -``` - -### Private PyPI Index - -```toml -# depkeeper.toml - -[depkeeper.pypi] -index_url = "https://pypi.example.com/simple" -extra_index_urls = [ - "https://pypi.org/simple", -] -timeout = 30 ``` --- @@ -291,7 +189,7 @@ depkeeper validates configuration on startup. Invalid values result in clear err ```bash $ depkeeper check -Error: Invalid configuration: update_strategy must be one of: patch, minor, major +Error: Invalid configuration: check_conflicts must be a boolean, got str ``` Use verbose mode to debug configuration loading: diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index f89ee57..09e62ec 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -157,22 +157,8 @@ Project configuration file for depkeeper settings. ```toml [depkeeper] -update_strategy = "minor" check_conflicts = true strict_version_matching = false -cache_ttl = 3600 -concurrent_requests = 10 - -[depkeeper.filters] -exclude = ["Django", "celery"] -include_pre_release = false - -[depkeeper.pypi] -index_url = "https://pypi.org/simple" -extra_index_urls = [ - "https://private.pypi.example.com/simple" -] -timeout = 30 ``` For the full list of options and their descriptions, see [Configuration Options](configuration-options.md). @@ -199,11 +185,8 @@ dev = [ ] [tool.depkeeper] -update_strategy = "minor" check_conflicts = true - -[tool.depkeeper.filters] -exclude = ["django"] +strict_version_matching = false ``` --- diff --git a/docs/reference/index.md b/docs/reference/index.md index bdcfafc..488a81d 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -84,9 +84,6 @@ depkeeper check --format json |---|---| | `DEPKEEPER_CONFIG` | Configuration file path | | `DEPKEEPER_COLOR` | Enable/disable colors | -| `DEPKEEPER_CACHE_DIR` | Cache directory | -| `DEPKEEPER_LOG_LEVEL` | Logging level | -| `DEPKEEPER_TIMEOUT` | HTTP timeout in seconds | ### Exit Codes diff --git a/docs/reference/python-api.md b/docs/reference/python-api.md index f03e08f..5e1b741 100644 --- a/docs/reference/python-api.md +++ b/docs/reference/python-api.md @@ -334,9 +334,9 @@ req = Requirement( # Convert to string print(req.to_string()) # requests[security]>=2.28.0,<3.0.0; python_version >= '3.8' -# Update version (replaces all specifiers with >=new_version) +# Update version (replaces all specifiers with ==new_version) updated = req.update_version("2.31.0") -print(updated) # requests[security]>=2.31.0; python_version >= '3.8' +print(updated) # requests[security]==2.31.0; python_version >= '3.8' ``` #### Attributes @@ -754,8 +754,55 @@ if __name__ == "__main__": --- +## Configuration + +### DepKeeperConfig + +Dataclass representing a parsed and validated configuration file. All fields carry defaults, so an empty or missing configuration file produces a fully usable config object. + +```python +from depkeeper.config import load_config, discover_config_file + +# Auto-discover and load (depkeeper.toml or pyproject.toml) +config = load_config() + +# Load from explicit path +config = load_config(Path("/project/depkeeper.toml")) + +# Access values +print(config.check_conflicts) # True +print(config.strict_version_matching) # False +``` + +#### Functions + +::: depkeeper.config + options: + show_root_heading: false + members: + - discover_config_file + - load_config + +#### Class + +::: depkeeper.config.DepKeeperConfig + options: + show_root_heading: true + members: + - to_log_dict + +#### Exception + +::: depkeeper.config.ConfigError + options: + show_root_heading: true + +--- + ## See Also - [Getting Started](../getting-started/quickstart.md) -- Quick start guide - [CLI Reference](cli-commands.md) -- Command-line interface +- [Configuration Guide](../guides/configuration.md) -- Configuration guide +- [Configuration Options](configuration-options.md) -- Full options reference - [Contributing](../contributing/development-setup.md) -- Development guide diff --git a/mkdocs.yml b/mkdocs.yml index 468df14..70dfe5e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -17,7 +17,7 @@ edit_uri: edit/main/docs/ copyright: Copyright © 2024-2026 Rahul Kaushal # Strict mode ensures broken links and references cause build failures -# strict: true +strict: true # ============================================================ # Theme Configuration diff --git a/pyproject.toml b/pyproject.toml index 4b7a109..2634db3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta" [project] name = "depkeeper" -version = "0.1.0.dev3" +version = "0.1.0" description = "Modern Python dependency management for requirements.txt files" readme = "README.md" requires-python = ">=3.8" @@ -53,6 +53,7 @@ dependencies = [ "packaging>=23.2", "httpx[http2]>=0.24.1", "rich>=13.9.4", + "tomli>=2.4.0", ] @@ -165,6 +166,7 @@ addopts = [ ] markers = [ + "unit: unit tests", "slow: slow tests", "integration: integration tests", "e2e: end-to-end tests", diff --git a/requirements.txt b/requirements.txt index 47e8f20..df34528 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ click>=8.1.8 packaging>=23.2 httpx[http2]>=0.24.1 rich>=13.9.4 +tomli>=2.4.0 diff --git a/scripts/setup_dev.ps1 b/scripts/setup_dev.ps1 new file mode 100644 index 0000000..94c3723 --- /dev/null +++ b/scripts/setup_dev.ps1 @@ -0,0 +1,176 @@ +# ================================================================ +# depkeeper Development Environment Setup Script (Windows) +# ================================================================ +# This script sets up a complete development environment for depkeeper. +# It creates a virtual environment, installs dev dependencies, +# installs pre-commit hooks, and verifies the installation. +# +# Usage: .\setup_dev.ps1 +# Note: You may need to run first (once, as admin): +# Set-ExecutionPolicy -Scope CurrentUser RemoteSigned +# ================================================================ + +$ErrorActionPreference = "Stop" + + +# ================================================================ +# Navigate to project root +# ================================================================ +$SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path +$PROJECT_ROOT = Split-Path -Parent $SCRIPT_DIR + +Push-Location $PROJECT_ROOT + + +# ================================================================ +# Colors & Pretty Printing +# ================================================================ +function Info { param($msg) Write-Host "i $msg" -ForegroundColor Cyan } +function Success { param($msg) Write-Host "v $msg" -ForegroundColor Green } +function Warn { param($msg) Write-Host "! $msg" -ForegroundColor Yellow } +function Err { param($msg) Write-Host "x $msg" -ForegroundColor Red } + + +# ================================================================ +# Header +# ================================================================ +Write-Host "" +Write-Host "==========================================" -ForegroundColor Cyan +Write-Host "depkeeper - Development Setup" -ForegroundColor Cyan +Write-Host "==========================================" -ForegroundColor Cyan +Write-Host "" + + +# ================================================================ +# Detect usable Python executable +# ================================================================ +Info "Detecting Python..." + +$PYTHON = $null + +foreach ($candidate in @("python", "python3")) { + if (Get-Command $candidate -ErrorAction SilentlyContinue) { + $PYTHON = $candidate + break + } +} + +if (-not $PYTHON) { + Err "Python 3.8+ not found. Please install Python from https://python.org" + exit 1 +} + +$PY_VERSION = & $PYTHON --version 2>&1 | ForEach-Object { $_ -replace 'Python ', '' } + +$parts = $PY_VERSION -split '\.' +$major = [int]$parts[0] +$minor = [int]$parts[1] + +if ($major -lt 3 -or ($major -eq 3 -and $minor -lt 8)) { + $msg = "Python >= 3.8 required (found $PY_VERSION)" + Err $msg + exit 1 +} + +Success "Using Python $PY_VERSION" + + +# ================================================================ +# Virtual environment +# ================================================================ +Info "Creating virtual environment..." + +if (-not (Test-Path ".venv")) { + & $PYTHON -m venv .venv + Success "Virtual environment created" +} else { + Warn "Virtual environment already exists - skipping creation" +} + + +# ================================================================ +# Activate the environment +# ================================================================ +Info "Activating virtual environment..." + +$activateScript = ".venv\Scripts\Activate.ps1" + +if (-not (Test-Path $activateScript)) { + Err "Activation script not found: $activateScript" + exit 1 +} + +& $activateScript +Success "Virtual environment activated" + + +# ================================================================ +# Upgrade pip & tooling +# ================================================================ +Info "Upgrading pip, setuptools, wheel..." +pip install --upgrade pip setuptools wheel --quiet +Success "Toolchain upgraded" + + +# ================================================================ +# Install depkeeper in dev mode +# ================================================================ +Info "Installing depkeeper (editable mode) with dev dependencies..." +pip install -e '.[dev]' --quiet +Success "depkeeper installed" + + +# ================================================================ +# Pre-commit hooks +# ================================================================ +Info "Installing pre-commit hooks..." +pre-commit install --hook-type pre-commit --hook-type commit-msg +Success "Pre-commit hooks installed" + + +# ================================================================ +# Run initial tests +# ================================================================ +Info "Running initial tests..." + +pytest -q --disable-warnings 2>$null +if ($LASTEXITCODE -eq 0) { + Success "Initial tests passed" +} else { + Warn "No tests found or some tests failed (expected in early development phases)" +} + + +# ================================================================ +# Finish +# ================================================================ +Write-Host "" +Write-Host "==========================================" -ForegroundColor Green +Write-Host "Development environment setup complete!" -ForegroundColor Green +Write-Host "==========================================" -ForegroundColor Green +Write-Host "" + +Write-Host @" +Next steps: + +1. Activate the virtual environment: + > .venv\Scripts\Activate.ps1 + + If you get an execution policy error, run once as admin: + > Set-ExecutionPolicy -Scope CurrentUser RemoteSigned + +2. Useful commands: + > make test -- Run tests + > make typecheck -- Mypy type checking + > make all -- Run all quality checks + +3. Try depkeeper: + > python -m depkeeper + > depkeeper --help + +Happy coding! +"@ + +Success "Setup completed successfully!" + +Pop-Location diff --git a/scripts/setup_dev.sh b/scripts/setup_dev.sh index ee07838..e9a83ad 100644 --- a/scripts/setup_dev.sh +++ b/scripts/setup_dev.sh @@ -11,6 +11,15 @@ set -e # Exit immediately on error +# ================================================================ +# Navigate to project root +# ================================================================ +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_ROOT" + + # ================================================================ # Colors & Pretty Printing # ================================================================ @@ -67,8 +76,8 @@ success "Using Python $PY_VERSION" # ================================================================ info "Creating virtual environment..." -if [[ ! -d venv ]]; then - $PYTHON -m venv venv +if [[ ! -d .venv ]]; then + $PYTHON -m venv .venv success "Virtual environment created" else warn "Virtual environment already exists" @@ -81,9 +90,9 @@ fi info "Activating virtual environment..." # shellcheck source=/dev/null -if ! source venv/bin/activate 2>/dev/null; then +if ! source .venv/bin/activate 2>/dev/null; then # Windows Git Bash fallback - if ! source venv/Scripts/activate 2>/dev/null; then + if ! source .venv/Scripts/activate 2>/dev/null; then error "Failed to activate virtual environment" exit 1 fi @@ -141,13 +150,13 @@ cat < None: + """Test DepKeeperConfig initializes with correct defaults.""" + config = DepKeeperConfig() + + assert config.check_conflicts is True + assert config.strict_version_matching is False + assert config.source_path is None + + def test_custom_initialization(self) -> None: + """Test DepKeeperConfig accepts custom values.""" + test_path = Path("/test/config.toml") + + config = DepKeeperConfig( + check_conflicts=False, + strict_version_matching=True, + source_path=test_path, + ) + + assert config.check_conflicts is False + assert config.strict_version_matching is True + assert config.source_path == test_path + + def test_to_log_dict(self) -> None: + """Test to_log_dict returns configuration without metadata.""" + config = DepKeeperConfig( + check_conflicts=False, + strict_version_matching=True, + source_path=Path("/test/path.toml"), + ) + + result = config.to_log_dict() + + assert result == { + "check_conflicts": False, + "strict_version_matching": True, + } + assert "source_path" not in result + + +@pytest.mark.unit +class TestDiscoverConfigFile: + """Tests for discover_config_file function.""" + + def test_explicit_path_priority(self, tmp_path: Path) -> None: + """Test explicit path is used when provided and exists.""" + config_file = tmp_path / "custom.toml" + config_file.write_text("[depkeeper]\n", encoding="utf-8") + + # Create auto-discoverable file that should be ignored + (tmp_path / "depkeeper.toml").write_text("[depkeeper]\n", encoding="utf-8") + + with patch("depkeeper.config.Path.cwd", return_value=tmp_path): + result = discover_config_file(config_file) + + assert result == config_file.resolve() + + def test_explicit_path_not_found_raises_error(self, tmp_path: Path) -> None: + """Test ConfigError raised when explicit path doesn't exist.""" + non_existent = tmp_path / "nonexistent.toml" + + with pytest.raises(ConfigError) as exc_info: + discover_config_file(non_existent) + + assert "not found" in str(exc_info.value).lower() + + def test_discovers_depkeeper_toml(self, tmp_path: Path) -> None: + """Test discovers depkeeper.toml in current directory.""" + config_file = tmp_path / "depkeeper.toml" + config_file.write_text("[depkeeper]\n", encoding="utf-8") + + with patch("depkeeper.config.Path.cwd", return_value=tmp_path): + result = discover_config_file() + + assert result == config_file + + def test_discovers_pyproject_toml_with_section(self, tmp_path: Path) -> None: + """Test discovers pyproject.toml with [tool.depkeeper] section.""" + config_file = tmp_path / "pyproject.toml" + config_file.write_text( + "[tool.depkeeper]\ncheck_conflicts = false\n", + encoding="utf-8", + ) + + with patch("depkeeper.config.Path.cwd", return_value=tmp_path): + result = discover_config_file() + + assert result == config_file + + def test_ignores_pyproject_toml_without_section(self, tmp_path: Path) -> None: + """Test ignores pyproject.toml without [tool.depkeeper] section.""" + config_file = tmp_path / "pyproject.toml" + config_file.write_text("[tool.other]\nkey = 'value'\n", encoding="utf-8") + + with patch("depkeeper.config.Path.cwd", return_value=tmp_path): + result = discover_config_file() + + assert result is None + + def test_returns_none_when_no_config_found(self, tmp_path: Path) -> None: + """Test returns None when no configuration file exists.""" + with patch("depkeeper.config.Path.cwd", return_value=tmp_path): + result = discover_config_file() + + assert result is None + + def test_precedence_order(self, tmp_path: Path) -> None: + """Test discovery precedence: depkeeper.toml before pyproject.toml.""" + depkeeper_toml = tmp_path / "depkeeper.toml" + depkeeper_toml.write_text("[depkeeper]\n", encoding="utf-8") + + pyproject_toml = tmp_path / "pyproject.toml" + pyproject_toml.write_text("[tool.depkeeper]\n", encoding="utf-8") + + with patch("depkeeper.config.Path.cwd", return_value=tmp_path): + result = discover_config_file() + + assert result == depkeeper_toml + + +@pytest.mark.unit +class TestPyprojectHasDepkeeperSection: + """Tests for _pyproject_has_depkeeper_section helper.""" + + def test_returns_true_when_section_exists(self, tmp_path: Path) -> None: + """Test returns True when [tool.depkeeper] section exists.""" + config_file = tmp_path / "pyproject.toml" + config_file.write_text( + "[tool.depkeeper]\ncheck_conflicts = true\n", + encoding="utf-8", + ) + + result = _pyproject_has_depkeeper_section(config_file) + + assert result is True + + def test_returns_false_when_section_missing(self, tmp_path: Path) -> None: + """Test returns False when [tool.depkeeper] section doesn't exist.""" + config_file = tmp_path / "pyproject.toml" + config_file.write_text("[tool.other]\nkey = 'value'\n", encoding="utf-8") + + result = _pyproject_has_depkeeper_section(config_file) + + assert result is False + + def test_returns_false_on_errors(self, tmp_path: Path) -> None: + """Test returns False gracefully on parse errors or missing files.""" + # Invalid TOML + config_file = tmp_path / "pyproject.toml" + config_file.write_text("invalid ][[", encoding="utf-8") + assert _pyproject_has_depkeeper_section(config_file) is False + + # Non-existent file + non_existent = tmp_path / "nonexistent.toml" + assert _pyproject_has_depkeeper_section(non_existent) is False + + +@pytest.mark.unit +class TestReadToml: + """Tests for _read_toml helper.""" + + def test_reads_valid_toml(self, tmp_path: Path) -> None: + """Test successfully reads and parses valid TOML file.""" + toml_file = tmp_path / "test.toml" + toml_file.write_text( + "[tool.depkeeper]\ncheck_conflicts = true\n", + encoding="utf-8", + ) + + result = _read_toml(toml_file) + + assert isinstance(result, dict) + assert result["tool"]["depkeeper"]["check_conflicts"] is True + + def test_raises_error_on_invalid_toml(self, tmp_path: Path) -> None: + """Test raises ConfigError when TOML is invalid.""" + toml_file = tmp_path / "invalid.toml" + toml_file.write_text("invalid ][[ toml", encoding="utf-8") + + with pytest.raises(ConfigError) as exc_info: + _read_toml(toml_file) + + assert "Invalid TOML" in str(exc_info.value) + + def test_raises_error_when_file_not_found(self, tmp_path: Path) -> None: + """Test raises ConfigError when file doesn't exist.""" + toml_file = tmp_path / "nonexistent.toml" + + with pytest.raises(ConfigError) as exc_info: + _read_toml(toml_file) + + assert "Cannot read" in str(exc_info.value) + + def test_raises_error_when_toml_library_unavailable(self, tmp_path: Path) -> None: + """Test raises ConfigError when TOML library is not available.""" + toml_file = tmp_path / "test.toml" + toml_file.write_text("[depkeeper]\n", encoding="utf-8") + + # Mock tomllib as None to simulate missing library + with patch("depkeeper.config.tomllib", None): + with pytest.raises(ConfigError) as exc_info: + _read_toml(toml_file) + + assert "TOML support requires" in str(exc_info.value) + assert "tomli" in str(exc_info.value) + + +@pytest.mark.unit +class TestParseSection: + """Tests for _parse_section configuration validator.""" + + def test_parses_empty_section(self) -> None: + """Test parsing empty section returns defaults.""" + result = _parse_section({}, config_path="test.toml") + + assert result.check_conflicts is True + assert result.strict_version_matching is False + + def test_parses_all_options(self) -> None: + """Test parsing all configuration options.""" + section = { + "check_conflicts": False, + "strict_version_matching": True, + } + + result = _parse_section(section, config_path="test.toml") + + assert result.check_conflicts is False + assert result.strict_version_matching is True + + def test_raises_error_on_unknown_keys(self) -> None: + """Test raises ConfigError when unknown keys are present.""" + section = {"unknown_key": "value", "another_unknown": True} + + with pytest.raises(ConfigError) as exc_info: + _parse_section(section, config_path="test.toml") + + assert "Unknown configuration keys" in str(exc_info.value) + assert "unknown_key" in str(exc_info.value) + + def test_raises_error_on_wrong_type_check_conflicts(self) -> None: + """Test raises ConfigError when check_conflicts has wrong type.""" + section = {"check_conflicts": "true"} # String instead of bool + + with pytest.raises(ConfigError) as exc_info: + _parse_section(section, config_path="test.toml") + + assert "check_conflicts must be a boolean" in str(exc_info.value) + + def test_raises_error_on_wrong_type_strict_version_matching(self) -> None: + """Test raises ConfigError when strict_version_matching has wrong type.""" + section = {"strict_version_matching": 1} # Integer instead of bool + + with pytest.raises(ConfigError) as exc_info: + _parse_section(section, config_path="test.toml") + + assert "strict_version_matching must be a boolean" in str(exc_info.value) + + +@pytest.mark.unit +class TestLoadConfig: + """Tests for load_config main function.""" + + def test_returns_defaults_when_no_config_found(self, tmp_path: Path) -> None: + """Test returns defaults when no configuration file exists.""" + with patch("depkeeper.config.Path.cwd", return_value=tmp_path): + result = load_config() + + assert result.check_conflicts is True + assert result.strict_version_matching is False + assert result.source_path is None + + def test_loads_depkeeper_toml(self, tmp_path: Path) -> None: + """Test loads configuration from depkeeper.toml.""" + config_file = tmp_path / "depkeeper.toml" + config_file.write_text( + "[depkeeper]\ncheck_conflicts = false\n", + encoding="utf-8", + ) + + with patch("depkeeper.config.Path.cwd", return_value=tmp_path): + result = load_config() + + assert result.check_conflicts is False + assert result.source_path == config_file + + def test_loads_pyproject_toml(self, tmp_path: Path) -> None: + """Test loads configuration from pyproject.toml.""" + config_file = tmp_path / "pyproject.toml" + config_file.write_text( + "[tool.depkeeper]\nstrict_version_matching = true\n", + encoding="utf-8", + ) + + with patch("depkeeper.config.Path.cwd", return_value=tmp_path): + result = load_config() + + assert result.strict_version_matching is True + assert result.source_path == config_file + + def test_loads_explicit_config_path(self, tmp_path: Path) -> None: + """Test loads configuration from explicitly specified path.""" + config_file = tmp_path / "custom.toml" + config_file.write_text( + "[depkeeper]\ncheck_conflicts = false\n", + encoding="utf-8", + ) + + result = load_config(config_file) + + assert result.check_conflicts is False + assert result.source_path == config_file.resolve() + + def test_raises_error_on_invalid_toml(self, tmp_path: Path) -> None: + """Test raises ConfigError when TOML file is invalid.""" + config_file = tmp_path / "depkeeper.toml" + config_file.write_text("invalid ][[ toml", encoding="utf-8") + + with patch("depkeeper.config.Path.cwd", return_value=tmp_path): + with pytest.raises(ConfigError): + load_config() + + def test_raises_error_on_unknown_keys(self, tmp_path: Path) -> None: + """Test raises ConfigError when config contains unknown keys.""" + config_file = tmp_path / "depkeeper.toml" + config_file.write_text( + "[depkeeper]\nunknown_option = true\n", + encoding="utf-8", + ) + + with patch("depkeeper.config.Path.cwd", return_value=tmp_path): + with pytest.raises(ConfigError) as exc_info: + load_config() + + assert "Unknown configuration keys" in str(exc_info.value) + + def test_handles_empty_depkeeper_section(self, tmp_path: Path) -> None: + """Test handles empty [depkeeper] section gracefully.""" + config_file = tmp_path / "depkeeper.toml" + config_file.write_text("[depkeeper]\n", encoding="utf-8") + + with patch("depkeeper.config.Path.cwd", return_value=tmp_path): + result = load_config() + + assert result.check_conflicts is True # Defaults + assert result.strict_version_matching is False + assert result.source_path == config_file diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 0000000..0b8467e --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import click +import pytest + +from depkeeper.config import DepKeeperConfig +from depkeeper.context import DepKeeperContext, pass_context + + +@pytest.mark.unit +class TestDepKeeperContext: + """Tests for DepKeeperContext class.""" + + def test_default_initialization(self) -> None: + """Test DepKeeperContext initializes with correct default values.""" + ctx = DepKeeperContext() + + assert ctx.config_path is None + assert ctx.verbose == 0 + assert ctx.color is True + assert ctx.config is None + + def test_instances_are_independent(self) -> None: + """Test multiple DepKeeperContext instances are independent.""" + ctx1 = DepKeeperContext() + ctx2 = DepKeeperContext() + + # Modify first instance + ctx1.verbose = 2 + ctx1.color = False + + # Second instance should be unaffected + assert ctx2.verbose == 0 + assert ctx2.color is True + assert ctx1 is not ctx2 + + def test_all_attributes_can_be_set(self) -> None: + """Test all context attributes can be set and retrieved.""" + ctx = DepKeeperContext() + test_path = Path("/path/to/config.toml") + mock_config = MagicMock(spec=DepKeeperConfig) + + # Set all attributes + ctx.config_path = test_path + ctx.verbose = 2 + ctx.color = False + ctx.config = mock_config + + # Verify all attributes + assert ctx.config_path == test_path + assert ctx.verbose == 2 + assert ctx.color is False + assert ctx.config is mock_config + + def test_slots_prevents_arbitrary_attributes(self) -> None: + """Test __slots__ prevents setting undefined attributes.""" + ctx = DepKeeperContext() + + with pytest.raises(AttributeError): + ctx.arbitrary_attribute = "value" # type: ignore + + +@pytest.mark.unit +class TestPassContextDecorator: + """Tests for pass_context decorator.""" + + def test_pass_context_injects_existing_context(self) -> None: + """Test pass_context decorator injects existing DepKeeperContext.""" + + @click.command() + @pass_context + def test_command(ctx: DepKeeperContext) -> DepKeeperContext: + return ctx + + # Create Click context with DepKeeperContext + click_ctx = click.Context(click.Command("test")) + depkeeper_ctx = DepKeeperContext() + click_ctx.obj = depkeeper_ctx + + result = click_ctx.invoke(test_command) + + assert result is depkeeper_ctx + + def test_pass_context_creates_context_when_missing(self) -> None: + """Test pass_context creates DepKeeperContext when none exists.""" + + @click.command() + @pass_context + def test_command(ctx: DepKeeperContext) -> DepKeeperContext: + return ctx + + # Create Click context without obj (no DepKeeperContext) + click_ctx = click.Context(click.Command("test")) + + result = click_ctx.invoke(test_command) + + # Should auto-create context with defaults + assert isinstance(result, DepKeeperContext) + assert result.verbose == 0 + assert result.color is True + + def test_pass_context_preserves_modifications(self) -> None: + """Test context modifications are preserved across decorator usage.""" + + @click.command() + @pass_context + def test_command(ctx: DepKeeperContext) -> None: + ctx.verbose = 3 + ctx.color = False + + click_ctx = click.Context(click.Command("test")) + depkeeper_ctx = DepKeeperContext() + click_ctx.obj = depkeeper_ctx + + click_ctx.invoke(test_command) + + # Modifications should persist + assert depkeeper_ctx.verbose == 3 + assert depkeeper_ctx.color is False diff --git a/tests/test_core/test_data_store.py b/tests/test_core/test_data_store.py index b29a5cd..25c94cd 100644 --- a/tests/test_core/test_data_store.py +++ b/tests/test_core/test_data_store.py @@ -1,27 +1,10 @@ -"""Unit tests for depkeeper.data_store module. - -This test suite provides comprehensive coverage of PyPI data store functionality, -including package data caching, version resolution, dependency fetching, Python -compatibility checking, and async concurrency control. - -Test Coverage: -- PyPIPackageData dataclass and query methods -- PyPIDataStore initialization and configuration -- Async package data fetching with double-checked locking -- Semaphore-based concurrent request limiting -- Version dependency caching and resolution -- Python version compatibility checking -- Package name normalization -- Edge cases (missing data, invalid versions, concurrent access, etc.) -""" - from __future__ import annotations import pytest import asyncio from typing import Any, Dict -from unittest.mock import AsyncMock, MagicMock, patch from packaging.version import Version +from unittest.mock import AsyncMock, MagicMock, patch from depkeeper.core.data_store import ( PyPIDataStore, @@ -32,29 +15,15 @@ from depkeeper.utils.http import HTTPClient -# ============================================================================ -# Fixtures -# ============================================================================ - - @pytest.fixture def mock_http_client() -> MagicMock: - """Create a mock HTTPClient for testing. - - Returns: - Mock HTTPClient with configurable response behavior. - """ - client = MagicMock(spec=HTTPClient) - return client + """Create a mock HTTPClient for testing.""" + return MagicMock(spec=HTTPClient) @pytest.fixture def sample_pypi_response() -> Dict[str, Any]: - """Create a sample PyPI JSON API response. - - Returns: - Dict mimicking PyPI's /pypi/{package}/json structure. - """ + """Create a sample PyPI JSON API response.""" return { "info": { "name": "requests", @@ -63,8 +32,6 @@ def sample_pypi_response() -> Dict[str, Any]: "requires_dist": [ "charset-normalizer>=2.0.0", "idna>=2.5", - "urllib3>=1.21.1", - "certifi>=2017.4.17", "PySocks>=1.5.6; extra == 'socks'", # Should be filtered ], }, @@ -75,14 +42,11 @@ def sample_pypi_response() -> Dict[str, Any]: "2.30.0": [ {"requires_python": ">=3.7", "filename": "requests-2.30.0.tar.gz"} ], - "2.29.0": [ - {"requires_python": ">=3.7", "filename": "requests-2.29.0.tar.gz"} - ], "2.0.0": [{"requires_python": None, "filename": "requests-2.0.0.tar.gz"}], "1.2.3": [ {"requires_python": ">=2.7", "filename": "requests-1.2.3.tar.gz"} ], - "3.0.0a1": [ # Pre-release + "3.0.0a1": [ {"requires_python": ">=3.8", "filename": "requests-3.0.0a1.tar.gz"} ], "invalid-version": [], # No files - should be skipped @@ -92,21 +56,16 @@ def sample_pypi_response() -> Dict[str, Any]: @pytest.fixture def sample_package_data() -> PyPIPackageData: - """Create a sample PyPIPackageData instance. - - Returns: - Pre-populated PyPIPackageData for testing query methods. - """ + """Create a sample PyPIPackageData instance for testing.""" return PyPIPackageData( name="requests", latest_version="2.31.0", latest_requires_python=">=3.7", latest_dependencies=["charset-normalizer>=2.0.0", "idna>=2.5"], - all_versions=["2.31.0", "2.30.0", "2.29.0", "2.0.0", "1.2.3"], + all_versions=["2.31.0", "2.30.0", "2.0.0", "1.2.3"], parsed_versions=[ ("2.31.0", Version("2.31.0")), ("2.30.0", Version("2.30.0")), - ("2.29.0", Version("2.29.0")), ("2.0.0", Version("2.0.0")), ("1.2.3", Version("1.2.3")), ("3.0.0a1", Version("3.0.0a1")), # Pre-release @@ -114,7 +73,6 @@ def sample_package_data() -> PyPIPackageData: python_requirements={ "2.31.0": ">=3.7", "2.30.0": ">=3.7", - "2.29.0": ">=3.7", "2.0.0": None, "1.2.3": ">=2.7", }, @@ -123,348 +81,119 @@ def sample_package_data() -> PyPIPackageData: ) -# ============================================================================ -# Test: _normalize helper function -# ============================================================================ - - +@pytest.mark.unit class TestNormalizeFunction: """Tests for _normalize package name normalization.""" - def test_lowercase_conversion(self) -> None: - """Test _normalize converts to lowercase. - - Happy path: Package names should be lowercased for consistency. - """ - assert _normalize("Requests") == "requests" - assert _normalize("FLASK") == "flask" - assert _normalize("DjAnGo") == "django" - - def test_underscore_to_hyphen(self) -> None: - """Test _normalize replaces underscores with hyphens. - - PyPI treats underscores and hyphens as equivalent. - """ - assert _normalize("flask_login") == "flask-login" - assert _normalize("my_package_name") == "my-package-name" - def test_combined_normalization(self) -> None: - """Test _normalize handles both case and underscores. - - Integration test: Both transformations applied together. - """ + """Test _normalize handles case and underscores together.""" assert _normalize("Flask_Login") == "flask-login" assert _normalize("My_PACKAGE_Name") == "my-package-name" - - def test_already_normalized(self) -> None: - """Test _normalize is idempotent for normalized names. - - Edge case: Already normalized names should pass through unchanged. - """ assert _normalize("requests") == "requests" - assert _normalize("flask-login") == "flask-login" - - def test_empty_string(self) -> None: - """Test _normalize handles empty strings. - - Edge case: Empty input should return empty output. - """ - assert _normalize("") == "" - - def test_special_characters_preserved(self) -> None: - """Test _normalize preserves other characters. - - Edge case: Only underscores converted, other chars unchanged. - """ - assert _normalize("package-v2.0") == "package-v2.0" - assert _normalize("my.package") == "my.package" - - -# ============================================================================ -# Test: PyPIPackageData dataclass -# ============================================================================ + assert _normalize("DJANGO") == "django" +@pytest.mark.unit class TestPyPIPackageData: - """Tests for PyPIPackageData dataclass and its query methods.""" - - def test_dataclass_initialization(self) -> None: - """Test PyPIPackageData can be initialized with required fields. + """Tests for PyPIPackageData dataclass and query methods.""" - Happy path: Minimal initialization with just name. - """ + def test_initialization(self) -> None: + """Test PyPIPackageData initializes with defaults.""" data = PyPIPackageData(name="test-package") + assert data.name == "test-package" assert data.latest_version is None assert data.all_versions == [] assert data.dependencies_cache == {} - def test_dataclass_with_all_fields(self) -> None: - """Test PyPIPackageData initialization with all fields. - - Verifies all fields are properly stored. - """ - data = PyPIPackageData( - name="requests", - latest_version="2.31.0", - latest_requires_python=">=3.7", - latest_dependencies=["dep1", "dep2"], - all_versions=["2.31.0", "2.30.0"], - parsed_versions=[("2.31.0", Version("2.31.0"))], - python_requirements={"2.31.0": ">=3.7"}, - releases={"2.31.0": []}, - dependencies_cache={"2.31.0": ["dep1"]}, - ) - assert data.name == "requests" - assert data.latest_version == "2.31.0" - assert len(data.all_versions) == 2 - - def test_get_versions_in_major_filters_correctly( - self, sample_package_data: PyPIPackageData - ) -> None: - """Test get_versions_in_major returns only specified major version. - - Happy path: Filter versions by major number. - """ + def test_get_versions_in_major(self, sample_package_data: PyPIPackageData) -> None: + """Test get_versions_in_major filters by major version number.""" v2_versions = sample_package_data.get_versions_in_major(2) + v1_versions = sample_package_data.get_versions_in_major(1) + v99_versions = sample_package_data.get_versions_in_major(99) + + # Version 2.x assert "2.31.0" in v2_versions assert "2.30.0" in v2_versions - assert "2.29.0" in v2_versions assert "1.2.3" not in v2_versions - def test_get_versions_in_major_excludes_prereleases( - self, sample_package_data: PyPIPackageData - ) -> None: - """Test get_versions_in_major excludes pre-release versions. + # Version 1.x + assert "1.2.3" in v1_versions - Pre-releases (alpha, beta, rc) should be filtered out. - """ - v3_versions = sample_package_data.get_versions_in_major(3) - assert "3.0.0a1" not in v3_versions + # Pre-releases excluded + assert "3.0.0a1" not in sample_package_data.get_versions_in_major(3) - def test_get_versions_in_major_empty_result( - self, sample_package_data: PyPIPackageData - ) -> None: - """Test get_versions_in_major returns empty for non-existent major. - - Edge case: No versions with specified major number. - """ - v99_versions = sample_package_data.get_versions_in_major(99) + # Non-existent major assert v99_versions == [] - def test_get_versions_in_major_descending_order( - self, sample_package_data: PyPIPackageData - ) -> None: - """Test get_versions_in_major maintains descending sort order. - - Versions should be returned newest-first. - """ - v2_versions = sample_package_data.get_versions_in_major(2) - assert v2_versions[0] == "2.31.0" - assert v2_versions[-1] == "2.0.0" - - def test_get_versions_in_major_handles_empty_release_tuple(self) -> None: - """Test get_versions_in_major skips versions with empty release tuple. - - Edge case: Malformed version objects should be filtered safely. - """ - data = PyPIPackageData( - name="test", - parsed_versions=[ - ("1.0", Version("1.0")), - ], - ) - # Version("1.0") has release=(1, 0), should work fine - result = data.get_versions_in_major(1) - assert "1.0" in result - - def test_is_python_compatible_with_requirement( - self, sample_package_data: PyPIPackageData - ) -> None: - """Test is_python_compatible checks version constraints. - - Happy path: Python version within requirements. - """ + def test_is_python_compatible(self, sample_package_data: PyPIPackageData) -> None: + """Test is_python_compatible checks Python version requirements.""" + # Compatible assert sample_package_data.is_python_compatible("2.31.0", "3.9.0") is True assert sample_package_data.is_python_compatible("2.31.0", "3.11.4") is True - def test_is_python_compatible_incompatible( - self, sample_package_data: PyPIPackageData - ) -> None: - """Test is_python_compatible rejects incompatible versions. - - Python version outside requirements should return False. - """ + # Incompatible assert sample_package_data.is_python_compatible("2.31.0", "3.6.0") is False assert sample_package_data.is_python_compatible("2.31.0", "2.7.18") is False - def test_is_python_compatible_no_requirement( - self, sample_package_data: PyPIPackageData - ) -> None: - """Test is_python_compatible returns True when no requirement set. - - Edge case: Missing requires_python should be permissive (pip behavior). - """ + # No requirement (permissive) assert sample_package_data.is_python_compatible("2.0.0", "2.7.0") is True - assert sample_package_data.is_python_compatible("2.0.0", "3.11.0") is True - def test_is_python_compatible_invalid_specifier( + def test_get_python_compatible_versions( self, sample_package_data: PyPIPackageData ) -> None: - """Test is_python_compatible handles malformed specifiers gracefully. + """Test get_python_compatible_versions filters by Python version.""" + # All majors + compatible_all = sample_package_data.get_python_compatible_versions("3.9.0") + assert "2.31.0" in compatible_all + assert "1.2.3" in compatible_all - Edge case: Invalid specifiers should be treated as compatible (permissive). - """ - sample_package_data.python_requirements["bad"] = "invalid specifier >><" - assert sample_package_data.is_python_compatible("bad", "3.9.0") is True - - def test_is_python_compatible_version_not_in_requirements( - self, sample_package_data: PyPIPackageData - ) -> None: - """Test is_python_compatible when version not in python_requirements dict. - - Edge case: Unknown version should return True (permissive). - """ - assert sample_package_data.is_python_compatible("99.99.99", "3.9.0") is True - - def test_get_python_compatible_versions_all_major( - self, sample_package_data: PyPIPackageData - ) -> None: - """Test get_python_compatible_versions without major filter. - - Happy path: Return all compatible versions across all major versions. - """ - compatible = sample_package_data.get_python_compatible_versions("3.9.0") - assert "2.31.0" in compatible - assert "2.30.0" in compatible - assert "1.2.3" in compatible # Compatible with Python 3.9 - - def test_get_python_compatible_versions_with_major( - self, sample_package_data: PyPIPackageData - ) -> None: - """Test get_python_compatible_versions with major version filter. - - Should only return versions from specified major and compatible with Python. - """ - compatible = sample_package_data.get_python_compatible_versions( + # Specific major + compatible_v2 = sample_package_data.get_python_compatible_versions( "3.9.0", major=2 ) - assert "2.31.0" in compatible - assert "1.2.3" not in compatible # Wrong major - - def test_get_python_compatible_versions_incompatible_python( - self, sample_package_data: PyPIPackageData - ) -> None: - """Test get_python_compatible_versions filters out incompatible versions. + assert "2.31.0" in compatible_v2 + assert "1.2.3" not in compatible_v2 - Old Python versions should exclude newer package versions. - """ - compatible = sample_package_data.get_python_compatible_versions( + # Incompatible Python version + old_python = sample_package_data.get_python_compatible_versions( "2.7.18", major=2 ) - # 2.31.0, 2.30.0, 2.29.0 require >=3.7, so excluded - assert "2.31.0" not in compatible - assert "2.0.0" in compatible # No python requirement - - def test_get_python_compatible_versions_excludes_prereleases( - self, sample_package_data: PyPIPackageData - ) -> None: - """Test get_python_compatible_versions excludes pre-releases. - - Alpha, beta, rc versions should be filtered out. - """ - compatible = sample_package_data.get_python_compatible_versions("3.9.0") - assert "3.0.0a1" not in compatible - - def test_get_python_compatible_versions_empty_result( - self, sample_package_data: PyPIPackageData - ) -> None: - """Test get_python_compatible_versions with no matches. - - Edge case: No compatible versions available. - """ - compatible = sample_package_data.get_python_compatible_versions( - "2.6.0", major=2 - ) - # Python 2.6 very old, likely no matches - assert isinstance(compatible, list) - - -# ============================================================================ -# Test: PyPIDataStore initialization -# ============================================================================ + assert "2.31.0" not in old_python # Requires >=3.7 + assert "2.0.0" in old_python # No requirement +@pytest.mark.unit class TestPyPIDataStoreInit: - """Tests for PyPIDataStore initialization and configuration.""" - - def test_initialization_with_defaults(self, mock_http_client: MagicMock) -> None: - """Test PyPIDataStore initializes with default concurrent_limit. + """Tests for PyPIDataStore initialization.""" - Happy path: Default semaphore limit should be 10. - """ + def test_initialization(self, mock_http_client: MagicMock) -> None: + """Test PyPIDataStore initializes with correct defaults.""" store = PyPIDataStore(mock_http_client) + assert store.http_client is mock_http_client - assert store._semaphore._value == 10 + assert store._semaphore._value == 10 # Default assert store._package_data == {} assert store._version_deps_cache == {} def test_initialization_with_custom_limit( self, mock_http_client: MagicMock ) -> None: - """Test PyPIDataStore accepts custom concurrent_limit. - - Semaphore should be configured with custom value. - """ + """Test PyPIDataStore accepts custom concurrent_limit.""" store = PyPIDataStore(mock_http_client, concurrent_limit=5) - assert store._semaphore._value == 5 - def test_initialization_zero_concurrent_limit( - self, mock_http_client: MagicMock - ) -> None: - """Test PyPIDataStore handles zero concurrent_limit. - - Edge case: Zero limit effectively blocks all concurrent requests. - """ - store = PyPIDataStore(mock_http_client, concurrent_limit=0) - assert store._semaphore._value == 0 - - def test_initialization_large_concurrent_limit( - self, mock_http_client: MagicMock - ) -> None: - """Test PyPIDataStore handles very large concurrent_limit. - - Edge case: Large values should work without issues. - """ - store = PyPIDataStore(mock_http_client, concurrent_limit=1000) - assert store._semaphore._value == 1000 - - def test_initial_state_empty_caches(self, mock_http_client: MagicMock) -> None: - """Test PyPIDataStore starts with empty caches. - - Both package and version caches should be empty initially. - """ - store = PyPIDataStore(mock_http_client) - assert len(store._package_data) == 0 - assert len(store._version_deps_cache) == 0 - - -# ============================================================================ -# Test: PyPIDataStore async fetching -# ============================================================================ + assert store._semaphore._value == 5 +@pytest.mark.unit class TestPyPIDataStoreGetPackageData: """Tests for PyPIDataStore.get_package_data async fetching.""" @pytest.mark.asyncio - async def test_fetch_package_success( + async def test_fetch_and_cache( self, mock_http_client: MagicMock, sample_pypi_response: Dict[str, Any] ) -> None: - """Test get_package_data fetches and caches package data. - - Happy path: Successful fetch from PyPI. - """ + """Test get_package_data fetches and caches package data.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = sample_pypi_response @@ -476,16 +205,12 @@ async def test_fetch_package_success( assert data.name == "requests" assert data.latest_version == "2.31.0" assert "charset-normalizer>=2.0.0" in data.latest_dependencies - assert "2.31.0" in data.all_versions @pytest.mark.asyncio - async def test_fetch_normalizes_package_name( + async def test_normalizes_package_name( self, mock_http_client: MagicMock, sample_pypi_response: Dict[str, Any] ) -> None: - """Test get_package_data normalizes package names. - - Different casings and underscores should map to same cached entry. - """ + """Test get_package_data normalizes package names.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = sample_pypi_response @@ -494,20 +219,16 @@ async def test_fetch_normalizes_package_name( store = PyPIDataStore(mock_http_client) data1 = await store.get_package_data("Requests") data2 = await store.get_package_data("REQUESTS") - data3 = await store.get_package_data("requests") - # All should return the same cached object - assert data1 is data2 is data3 + # Same cached object + assert data1 is data2 assert mock_http_client.get.call_count == 1 @pytest.mark.asyncio - async def test_fetch_caches_result( + async def test_returns_cached_data( self, mock_http_client: MagicMock, sample_pypi_response: Dict[str, Any] ) -> None: - """Test get_package_data caches to avoid redundant fetches. - - Second call should return cached data without HTTP request. - """ + """Test get_package_data returns cached data on second call.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = sample_pypi_response @@ -517,55 +238,29 @@ async def test_fetch_caches_result( data1 = await store.get_package_data("requests") data2 = await store.get_package_data("requests") - assert data1 is data2 # Same object - assert mock_http_client.get.call_count == 1 # Only one fetch + assert data1 is data2 + assert mock_http_client.get.call_count == 1 @pytest.mark.asyncio - async def test_fetch_404_raises_pypi_error( - self, mock_http_client: MagicMock - ) -> None: - """Test get_package_data raises PyPIError on 404. - - Package not found should raise descriptive error. - """ + async def test_raises_pypi_error_on_404(self, mock_http_client: MagicMock) -> None: + """Test get_package_data raises PyPIError on 404.""" mock_response = MagicMock() mock_response.status_code = 404 mock_http_client.get = AsyncMock(return_value=mock_response) store = PyPIDataStore(mock_http_client) + with pytest.raises(PyPIError) as exc_info: await store.get_package_data("nonexistent-package") assert "not found" in str(exc_info.value).lower() assert exc_info.value.package_name == "nonexistent-package" - @pytest.mark.asyncio - async def test_fetch_non_200_raises_pypi_error( - self, mock_http_client: MagicMock - ) -> None: - """Test get_package_data raises PyPIError on non-200 status. - - Server errors should be reported with status code. - """ - mock_response = MagicMock() - mock_response.status_code = 500 - mock_http_client.get = AsyncMock(return_value=mock_response) - - store = PyPIDataStore(mock_http_client) - with pytest.raises(PyPIError) as exc_info: - await store.get_package_data("test-package") - - assert "500" in str(exc_info.value) - @pytest.mark.asyncio async def test_concurrent_requests_deduplicated( self, mock_http_client: MagicMock, sample_pypi_response: Dict[str, Any] ) -> None: - """Test concurrent requests for same package deduplicated. - - Multiple simultaneous requests should trigger only one HTTP fetch - (double-checked locking). - """ + """Test concurrent requests for same package trigger only one fetch.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = sample_pypi_response @@ -573,49 +268,18 @@ async def test_concurrent_requests_deduplicated( store = PyPIDataStore(mock_http_client) - # Fire 10 concurrent requests for same package + # Fire multiple concurrent requests results = await asyncio.gather( - *[store.get_package_data("requests") for _ in range(10)] + *[store.get_package_data("requests") for _ in range(5)] ) - # All should return same cached object + # All return same cached object assert all(r is results[0] for r in results) - # Only one HTTP call made + # Only one HTTP call assert mock_http_client.get.call_count == 1 - @pytest.mark.asyncio - async def test_concurrent_different_packages( - self, mock_http_client: MagicMock, sample_pypi_response: Dict[str, Any] - ) -> None: - """Test concurrent requests for different packages processed concurrently. - - Different packages should not block each other (subject to semaphore). - """ - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = sample_pypi_response - mock_http_client.get = AsyncMock(return_value=mock_response) - - store = PyPIDataStore(mock_http_client, concurrent_limit=5) - - # Request 3 different packages concurrently - results = await asyncio.gather( - store.get_package_data("requests"), - store.get_package_data("flask"), - store.get_package_data("django"), - ) - - # Should have made 3 separate HTTP calls - assert mock_http_client.get.call_count == 3 - # Each should be cached separately - assert len(store._package_data) == 3 - - -# ============================================================================ -# Test: PyPIDataStore prefetch -# ============================================================================ - +@pytest.mark.unit class TestPyPIDataStorePrefetch: """Tests for PyPIDataStore.prefetch_packages batch loading.""" @@ -623,10 +287,7 @@ class TestPyPIDataStorePrefetch: async def test_prefetch_multiple_packages( self, mock_http_client: MagicMock, sample_pypi_response: Dict[str, Any] ) -> None: - """Test prefetch_packages loads multiple packages concurrently. - - Happy path: Batch prefetch should populate cache. - """ + """Test prefetch_packages loads multiple packages concurrently.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = sample_pypi_response @@ -635,17 +296,14 @@ async def test_prefetch_multiple_packages( store = PyPIDataStore(mock_http_client) await store.prefetch_packages(["requests", "flask", "django"]) - # All should be cached + # All cached assert "requests" in store._package_data assert "flask" in store._package_data assert "django" in store._package_data @pytest.mark.asyncio async def test_prefetch_silences_errors(self, mock_http_client: MagicMock) -> None: - """Test prefetch_packages continues despite individual failures. - - Edge case: One bad package shouldn't stop the rest from loading. - """ + """Test prefetch_packages continues despite individual failures.""" async def mock_get_package_data(name: str): if name == "bad-package": @@ -657,39 +315,21 @@ async def mock_get_package_data(name: str): store = PyPIDataStore(mock_http_client) with patch.object(store, "get_package_data", side_effect=mock_get_package_data): - # Should not raise even though bad-package fails await store.prefetch_packages(["good1", "bad-package", "good2"]) assert "good1" in store._package_data assert "good2" in store._package_data - @pytest.mark.asyncio - async def test_prefetch_empty_list(self, mock_http_client: MagicMock) -> None: - """Test prefetch_packages handles empty package list. - - Edge case: Empty list should be a no-op. - """ - store = PyPIDataStore(mock_http_client) - await store.prefetch_packages([]) - assert len(store._package_data) == 0 - - -# ============================================================================ -# Test: PyPIDataStore version dependencies -# ============================================================================ - +@pytest.mark.unit class TestPyPIDataStoreGetVersionDependencies: """Tests for PyPIDataStore.get_version_dependencies.""" @pytest.mark.asyncio - async def test_get_latest_version_dependencies_from_cache( + async def test_get_latest_version_from_cache( self, mock_http_client: MagicMock, sample_pypi_response: Dict[str, Any] ) -> None: - """Test get_version_dependencies for latest uses package cache. - - Latest version deps should be available from initial package fetch. - """ + """Test get_version_dependencies for latest uses cached data.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = sample_pypi_response @@ -698,24 +338,17 @@ async def test_get_latest_version_dependencies_from_cache( store = PyPIDataStore(mock_http_client) await store.get_package_data("requests") - # Reset call count after initial fetch mock_http_client.get.reset_mock() - # Get latest version deps + # Get latest version deps - should use cache deps = await store.get_version_dependencies("requests", "2.31.0") - # Should use cached data, no additional HTTP call assert mock_http_client.get.call_count == 0 assert "charset-normalizer>=2.0.0" in deps @pytest.mark.asyncio - async def test_get_non_latest_version_dependencies_fetches( - self, mock_http_client: MagicMock - ) -> None: - """Test get_version_dependencies fetches specific version. - - Non-latest versions require separate /pypi/{pkg}/{ver}/json fetch. - """ + async def test_fetch_non_latest_version(self, mock_http_client: MagicMock) -> None: + """Test get_version_dependencies fetches non-latest versions.""" version_response = { "info": { "version": "2.0.0", @@ -732,18 +365,14 @@ async def test_get_non_latest_version_dependencies_fetches( assert "urllib3>=1.0" in deps assert "certifi>=2016" in deps - assert mock_http_client.get.call_count == 1 @pytest.mark.asyncio - async def test_get_version_dependencies_caches_result( + async def test_caches_fetched_dependencies( self, mock_http_client: MagicMock ) -> None: - """Test get_version_dependencies caches fetched deps. - - Second call for same version should use cache. - """ + """Test get_version_dependencies caches fetched deps.""" version_response = { - "info": {"version": "2.0.0", "requires_dist": ["dep1", "dep2"]} + "info": {"version": "2.0.0", "requires_dist": ["dep1>=1.0"]} } mock_response = MagicMock() mock_response.status_code = 200 @@ -755,39 +384,20 @@ async def test_get_version_dependencies_caches_result( deps2 = await store.get_version_dependencies("requests", "2.0.0") assert deps1 == deps2 - # Only one HTTP call assert mock_http_client.get.call_count == 1 @pytest.mark.asyncio - async def test_get_version_dependencies_handles_fetch_errors( - self, mock_http_client: MagicMock - ) -> None: - """Test get_version_dependencies returns empty list on errors. - - Edge case: Network/parse errors should not crash, return empty deps. - """ - mock_http_client.get = AsyncMock(side_effect=Exception("Network error")) - - store = PyPIDataStore(mock_http_client) - deps = await store.get_version_dependencies("requests", "2.0.0") - - assert deps == [] - - @pytest.mark.asyncio - async def test_get_version_dependencies_filters_extras( + async def test_filters_extras_and_strips_markers( self, mock_http_client: MagicMock ) -> None: - """Test get_version_dependencies excludes extra dependencies. - - Dependencies with 'extra ==' should be filtered out. - """ + """Test get_version_dependencies filters extras and strips markers.""" version_response = { "info": { "version": "2.0.0", "requires_dist": [ "base-dep>=1.0", "extra-dep>=2.0; extra == 'dev'", - "another-extra; extra=='test'", + "platform-dep>=3.0; sys_platform == 'win32'", ], } } @@ -800,153 +410,47 @@ async def test_get_version_dependencies_filters_extras( deps = await store.get_version_dependencies("requests", "2.0.0") assert "base-dep>=1.0" in deps + assert "platform-dep>=3.0" in deps + # Extra filtered out assert not any("extra-dep" in d for d in deps) - assert not any("another-extra" in d for d in deps) - - @pytest.mark.asyncio - async def test_get_version_dependencies_strips_markers( - self, mock_http_client: MagicMock - ) -> None: - """Test get_version_dependencies removes environment markers. - - Markers after ';' should be stripped (except extra markers). - """ - version_response = { - "info": { - "version": "2.0.0", - "requires_dist": [ - "dep1>=1.0; python_version < '3.0'", - "dep2>=2.0; sys_platform == 'win32'", - ], - } - } - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = version_response - mock_http_client.get = AsyncMock(return_value=mock_response) - - store = PyPIDataStore(mock_http_client) - deps = await store.get_version_dependencies("requests", "2.0.0") - - assert "dep1>=1.0" in deps - assert "dep2>=2.0" in deps - # Markers should be stripped - assert not any("python_version" in d for d in deps) - - @pytest.mark.asyncio - async def test_get_version_dependencies_updates_package_cache( - self, mock_http_client: MagicMock, sample_pypi_response: Dict[str, Any] - ) -> None: - """Test get_version_dependencies back-fills package-level cache. - - Fetched deps should be added to pkg_data.dependencies_cache. - """ - # First fetch package - mock_response1 = MagicMock() - mock_response1.status_code = 200 - mock_response1.json.return_value = sample_pypi_response - - # Then fetch specific version - version_response = { - "info": {"version": "2.0.0", "requires_dist": ["old-dep>=1.0"]} - } - mock_response2 = MagicMock() - mock_response2.status_code = 200 - mock_response2.json.return_value = version_response - - mock_http_client.get = AsyncMock(side_effect=[mock_response1, mock_response2]) - - store = PyPIDataStore(mock_http_client) - await store.get_package_data("requests") - await store.get_version_dependencies("requests", "2.0.0") - - # Should now be in package-level cache - pkg_data = store.get_cached_package("requests") - assert "2.0.0" in pkg_data.dependencies_cache - assert "old-dep>=1.0" in pkg_data.dependencies_cache["2.0.0"] - - -# ============================================================================ -# Test: PyPIDataStore synchronous accessors -# ============================================================================ + # Marker stripped + assert not any("sys_platform" in d for d in deps) +@pytest.mark.unit class TestPyPIDataStoreSyncAccessors: """Tests for PyPIDataStore synchronous (cache-only) methods.""" - def test_get_cached_package_returns_cached( - self, mock_http_client: MagicMock - ) -> None: - """Test get_cached_package returns previously fetched data. - - Happy path: Cached package should be returned. - """ + def test_get_cached_package(self, mock_http_client: MagicMock) -> None: + """Test get_cached_package returns cached data or None.""" store = PyPIDataStore(mock_http_client) pkg_data = PyPIPackageData(name="requests", latest_version="2.31.0") store._package_data["requests"] = pkg_data - result = store.get_cached_package("requests") - assert result is pkg_data - - def test_get_cached_package_returns_none_if_not_cached( - self, mock_http_client: MagicMock - ) -> None: - """Test get_cached_package returns None for unfetched packages. - - Edge case: Package not yet in cache. - """ - store = PyPIDataStore(mock_http_client) - result = store.get_cached_package("nonexistent") - assert result is None - - def test_get_cached_package_normalizes_name( - self, mock_http_client: MagicMock - ) -> None: - """Test get_cached_package normalizes package names. - - Different casings should retrieve same cached entry. - """ - store = PyPIDataStore(mock_http_client) - pkg_data = PyPIPackageData(name="requests") - store._package_data["requests"] = pkg_data - - assert store.get_cached_package("Requests") is pkg_data - assert store.get_cached_package("REQUESTS") is pkg_data + # Cached package + assert store.get_cached_package("requests") is pkg_data + assert store.get_cached_package("REQUESTS") is pkg_data # Normalized - def test_get_versions_returns_all_versions( - self, mock_http_client: MagicMock - ) -> None: - """Test get_versions returns cached version list. + # Not cached + assert store.get_cached_package("nonexistent") is None - Happy path: Should return all_versions from cached package data. - """ + def test_get_versions(self, mock_http_client: MagicMock) -> None: + """Test get_versions returns cached version list.""" store = PyPIDataStore(mock_http_client) pkg_data = PyPIPackageData( name="requests", all_versions=["2.31.0", "2.30.0", "2.29.0"] ) store._package_data["requests"] = pkg_data + # Cached versions versions = store.get_versions("requests") assert versions == ["2.31.0", "2.30.0", "2.29.0"] - def test_get_versions_returns_empty_if_not_cached( - self, mock_http_client: MagicMock - ) -> None: - """Test get_versions returns empty list for unfetched packages. - - Edge case: Package not in cache should return []. - """ - store = PyPIDataStore(mock_http_client) - versions = store.get_versions("nonexistent") - assert versions == [] - - def test_is_python_compatible_delegates_to_package_data( - self, mock_http_client: MagicMock - ) -> None: - """Test is_python_compatible uses cached package data. + # Not cached + assert store.get_versions("nonexistent") == [] - Happy path: Should delegate to PyPIPackageData method. - """ + def test_is_python_compatible(self, mock_http_client: MagicMock) -> None: + """Test is_python_compatible uses cached package data.""" store = PyPIDataStore(mock_http_client) pkg_data = PyPIPackageData( name="requests", @@ -954,38 +458,9 @@ def test_is_python_compatible_delegates_to_package_data( ) store._package_data["requests"] = pkg_data + # Cached - compatible assert store.is_python_compatible("requests", "2.31.0", "3.9.0") is True + # Cached - incompatible assert store.is_python_compatible("requests", "2.31.0", "3.6.0") is False - - def test_is_python_compatible_returns_true_if_not_cached( - self, mock_http_client: MagicMock - ) -> None: - """Test is_python_compatible returns True for unfetched packages. - - Edge case: Permissive default when package not in cache. - """ - store = PyPIDataStore(mock_http_client) + # Not cached - permissive assert store.is_python_compatible("unknown", "1.0.0", "3.9.0") is True - - -# ============================================================================ -# Test: PyPIDataStore parsing helpers -# ============================================================================ - - -class TestPyPIDataStoreParsePackageData: - """Tests for PyPIDataStore._parse_package_data.""" - - def test_parse_package_data_extracts_basic_info( - self, mock_http_client: MagicMock, sample_pypi_response: Dict[str, Any] - ) -> None: - """Test _parse_package_data extracts basic package metadata. - - Happy path: Should populate all core fields from PyPI response. - """ - store = PyPIDataStore(mock_http_client) - pkg_data = store._parse_package_data("requests", sample_pypi_response) - - assert pkg_data.name == "requests" - assert pkg_data.latest_version == "2.31.0" - assert pkg_data.latest_requires_python == ">=3.7" diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..d205f1d --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from depkeeper.__main__ import _print_startup_error, main + + +@pytest.mark.unit +class TestMain: + """Tests for main() entry point function.""" + + @pytest.mark.parametrize( + "exit_code", + [0, 1, 130], + ids=["success", "error", "interrupted"], + ) + def test_main_returns_cli_exit_code(self, exit_code: int) -> None: + """Test main returns exit code from cli_main when import succeeds.""" + mock_cli_module = MagicMock() + mock_cli_module.main = MagicMock(return_value=exit_code) + + with patch.dict("sys.modules", {"depkeeper.cli": mock_cli_module}): + result = main() + + assert result == exit_code + mock_cli_module.main.assert_called_once() + + def test_main_import_error_returns_one(self, capsys: pytest.CaptureFixture) -> None: + """Test main returns 1 when cli module import fails.""" + import_error = ImportError("No module named 'depkeeper.cli'") + + with patch.dict("sys.modules", {"depkeeper.cli": None}): + with patch( + "builtins.__import__", + side_effect=lambda name, *args, **kwargs: ( + (_ for _ in ()).throw(import_error) + if name == "depkeeper.cli" + else __import__(name, *args, **kwargs) + ), + ): + result = main() + + assert result == 1 + captured = capsys.readouterr() + assert "ImportError:" in captured.err + assert "No module named 'depkeeper.cli'" in captured.err + + def test_main_calls_cli_main_without_arguments(self) -> None: + """Test main calls cli_main without passing any arguments.""" + mock_cli_module = MagicMock() + mock_cli_module.main = MagicMock(return_value=0) + + with patch.dict("sys.modules", {"depkeeper.cli": mock_cli_module}): + main() + + mock_cli_module.main.assert_called_once_with() + + +@pytest.mark.unit +class TestPrintStartupError: + """Tests for _print_startup_error helper function.""" + + def test_print_startup_error_with_version( + self, capsys: pytest.CaptureFixture + ) -> None: + """Test _print_startup_error prints version when available.""" + test_error = ImportError("Test error message") + test_version = "1.2.3" + mock_version_module = MagicMock(__version__=test_version) + + with patch.dict(sys.modules, {"depkeeper.__version__": mock_version_module}): + _print_startup_error(test_error) + + captured = capsys.readouterr() + assert f"depkeeper version: {test_version}" in captured.err + assert "ImportError: Test error message" in captured.err + + def test_print_startup_error_version_import_fails( + self, capsys: pytest.CaptureFixture + ) -> None: + """Test _print_startup_error handles version import failure gracefully.""" + test_error = ImportError("Test error message") + + def mock_import(name, *args, **kwargs): + if "depkeeper.__version__" in name or name == "depkeeper.__version__": + raise ImportError("Cannot import version") + return __import__(name, *args, **kwargs) + + with patch("builtins.__import__", side_effect=mock_import): + _print_startup_error(test_error) + + captured = capsys.readouterr() + assert "depkeeper version: " in captured.err + assert "ImportError: Test error message" in captured.err + + def test_print_startup_error_writes_to_stderr( + self, capsys: pytest.CaptureFixture + ) -> None: + """Test _print_startup_error writes output to stderr, not stdout.""" + test_error = ImportError("Test error") + + _print_startup_error(test_error) + + captured = capsys.readouterr() + assert len(captured.err) > 0 + assert "ImportError:" in captured.err + assert captured.out == "" + + def test_print_startup_error_includes_blank_line( + self, capsys: pytest.CaptureFixture + ) -> None: + """Test _print_startup_error includes blank line for readability.""" + test_error = ImportError("Test error") + test_version = "1.0.0" + mock_version_module = MagicMock(__version__=test_version) + + with patch.dict(sys.modules, {"depkeeper.__version__": mock_version_module}): + _print_startup_error(test_error) + + captured = capsys.readouterr() + lines = captured.err.split("\n") + + # Check for version line, blank line, then error + assert any("depkeeper version:" in line for line in lines) + assert any("ImportError:" in line for line in lines) + assert "" in lines # blank line present diff --git a/tests/test_models/test_conflict.py b/tests/test_models/test_conflict.py index 07d2294..45808be 100644 --- a/tests/test_models/test_conflict.py +++ b/tests/test_models/test_conflict.py @@ -1,89 +1,151 @@ -"""Unit tests for depkeeper.models.conflict module. - -This test suite provides comprehensive coverage of dependency conflict -data models, including conflict representation, normalization, version -compatibility checking, and specifier set operations. - -Test Coverage: -- Conflict initialization and validation -- Package name normalization (PEP 503) -- Conflict string representations -- JSON serialization -- ConflictSet management and operations -- Version compatibility resolution -- Specifier set parsing and validation -- Edge cases (invalid versions, pre-releases, empty data) -""" - from __future__ import annotations +from typing import List + import pytest from depkeeper.models.conflict import Conflict, ConflictSet, _normalize_name -class TestNormalizeName: - """Tests for _normalize_name package normalization.""" - - def test_lowercase_conversion(self) -> None: - """Test package names are converted to lowercase. - - Per PEP 503, package names should be case-insensitive. - """ - assert _normalize_name("Django") == "django" - assert _normalize_name("REQUESTS") == "requests" - assert _normalize_name("NumPy") == "numpy" +@pytest.fixture +def sample_conflict() -> Conflict: + """Create a sample Conflict instance for testing. - def test_underscore_to_dash(self) -> None: - """Test underscores are replaced with dashes. + Returns: + Conflict: A configured conflict with standard test data. - PEP 503 normalization converts underscores to hyphens. - """ - assert _normalize_name("python_package") == "python-package" - assert _normalize_name("my_test_pkg") == "my-test-pkg" + Note: + Uses common test values: django requires requests>=2.0.0. + """ + return Conflict( + source_package="django", + target_package="requests", + required_spec=">=2.0.0", + conflicting_version="1.5.0", + ) - def test_combined_normalization(self) -> None: - """Test combined case and underscore normalization. - Should handle both transformations simultaneously. - """ - assert _normalize_name("My_Package") == "my-package" - assert _normalize_name("Test_PKG_Name") == "test-pkg-name" +@pytest.fixture +def sample_conflict_with_version() -> Conflict: + """Create a sample Conflict with source version for testing. - def test_already_normalized(self) -> None: - """Test already normalized names remain unchanged. + Returns: + Conflict: A conflict instance including source_version. + """ + return Conflict( + source_package="django", + target_package="requests", + required_spec=">=2.0.0", + conflicting_version="1.5.0", + source_version="4.0.0", + ) - Happy path: Properly formatted names should pass through. - """ - assert _normalize_name("requests") == "requests" - assert _normalize_name("django-rest-framework") == "django-rest-framework" - def test_empty_string(self) -> None: - """Test empty string normalization. +@pytest.fixture +def sample_conflict_set() -> ConflictSet: + """Create an empty ConflictSet for testing. - Edge case: Empty strings should remain empty. - """ - assert _normalize_name("") == "" + Returns: + ConflictSet: An empty conflict set for the 'requests' package. + """ + return ConflictSet(package_name="requests") - def test_multiple_underscores(self) -> None: - """Test multiple consecutive underscores. - Edge case: Multiple underscores should all convert to dashes. - """ - assert _normalize_name("my__package___name") == "my--package---name" +@pytest.fixture +def populated_conflict_set() -> ConflictSet: + """Create a ConflictSet with pre-populated conflicts. - def test_special_characters_preserved(self) -> None: - """Test other special characters are preserved. + Returns: + ConflictSet: A conflict set with multiple conflicts for testing. + """ + conflict_set = ConflictSet(package_name="requests") + conflict_set.add_conflict(Conflict("django", "requests", ">=2.0.0", "1.5.0")) + conflict_set.add_conflict(Conflict("flask", "requests", "<3.0.0", "3.5.0")) + return conflict_set - Edge case: Only underscores should be replaced, other chars unchanged. - """ - assert _normalize_name("pkg.name") == "pkg.name" - assert _normalize_name("pkg-v2") == "pkg-v2" +@pytest.mark.unit +class TestNormalizeName: + """Tests for _normalize_name package normalization.""" + @pytest.mark.parametrize( + "input_name,expected", + [ + # Lowercase conversion + ("Django", "django"), + ("REQUESTS", "requests"), + ("NumPy", "numpy"), + # Underscore to dash + ("python_package", "python-package"), + ("my_test_pkg", "my-test-pkg"), + # Combined normalization + ("My_Package", "my-package"), + ("Test_PKG_Name", "test-pkg-name"), + # Already normalized + ("requests", "requests"), + ("django-rest-framework", "django-rest-framework"), + # Edge cases + ("", ""), + ("my__package___name", "my--package---name"), + ("pkg.name", "pkg.name"), + ("pkg-v2", "pkg-v2"), + ], + ids=[ + "uppercase", + "all-caps", + "mixed-case", + "underscores", + "multiple-underscores", + "combined-mixed", + "combined-caps", + "already-normalized", + "hyphenated", + "empty-string", + "multiple-consecutive-underscores", + "dots-preserved", + "existing-hyphens", + ], + ) + def test_normalize_name_variations(self, input_name: str, expected: str) -> None: + """Test package name normalization with various inputs. + + Parametrized test covering lowercase conversion, underscore replacement, + combined transformations, and edge cases. Per PEP 503, package names + should be case-insensitive and use hyphens. + + Args: + input_name: Package name to normalize. + expected: Expected normalized result. + """ + # Act + result = _normalize_name(input_name) + + # Assert + assert result == expected + + @pytest.mark.unit + def test_normalization_idempotent(self) -> None: + """Test normalization is idempotent. + + Applying normalization multiple times should produce same result. + """ + # Arrange + name = "My_Package_NAME" + + # Act + first_pass = _normalize_name(name) + second_pass = _normalize_name(first_pass) + + # Assert + assert first_pass == second_pass + assert first_pass == "my-package-name" + + +@pytest.mark.unit class TestConflictInit: """Tests for Conflict initialization and post-init processing.""" + @pytest.mark.unit def test_basic_initialization(self) -> None: """Test Conflict can be created with required parameters. @@ -101,6 +163,7 @@ def test_basic_initialization(self) -> None: assert conflict.conflicting_version == "1.5.0" assert conflict.source_version is None + @pytest.mark.unit def test_with_source_version(self) -> None: """Test Conflict initialization with source version. @@ -115,6 +178,7 @@ def test_with_source_version(self) -> None: ) assert conflict.source_version == "4.0.0" + @pytest.mark.unit def test_package_name_normalization_on_init(self) -> None: """Test package names are normalized in __post_init__. @@ -129,6 +193,7 @@ def test_package_name_normalization_on_init(self) -> None: assert conflict.source_package == "django-app" assert conflict.target_package == "requests-lib" + @pytest.mark.unit def test_immutability(self) -> None: """Test Conflict is frozen (immutable). @@ -143,6 +208,7 @@ def test_immutability(self) -> None: with pytest.raises(AttributeError): conflict.source_package = "flask" + @pytest.mark.unit def test_empty_required_spec(self) -> None: """Test Conflict with empty specifier string. @@ -156,6 +222,7 @@ def test_empty_required_spec(self) -> None: ) assert conflict.required_spec == "" + @pytest.mark.unit def test_complex_version_specifier(self) -> None: """Test Conflict with complex version specifier. @@ -169,6 +236,7 @@ def test_complex_version_specifier(self) -> None: ) assert conflict.required_spec == ">=2.0.0,<3.0.0,!=2.5.0" + @pytest.mark.unit def test_wildcard_version(self) -> None: """Test Conflict with wildcard version specifier. @@ -183,68 +251,76 @@ def test_wildcard_version(self) -> None: assert conflict.required_spec == "==2.*" +@pytest.mark.unit class TestConflictDisplayMethods: """Tests for Conflict string representation methods.""" - def test_to_display_string_without_source_version(self) -> None: + @pytest.mark.unit + def test_to_display_string_without_source_version( + self, sample_conflict: Conflict + ) -> None: """Test display string when source version is not known. Should show only source package name, not version. + + Args: + sample_conflict: Fixture providing a basic Conflict instance. """ - conflict = Conflict( - source_package="django", - target_package="requests", - required_spec=">=2.0.0", - conflicting_version="1.5.0", - ) - result = conflict.to_display_string() + # Act + result = sample_conflict.to_display_string() + + # Assert assert result == "django requires requests>=2.0.0" - def test_to_display_string_with_source_version(self) -> None: + @pytest.mark.unit + def test_to_display_string_with_source_version( + self, sample_conflict_with_version: Conflict + ) -> None: """Test display string when source version is known. Should show source package with version pinned. + + Args: + sample_conflict_with_version: Fixture providing a Conflict with source_version. """ - conflict = Conflict( - source_package="django", - target_package="requests", - required_spec=">=2.0.0", - conflicting_version="1.5.0", - source_version="4.0.0", - ) - result = conflict.to_display_string() + # Act + result = sample_conflict_with_version.to_display_string() + + # Assert assert result == "django==4.0.0 requires requests>=2.0.0" - def test_to_short_string(self) -> None: + @pytest.mark.unit + def test_to_short_string(self, sample_conflict: Conflict) -> None: """Test compact conflict summary. Should provide abbreviated format with just source and spec. + + Args: + sample_conflict: Fixture providing a basic Conflict instance. """ - conflict = Conflict( - source_package="django", - target_package="requests", - required_spec=">=2.0.0", - conflicting_version="1.5.0", - ) - result = conflict.to_short_string() + # Act + result = sample_conflict.to_short_string() + + # Assert assert result == "django needs >=2.0.0" - def test_str_method(self) -> None: + @pytest.mark.unit + def test_str_method(self, sample_conflict_with_version: Conflict) -> None: """Test __str__ delegates to to_display_string. String conversion should use the full display format. + + Args: + sample_conflict_with_version: Fixture providing a Conflict with source_version. """ - conflict = Conflict( - source_package="django", - target_package="requests", - required_spec=">=2.0.0", - conflicting_version="1.5.0", - source_version="4.0.0", - ) - result = str(conflict) - assert result == conflict.to_display_string() + # Act + result = str(sample_conflict_with_version) + + # Assert + assert result == sample_conflict_with_version.to_display_string() assert "django==4.0.0" in result + @pytest.mark.unit def test_repr_method(self) -> None: """Test __repr__ provides developer-friendly representation. @@ -263,6 +339,7 @@ def test_repr_method(self) -> None: assert "required_spec='>=2.0.0'" in result assert "conflicting_version='1.5.0'" in result + @pytest.mark.unit def test_display_string_with_complex_spec(self) -> None: """Test display string with compound version specifier. @@ -277,6 +354,7 @@ def test_display_string_with_complex_spec(self) -> None: result = conflict.to_display_string() assert ">=2.0,<3.0,!=2.5.0" in result + @pytest.mark.unit def test_display_string_with_special_characters(self) -> None: """Test display string with packages containing special chars. @@ -294,9 +372,11 @@ def test_display_string_with_special_characters(self) -> None: assert "other-lib" in result +@pytest.mark.unit class TestConflictJSONSerialization: """Tests for Conflict.to_json method.""" + @pytest.mark.unit def test_to_json_without_source_version(self) -> None: """Test JSON serialization without source version. @@ -316,6 +396,7 @@ def test_to_json_without_source_version(self) -> None: assert result["conflicting_version"] == "1.5.0" assert result["source_version"] is None + @pytest.mark.unit def test_to_json_with_source_version(self) -> None: """Test JSON serialization with source version. @@ -332,6 +413,7 @@ def test_to_json_with_source_version(self) -> None: assert result["source_version"] == "4.0.0" + @pytest.mark.unit def test_to_json_dict_structure(self) -> None: """Test JSON output is a dictionary with correct keys. @@ -355,6 +437,7 @@ def test_to_json_dict_structure(self) -> None: } assert set(result.keys()) == expected_keys + @pytest.mark.unit def test_to_json_with_normalized_names(self) -> None: """Test JSON serialization uses normalized package names. @@ -371,6 +454,7 @@ def test_to_json_with_normalized_names(self) -> None: assert result["source_package"] == "django-app" assert result["target_package"] == "requests-lib" + @pytest.mark.unit def test_to_json_roundtrip_compatibility(self) -> None: """Test JSON output can be used to reconstruct Conflict. @@ -396,9 +480,11 @@ def test_to_json_roundtrip_compatibility(self) -> None: assert reconstructed.source_version == original.source_version +@pytest.mark.unit class TestConflictSetInit: """Tests for ConflictSet initialization.""" + @pytest.mark.unit def test_basic_initialization(self) -> None: """Test ConflictSet can be created with package name. @@ -408,6 +494,7 @@ def test_basic_initialization(self) -> None: assert conflict_set.package_name == "requests" assert conflict_set.conflicts == [] + @pytest.mark.unit def test_initialization_with_conflicts(self) -> None: """Test ConflictSet can be initialized with existing conflicts. @@ -421,6 +508,7 @@ def test_initialization_with_conflicts(self) -> None: assert len(conflict_set.conflicts) == 2 assert conflict_set.conflicts == conflicts + @pytest.mark.unit def test_package_name_normalization(self) -> None: """Test package name is normalized in __post_init__. @@ -429,6 +517,7 @@ def test_package_name_normalization(self) -> None: conflict_set = ConflictSet(package_name="My_Package") assert conflict_set.package_name == "my-package" + @pytest.mark.unit def test_mutable_dataclass(self) -> None: """Test ConflictSet is mutable (not frozen). @@ -438,6 +527,7 @@ def test_mutable_dataclass(self) -> None: conflict_set.package_name = "flask" # Should not raise assert conflict_set.package_name == "flask" + @pytest.mark.unit def test_empty_package_name(self) -> None: """Test ConflictSet with empty package name. @@ -447,90 +537,126 @@ def test_empty_package_name(self) -> None: assert conflict_set.package_name == "" +@pytest.mark.unit class TestConflictSetAddConflict: """Tests for ConflictSet.add_conflict method.""" - def test_add_single_conflict(self) -> None: + @pytest.mark.unit + def test_add_single_conflict( + self, sample_conflict_set: ConflictSet, sample_conflict: Conflict + ) -> None: """Test adding a single conflict to the set. Happy path: Conflict should be appended to conflicts list. - """ - conflict_set = ConflictSet(package_name="requests") - conflict = Conflict("django", "requests", ">=2.0", "1.5") - conflict_set.add_conflict(conflict) + Args: + sample_conflict_set: Fixture providing an empty ConflictSet. + sample_conflict: Fixture providing a basic Conflict instance. + """ + # Act + sample_conflict_set.add_conflict(sample_conflict) - assert len(conflict_set.conflicts) == 1 - assert conflict_set.conflicts[0] == conflict + # Assert + assert len(sample_conflict_set.conflicts) == 1 + assert sample_conflict_set.conflicts[0] == sample_conflict - def test_add_multiple_conflicts(self) -> None: + @pytest.mark.unit + def test_add_multiple_conflicts(self, sample_conflict_set: ConflictSet) -> None: """Test adding multiple conflicts sequentially. All conflicts should be preserved in order. + + Args: + sample_conflict_set: Fixture providing an empty ConflictSet. """ - conflict_set = ConflictSet(package_name="requests") + # Arrange conflict1 = Conflict("django", "requests", ">=2.0", "1.5") conflict2 = Conflict("flask", "requests", ">=2.5", "1.5") conflict3 = Conflict("fastapi", "requests", ">=3.0", "1.5") - conflict_set.add_conflict(conflict1) - conflict_set.add_conflict(conflict2) - conflict_set.add_conflict(conflict3) + # Act + sample_conflict_set.add_conflict(conflict1) + sample_conflict_set.add_conflict(conflict2) + sample_conflict_set.add_conflict(conflict3) - assert len(conflict_set.conflicts) == 3 - assert conflict_set.conflicts == [conflict1, conflict2, conflict3] + # Assert + assert len(sample_conflict_set.conflicts) == 3 + assert sample_conflict_set.conflicts == [conflict1, conflict2, conflict3] - def test_add_duplicate_conflicts(self) -> None: + @pytest.mark.unit + def test_add_duplicate_conflicts( + self, sample_conflict_set: ConflictSet, sample_conflict: Conflict + ) -> None: """Test adding duplicate conflicts. Edge case: Duplicates should be allowed (no deduplication). - """ - conflict_set = ConflictSet(package_name="requests") - conflict = Conflict("django", "requests", ">=2.0", "1.5") - conflict_set.add_conflict(conflict) - conflict_set.add_conflict(conflict) + Args: + sample_conflict_set: Fixture providing an empty ConflictSet. + sample_conflict: Fixture providing a basic Conflict instance. + """ + # Act + sample_conflict_set.add_conflict(sample_conflict) + sample_conflict_set.add_conflict(sample_conflict) - assert len(conflict_set.conflicts) == 2 - assert conflict_set.conflicts[0] is conflict_set.conflicts[1] + # Assert + assert len(sample_conflict_set.conflicts) == 2 + assert sample_conflict_set.conflicts[0] is sample_conflict_set.conflicts[1] +@pytest.mark.unit class TestConflictSetHasConflicts: """Tests for ConflictSet.has_conflicts method.""" - def test_has_conflicts_when_empty(self) -> None: + @pytest.mark.unit + def test_has_conflicts_when_empty(self, sample_conflict_set: ConflictSet) -> None: """Test has_conflicts returns False for empty set. Empty conflicts list should return False. + + Args: + sample_conflict_set: Fixture providing an empty ConflictSet. """ - conflict_set = ConflictSet(package_name="requests") - assert conflict_set.has_conflicts() is False + # Act & Assert + assert sample_conflict_set.has_conflicts() is False - def test_has_conflicts_when_populated(self) -> None: + @pytest.mark.unit + def test_has_conflicts_when_populated( + self, sample_conflict_set: ConflictSet, sample_conflict: Conflict + ) -> None: """Test has_conflicts returns True when conflicts exist. Non-empty conflicts list should return True. + + Args: + sample_conflict_set: Fixture providing an empty ConflictSet. + sample_conflict: Fixture providing a basic Conflict instance. """ - conflict_set = ConflictSet(package_name="requests") - conflict = Conflict("django", "requests", ">=2.0", "1.5") - conflict_set.add_conflict(conflict) + # Arrange + sample_conflict_set.add_conflict(sample_conflict) - assert conflict_set.has_conflicts() is True + # Act & Assert + assert sample_conflict_set.has_conflicts() is True + @pytest.mark.unit def test_has_conflicts_after_initialization(self) -> None: """Test has_conflicts with conflicts provided at init. Should return True when initialized with conflicts. """ + # Arrange conflicts = [Conflict("django", "requests", ">=2.0", "1.5")] conflict_set = ConflictSet(package_name="requests", conflicts=conflicts) + # Act & Assert assert conflict_set.has_conflicts() is True +@pytest.mark.unit class TestConflictSetGetMaxCompatibleVersion: """Tests for ConflictSet.get_max_compatible_version method.""" + @pytest.mark.unit def test_no_conflicts_returns_none(self) -> None: """Test returns None when no conflicts exist. @@ -540,6 +666,7 @@ def test_no_conflicts_returns_none(self) -> None: result = conflict_set.get_max_compatible_version(["1.0.0", "2.0.0"]) assert result is None + @pytest.mark.unit def test_single_conflict_compatible_version(self) -> None: """Test finds compatible version with single conflict. @@ -553,6 +680,7 @@ def test_single_conflict_compatible_version(self) -> None: assert result == "3.0.0" + @pytest.mark.unit def test_multiple_conflicts_intersection(self) -> None: """Test finds version satisfying multiple constraints. @@ -568,6 +696,7 @@ def test_multiple_conflicts_intersection(self) -> None: # Should be >=2.0.0 AND <3.0.0, so 2.5.0 is max assert result == "2.5.0" + @pytest.mark.unit def test_no_compatible_version_returns_none(self) -> None: """Test returns None when no version satisfies all constraints. @@ -583,6 +712,7 @@ def test_no_compatible_version_returns_none(self) -> None: # No version satisfies both >=3.0.0 AND <2.0.0 assert result is None + @pytest.mark.unit def test_excludes_prerelease_versions(self) -> None: """Test pre-release versions are ignored. @@ -597,6 +727,7 @@ def test_excludes_prerelease_versions(self) -> None: # Should return 2.5.0, not any 3.0.0 pre-release assert result == "2.5.0" + @pytest.mark.unit def test_handles_invalid_version_strings(self) -> None: """Test gracefully handles invalid version strings. @@ -611,6 +742,7 @@ def test_handles_invalid_version_strings(self) -> None: # Should skip invalid and return 2.5.0 assert result == "2.5.0" + @pytest.mark.unit def test_handles_invalid_specifier_returns_none(self) -> None: """Test returns None when conflict has invalid specifier. @@ -627,6 +759,7 @@ def test_handles_invalid_specifier_returns_none(self) -> None: assert result is None + @pytest.mark.unit def test_empty_available_versions(self) -> None: """Test with empty available versions list. @@ -638,6 +771,7 @@ def test_empty_available_versions(self) -> None: result = conflict_set.get_max_compatible_version([]) assert result is None + @pytest.mark.unit def test_complex_specifier_combinations(self) -> None: """Test complex version specifiers with multiple operators. @@ -654,6 +788,7 @@ def test_complex_specifier_combinations(self) -> None: # Should return 2.6.0 (not 2.5.0 which is excluded, not 3.0.0 which is >=3.0) assert result == "2.6.0" + @pytest.mark.unit def test_wildcard_specifiers(self) -> None: """Test wildcard version specifiers like ==2.*. @@ -668,6 +803,7 @@ def test_wildcard_specifiers(self) -> None: # Should return highest 2.x version assert result == "2.9.9" + @pytest.mark.unit def test_exact_version_match(self) -> None: """Test exact version specifier ==X.Y.Z. @@ -682,6 +818,7 @@ def test_exact_version_match(self) -> None: # Should return exactly 2.5.0 assert result == "2.5.0" + @pytest.mark.unit def test_less_than_specifier(self) -> None: """Test less-than version specifier None: # Should return 2.9.9 (highest < 3.0.0) assert result == "2.9.9" + @pytest.mark.unit def test_tilde_compatible_release(self) -> None: """Test tilde compatible release specifier ~=X.Y. @@ -710,6 +848,7 @@ def test_tilde_compatible_release(self) -> None: # ~=2.5 means >=2.5,<3.0, so 2.6.0 is max assert result == "2.6.0" + @pytest.mark.unit def test_version_sorting(self) -> None: """Test versions are correctly sorted to find max. @@ -725,6 +864,7 @@ def test_version_sorting(self) -> None: # Should return 10.0.0 (not "2.9" by string comparison) assert result == "10.0.0" + @pytest.mark.unit def test_dev_versions_excluded(self) -> None: """Test development versions are excluded. @@ -739,6 +879,7 @@ def test_dev_versions_excluded(self) -> None: # Should not include dev versions assert result == "2.5.0" + @pytest.mark.unit def test_post_release_versions_included(self) -> None: """Test post-release versions are included. @@ -754,9 +895,11 @@ def test_post_release_versions_included(self) -> None: assert result == "2.5.0.post2" +@pytest.mark.unit class TestConflictSetMagicMethods: """Tests for ConflictSet magic methods.""" + @pytest.mark.unit def test_len_empty(self) -> None: """Test __len__ returns 0 for empty conflict set. @@ -765,6 +908,7 @@ def test_len_empty(self) -> None: conflict_set = ConflictSet(package_name="requests") assert len(conflict_set) == 0 + @pytest.mark.unit def test_len_with_conflicts(self) -> None: """Test __len__ returns count of conflicts. @@ -776,6 +920,7 @@ def test_len_with_conflicts(self) -> None: assert len(conflict_set) == 2 + @pytest.mark.unit def test_iter_empty(self) -> None: """Test __iter__ on empty conflict set. @@ -785,6 +930,7 @@ def test_iter_empty(self) -> None: conflicts = list(conflict_set) assert conflicts == [] + @pytest.mark.unit def test_iter_with_conflicts(self) -> None: """Test __iter__ yields all conflicts. @@ -801,6 +947,7 @@ def test_iter_with_conflicts(self) -> None: assert conflicts[0] is conflict1 assert conflicts[1] is conflict2 + @pytest.mark.unit def test_iter_in_for_loop(self) -> None: """Test __iter__ works in for loop. @@ -818,9 +965,11 @@ def test_iter_in_for_loop(self) -> None: assert count == 2 +@pytest.mark.unit class TestConflictEquality: """Tests for Conflict equality comparison.""" + @pytest.mark.unit def test_equal_conflicts(self) -> None: """Test two conflicts with same data are equal. @@ -831,6 +980,7 @@ def test_equal_conflicts(self) -> None: assert conflict1 == conflict2 + @pytest.mark.unit def test_unequal_source_package(self) -> None: """Test conflicts differ when source package differs. @@ -841,6 +991,7 @@ def test_unequal_source_package(self) -> None: assert conflict1 != conflict2 + @pytest.mark.unit def test_unequal_required_spec(self) -> None: """Test conflicts differ when required spec differs. @@ -851,6 +1002,7 @@ def test_unequal_required_spec(self) -> None: assert conflict1 != conflict2 + @pytest.mark.unit def test_normalized_names_affect_equality(self) -> None: """Test normalization affects equality comparison. @@ -863,9 +1015,11 @@ def test_normalized_names_affect_equality(self) -> None: assert conflict1 == conflict2 +@pytest.mark.integration class TestConflictSetIntegration: """Integration tests combining multiple ConflictSet features.""" + @pytest.mark.integration def test_full_workflow(self) -> None: """Test complete workflow from creation to version resolution. @@ -890,6 +1044,7 @@ def test_full_workflow(self) -> None: # So max is 2.31.0 assert compatible == "2.31.0" + @pytest.mark.integration def test_iterate_and_display_conflicts(self) -> None: """Test iterating and displaying all conflicts. @@ -904,6 +1059,7 @@ def test_iterate_and_display_conflicts(self) -> None: assert "django==4.0 requires requests>=2.0" in displays assert "flask==2.0 requires requests>=2.5" in displays + @pytest.mark.integration def test_json_serialization_workflow(self) -> None: """Test serializing all conflicts to JSON. @@ -920,9 +1076,11 @@ def test_json_serialization_workflow(self) -> None: assert json_list[1]["source_package"] == "flask" +@pytest.mark.unit class TestEdgeCases: """Additional edge case tests.""" + @pytest.mark.unit def test_conflict_with_local_version(self) -> None: """Test conflict with local version identifier. @@ -931,6 +1089,7 @@ def test_conflict_with_local_version(self) -> None: conflict = Conflict("django", "requests", ">=2.0", "1.0+local") assert conflict.conflicting_version == "1.0+local" + @pytest.mark.unit def test_conflict_with_epoch(self) -> None: """Test conflict with epoch version. @@ -939,6 +1098,7 @@ def test_conflict_with_epoch(self) -> None: conflict = Conflict("django", "requests", ">=2.0", "1!2.0.0") assert conflict.conflicting_version == "1!2.0.0" + @pytest.mark.unit def test_very_long_package_names(self) -> None: """Test conflicts with very long package names. @@ -948,6 +1108,7 @@ def test_very_long_package_names(self) -> None: conflict = Conflict(long_name, "requests", ">=2.0", "1.5") assert len(conflict.source_package) == 200 + @pytest.mark.unit def test_unicode_in_version_spec(self) -> None: """Test handling of unicode characters in specifiers. @@ -957,6 +1118,7 @@ def test_unicode_in_version_spec(self) -> None: conflict = Conflict("django", "requests", ">=2.0™", "1.5") assert conflict.required_spec == ">=2.0™" + @pytest.mark.unit def test_max_compatible_with_only_prereleases(self) -> None: """Test version resolution when only pre-releases available. @@ -971,6 +1133,7 @@ def test_max_compatible_with_only_prereleases(self) -> None: # All are pre-releases, should return None assert result is None + @pytest.mark.unit def test_conflict_set_with_hundreds_of_conflicts(self) -> None: """Test ConflictSet performance with many conflicts. @@ -987,16 +1150,379 @@ def test_conflict_set_with_hundreds_of_conflicts(self) -> None: assert len(conflict_set) == 100 assert conflict_set.has_conflicts() + @pytest.mark.unit def test_version_with_many_segments(self) -> None: """Test versions with many segments like 1.2.3.4.5.6. Edge case: Non-standard version formats. """ + # Arrange conflict_set = ConflictSet(package_name="requests") conflict_set.add_conflict(Conflict("django", "requests", ">=1.2.3.4", "1.0")) available = ["1.2.3.3", "1.2.3.4", "1.2.3.5", "1.2.4.0"] + + # Act result = conflict_set.get_max_compatible_version(available) - # Should handle multi-segment versions + # Assert - Should handle multi-segment versions assert result == "1.2.4.0" + + +@pytest.mark.unit +class TestConflictSetParametrized: + """Parametrized tests for ConflictSet with various version specifiers.""" + + @pytest.mark.parametrize( + "spec,available,expected", + [ + # Greater than or equal + (">=2.0.0", ["1.0.0", "2.0.0", "3.0.0"], "3.0.0"), + (">=2.5.0", ["2.0.0", "2.5.0", "2.6.0"], "2.6.0"), + # Less than + ("<3.0.0", ["2.0.0", "2.9.0", "3.0.0"], "2.9.0"), + ("<2.0", ["1.5.0", "1.9.0", "2.0.0"], "1.9.0"), + # Exact match + ("==2.5.0", ["2.0.0", "2.5.0", "3.0.0"], "2.5.0"), + ("==1.0", ["0.9.0", "1.0", "1.1.0"], "1.0"), + # Not equal (should get highest that isn't excluded) + ("!=2.5.0", ["2.4.0", "2.5.0", "2.6.0"], "2.6.0"), + # Compatible release + ("~=2.5", ["2.0.0", "2.5.0", "2.9.0", "3.0.0"], "2.9.0"), + ("~=1.4.2", ["1.4.0", "1.4.2", "1.4.9", "1.5.0"], "1.4.9"), + # Compound specifiers + (">=2.0,<3.0", ["1.5.0", "2.5.0", "3.0.0"], "2.5.0"), + (">=1.0,<=2.0", ["0.5.0", "1.5.0", "2.0.0", "2.5.0"], "2.0.0"), + (">=1.0,<2.0,!=1.5.0", ["1.0.0", "1.5.0", "1.9.0", "2.0.0"], "1.9.0"), + ], + ids=[ + "gte-simple", + "gte-specific", + "lt-major", + "lt-minor", + "exact-patch", + "exact-minor", + "not-equal", + "compatible-minor", + "compatible-patch", + "compound-range", + "compound-inclusive", + "compound-exclusion", + ], + ) + def test_version_specifier_matching( + self, spec: str, available: List[str], expected: str + ) -> None: + """Test version resolution with various specifiers. + + Parametrized test covering all common version specifier patterns. + + Args: + spec: Version specifier string to test. + available: List of available version strings. + expected: Expected maximum compatible version. + """ + # Arrange + conflict_set = ConflictSet(package_name="test-pkg") + conflict_set.add_conflict(Conflict("django", "test-pkg", spec, "0.0.0")) + + # Act + result = conflict_set.get_max_compatible_version(available) + + # Assert + assert result == expected + + @pytest.mark.parametrize( + "spec,available", + [ + # No compatible versions + (">=5.0.0", ["1.0.0", "2.0.0", "3.0.0"]), + ("<1.0.0", ["1.0.0", "2.0.0", "3.0.0"]), + ("==4.0.0", ["1.0.0", "2.0.0", "3.0.0"]), + # Only pre-releases available + (">=1.0.0", ["1.0.0a1", "1.0.0b1", "1.0.0rc1"]), + # Contradictory specifiers + (">=3.0.0,<2.0.0", ["1.0.0", "2.0.0", "3.0.0"]), + ], + ids=[ + "gte-too-high", + "lt-too-low", + "exact-missing", + "only-prereleases", + "contradictory", + ], + ) + def test_no_compatible_version_cases(self, spec: str, available: List[str]) -> None: + """Test cases where no compatible version should be found. + + Parametrized test for various scenarios that should return None. + + Args: + spec: Version specifier string to test. + available: List of available version strings. + """ + # Arrange + conflict_set = ConflictSet(package_name="test-pkg") + conflict_set.add_conflict(Conflict("django", "test-pkg", spec, "0.0.0")) + + # Act + result = conflict_set.get_max_compatible_version(available) + + # Assert + assert result is None + + +@pytest.mark.unit +class TestConflictDataConsistency: + """Tests for data consistency and immutability expectations.""" + + @pytest.mark.unit + def test_conflict_hash_consistency(self) -> None: + """Test that equal conflicts have equal hashes. + + Frozen dataclasses should be hashable and consistent. + """ + # Arrange + conflict1 = Conflict("django", "requests", ">=2.0", "1.5", "4.0") + conflict2 = Conflict("django", "requests", ">=2.0", "1.5", "4.0") + + # Act & Assert + assert hash(conflict1) == hash(conflict2) + # Should be usable in sets/dicts + conflict_set = {conflict1, conflict2} + assert len(conflict_set) == 1 # Should deduplicate + + @pytest.mark.unit + def test_conflict_set_mutations_dont_affect_conflicts(self) -> None: + """Test that ConflictSet mutations don't affect stored Conflicts. + + Edge case: Frozen Conflicts should remain immutable after being added. + """ + # Arrange + conflict = Conflict("django", "requests", ">=2.0", "1.5") + conflict_set = ConflictSet(package_name="requests") + + # Act + conflict_set.add_conflict(conflict) + # Try to mutate the set + conflict_set.package_name = "different" + + # Assert - Original conflict should be unchanged + assert conflict.target_package == "requests" + assert conflict_set.conflicts[0] is conflict + + @pytest.mark.unit + def test_conflict_set_clear_behavior(self) -> None: + """Test clearing all conflicts from a ConflictSet. + + Should be able to remove all conflicts and reset state. + """ + # Arrange + conflict_set = ConflictSet(package_name="requests") + conflict_set.add_conflict(Conflict("django", "requests", ">=2.0", "1.5")) + conflict_set.add_conflict(Conflict("flask", "requests", ">=2.5", "1.5")) + + # Act + conflict_set.conflicts.clear() + + # Assert + assert len(conflict_set) == 0 + assert not conflict_set.has_conflicts() + + +@pytest.mark.unit +class TestConflictSetRobustness: + """Tests for robustness and error handling.""" + + @pytest.mark.unit + def test_get_max_compatible_with_mixed_valid_invalid_versions(self) -> None: + """Test version resolution with mix of valid and invalid versions. + + Should skip invalid versions and process valid ones normally. + """ + # Arrange + conflict_set = ConflictSet(package_name="requests") + conflict_set.add_conflict(Conflict("django", "requests", ">=2.0", "1.5")) + + # Mix of valid and invalid versions + # Note: "v3.0.0" is parsed as valid by packaging (v prefix is allowed) + available = [ + "invalid", + "2.0.0", + "not-a-version", + "2.5.0", + "version-string", # Invalid + "3.0.0", + "bad-version", + ] + + # Act + result = conflict_set.get_max_compatible_version(available) + + # Assert - Should return highest valid version + assert result == "3.0.0" + + @pytest.mark.unit + def test_conflict_set_with_empty_string_versions(self) -> None: + """Test handling of empty string versions in available list. + + Edge case: Empty strings should be skipped gracefully. + """ + # Arrange + conflict_set = ConflictSet(package_name="requests") + conflict_set.add_conflict(Conflict("django", "requests", ">=2.0", "1.5")) + + available = ["", "2.0.0", "", "2.5.0", ""] + + # Act + result = conflict_set.get_max_compatible_version(available) + + # Assert + assert result == "2.5.0" + + @pytest.mark.unit + def test_conflict_set_iteration_after_modifications(self) -> None: + """Test that iteration works correctly after adding/removing conflicts. + + Should reflect current state of conflicts list. + """ + # Arrange + conflict_set = ConflictSet(package_name="requests") + conflict1 = Conflict("django", "requests", ">=2.0", "1.5") + conflict2 = Conflict("flask", "requests", ">=2.5", "1.5") + + # Act - Add, iterate, add more, iterate again + conflict_set.add_conflict(conflict1) + first_iteration = list(conflict_set) + assert len(first_iteration) == 1 + + conflict_set.add_conflict(conflict2) + second_iteration = list(conflict_set) + assert len(second_iteration) == 2 + + # Assert + assert first_iteration[0] is conflict1 + assert second_iteration[0] is conflict1 + assert second_iteration[1] is conflict2 + + @pytest.mark.parametrize( + "package_name,expected", + [ + ("CamelCase", "camelcase"), + ("under_score", "under-score"), + ("Mixed_CASE_under", "mixed-case-under"), + ("dots.in.name", "dots.in.name"), + ("123-numeric", "123-numeric"), + ], + ids=["camelcase", "underscore", "mixed", "dots", "numeric"], + ) + def test_conflict_set_name_normalization_parametrized( + self, package_name: str, expected: str + ) -> None: + """Test ConflictSet normalizes various package name formats. + + Parametrized test for PEP 503 normalization in ConflictSet.__post_init__. + + Args: + package_name: Input package name to test. + expected: Expected normalized package name. + """ + # Act + conflict_set = ConflictSet(package_name=package_name) + + # Assert + assert conflict_set.package_name == expected + + +@pytest.mark.unit +class TestConflictJSONRobustness: + """Tests for JSON serialization robustness and edge cases.""" + + @pytest.mark.unit + def test_to_json_with_none_values(self) -> None: + """Test JSON serialization explicitly includes None values. + + Should have source_version key even when None. + """ + # Arrange + conflict = Conflict("django", "requests", ">=2.0", "1.5") + + # Act + result = conflict.to_json() + + # Assert + assert "source_version" in result + assert result["source_version"] is None + + @pytest.mark.unit + def test_to_json_preserves_all_data( + self, sample_conflict_with_version: Conflict + ) -> None: + """Test JSON serialization preserves all conflict data. + + No data should be lost during serialization. + + Args: + sample_conflict_with_version: Fixture providing a complete Conflict. + """ + # Act + result = sample_conflict_with_version.to_json() + + # Assert + assert result["source_package"] == sample_conflict_with_version.source_package + assert result["target_package"] == sample_conflict_with_version.target_package + assert result["required_spec"] == sample_conflict_with_version.required_spec + assert ( + result["conflicting_version"] + == sample_conflict_with_version.conflicting_version + ) + assert result["source_version"] == sample_conflict_with_version.source_version + + @pytest.mark.parametrize( + "source,target,spec,conflicting,source_ver", + [ + ("pkg-a", "pkg-b", ">=1.0", "0.5", None), + ("Pkg_A", "Pkg_B", ">=1.0", "0.5", "2.0"), + ("", "", "", "", None), + ("a" * 100, "b" * 100, ">=1.0" * 10, "0.0.1", "1!2.3"), + ], + ids=["basic", "needs-normalization", "empty", "extreme"], + ) + def test_json_serialization_various_inputs( + self, + source: str, + target: str, + spec: str, + conflicting: str, + source_ver: str | None, + ) -> None: + """Test JSON serialization with various input combinations. + + Parametrized test ensuring JSON serialization works for edge cases. + + Args: + source: Source package name. + target: Target package name. + spec: Version specifier. + conflicting: Conflicting version string. + source_ver: Optional source version. + """ + # Arrange + conflict = Conflict(source, target, spec, conflicting, source_ver) + + # Act + result = conflict.to_json() + + # Assert - Should always be a dict with correct keys + assert isinstance(result, dict) + assert len(result) == 5 + assert all( + key in result + for key in [ + "source_package", + "target_package", + "required_spec", + "conflicting_version", + "source_version", + ] + ) diff --git a/tests/test_models/test_package.py b/tests/test_models/test_package.py index ab5aa47..4f84575 100644 --- a/tests/test_models/test_package.py +++ b/tests/test_models/test_package.py @@ -1,99 +1,259 @@ -"""Unit tests for depkeeper.models.package module. - -This test suite provides comprehensive coverage of the Package data model, -including version parsing, conflict management, update detection, Python -compatibility checking, and serialization. - -Test Coverage: -- Package initialization and normalization -- Version parsing and caching -- Version comparison properties (current, latest, recommended) -- Update detection and downgrade requirements -- Conflict management and reporting -- Python version requirement handling -- Status summary computation -- JSON serialization -- Display data generation -- String representations -- Edge cases (invalid versions, missing data, complex scenarios) -""" - from __future__ import annotations +import pytest + from packaging.version import Version -from depkeeper.models.package import Package, _normalize_name from depkeeper.models.conflict import Conflict +from depkeeper.models.package import Package, _normalize_name +@pytest.fixture +def basic_package() -> Package: + """Create a basic package with standard versions for reusable testing. + + Returns: + Package: Package with consistent test data. + """ + return Package( + name="requests", + current_version="2.20.0", + latest_version="2.31.0", + recommended_version="2.28.0", + ) + + +@pytest.fixture +def package_with_conflicts() -> Package: + """Create a package with pre-configured conflicts for testing. + + Returns: + Package: Package with two sample conflicts. + """ + pkg = Package(name="requests", current_version="3.0.0", latest_version="3.0.0") + conflicts = [ + Conflict("django", "requests", ">=2.0", "1.5", "4.0"), + Conflict("flask", "requests", ">=2.5", "1.5", "2.0"), + ] + pkg.set_conflicts(conflicts, resolved_version="2.28.0") + return pkg + + +@pytest.fixture +def package_with_metadata() -> Package: + """Create a package with full metadata for testing. + + Returns: + Package: Package with Python requirements in metadata. + """ + return Package( + name="requests", + current_version="2.28.0", + latest_version="2.31.0", + recommended_version="2.28.0", + metadata={ + "current_metadata": {"requires_python": ">=3.7"}, + "latest_metadata": {"requires_python": ">=3.8"}, + "recommended_metadata": {"requires_python": ">=3.7"}, + }, + ) + + +@pytest.fixture +def up_to_date_package() -> Package: + """Create a package that is up to date (current == recommended == latest).""" + return Package( + name="requests", + current_version="2.28.0", + latest_version="2.28.0", + recommended_version="2.28.0", + ) + + +@pytest.fixture +def outdated_package() -> Package: + """Create a package that needs an update (current < recommended).""" + return Package( + name="requests", + current_version="2.20.0", + latest_version="2.31.0", + recommended_version="2.28.0", + ) + + +@pytest.fixture +def downgrade_package() -> Package: + """Create a package that needs a downgrade (current > recommended).""" + return Package( + name="requests", + current_version="3.0.0", + latest_version="3.0.0", + recommended_version="2.28.0", + ) + + +@pytest.fixture +def minimal_package() -> Package: + """Create a minimal package with only name.""" + return Package(name="requests") + + +@pytest.fixture +def new_package() -> Package: + """Create a package for installation (no current version).""" + return Package( + name="requests", + latest_version="2.28.0", + recommended_version="2.28.0", + ) + + +@pytest.fixture +def sample_conflict() -> Conflict: + """Create a sample conflict for testing.""" + return Conflict("django", "requests", ">=2.0", "1.5") + + +@pytest.fixture +def sample_conflicts() -> list[Conflict]: + """Create multiple sample conflicts for testing.""" + return [ + Conflict("django", "requests", ">=2.0", "1.5", "4.0"), + Conflict("flask", "requests", ">=2.5", "1.5", "2.0"), + ] + + +@pytest.fixture +def sample_metadata() -> dict: + """Create sample metadata with Python requirements.""" + return { + "current_metadata": {"requires_python": ">=3.7"}, + "latest_metadata": {"requires_python": ">=3.8"}, + "recommended_metadata": {"requires_python": ">=3.7"}, + } + + +@pytest.fixture +def partial_metadata() -> dict: + """Create metadata with only current version info.""" + return { + "current_metadata": {"requires_python": ">=3.7"}, + } + + +@pytest.mark.unit class TestNormalizeName: """Tests for _normalize_name package normalization.""" - def test_lowercase_conversion(self) -> None: + @pytest.mark.parametrize( + "input_name,expected", + [ + ("Django", "django"), + ("REQUESTS", "requests"), + ("NumPy", "numpy"), + ], + ids=["mixed-case", "uppercase", "camelcase"], + ) + def test_lowercase_conversion(self, input_name: str, expected: str) -> None: """Test package names are converted to lowercase. Per PEP 503, package names should be case-insensitive. """ - assert _normalize_name("Django") == "django" - assert _normalize_name("REQUESTS") == "requests" - assert _normalize_name("NumPy") == "numpy" - - def test_underscore_to_dash(self) -> None: + # Act + result = _normalize_name(input_name) + # Assert + assert result == expected + + @pytest.mark.parametrize( + "input_name,expected", + [ + ("python_package", "python-package"), + ("my_test_pkg", "my-test-pkg"), + ], + ids=["single-underscore", "multiple-underscores"], + ) + def test_underscore_to_dash(self, input_name: str, expected: str) -> None: """Test underscores are replaced with dashes. PEP 503 normalization converts underscores to hyphens. """ - assert _normalize_name("python_package") == "python-package" - assert _normalize_name("my_test_pkg") == "my-test-pkg" - - def test_combined_normalization(self) -> None: + # Act + result = _normalize_name(input_name) + # Assert + assert result == expected + + @pytest.mark.parametrize( + "input_name,expected", + [ + ("My_Package", "my-package"), + ("Test_PKG_Name", "test-pkg-name"), + ], + ids=["mixed-case-underscore", "complex-mix"], + ) + def test_combined_normalization(self, input_name: str, expected: str) -> None: """Test combined case and underscore normalization. Should handle both transformations simultaneously. """ - assert _normalize_name("My_Package") == "my-package" - assert _normalize_name("Test_PKG_Name") == "test-pkg-name" - - def test_already_normalized(self) -> None: + # Act + result = _normalize_name(input_name) + # Assert + assert result == expected + + @pytest.mark.parametrize( + "input_name", + ["requests", "django-rest-framework", "pytest-cov"], + ids=["simple", "with-dashes", "multiple-parts"], + ) + def test_already_normalized(self, input_name: str) -> None: """Test already normalized names remain unchanged. Happy path: Properly formatted names should pass through. """ - assert _normalize_name("requests") == "requests" - assert _normalize_name("django-rest-framework") == "django-rest-framework" + # Act + result = _normalize_name(input_name) + # Assert + assert result == input_name def test_empty_string(self) -> None: """Test empty string normalization. Edge case: Empty strings should remain empty. """ - assert _normalize_name("") == "" + # Act + result = _normalize_name("") + # Assert + assert result == "" +@pytest.mark.unit class TestPackageInit: """Tests for Package initialization.""" - def test_minimal_initialization(self) -> None: + def test_minimal_initialization(self, minimal_package: Package) -> None: """Test Package can be created with only name. Happy path: Minimal package with defaults. """ - pkg = Package(name="requests") - assert pkg.name == "requests" - assert pkg.current_version is None - assert pkg.latest_version is None - assert pkg.recommended_version is None - assert pkg.metadata == {} - assert pkg.conflicts == [] - - def test_full_initialization(self) -> None: + # Assert + assert minimal_package.name == "requests" + assert minimal_package.current_version is None + assert minimal_package.latest_version is None + assert minimal_package.recommended_version is None + assert minimal_package.metadata == {} + assert minimal_package.conflicts == [] + + def test_full_initialization( + self, sample_conflict: Conflict, sample_metadata: dict + ) -> None: """Test Package with all parameters. Should accept and store all optional parameters. """ - conflicts = [Conflict("django", "requests", ">=2.0", "1.5")] + # Arrange + conflicts = [sample_conflict] metadata = {"info": {"author": "Test"}} - + # Act pkg = Package( name="requests", current_version="2.0.0", @@ -102,7 +262,7 @@ def test_full_initialization(self) -> None: metadata=metadata, conflicts=conflicts, ) - + # Assert assert pkg.name == "requests" assert pkg.current_version == "2.0.0" assert pkg.latest_version == "2.28.0" @@ -110,103 +270,117 @@ def test_full_initialization(self) -> None: assert pkg.metadata == metadata assert pkg.conflicts == conflicts - def test_name_normalization_on_init(self) -> None: + @pytest.mark.parametrize( + "input_name,expected", + [ + ("Django_App", "django-app"), + ("MY_PACKAGE", "my-package"), + ("Test_PKG", "test-pkg"), + ], + ids=["mixed-case-underscore", "uppercase-underscore", "short-name"], + ) + def test_name_normalization_on_init(self, input_name: str, expected: str) -> None: """Test package name is normalized in __post_init__. Name should be normalized according to PEP 503. """ - pkg = Package(name="Django_App") - assert pkg.name == "django-app" + # Act + pkg = Package(name=input_name) + # Assert + assert pkg.name == expected def test_default_factory_creates_new_instances(self) -> None: """Test default factories create independent instances. Edge case: Multiple packages shouldn't share metadata/conflicts. """ + # Act pkg1 = Package(name="requests") pkg2 = Package(name="django") - pkg1.metadata["key"] = "value1" pkg2.metadata["key"] = "value2" - + # Assert assert pkg1.metadata != pkg2.metadata assert pkg1.conflicts is not pkg2.conflicts - def test_parsed_versions_cache_initialized(self) -> None: + def test_parsed_versions_cache_initialized(self, minimal_package: Package) -> None: """Test _parsed_versions cache is initialized empty. Internal cache should start empty. """ - pkg = Package(name="requests") - assert pkg._parsed_versions == {} + # Assert + assert minimal_package._parsed_versions == {} +@pytest.mark.unit class TestParseVersion: """Tests for Package._parse_version method.""" - def test_parse_valid_version(self) -> None: + def test_parse_valid_version(self, minimal_package: Package) -> None: """Test parsing a valid version string. Happy path: Standard version should parse correctly. """ - pkg = Package(name="requests") - result = pkg._parse_version("2.28.0") - + # Act + result = minimal_package._parse_version("2.28.0") + # Assert assert result is not None assert isinstance(result, Version) assert str(result) == "2.28.0" - def test_parse_none_returns_none(self) -> None: + def test_parse_none_returns_none(self, minimal_package: Package) -> None: """Test parsing None returns None. Edge case: None input should return None. """ - pkg = Package(name="requests") - result = pkg._parse_version(None) + # Act + result = minimal_package._parse_version(None) + # Assert assert result is None - def test_parse_invalid_version_returns_none(self) -> None: + @pytest.mark.parametrize( + "invalid_version", + ["invalid.version", "not-a-version", "abc"], + ids=["dots", "dashes", "letters"], + ) + def test_parse_invalid_version_returns_none( + self, minimal_package: Package, invalid_version: str + ) -> None: """Test parsing invalid version string returns None. Edge case: Invalid versions should not raise, return None. """ - pkg = Package(name="requests") - result = pkg._parse_version("invalid.version") + # Act + result = minimal_package._parse_version(invalid_version) + # Assert assert result is None - def test_version_caching(self) -> None: + def test_version_caching(self, minimal_package: Package) -> None: """Test parsed versions are cached. Multiple calls with same version should use cache. """ - pkg = Package(name="requests") - - result1 = pkg._parse_version("2.28.0") - result2 = pkg._parse_version("2.28.0") - - # Should be same object from cache + # Act + result1 = minimal_package._parse_version("2.28.0") + result2 = minimal_package._parse_version("2.28.0") + # Assert - Should be same object from cache assert result1 is result2 - assert "2.28.0" in pkg._parsed_versions + assert "2.28.0" in minimal_package._parsed_versions - def test_invalid_version_cached(self) -> None: + def test_invalid_version_cached(self, minimal_package: Package) -> None: """Test invalid versions are cached as None. Should cache None for invalid versions to avoid reparsing. """ - pkg = Package(name="requests") - - pkg._parse_version("invalid") - assert "invalid" in pkg._parsed_versions - assert pkg._parsed_versions["invalid"] is None + # Act + minimal_package._parse_version("invalid") + # Assert + assert "invalid" in minimal_package._parsed_versions + assert minimal_package._parsed_versions["invalid"] is None - def test_complex_version_formats(self) -> None: - """Test parsing various PEP 440 version formats. - - Should handle pre-releases, post-releases, epochs, local. - """ - pkg = Package(name="requests") - - versions = [ + @pytest.mark.parametrize( + "version_string", + [ "1.0.0a1", # Pre-release "1.0.0b2", # Beta "1.0.0rc3", # Release candidate @@ -214,72 +388,80 @@ def test_complex_version_formats(self) -> None: "1.0.0.dev1", # Dev release "1!2.0.0", # Epoch "1.0.0+local", # Local version - ] + ], + ids=["alpha", "beta", "rc", "post", "dev", "epoch", "local"], + ) + def test_complex_version_formats( + self, minimal_package: Package, version_string: str + ) -> None: + """Test parsing various PEP 440 version formats. - for ver_str in versions: - result = pkg._parse_version(ver_str) - assert result is not None, f"Failed to parse {ver_str}" - assert isinstance(result, Version) + Should handle pre-releases, post-releases, epochs, local. + """ + # Act + result = minimal_package._parse_version(version_string) + # Assert + assert result is not None, f"Failed to parse {version_string}" + assert isinstance(result, Version) +@pytest.mark.unit class TestVersionProperties: """Tests for Package version accessor properties.""" - def test_current_property(self) -> None: + def test_current_property(self, basic_package: Package) -> None: """Test current property returns parsed current_version. Happy path: Should parse and return Version object. """ - pkg = Package(name="requests", current_version="2.28.0") - current = pkg.current - + # Act + current = basic_package.current + # Assert assert current is not None assert isinstance(current, Version) - assert str(current) == "2.28.0" + assert str(current) == "2.20.0" - def test_current_property_none(self) -> None: + def test_current_property_none(self, minimal_package: Package) -> None: """Test current property returns None when not set. Edge case: No current version should return None. """ - pkg = Package(name="requests") - assert pkg.current is None + # Act & Assert + assert minimal_package.current is None - def test_latest_property(self) -> None: + def test_latest_property(self, basic_package: Package) -> None: """Test latest property returns parsed latest_version. Happy path: Should parse and return Version object. """ - pkg = Package(name="requests", latest_version="2.31.0") - latest = pkg.latest - + # Act + latest = basic_package.latest + # Assert assert latest is not None assert isinstance(latest, Version) assert str(latest) == "2.31.0" - def test_recommended_property(self) -> None: + def test_recommended_property(self, basic_package: Package) -> None: """Test recommended property returns parsed recommended_version. Happy path: Should parse and return Version object. """ - pkg = Package(name="requests", recommended_version="2.28.0") - recommended = pkg.recommended - + # Act + recommended = basic_package.recommended + # Assert assert recommended is not None assert isinstance(recommended, Version) assert str(recommended) == "2.28.0" - def test_properties_use_cache(self) -> None: + def test_properties_use_cache(self, basic_package: Package) -> None: """Test properties use version parsing cache. Multiple property accesses should use cached values. """ - pkg = Package(name="requests", current_version="2.28.0") - - current1 = pkg.current - current2 = pkg.current - - # Should be same cached object + # Act + current1 = basic_package.current + current2 = basic_package.current + # Assert - Should be same cached object assert current1 is current2 def test_invalid_version_property_returns_none(self) -> None: @@ -287,57 +469,58 @@ def test_invalid_version_property_returns_none(self) -> None: Edge case: Invalid version strings should return None. """ + # Arrange pkg = Package(name="requests", current_version="invalid") + # Act & Assert assert pkg.current is None +@pytest.mark.unit class TestRequiresDowngrade: """Tests for Package.requires_downgrade property.""" - def test_requires_downgrade_true(self) -> None: + def test_requires_downgrade_true(self, downgrade_package: Package) -> None: """Test requires_downgrade when recommended < current. Happy path: Downgrade is needed. """ - pkg = Package( - name="requests", current_version="3.0.0", recommended_version="2.28.0" - ) - assert pkg.requires_downgrade is True + # Act & Assert + assert downgrade_package.requires_downgrade is True - def test_requires_downgrade_false_same_version(self) -> None: + def test_requires_downgrade_false_same_version( + self, up_to_date_package: Package + ) -> None: """Test requires_downgrade when versions are equal. Same version should not require downgrade. """ - pkg = Package( - name="requests", current_version="2.28.0", recommended_version="2.28.0" - ) - assert pkg.requires_downgrade is False + # Act & Assert + assert up_to_date_package.requires_downgrade is False - def test_requires_downgrade_false_upgrade(self) -> None: + def test_requires_downgrade_false_upgrade(self, outdated_package: Package) -> None: """Test requires_downgrade when recommended > current. Upgrade case should not require downgrade. """ - pkg = Package( - name="requests", current_version="2.20.0", recommended_version="2.28.0" - ) - assert pkg.requires_downgrade is False + # Act & Assert + assert outdated_package.requires_downgrade is False - def test_requires_downgrade_no_current(self) -> None: + def test_requires_downgrade_no_current(self, new_package: Package) -> None: """Test requires_downgrade when current version is None. Edge case: No current version means no downgrade. """ - pkg = Package(name="requests", recommended_version="2.28.0") - assert pkg.requires_downgrade is False + # Act & Assert + assert new_package.requires_downgrade is False def test_requires_downgrade_no_recommended(self) -> None: """Test requires_downgrade when recommended version is None. Edge case: No recommended version means no downgrade. """ + # Arrange pkg = Package(name="requests", current_version="2.28.0") + # Act & Assert assert pkg.requires_downgrade is False def test_requires_downgrade_invalid_versions(self) -> None: @@ -345,244 +528,262 @@ def test_requires_downgrade_invalid_versions(self) -> None: Edge case: Invalid versions should result in False. """ + # Arrange pkg = Package( name="requests", current_version="invalid", recommended_version="2.28.0" ) + # Act & Assert assert pkg.requires_downgrade is False +@pytest.mark.unit class TestHasConflicts: """Tests for Package.has_conflicts method.""" - def test_has_conflicts_empty(self) -> None: + def test_has_conflicts_empty(self, minimal_package: Package) -> None: """Test has_conflicts returns False when no conflicts. Happy path: Empty conflicts list. """ - pkg = Package(name="requests") - assert pkg.has_conflicts() is False + # Act & Assert + assert minimal_package.has_conflicts() is False - def test_has_conflicts_populated(self) -> None: + def test_has_conflicts_populated( + self, minimal_package: Package, sample_conflict: Conflict + ) -> None: """Test has_conflicts returns True when conflicts exist. Happy path: Non-empty conflicts list. """ - pkg = Package(name="requests") - pkg.conflicts = [Conflict("django", "requests", ">=2.0", "1.5")] - assert pkg.has_conflicts() is True + # Arrange + minimal_package.conflicts = [sample_conflict] + # Act & Assert + assert minimal_package.has_conflicts() is True - def test_has_conflicts_multiple(self) -> None: + def test_has_conflicts_multiple( + self, minimal_package: Package, sample_conflicts: list[Conflict] + ) -> None: """Test has_conflicts with multiple conflicts. Multiple conflicts should return True. """ - pkg = Package(name="requests") - pkg.conflicts = [ - Conflict("django", "requests", ">=2.0", "1.5"), - Conflict("flask", "requests", ">=2.5", "1.5"), - ] - assert pkg.has_conflicts() is True + # Arrange + minimal_package.conflicts = sample_conflicts + # Act & Assert + assert minimal_package.has_conflicts() is True +@pytest.mark.unit class TestSetConflicts: """Tests for Package.set_conflicts method.""" - def test_set_conflicts_basic(self) -> None: + def test_set_conflicts_basic( + self, minimal_package: Package, sample_conflict: Conflict + ) -> None: """Test setting conflicts updates conflicts list. Happy path: Basic conflict assignment. """ - pkg = Package(name="requests") - conflicts = [Conflict("django", "requests", ">=2.0", "1.5")] - - pkg.set_conflicts(conflicts) - - assert pkg.conflicts == conflicts - assert pkg.has_conflicts() - - def test_set_conflicts_with_resolved_version(self) -> None: + # Arrange + conflicts = [sample_conflict] + # Act + minimal_package.set_conflicts(conflicts) + # Assert + assert minimal_package.conflicts == conflicts + assert minimal_package.has_conflicts() + + def test_set_conflicts_with_resolved_version( + self, minimal_package: Package, sample_conflict: Conflict + ) -> None: """Test setting conflicts with resolved version. Should update both conflicts and recommended_version. """ - pkg = Package(name="requests") - conflicts = [Conflict("django", "requests", ">=2.0", "1.5")] - - pkg.set_conflicts(conflicts, resolved_version="2.28.0") - - assert pkg.conflicts == conflicts - assert pkg.recommended_version == "2.28.0" + # Arrange + conflicts = [sample_conflict] + # Act + minimal_package.set_conflicts(conflicts, resolved_version="2.28.0") + # Assert + assert minimal_package.conflicts == conflicts + assert minimal_package.recommended_version == "2.28.0" - def test_set_conflicts_replaces_existing(self) -> None: + def test_set_conflicts_replaces_existing(self, minimal_package: Package) -> None: """Test setting conflicts replaces previous conflicts. Should completely replace, not append. """ - pkg = Package(name="requests") + # Arrange old_conflicts = [Conflict("django", "requests", ">=2.0", "1.5")] new_conflicts = [Conflict("flask", "requests", ">=2.5", "1.5")] - - pkg.set_conflicts(old_conflicts) - pkg.set_conflicts(new_conflicts) - - assert pkg.conflicts == new_conflicts - assert len(pkg.conflicts) == 1 - - def test_set_conflicts_empty_list(self) -> None: + # Act + minimal_package.set_conflicts(old_conflicts) + minimal_package.set_conflicts(new_conflicts) + # Assert + assert minimal_package.conflicts == new_conflicts + assert len(minimal_package.conflicts) == 1 + + def test_set_conflicts_empty_list( + self, minimal_package: Package, sample_conflict: Conflict + ) -> None: """Test setting empty conflicts list. Edge case: Should clear conflicts. """ - pkg = Package(name="requests") - pkg.conflicts = [Conflict("django", "requests", ">=2.0", "1.5")] - - pkg.set_conflicts([]) - - assert pkg.conflicts == [] - assert not pkg.has_conflicts() - - def test_set_conflicts_without_resolved_version(self) -> None: + # Arrange + minimal_package.conflicts = [sample_conflict] + # Act + minimal_package.set_conflicts([]) + # Assert + assert minimal_package.conflicts == [] + assert not minimal_package.has_conflicts() + + def test_set_conflicts_without_resolved_version( + self, sample_conflict: Conflict + ) -> None: """Test setting conflicts without resolved version. Should not modify recommended_version. """ + # Arrange pkg = Package(name="requests", recommended_version="2.20.0") - conflicts = [Conflict("django", "requests", ">=2.0", "1.5")] - + conflicts = [sample_conflict] + # Act pkg.set_conflicts(conflicts) - + # Assert assert pkg.recommended_version == "2.20.0" +@pytest.mark.unit class TestConflictReporting: """Tests for conflict summary and detail methods.""" - def test_get_conflict_summary_empty(self) -> None: + def test_get_conflict_summary_empty(self, minimal_package: Package) -> None: """Test conflict summary with no conflicts. Empty conflicts should return empty list. """ - pkg = Package(name="requests") - summary = pkg.get_conflict_summary() + # Act + summary = minimal_package.get_conflict_summary() + # Assert assert summary == [] - def test_get_conflict_summary_single(self) -> None: + def test_get_conflict_summary_single( + self, minimal_package: Package, sample_conflict: Conflict + ) -> None: """Test conflict summary with one conflict. Happy path: Should return list with one short string. """ - pkg = Package(name="requests") - pkg.conflicts = [Conflict("django", "requests", ">=2.0", "1.5")] - - summary = pkg.get_conflict_summary() - + # Arrange + minimal_package.conflicts = [sample_conflict] + # Act + summary = minimal_package.get_conflict_summary() + # Assert assert len(summary) == 1 assert "django needs >=2.0" in summary[0] - def test_get_conflict_summary_multiple(self) -> None: + def test_get_conflict_summary_multiple( + self, minimal_package: Package, sample_conflicts: list[Conflict] + ) -> None: """Test conflict summary with multiple conflicts. Should return list of all conflict summaries. """ - pkg = Package(name="requests") - pkg.conflicts = [ - Conflict("django", "requests", ">=2.0", "1.5"), - Conflict("flask", "requests", ">=2.5", "1.5"), - ] - - summary = pkg.get_conflict_summary() - + # Arrange + minimal_package.conflicts = sample_conflicts + # Act + summary = minimal_package.get_conflict_summary() + # Assert assert len(summary) == 2 assert any("django" in s for s in summary) assert any("flask" in s for s in summary) - def test_get_conflict_details_empty(self) -> None: + def test_get_conflict_details_empty(self, minimal_package: Package) -> None: """Test conflict details with no conflicts. Empty conflicts should return empty list. """ - pkg = Package(name="requests") - details = pkg.get_conflict_details() + # Act + details = minimal_package.get_conflict_details() + # Assert assert details == [] - def test_get_conflict_details_single(self) -> None: + def test_get_conflict_details_single(self, minimal_package: Package) -> None: """Test conflict details with one conflict. Happy path: Should return detailed description. """ - pkg = Package(name="requests") - pkg.conflicts = [Conflict("django", "requests", ">=2.0", "1.5", "4.0")] - - details = pkg.get_conflict_details() - + # Arrange + minimal_package.conflicts = [ + Conflict("django", "requests", ">=2.0", "1.5", "4.0") + ] + # Act + details = minimal_package.get_conflict_details() + # Assert assert len(details) == 1 assert "django==4.0 requires requests>=2.0" in details[0] - def test_get_conflict_details_multiple(self) -> None: + def test_get_conflict_details_multiple( + self, minimal_package: Package, sample_conflicts: list[Conflict] + ) -> None: """Test conflict details with multiple conflicts. Should return all detailed descriptions. """ - pkg = Package(name="requests") - pkg.conflicts = [ - Conflict("django", "requests", ">=2.0", "1.5", "4.0"), - Conflict("flask", "requests", ">=2.5", "1.5", "2.0"), - ] - - details = pkg.get_conflict_details() - + # Arrange + minimal_package.conflicts = sample_conflicts + # Act + details = minimal_package.get_conflict_details() + # Assert assert len(details) == 2 assert any("django==4.0" in d for d in details) assert any("flask==2.0" in d for d in details) +@pytest.mark.unit class TestHasUpdate: """Tests for Package.has_update method.""" - def test_has_update_true(self) -> None: + def test_has_update_true(self, outdated_package: Package) -> None: """Test has_update when recommended > current. Happy path: Update is available. """ - pkg = Package( - name="requests", current_version="2.20.0", recommended_version="2.28.0" - ) - assert pkg.has_update() is True + # Act & Assert + assert outdated_package.has_update() is True - def test_has_update_false_same_version(self) -> None: + def test_has_update_false_same_version(self, up_to_date_package: Package) -> None: """Test has_update when versions are equal. Same version means no update. """ - pkg = Package( - name="requests", current_version="2.28.0", recommended_version="2.28.0" - ) - assert pkg.has_update() is False + # Act & Assert + assert up_to_date_package.has_update() is False - def test_has_update_false_downgrade(self) -> None: + def test_has_update_false_downgrade(self, downgrade_package: Package) -> None: """Test has_update when recommended < current. Downgrade case should return False for has_update. """ - pkg = Package( - name="requests", current_version="3.0.0", recommended_version="2.28.0" - ) - assert pkg.has_update() is False + # Act & Assert + assert downgrade_package.has_update() is False - def test_has_update_no_current(self) -> None: + def test_has_update_no_current(self, new_package: Package) -> None: """Test has_update when current version is None. Edge case: No current version means no update. """ - pkg = Package(name="requests", recommended_version="2.28.0") - assert pkg.has_update() is False + # Act & Assert + assert new_package.has_update() is False def test_has_update_no_recommended(self) -> None: """Test has_update when recommended version is None. Edge case: No recommended version means no update. """ + # Arrange pkg = Package(name="requests", current_version="2.28.0") + # Act & Assert assert pkg.has_update() is False def test_has_update_invalid_versions(self) -> None: @@ -590,24 +791,28 @@ def test_has_update_invalid_versions(self) -> None: Edge case: Invalid versions should result in False. """ + # Arrange pkg = Package( name="requests", current_version="invalid", recommended_version="2.28.0" ) + # Act & Assert assert pkg.has_update() is False +@pytest.mark.unit class TestGetVersionPythonReq: """Tests for Package.get_version_python_req method.""" - def test_get_current_python_req(self) -> None: + def test_get_current_python_req(self, partial_metadata: dict) -> None: """Test retrieving Python requirement for current version. Happy path: Should return requires_python from metadata. """ - pkg = Package( - name="requests", metadata={"current_metadata": {"requires_python": ">=3.7"}} - ) + # Arrange + pkg = Package(name="requests", metadata=partial_metadata) + # Act result = pkg.get_version_python_req("current") + # Assert assert result == ">=3.7" def test_get_latest_python_req(self) -> None: @@ -615,10 +820,13 @@ def test_get_latest_python_req(self) -> None: Should access latest_metadata. """ + # Arrange pkg = Package( name="requests", metadata={"latest_metadata": {"requires_python": ">=3.8"}} ) + # Act result = pkg.get_version_python_req("latest") + # Assert assert result == ">=3.8" def test_get_recommended_python_req(self) -> None: @@ -626,31 +834,45 @@ def test_get_recommended_python_req(self) -> None: Should access recommended_metadata. """ + # Arrange pkg = Package( name="requests", metadata={"recommended_metadata": {"requires_python": ">=3.7"}}, ) + # Act result = pkg.get_version_python_req("recommended") + # Assert assert result == ">=3.7" - def test_get_python_req_no_metadata(self) -> None: + @pytest.mark.parametrize( + "version_key", + ["current", "latest", "recommended"], + ids=["current", "latest", "recommended"], + ) + def test_get_python_req_no_metadata( + self, minimal_package: Package, version_key: str + ) -> None: """Test returns None when metadata doesn't exist. Edge case: Missing metadata key should return None. """ - pkg = Package(name="requests") - result = pkg.get_version_python_req("current") + # Act + result = minimal_package.get_version_python_req(version_key) + # Assert assert result is None - def test_get_python_req_no_requires_python(self) -> None: + def test_get_python_req_no_requires_python(self, partial_metadata: dict) -> None: """Test returns None when requires_python not in metadata. Edge case: Metadata exists but no requires_python field. """ + # Arrange pkg = Package( name="requests", metadata={"current_metadata": {"author": "Test"}} ) + # Act result = pkg.get_version_python_req("current") + # Assert assert result is None def test_get_python_req_invalid_metadata_type(self) -> None: @@ -658,8 +880,11 @@ def test_get_python_req_invalid_metadata_type(self) -> None: Edge case: Malformed metadata should return None. """ + # Arrange pkg = Package(name="requests", metadata={"current_metadata": "invalid"}) + # Act result = pkg.get_version_python_req("current") + # Assert assert result is None def test_get_python_req_invalid_value_type(self) -> None: @@ -667,18 +892,22 @@ def test_get_python_req_invalid_value_type(self) -> None: Edge case: Non-string value should return None. """ + # Arrange pkg = Package( name="requests", metadata={"current_metadata": {"requires_python": 3.7}}, # Invalid type ) + # Act result = pkg.get_version_python_req("current") + # Assert assert result is None - def test_get_python_req_all_versions(self) -> None: + def test_get_python_req_all_versions(self, sample_metadata: dict) -> None: """Test retrieving requirements for all version types. Integration test: Multiple version requirements. """ + # Arrange pkg = Package( name="requests", metadata={ @@ -687,79 +916,55 @@ def test_get_python_req_all_versions(self) -> None: "recommended_metadata": {"requires_python": ">=3.7"}, }, ) - + # Act & Assert assert pkg.get_version_python_req("current") == ">=3.6" assert pkg.get_version_python_req("latest") == ">=3.8" assert pkg.get_version_python_req("recommended") == ">=3.7" +@pytest.mark.unit class TestGetStatusSummary: """Tests for Package.get_status_summary method.""" - def test_status_install_new_package(self) -> None: + def test_status_install_new_package(self, new_package: Package) -> None: """Test status for package not yet installed. No current version should give 'install' status. """ - pkg = Package( - name="requests", latest_version="2.28.0", recommended_version="2.28.0" - ) - status, installed, latest, recommended = pkg.get_status_summary() - + status, installed, latest, recommended = new_package.get_status_summary() assert status == "install" assert installed == "none" assert latest == "2.28.0" assert recommended == "2.28.0" - def test_status_latest_up_to_date(self) -> None: + def test_status_latest_up_to_date(self, up_to_date_package: Package) -> None: """Test status when package is up to date. Current == recommended should give 'latest' status. """ - pkg = Package( - name="requests", - current_version="2.28.0", - latest_version="2.28.0", - recommended_version="2.28.0", - ) - status, installed, latest, recommended = pkg.get_status_summary() - + status, installed, latest, recommended = up_to_date_package.get_status_summary() assert status == "latest" assert installed == "2.28.0" assert latest == "2.28.0" assert recommended == "2.28.0" - def test_status_outdated(self) -> None: + def test_status_outdated(self, outdated_package: Package) -> None: """Test status when package needs update. recommended > current should give 'outdated' status. """ - pkg = Package( - name="requests", - current_version="2.20.0", - latest_version="2.31.0", - recommended_version="2.28.0", - ) - status, installed, latest, recommended = pkg.get_status_summary() - + status, installed, latest, recommended = outdated_package.get_status_summary() assert status == "outdated" assert installed == "2.20.0" assert latest == "2.31.0" assert recommended == "2.28.0" - def test_status_downgrade(self) -> None: + def test_status_downgrade(self, downgrade_package: Package) -> None: """Test status when downgrade is needed. recommended < current should give 'downgrade' status. """ - pkg = Package( - name="requests", - current_version="3.0.0", - latest_version="3.0.0", - recommended_version="2.28.0", - ) - status, installed, latest, recommended = pkg.get_status_summary() - + status, installed, latest, recommended = downgrade_package.get_status_summary() assert status == "downgrade" assert installed == "3.0.0" assert latest == "3.0.0" @@ -774,7 +979,6 @@ def test_status_no_update_no_recommended(self) -> None: name="requests", current_version="2.28.0", latest_version="2.28.0" ) status, installed, latest, recommended = pkg.get_status_summary() - assert status == "no-update" assert installed == "2.28.0" assert latest == "2.28.0" @@ -787,36 +991,30 @@ def test_status_error_no_latest(self) -> None: """ pkg = Package(name="requests", current_version="2.28.0") status, installed, latest, recommended = pkg.get_status_summary() - assert latest == "error" +@pytest.mark.unit class TestToJSON: """Tests for Package.to_json serialization.""" - def test_to_json_minimal(self) -> None: + def test_to_json_minimal(self, minimal_package: Package) -> None: """Test JSON serialization with minimal data. Only name and no-update status. """ - pkg = Package(name="requests") - result = pkg.to_json() - + result = minimal_package.to_json() assert result["name"] == "requests" assert result["status"] == "no-update" assert result["error"] == "Package information unavailable" assert "versions" not in result - def test_to_json_install_status(self) -> None: + def test_to_json_install_status(self, new_package: Package) -> None: """Test JSON for new package installation. Should show install status with versions. """ - pkg = Package( - name="requests", latest_version="2.28.0", recommended_version="2.28.0" - ) - result = pkg.to_json() - + result = new_package.to_json() assert result["name"] == "requests" assert result["status"] == "install" assert result["versions"]["latest"] == "2.28.0" @@ -824,111 +1022,72 @@ def test_to_json_install_status(self) -> None: assert "current" not in result["versions"] assert "update_type" not in result - def test_to_json_latest_status(self) -> None: + def test_to_json_latest_status(self, up_to_date_package: Package) -> None: """Test JSON for up-to-date package. Should show latest status. """ - pkg = Package( - name="requests", - current_version="2.28.0", - latest_version="2.28.0", - recommended_version="2.28.0", - ) - result = pkg.to_json() - + result = up_to_date_package.to_json() assert result["status"] == "latest" assert result["versions"]["current"] == "2.28.0" assert "update_type" not in result - def test_to_json_outdated_status(self) -> None: + def test_to_json_outdated_status(self, outdated_package: Package) -> None: """Test JSON for outdated package. Should show outdated status with update_type. """ - pkg = Package( - name="requests", - current_version="2.20.0", - latest_version="2.31.0", - recommended_version="2.28.0", - ) - result = pkg.to_json() - + result = outdated_package.to_json() assert result["status"] == "outdated" assert "update_type" in result assert result["update_type"] in ["major", "minor", "patch"] - def test_to_json_downgrade_status(self) -> None: + def test_to_json_downgrade_status(self, downgrade_package: Package) -> None: """Test JSON for package requiring downgrade. Should show downgrade status with update_type. """ - pkg = Package( - name="requests", - current_version="3.0.0", - latest_version="3.0.0", - recommended_version="2.28.0", - ) - result = pkg.to_json() - + result = downgrade_package.to_json() assert result["status"] == "downgrade" assert "update_type" in result - def test_to_json_with_python_requirements(self) -> None: + def test_to_json_with_python_requirements( + self, package_with_metadata: Package + ) -> None: """Test JSON includes Python requirements when present. Should serialize requires_python metadata. """ - pkg = Package( - name="requests", - current_version="2.28.0", - latest_version="2.28.0", - recommended_version="2.28.0", - metadata={ - "current_metadata": {"requires_python": ">=3.7"}, - "latest_metadata": {"requires_python": ">=3.8"}, - "recommended_metadata": {"requires_python": ">=3.7"}, - }, - ) - result = pkg.to_json() - + result = package_with_metadata.to_json() assert "python_requirements" in result assert result["python_requirements"]["current"] == ">=3.7" assert result["python_requirements"]["latest"] == ">=3.8" assert result["python_requirements"]["recommended"] == ">=3.7" - def test_to_json_with_conflicts(self) -> None: + def test_to_json_with_conflicts( + self, minimal_package: Package, sample_conflicts: list[Conflict] + ) -> None: """Test JSON includes conflicts when present. Should serialize conflict list. """ - pkg = Package(name="requests") - pkg.conflicts = [ - Conflict("django", "requests", ">=2.0", "1.5", "4.0"), - Conflict("flask", "requests", ">=2.5", "1.5", "2.0"), - ] - pkg.recommended_version = "2.28.0" - - result = pkg.to_json() - + # Arrange + minimal_package.conflicts = sample_conflicts + minimal_package.recommended_version = "2.28.0" + # Act + result = minimal_package.to_json() + # Assert assert "conflicts" in result assert len(result["conflicts"]) == 2 assert result["conflicts"][0]["source_package"] == "django" assert result["conflicts"][1]["source_package"] == "flask" - def test_to_json_no_python_requirements(self) -> None: + def test_to_json_no_python_requirements(self, up_to_date_package: Package) -> None: """Test JSON omits python_requirements when not present. Edge case: Empty requirements should not create key. """ - pkg = Package( - name="requests", - current_version="2.28.0", - latest_version="2.28.0", - recommended_version="2.28.0", - ) - result = pkg.to_json() - + result = up_to_date_package.to_json() assert "python_requirements" not in result def test_to_json_partial_versions(self) -> None: @@ -940,26 +1099,25 @@ def test_to_json_partial_versions(self) -> None: name="requests", current_version="2.28.0", recommended_version="2.28.0" ) result = pkg.to_json() - assert "versions" in result assert "current" in result["versions"] assert "recommended" in result["versions"] assert "latest" not in result["versions"] +@pytest.mark.unit class TestRenderPythonCompatibility: """Tests for Package.render_python_compatibility method.""" - def test_render_no_requirements(self) -> None: + def test_render_no_requirements(self, minimal_package: Package) -> None: """Test rendering when no Python requirements exist. Should return dim placeholder. """ - pkg = Package(name="requests") - result = pkg.render_python_compatibility() + result = minimal_package.render_python_compatibility() assert result == "[dim]-[/dim]" - def test_render_current_only(self) -> None: + def test_render_current_only(self, partial_metadata: dict) -> None: """Test rendering with only current requirement. Should show current line only. @@ -967,7 +1125,7 @@ def test_render_current_only(self) -> None: pkg = Package( name="requests", current_version="2.28.0", - metadata={"current_metadata": {"requires_python": ">=3.7"}}, + metadata=partial_metadata, ) result = pkg.render_python_compatibility() assert "Current: >=3.7" in result @@ -988,11 +1146,12 @@ def test_render_current_and_latest(self) -> None: }, ) result = pkg.render_python_compatibility() - assert "Current: >=3.7" in result assert "Latest: >=3.8" in result - def test_render_with_update_includes_recommended(self) -> None: + def test_render_with_update_includes_recommended( + self, sample_metadata: dict + ) -> None: """Test rendering includes recommended when update available. Should show recommended line when has_update() is True. @@ -1009,7 +1168,6 @@ def test_render_with_update_includes_recommended(self) -> None: }, ) result = pkg.render_python_compatibility() - assert "Current: >=3.6" in result assert "Latest: >=3.8" in result assert "Recommended:>=3.7" in result @@ -1031,7 +1189,6 @@ def test_render_no_update_excludes_recommended(self) -> None: }, ) result = pkg.render_python_compatibility() - assert "Recommended:" not in result def test_render_multiline_format(self) -> None: @@ -1049,26 +1206,22 @@ def test_render_multiline_format(self) -> None: }, ) result = pkg.render_python_compatibility() - lines = result.split("\n") assert len(lines) == 2 assert lines[0].startswith("Current:") assert lines[1].startswith("Latest:") +@pytest.mark.unit class TestGetDisplayData: """Tests for Package.get_display_data method.""" - def test_display_data_no_update(self) -> None: + def test_display_data_no_update(self, up_to_date_package: Package) -> None: """Test display data when package is up to date. Should show no update available. """ - pkg = Package( - name="requests", current_version="2.28.0", recommended_version="2.28.0" - ) - data = pkg.get_display_data() - + data = up_to_date_package.get_display_data() assert data["update_available"] is False assert data["requires_downgrade"] is False assert data["update_target"] == "2.28.0" @@ -1076,62 +1229,51 @@ def test_display_data_no_update(self) -> None: assert data["has_conflicts"] is False assert data["conflict_summary"] == [] - def test_display_data_update_available(self) -> None: + def test_display_data_update_available(self, outdated_package: Package) -> None: """Test display data when update is available. Should show update details. """ - pkg = Package( - name="requests", current_version="2.20.0", recommended_version="2.28.0" - ) - data = pkg.get_display_data() - + data = outdated_package.get_display_data() assert data["update_available"] is True assert data["requires_downgrade"] is False assert data["update_target"] == "2.28.0" assert data["update_type"] in ["major", "minor", "patch"] - def test_display_data_downgrade_required(self) -> None: + def test_display_data_downgrade_required(self, downgrade_package: Package) -> None: """Test display data when downgrade is needed. Should show downgrade requirement. """ - pkg = Package( - name="requests", current_version="3.0.0", recommended_version="2.28.0" - ) - data = pkg.get_display_data() - + data = downgrade_package.get_display_data() assert data["update_available"] is False assert data["requires_downgrade"] is True assert data["update_target"] == "2.28.0" assert data["update_type"] is not None - def test_display_data_with_conflicts(self) -> None: + def test_display_data_with_conflicts( + self, minimal_package: Package, sample_conflicts: list[Conflict] + ) -> None: """Test display data includes conflict information. Should show conflict details. """ - pkg = Package(name="requests") - pkg.conflicts = [ - Conflict("django", "requests", ">=2.0", "1.5"), - Conflict("flask", "requests", ">=2.5", "1.5"), - ] - pkg.recommended_version = "2.28.0" - - data = pkg.get_display_data() - + # Arrange + minimal_package.conflicts = sample_conflicts + minimal_package.recommended_version = "2.28.0" + # Act + data = minimal_package.get_display_data() + # Assert assert data["has_conflicts"] is True assert len(data["conflict_summary"]) == 2 assert any("django" in s for s in data["conflict_summary"]) - def test_display_data_structure(self) -> None: + def test_display_data_structure(self, minimal_package: Package) -> None: """Test display data has all expected keys. Should contain all required fields. """ - pkg = Package(name="requests") - data = pkg.get_display_data() - + data = minimal_package.get_display_data() expected_keys = { "update_available", "requires_downgrade", @@ -1143,16 +1285,16 @@ def test_display_data_structure(self) -> None: assert set(data.keys()) == expected_keys +@pytest.mark.unit class TestStringRepresentations: """Tests for Package.__str__ and __repr__ methods.""" - def test_str_minimal(self) -> None: + def test_str_minimal(self, minimal_package: Package) -> None: """Test __str__ with only package name. Minimal package should show just name. """ - pkg = Package(name="requests") - result = str(pkg) + result = str(minimal_package) assert result == "requests" def test_str_with_latest_only(self) -> None: @@ -1164,65 +1306,45 @@ def test_str_with_latest_only(self) -> None: result = str(pkg) assert result == "requests (latest: 2.28.0)" - def test_str_up_to_date(self) -> None: + def test_str_up_to_date(self, up_to_date_package: Package) -> None: """Test __str__ for up-to-date package. Should show up-to-date status. """ - pkg = Package( - name="requests", current_version="2.28.0", latest_version="2.28.0" - ) - result = str(pkg) - + result = str(up_to_date_package) assert "requests" in result assert "2.28.0" in result assert "up-to-date" in result - def test_str_outdated(self) -> None: + def test_str_outdated(self, outdated_package: Package) -> None: """Test __str__ for outdated package. Should show outdated status with recommended. """ - pkg = Package( - name="requests", - current_version="2.20.0", - latest_version="2.31.0", - recommended_version="2.28.0", - ) - result = str(pkg) - + result = str(outdated_package) assert "requests" in result assert "2.20.0" in result assert "2.31.0" in result assert "outdated" in result assert "recommended: 2.28.0" in result - def test_repr_minimal(self) -> None: + def test_repr_minimal(self, minimal_package: Package) -> None: """Test __repr__ with minimal data. Should show Package constructor format. """ - pkg = Package(name="requests") - result = repr(pkg) - + result = repr(minimal_package) assert result.startswith("Package(") assert "name='requests'" in result assert "current_version=None" in result assert "outdated=False" in result - def test_repr_full(self) -> None: + def test_repr_full(self, outdated_package: Package) -> None: """Test __repr__ with all version data. Should show all version fields. """ - pkg = Package( - name="requests", - current_version="2.20.0", - latest_version="2.31.0", - recommended_version="2.28.0", - ) - result = repr(pkg) - + result = repr(outdated_package) assert "name='requests'" in result assert "current_version='2.20.0'" in result assert "latest_version='2.31.0'" in result @@ -1230,6 +1352,7 @@ def test_repr_full(self) -> None: assert "outdated=True" in result +@pytest.mark.integration class TestIntegrationScenarios: """Integration tests for complex real-world scenarios.""" @@ -1250,26 +1373,21 @@ def test_complete_outdated_package_workflow(self) -> None: "recommended_metadata": {"requires_python": ">=3.8"}, }, ) - # Check name normalized assert pkg.name == "django-package" - # Check status assert pkg.has_update() assert not pkg.requires_downgrade assert not pkg.has_conflicts() - # Check display data data = pkg.get_display_data() assert data["update_available"] assert data["update_target"] == "4.0.0" - # Check JSON output json_data = pkg.to_json() assert json_data["status"] == "outdated" assert "update_type" in json_data assert "python_requirements" in json_data - # Check string representation str_repr = str(pkg) assert "outdated" in str_repr @@ -1282,32 +1400,26 @@ def test_complete_conflict_resolution_workflow(self) -> None: """ # Create package with conflicts pkg = Package(name="requests", current_version="3.0.0", latest_version="3.0.0") - # Add conflicts with resolved version conflicts = [ Conflict("django", "requests", ">=2.20.0,<3.0.0", "3.0.0", "4.0"), Conflict("flask", "requests", ">=2.25.0,<3.0.0", "3.0.0", "2.0"), ] pkg.set_conflicts(conflicts, resolved_version="2.28.0") - # Check conflict state assert pkg.has_conflicts() assert pkg.requires_downgrade assert pkg.recommended_version == "2.28.0" - # Check conflict reporting summary = pkg.get_conflict_summary() assert len(summary) == 2 - details = pkg.get_conflict_details() assert any("django==4.0" in d for d in details) - # Check display data data = pkg.get_display_data() assert data["requires_downgrade"] assert data["has_conflicts"] assert len(data["conflict_summary"]) == 2 - # Check JSON includes conflicts json_data = pkg.to_json() assert json_data["status"] == "downgrade" @@ -1328,23 +1440,21 @@ def test_new_package_installation_workflow(self) -> None: "recommended_metadata": {"requires_python": ">=3.8"}, }, ) - # Check status assert not pkg.has_update() assert not pkg.requires_downgrade assert pkg.current is None - # Check status summary status, installed, latest, recommended = pkg.get_status_summary() assert status == "install" assert installed == "none" - # Check JSON json_data = pkg.to_json() assert json_data["status"] == "install" assert "current" not in json_data.get("versions", {}) +@pytest.mark.unit class TestEdgeCases: """Additional edge case tests.""" @@ -1356,7 +1466,6 @@ def test_version_with_local_identifier(self) -> None: pkg = Package( name="requests", current_version="2.28.0+local", latest_version="2.28.0" ) - # Should parse successfully assert pkg.current is not None assert isinstance(pkg.current, Version) @@ -1369,7 +1478,6 @@ def test_version_with_epoch(self) -> None: pkg = Package( name="requests", current_version="1!2.0.0", recommended_version="1!2.5.0" ) - # Should handle epochs correctly assert pkg.has_update() @@ -1384,7 +1492,6 @@ def test_prerelease_versions(self) -> None: latest_version="3.0.0a1", recommended_version="2.28.0", ) - # Should parse pre-release versions assert pkg.latest is not None assert pkg.latest.is_prerelease @@ -1409,80 +1516,79 @@ def test_metadata_with_nested_structures(self) -> None: "current_metadata": { "requires_python": ">=3.7", "info": {"nested": {"deep": "value"}}, - } + }, }, ) - # Should handle nested structures without errors req = pkg.get_version_python_req("current") assert req == ">=3.7" - def test_empty_metadata_dict(self) -> None: + def test_empty_metadata_dict(self, minimal_package: Package) -> None: """Test package with empty metadata. Edge case: Empty metadata should not cause issues. """ pkg = Package(name="requests", metadata={}) - assert pkg.get_version_python_req("current") is None assert pkg.render_python_compatibility() == "[dim]-[/dim]" - def test_many_conflicts(self) -> None: + def test_many_conflicts(self, minimal_package: Package) -> None: """Test package with many conflicts. Edge case: Large number of conflicts. """ - pkg = Package(name="requests") + # Arrange conflicts = [ Conflict(f"package{i}", "requests", f">={i}.0", "1.0") for i in range(50) ] - pkg.set_conflicts(conflicts) - - assert len(pkg.conflicts) == 50 - assert len(pkg.get_conflict_summary()) == 50 + # Act + minimal_package.set_conflicts(conflicts) + # Assert + assert len(minimal_package.conflicts) == 50 + assert len(minimal_package.get_conflict_summary()) == 50 def test_version_comparison_with_invalid(self) -> None: """Test version comparisons when some versions invalid. Edge case: Invalid versions should not break comparisons. """ + # Arrange pkg = Package( name="requests", current_version="invalid", recommended_version="2.28.0" ) - - # Should return False for comparisons with invalid versions + # Act & Assert - Should return False for comparisons with invalid versions assert not pkg.has_update() assert not pkg.requires_downgrade - def test_simultaneous_update_and_conflict(self) -> None: + def test_simultaneous_update_and_conflict(self, sample_conflict: Conflict) -> None: """Test package with both update and conflicts. Edge case: Complex scenario with multiple issues. """ + # Arrange pkg = Package( name="requests", current_version="2.20.0", latest_version="3.0.0", recommended_version="2.28.0", ) - pkg.conflicts = [Conflict("django", "requests", ">=2.25.0", "2.20.0")] - + pkg.conflicts = [sample_conflict] + # Act + data = pkg.get_display_data() + # Assert assert pkg.has_update() assert pkg.has_conflicts() - - data = pkg.get_display_data() assert data["update_available"] assert data["has_conflicts"] - def test_json_with_none_values(self) -> None: + def test_json_with_none_values(self, minimal_package: Package) -> None: """Test JSON serialization handles None values correctly. Edge case: None values should be omitted or handled properly. """ - pkg = Package(name="requests") - json_data = pkg.to_json() - - # Should not include version keys when None + # Act + json_data = minimal_package.to_json() + # Assert - Should not include version keys when None assert "versions" not in json_data or len(json_data.get("versions", {})) == 0 def test_unicode_in_package_name(self) -> None: @@ -1490,9 +1596,9 @@ def test_unicode_in_package_name(self) -> None: Edge case: International characters in package names. """ + # Arrange & Act pkg = Package(name="pàckage-naмe-日本") - - # Should handle unicode without errors - assert len(pkg.name) > 0 str_repr = str(pkg) + # Assert - Should handle unicode without errors + assert len(pkg.name) > 0 assert len(str_repr) > 0 diff --git a/tests/test_models/test_requirement.py b/tests/test_models/test_requirement.py index 04cfe26..6a6627f 100644 --- a/tests/test_models/test_requirement.py +++ b/tests/test_models/test_requirement.py @@ -1,85 +1,364 @@ -"""Unit tests for depkeeper.models.requirement module. - -This test suite provides comprehensive coverage of the Requirement data model, -including parsing representation, string rendering, version updates, and -edge cases for various requirement formats. - -Test Coverage: -- Requirement initialization with various parameters -- String rendering with and without hashes/comments -- Version update operations -- Extras and markers handling -- Editable installations -- URL-based requirements -- Hash verification entries -- Comment preservation -- Line number tracking -- String representations (__str__ and __repr__) -- Edge cases (empty values, special characters, complex markers) -""" - from __future__ import annotations +import pytest + from depkeeper.models.requirement import Requirement +@pytest.fixture +def simple_requirement() -> Requirement: + """Create a simple Requirement with only a package name. + + Returns: + Requirement: A minimal requirement instance for testing. + """ + return Requirement(name="requests") + + +@pytest.fixture +def requirement_with_version() -> Requirement: + """Create a Requirement with version specifiers. + + Returns: + Requirement: A requirement with version constraints. + """ + return Requirement(name="requests", specs=[(">=", "2.0.0")]) + + +@pytest.fixture +def complex_requirement() -> Requirement: + """Create a Requirement with multiple features. + + Returns: + Requirement: A requirement with specs, extras, and markers. + """ + return Requirement( + name="requests", + specs=[(">=", "2.0.0"), ("<", "3.0.0")], + extras=["security", "socks"], + markers='python_version >= "3.7"', + ) + + +@pytest.fixture +def requirement_with_hashes() -> Requirement: + """Create a Requirement with hash verification. + + Returns: + Requirement: A requirement with multiple hash values. + """ + return Requirement( + name="requests", + specs=[(">=", "2.0.0")], + hashes=["sha256:abc123", "sha256:def456"], + ) + + +@pytest.fixture +def requirement_with_comment() -> Requirement: + """Create a Requirement with an inline comment. + + Returns: + Requirement: A requirement with comment metadata. + """ + return Requirement( + name="requests", + specs=[(">=", "2.0.0")], + comment="Production dependency", + ) + + +@pytest.fixture +def editable_requirement() -> Requirement: + """Create an editable Requirement. + + Returns: + Requirement: An editable installation requirement. + """ + return Requirement( + name="mypackage", + url="/path/to/local/package", + editable=True, + ) + + +@pytest.fixture +def url_requirement() -> Requirement: + """Create a URL-based Requirement. + + Returns: + Requirement: A requirement with direct URL. + """ + return Requirement( + name="requests", + url="https://github.com/psf/requests/archive/v2.28.0.tar.gz", + ) + + +@pytest.fixture +def full_featured_requirement() -> Requirement: + """Create a Requirement with all features enabled. + + Returns: + Requirement: A requirement using all available features. + """ + return Requirement( + name="requests", + specs=[(">=", "2.0.0"), ("<", "3.0.0")], + extras=["security", "socks"], + markers='python_version >= "3.7"', + url="https://github.com/psf/requests/archive/v2.28.0.tar.gz", + editable=True, + hashes=["sha256:abc123", "sha256:def456"], + comment="Production dependency", + line_number=42, + raw_line="-e https://github.com/psf/requests/archive/v2.28.0.tar.gz", + ) + + +@pytest.fixture +def requirement_factory(): + """Factory fixture for creating Requirement instances with custom parameters. + + Returns: + Callable: Function to create Requirements with specified attributes. + """ + + def _create(**kwargs): + defaults = {"name": "requests"} + defaults.update(kwargs) + return Requirement(**defaults) + + return _create + + +@pytest.fixture +def spec_factory(): + """Factory for creating common version specifiers. + + Returns: + dict: Common spec patterns for reuse. + """ + return { + "pinned": [("==", "2.28.0")], + "range": [(">=", "2.0.0"), ("<", "3.0.0")], + "exclude": [(">=", "2.0.0"), ("<", "3.0.0"), ("!=", "2.5.0")], + "min_only": [(">=", "2.0.0")], + "wildcard": [("==", "2.*")], + "complex": [(">=", "3.2"), ("<", "5.0"), ("!=", "4.0")], + } + + +@pytest.fixture +def url_factory(): + """Factory for creating common URL patterns. + + Returns: + dict: Common URL patterns for testing. + """ + return { + "github_archive": "https://github.com/psf/requests/archive/v2.28.0.tar.gz", + "github_main": "https://github.com/psf/requests/archive/main.zip", + "git_https": "git+https://github.com/user/repo.git@main#egg=mypackage", + "git_ssh": "git+ssh://git@github.com/user/repo.git", + "git_branch": "git+https://github.com/user/my-lib.git@develop", + "git_subdirectory": "git+https://github.com/user/repo.git@feature-branch#subdirectory=packages/mypackage", + "local": ".", + } + + +@pytest.fixture +def marker_factory(): + """Factory for creating common environment markers. + + Returns: + dict: Common marker expressions for testing. + """ + return { + "python_version": 'python_version >= "3.7"', + "python_version_38": 'python_version >= "3.8"', + "python_version_39": 'python_version >= "3.9"', + "linux": 'sys_platform == "linux"', + "windows": 'sys_platform == "win32"', + "not_windows": 'sys_platform != "win32"', + "complex": 'python_version >= "3.7" and sys_platform == "linux" and platform_machine == "x86_64"', + "or_condition": 'sys_platform == "win32" or sys_platform == "darwin"', + } + + +@pytest.fixture +def extras_factory(): + """Factory for common extra specifications. + + Returns: + dict: Common extra combinations for testing. + """ + return { + "single": ["security"], + "multiple": ["security", "socks"], + "dev": ["dev", "test"], + "ordered": ["z-extra", "a-extra", "m-extra"], + "django": ["bcrypt"], + "numpy": ["dev"], + "flask": ["async"], + } + + +@pytest.fixture +def hash_factory(): + """Factory for hash values. + + Returns: + dict: Common hash patterns for testing. + """ + return { + "single_sha256": ["sha256:abc123def456"], + "multiple_sha256": ["sha256:abc123", "sha256:def456"], + "multiple_sha256_three": ["sha256:abc123", "sha256:def456", "sha256:ghi789"], + "mixed_algorithms": ["sha256:abc123", "sha256:def456"], + "different_algorithms": ["sha256:abc123", "sha512:def456ghi789", "md5:xyz890"], + "security": ["sha256:hash1", "sha256:hash2"], + } + + +@pytest.fixture +def version_factory(): + """Factory for version strings. + + Returns: + dict: Common version patterns for testing. + """ + return { + "stable": "2.28.0", + "updated": "2.31.0", + "new_major": "3.0.0", + "prerelease": "3.0.0a1", + "dev": "3.0.0.dev1", + "local": "2.28.0+local", + "epoch": "1!2.0.0", + "wildcard": "2.*", + } + + +# ============================================================================ +# Reusable Data Fixtures +# ============================================================================ + + +@pytest.fixture +def package_names(): + """Common package names for testing. + + Returns: + dict: Package names categorized by use case. + """ + return { + "simple": "requests", + "django": "django", + "flask": "flask", + "numpy": "numpy", + "pytest": "pytest", + "pillow": "pillow", + "pywin32": "pywin32", + "mypackage": "mypackage", + "myproject": "myproject", + "my-lib": "my-lib", + "empty": "", + "special_chars": "my-package.name_v2", + "long": "package-" * 50 + "name", + } + + +@pytest.fixture +def all_operators(): + """All valid PEP 440 operators. + + Returns: + list: All comparison operators. + """ + return ["==", "!=", ">=", "<=", ">", "<", "~=", "==="] + + +@pytest.fixture +def comment_factory(): + """Factory for common comment patterns. + + Returns: + dict: Common comment strings for testing. + """ + return { + "simple": "Production dependency", + "security": "Pinned for security", + "web_framework": "Web framework", + "testing": "Testing framework", + "local_dev": "Local development", + "develop_branch": "Latest develop branch", + "breaking_changes": "Avoid Django 4.0 due to breaking changes", + "cve": "Exclude vulnerable versions (CVE-2023-XXXXX)", + "windows": "Windows-specific", + "scientific": "Scientific computing", + "special_chars": "Critical! ⚠️ Don't update (see issue #123)", + "hash_symbols": "See issue #123 and PR #456", + "long": "This is a very long comment " * 20, + } + + +@pytest.mark.unit class TestRequirementInit: """Tests for Requirement initialization.""" - def test_minimal_initialization(self) -> None: + @pytest.mark.unit + def test_minimal_initialization(self, simple_requirement: Requirement) -> None: """Test Requirement with only package name. Happy path: Minimal requirement with just name. - """ - req = Requirement(name="requests") - - assert req.name == "requests" - assert req.specs == [] - assert req.extras == [] - assert req.markers is None - assert req.url is None - assert req.editable is False - assert req.hashes == [] - assert req.comment is None - assert req.line_number == 0 - assert req.raw_line is None - def test_full_initialization(self) -> None: + Args: + simple_requirement: Fixture providing a minimal Requirement. + """ + # Act & Assert + assert simple_requirement.name == "requests" + assert simple_requirement.specs == [] + assert simple_requirement.extras == [] + assert simple_requirement.markers is None + assert simple_requirement.url is None + assert simple_requirement.editable is False + assert simple_requirement.hashes == [] + assert simple_requirement.comment is None + assert simple_requirement.line_number == 0 + assert simple_requirement.raw_line is None + + @pytest.mark.unit + def test_full_initialization(self, full_featured_requirement: Requirement) -> None: """Test Requirement with all parameters. Should accept and store all optional parameters. - """ - req = Requirement( - name="requests", - specs=[(">=", "2.0.0"), ("<", "3.0.0")], - extras=["security", "socks"], - markers='python_version >= "3.7"', - url="https://github.com/psf/requests/archive/v2.28.0.tar.gz", - editable=True, - hashes=["sha256:abc123", "sha256:def456"], - comment="Production dependency", - line_number=42, - raw_line="requests>=2.0.0,<3.0.0 # Production dependency", + + Args: + full_featured_requirement: Fixture with all features. + """ + # Act & Assert + assert full_featured_requirement.name == "requests" + assert full_featured_requirement.specs == [(">=", "2.0.0"), ("<", "3.0.0")] + assert full_featured_requirement.extras == ["security", "socks"] + assert full_featured_requirement.markers == 'python_version >= "3.7"' + assert ( + full_featured_requirement.url + == "https://github.com/psf/requests/archive/v2.28.0.tar.gz" ) + assert full_featured_requirement.editable is True + assert full_featured_requirement.hashes == ["sha256:abc123", "sha256:def456"] + assert full_featured_requirement.comment == "Production dependency" + assert full_featured_requirement.line_number == 42 - assert req.name == "requests" - assert req.specs == [(">=", "2.0.0"), ("<", "3.0.0")] - assert req.extras == ["security", "socks"] - assert req.markers == 'python_version >= "3.7"' - assert req.url == "https://github.com/psf/requests/archive/v2.28.0.tar.gz" - assert req.editable is True - assert req.hashes == ["sha256:abc123", "sha256:def456"] - assert req.comment == "Production dependency" - assert req.line_number == 42 - assert req.raw_line == "requests>=2.0.0,<3.0.0 # Production dependency" - - def test_default_factories_create_new_instances(self) -> None: + @pytest.mark.unit + def test_default_factories_create_new_instances(self, requirement_factory) -> None: """Test default factories create independent instances. Edge case: Multiple requirements shouldn't share lists. """ - req1 = Requirement(name="requests") - req2 = Requirement(name="django") + req1 = requirement_factory(name="requests") + req2 = requirement_factory(name="django") req1.specs.append((">=", "2.0.0")) req2.specs.append((">=", "4.0.0")) @@ -88,143 +367,168 @@ def test_default_factories_create_new_instances(self) -> None: assert req1.extras is not req2.extras assert req1.hashes is not req2.hashes - def test_initialization_with_empty_lists(self) -> None: + @pytest.mark.unit + def test_initialization_with_empty_lists(self, requirement_factory) -> None: """Test Requirement with explicitly empty lists. Edge case: Empty lists should be accepted. """ - req = Requirement(name="requests", specs=[], extras=[], hashes=[]) + req = requirement_factory(name="requests", specs=[], extras=[], hashes=[]) assert req.specs == [] assert req.extras == [] assert req.hashes == [] +@pytest.mark.unit class TestToStringBasic: """Tests for Requirement.to_string method - basic cases.""" - def test_simple_package_name_only(self) -> None: + @pytest.mark.unit + def test_simple_package_name_only(self, simple_requirement) -> None: """Test rendering requirement with only package name. Happy path: Simplest possible requirement. """ - req = Requirement(name="requests") - result = req.to_string() + result = simple_requirement.to_string() assert result == "requests" - def test_with_single_spec(self) -> None: + @pytest.mark.unit + def test_with_single_spec(self, requirement_with_version) -> None: """Test rendering requirement with single version specifier. Happy path: Common format with version constraint. """ - req = Requirement(name="requests", specs=[(">=", "2.0.0")]) - result = req.to_string() + result = requirement_with_version.to_string() assert result == "requests>=2.0.0" - def test_with_multiple_specs(self) -> None: + @pytest.mark.unit + def test_with_multiple_specs(self, requirement_factory, spec_factory) -> None: """Test rendering requirement with multiple version specifiers. Should concatenate specifiers with commas. """ - req = Requirement( + req = requirement_factory( name="requests", specs=[(">=", "2.0.0"), ("<", "3.0.0"), ("!=", "2.5.0")] ) result = req.to_string() assert result == "requests>=2.0.0,<3.0.0,!=2.5.0" - def test_with_single_extra(self) -> None: + @pytest.mark.unit + def test_with_single_extra(self, requirement_factory, extras_factory) -> None: """Test rendering requirement with single extra. Extras should be enclosed in square brackets. """ - req = Requirement(name="requests", extras=["security"]) + req = requirement_factory(name="requests", extras=extras_factory["single"]) result = req.to_string() assert result == "requests[security]" - def test_with_multiple_extras(self) -> None: + @pytest.mark.unit + def test_with_multiple_extras(self, requirement_factory, extras_factory) -> None: """Test rendering requirement with multiple extras. Multiple extras should be comma-separated. """ - req = Requirement(name="requests", extras=["security", "socks"]) + req = requirement_factory(name="requests", extras=extras_factory["multiple"]) result = req.to_string() assert result == "requests[security,socks]" - def test_with_extras_and_specs(self) -> None: + @pytest.mark.unit + def test_with_extras_and_specs( + self, requirement_factory, spec_factory, extras_factory + ) -> None: """Test rendering requirement with both extras and specs. Format should be: package[extras]specs """ - req = Requirement(name="requests", specs=[(">=", "2.0.0")], extras=["security"]) + req = requirement_factory( + name="requests", + specs=spec_factory["min_only"], + extras=extras_factory["single"], + ) result = req.to_string() assert result == "requests[security]>=2.0.0" - def test_with_markers(self) -> None: + @pytest.mark.unit + def test_with_markers( + self, requirement_factory, marker_factory, spec_factory + ) -> None: """Test rendering requirement with environment markers. Markers should be preceded by semicolon and space. """ - req = Requirement( - name="requests", specs=[(">=", "2.0.0")], markers='python_version >= "3.7"' + req = requirement_factory( + name="requests", + specs=spec_factory["min_only"], + markers=marker_factory["python_version"], ) result = req.to_string() assert result == 'requests>=2.0.0 ; python_version >= "3.7"' - def test_with_url(self) -> None: + @pytest.mark.unit + def test_with_url(self, requirement_factory, url_factory) -> None: """Test rendering URL-based requirement. URL should replace package name in output. """ - req = Requirement( - name="requests", url="https://github.com/psf/requests/archive/main.zip" - ) + req = requirement_factory(name="requests", url=url_factory["github_main"]) result = req.to_string() assert result == "https://github.com/psf/requests/archive/main.zip" - def test_editable_package(self) -> None: + @pytest.mark.unit + def test_editable_package(self, requirement_factory) -> None: """Test rendering editable installation. Should prefix with -e flag. """ - req = Requirement(name="mypackage", editable=True) + req = requirement_factory(name="mypackage", editable=True) result = req.to_string() assert result == "-e mypackage" - def test_editable_url(self) -> None: + @pytest.mark.unit + def test_editable_url(self, requirement_factory, url_factory) -> None: """Test rendering editable URL installation. Should prefix URL with -e flag. """ - req = Requirement( - name="mypackage", url="git+https://github.com/user/repo.git", editable=True + req = requirement_factory( + name="mypackage", url=url_factory["git_https"], editable=True ) result = req.to_string() - assert result == "-e git+https://github.com/user/repo.git" + assert result == "-e git+https://github.com/user/repo.git@main#egg=mypackage" +@pytest.mark.unit class TestToStringWithHashes: """Tests for Requirement.to_string with hash handling.""" - def test_single_hash(self) -> None: + @pytest.mark.unit + def test_single_hash(self, requirement_factory, spec_factory, hash_factory) -> None: """Test rendering requirement with single hash. Hash should be appended with --hash= prefix. """ - req = Requirement( - name="requests", specs=[("==", "2.28.0")], hashes=["sha256:abc123def456"] + req = requirement_factory( + name="requests", + specs=spec_factory["pinned"], + hashes=hash_factory["single_sha256"], ) result = req.to_string(include_hashes=True) assert result == "requests==2.28.0 --hash=sha256:abc123def456" - def test_multiple_hashes(self) -> None: + @pytest.mark.unit + def test_multiple_hashes( + self, requirement_factory, spec_factory, hash_factory + ) -> None: """Test rendering requirement with multiple hashes. Multiple hashes should each have --hash= prefix. """ - req = Requirement( + req = requirement_factory( name="requests", - specs=[("==", "2.28.0")], - hashes=["sha256:abc123", "sha256:def456", "sha256:ghi789"], + specs=spec_factory["pinned"], + hashes=hash_factory["multiple_sha256_three"], ) result = req.to_string(include_hashes=True) @@ -233,89 +537,97 @@ def test_multiple_hashes(self) -> None: assert "--hash=sha256:def456" in result assert "--hash=sha256:ghi789" in result - def test_hashes_excluded_when_flag_false(self) -> None: + @pytest.mark.unit + def test_hashes_excluded_when_flag_false( + self, requirement_factory, spec_factory, hash_factory + ) -> None: """Test hashes are omitted when include_hashes=False. Should not include hash entries when flag is False. """ - req = Requirement( - name="requests", specs=[("==", "2.28.0")], hashes=["sha256:abc123"] + req = requirement_factory( + name="requests", + specs=spec_factory["pinned"], + hashes=hash_factory["single_sha256"], ) result = req.to_string(include_hashes=False) assert result == "requests==2.28.0" assert "--hash=" not in result - def test_no_hashes_with_flag_true(self) -> None: + @pytest.mark.unit + def test_no_hashes_with_flag_true(self, requirement_factory, spec_factory) -> None: """Test rendering with include_hashes=True but no hashes. Edge case: Flag is True but no hashes to include. """ - req = Requirement(name="requests", specs=[("==", "2.28.0")]) + req = requirement_factory(name="requests", specs=spec_factory["pinned"]) result = req.to_string(include_hashes=True) assert result == "requests==2.28.0" assert "--hash=" not in result +@pytest.mark.unit class TestToStringWithComments: """Tests for Requirement.to_string with comment handling.""" - def test_simple_comment(self) -> None: + @pytest.mark.unit + def test_simple_comment(self, requirement_with_comment) -> None: """Test rendering requirement with comment. Comment should be appended with # prefix and space. """ - req = Requirement( - name="requests", specs=[(">=", "2.0.0")], comment="Production dependency" - ) - result = req.to_string(include_comment=True) + result = requirement_with_comment.to_string(include_comment=True) assert result == "requests>=2.0.0 # Production dependency" - def test_comment_excluded_when_flag_false(self) -> None: + @pytest.mark.unit + def test_comment_excluded_when_flag_false(self, requirement_with_comment) -> None: """Test comment is omitted when include_comment=False. Should not include comment when flag is False. """ - req = Requirement( - name="requests", specs=[(">=", "2.0.0")], comment="Production dependency" - ) - result = req.to_string(include_comment=False) + result = requirement_with_comment.to_string(include_comment=False) assert result == "requests>=2.0.0" assert "#" not in result - def test_no_comment_with_flag_true(self) -> None: + @pytest.mark.unit + def test_no_comment_with_flag_true(self, requirement_with_version) -> None: """Test rendering with include_comment=True but no comment. Edge case: Flag is True but no comment to include. """ - req = Requirement(name="requests", specs=[(">=", "2.0.0")]) - result = req.to_string(include_comment=True) + result = requirement_with_version.to_string(include_comment=True) assert result == "requests>=2.0.0" assert "#" not in result - def test_comment_with_hashes(self) -> None: + @pytest.mark.unit + def test_comment_with_hashes( + self, requirement_factory, spec_factory, hash_factory, comment_factory + ) -> None: """Test rendering with both hashes and comment. Comment should come after hashes. """ - req = Requirement( + req = requirement_factory( name="requests", - specs=[("==", "2.28.0")], + specs=spec_factory["pinned"], hashes=["sha256:abc123"], - comment="Pinned for security", + comment=comment_factory["security"], ) result = req.to_string(include_hashes=True, include_comment=True) - assert result.endswith("# Pinned for security") assert "--hash=sha256:abc123 #" in result - def test_comment_without_hashes(self) -> None: + @pytest.mark.unit + def test_comment_without_hashes( + self, requirement_factory, spec_factory, comment_factory + ) -> None: """Test rendering with comment but hashes excluded. Comment should still appear when hashes are excluded. """ - req = Requirement( + req = requirement_factory( name="requests", - specs=[("==", "2.28.0")], + specs=spec_factory["pinned"], hashes=["sha256:abc123"], comment="Pinned", ) @@ -323,19 +635,28 @@ def test_comment_without_hashes(self) -> None: assert result == "requests==2.28.0 # Pinned" +@pytest.mark.unit class TestToStringComplex: """Tests for Requirement.to_string with complex combinations.""" - def test_all_features_combined(self) -> None: + @pytest.mark.unit + def test_all_features_combined( + self, + requirement_factory, + spec_factory, + extras_factory, + marker_factory, + comment_factory, + ) -> None: """Test rendering with all features enabled. Integration test: extras, specs, markers, hashes, comment. """ - req = Requirement( + req = requirement_factory( name="requests", - specs=[(">=", "2.0.0"), ("<", "3.0.0")], - extras=["security"], - markers='python_version >= "3.7"', + specs=spec_factory["range"], + extras=extras_factory["single"], + markers=marker_factory["python_version"], hashes=["sha256:abc123"], comment="Production", ) @@ -346,224 +667,276 @@ def test_all_features_combined(self) -> None: assert "--hash=sha256:abc123" in result assert "# Production" in result - def test_editable_with_all_features(self) -> None: + @pytest.mark.unit + def test_editable_with_all_features( + self, requirement_factory, url_factory, marker_factory, comment_factory + ) -> None: """Test editable requirement with multiple features. Should handle -e flag with extras and markers. """ - req = Requirement( + req = requirement_factory( name="mypackage", - url="git+https://github.com/user/repo.git", + url=url_factory["git_https"], editable=True, markers='sys_platform == "linux"', - comment="Development version", + comment=comment_factory["local_dev"], ) result = req.to_string(include_comment=True) assert result.startswith("-e") assert "git+https://github.com/user/repo.git" in result assert '; sys_platform == "linux"' in result - assert "# Development version" in result + assert "# Local development" in result - def test_url_with_extras(self) -> None: + @pytest.mark.unit + def test_url_with_extras( + self, requirement_factory, url_factory, extras_factory + ) -> None: """Test URL-based requirement with extras. Edge case: Extras should be added to URL. """ - req = Requirement( + req = requirement_factory( name="requests", - url="https://github.com/psf/requests/archive/main.zip", - extras=["security"], + url=url_factory["github_main"], + extras=extras_factory["single"], ) result = req.to_string() - # URL should include extras assert "https://github.com/psf/requests/archive/main.zip[security]" in result +@pytest.mark.unit class TestUpdateVersion: """Tests for Requirement.update_version method.""" - def test_update_simple_requirement(self) -> None: + @pytest.mark.unit + def test_update_simple_requirement( + self, requirement_factory, spec_factory, version_factory + ) -> None: """Test updating version of simple requirement. - Happy path: Basic version update with >= operator. + Happy path: Basic version update with == operator. """ - req = Requirement(name="requests", specs=[("==", "2.20.0")]) - result = req.update_version("2.28.0", preserve_trailing_newline=False) - - assert result == "requests>=2.28.0" + req = requirement_factory(name="requests", specs=[("==", "2.20.0")]) + result = req.update_version( + version_factory["stable"], preserve_trailing_newline=False + ) + assert result == "requests==2.28.0" - def test_update_replaces_all_specs(self) -> None: + @pytest.mark.unit + def test_update_replaces_all_specs( + self, requirement_factory, spec_factory, version_factory + ) -> None: """Test update replaces all existing specifiers. - Multiple old specifiers should be replaced with single >=. + Multiple old specifiers should be replaced with single ==. """ - req = Requirement( - name="requests", specs=[(">=", "2.0.0"), ("<", "3.0.0"), ("!=", "2.5.0")] + req = requirement_factory(name="requests", specs=spec_factory["exclude"]) + result = req.update_version( + version_factory["stable"], preserve_trailing_newline=False ) - result = req.update_version("2.28.0", preserve_trailing_newline=False) - - assert result == "requests>=2.28.0" + assert result == "requests==2.28.0" assert "<3.0.0" not in result assert "!=2.5.0" not in result - def test_update_preserves_extras(self) -> None: + @pytest.mark.unit + def test_update_preserves_extras( + self, requirement_factory, extras_factory, version_factory + ) -> None: """Test update preserves extras. Extras should remain in updated requirement. """ - req = Requirement( - name="requests", specs=[("==", "2.20.0")], extras=["security", "socks"] + req = requirement_factory( + name="requests", specs=[("==", "2.20.0")], extras=extras_factory["multiple"] ) - result = req.update_version("2.28.0") + result = req.update_version(version_factory["stable"]) + assert result == "requests[security,socks]==2.28.0\n" - assert result == "requests[security,socks]>=2.28.0\n" - - def test_update_preserves_markers(self) -> None: + @pytest.mark.unit + def test_update_preserves_markers( + self, requirement_factory, marker_factory, version_factory + ) -> None: """Test update preserves environment markers. Markers should remain in updated requirement. """ - req = Requirement( - name="requests", specs=[("==", "2.20.0")], markers='python_version >= "3.7"' + req = requirement_factory( + name="requests", + specs=[("==", "2.20.0")], + markers=marker_factory["python_version"], ) - result = req.update_version("2.28.0") - + result = req.update_version(version_factory["stable"]) assert 'python_version >= "3.7"' in result - def test_update_preserves_url(self) -> None: + @pytest.mark.unit + def test_update_preserves_url( + self, requirement_factory, url_factory, version_factory + ) -> None: """Test update preserves URL. URL-based requirements should keep URL. """ - req = Requirement( + req = requirement_factory( name="requests", - url="https://github.com/psf/requests/archive/main.zip", + url=url_factory["github_main"], specs=[("==", "2.20.0")], ) - result = req.update_version("2.28.0") - + result = req.update_version(version_factory["stable"]) assert "https://github.com/psf/requests/archive/main.zip" in result - def test_update_preserves_editable_flag(self) -> None: + @pytest.mark.unit + def test_update_preserves_editable_flag( + self, requirement_factory, version_factory + ) -> None: """Test update preserves editable flag. Editable installs should remain editable. """ - req = Requirement(name="mypackage", specs=[("==", "1.0.0")], editable=True) + req = requirement_factory( + name="mypackage", specs=[("==", "1.0.0")], editable=True + ) result = req.update_version("1.5.0") - assert result.startswith("-e") - def test_update_removes_hashes(self) -> None: + @pytest.mark.unit + def test_update_removes_hashes( + self, requirement_factory, hash_factory, version_factory + ) -> None: """Test update removes hash entries. Hashes are version-specific and should be removed. """ - req = Requirement( + req = requirement_factory( name="requests", specs=[("==", "2.20.0")], - hashes=["sha256:abc123", "sha256:def456"], + hashes=hash_factory["multiple_sha256"], ) - result = req.update_version("2.28.0") - + result = req.update_version(version_factory["stable"]) assert "--hash=" not in result - def test_update_preserves_comment(self) -> None: + @pytest.mark.unit + def test_update_preserves_comment( + self, requirement_with_comment, version_factory + ) -> None: """Test update preserves inline comment. Comments should remain in updated requirement. """ - req = Requirement( - name="requests", specs=[("==", "2.20.0")], comment="Production dependency" - ) - result = req.update_version("2.28.0") - + req = requirement_with_comment + result = req.update_version(version_factory["stable"]) assert "# Production dependency" in result - def test_update_with_newline_preserved(self) -> None: + @pytest.mark.unit + def test_update_with_newline_preserved( + self, requirement_factory, version_factory + ) -> None: """Test update with trailing newline preservation. Default behavior should add trailing newline. """ - req = Requirement(name="requests", specs=[("==", "2.20.0")]) - result = req.update_version("2.28.0", preserve_trailing_newline=True) - - assert result.endswith("") + req = requirement_factory(name="requests", specs=[("==", "2.20.0")]) + result = req.update_version( + version_factory["stable"], preserve_trailing_newline=True + ) + assert result.endswith("\n") - def test_update_without_newline(self) -> None: + @pytest.mark.unit + def test_update_without_newline(self, requirement_factory, version_factory) -> None: """Test update without trailing newline. preserve_trailing_newline=False should not add newline. """ - req = Requirement(name="requests", specs=[("==", "2.20.0")]) - result = req.update_version("2.28.0", preserve_trailing_newline=False) - + req = requirement_factory(name="requests", specs=[("==", "2.20.0")]) + result = req.update_version( + version_factory["stable"], preserve_trailing_newline=False + ) assert not result.endswith("\n") - assert result == "requests>=2.28.0" + assert result == "requests==2.28.0" - def test_update_with_all_features(self) -> None: + @pytest.mark.unit + def test_update_with_all_features( + self, + requirement_factory, + spec_factory, + extras_factory, + marker_factory, + comment_factory, + version_factory, + ) -> None: """Test update with complex requirement. Integration test: Update requirement with all features. """ - req = Requirement( + req = requirement_factory( name="requests", - specs=[(">=", "2.0.0"), ("<", "3.0.0")], - extras=["security"], - markers='python_version >= "3.7"', + specs=spec_factory["range"], + extras=extras_factory["single"], + markers=marker_factory["python_version"], hashes=["sha256:abc123"], - comment="Pinned for stability", + comment=comment_factory["security"], editable=False, ) - result = req.update_version("2.28.0") + result = req.update_version(version_factory["stable"]) # Should have new version - assert ">=2.28.0" in result + assert "==2.28.0" in result # Should preserve extras, markers, comment assert "[security]" in result assert 'python_version >= "3.7"' in result - assert "# Pinned for stability" in result + assert "# Pinned for security" in result # Should not have old specs or hashes assert "<3.0.0" not in result assert "--hash=" not in result - def test_update_preserves_line_number(self) -> None: + @pytest.mark.unit + def test_update_preserves_line_number(self, requirement_factory) -> None: """Test update preserves original line number. Line number tracking should be maintained. """ - req = Requirement(name="requests", specs=[("==", "2.20.0")], line_number=42) + req = requirement_factory( + name="requests", specs=[("==", "2.20.0")], line_number=42 + ) # Create updated requirement object to verify updated_req = Requirement( name=req.name, specs=[(">=", "2.28.0")], line_number=req.line_number ) - assert updated_req.line_number == 42 +@pytest.mark.unit class TestStringRepresentations: """Tests for Requirement.__str__ and __repr__ methods.""" - def test_str_simple(self) -> None: + @pytest.mark.unit + def test_str_simple(self, requirement_with_version) -> None: """Test __str__ with simple requirement. Should delegate to to_string(). """ - req = Requirement(name="requests", specs=[(">=", "2.0.0")]) - result = str(req) + result = str(requirement_with_version) assert result == "requests>=2.0.0" - def test_str_complex(self) -> None: + @pytest.mark.unit + def test_str_complex( + self, + requirement_factory, + spec_factory, + extras_factory, + hash_factory, + comment_factory, + ) -> None: """Test __str__ with complex requirement. Should include all features via to_string(). """ - req = Requirement( + req = requirement_factory( name="requests", - specs=[(">=", "2.0.0")], - extras=["security"], + specs=spec_factory["min_only"], + extras=extras_factory["single"], hashes=["sha256:abc123"], comment="Production", ) @@ -573,13 +946,13 @@ def test_str_complex(self) -> None: assert "--hash=sha256:abc123" in result assert "# Production" in result - def test_repr_minimal(self) -> None: + @pytest.mark.unit + def test_repr_minimal(self, simple_requirement) -> None: """Test __repr__ with minimal data. Should show constructor format for debugging. """ - req = Requirement(name="requests") - result = repr(req) + result = repr(simple_requirement) assert result.startswith("Requirement(") assert "name='requests'" in result @@ -588,15 +961,16 @@ def test_repr_minimal(self) -> None: assert "editable=False" in result assert "line_number=0" in result - def test_repr_full(self) -> None: + @pytest.mark.unit + def test_repr_full(self, requirement_factory, spec_factory, extras_factory) -> None: """Test __repr__ with full data. Should show key fields in constructor format. """ - req = Requirement( + req = requirement_factory( name="requests", - specs=[(">=", "2.0.0"), ("<", "3.0.0")], - extras=["security"], + specs=spec_factory["range"], + extras=extras_factory["single"], editable=True, line_number=42, ) @@ -608,87 +982,104 @@ def test_repr_full(self) -> None: assert "editable=True" in result assert "line_number=42" in result - def test_str_vs_repr_difference(self) -> None: + @pytest.mark.unit + def test_str_vs_repr_difference(self, requirement_with_version) -> None: """Test str() and repr() produce different outputs. str() should be user-friendly, repr() for debugging. """ - req = Requirement(name="requests", specs=[(">=", "2.0.0")]) - - str_result = str(req) - repr_result = repr(req) + str_result = str(requirement_with_version) + repr_result = repr(requirement_with_version) assert str_result == "requests>=2.0.0" assert "Requirement(" in repr_result assert str_result != repr_result +@pytest.mark.unit class TestEdgeCases: """Tests for edge cases and unusual inputs.""" - def test_empty_package_name(self) -> None: + @pytest.mark.unit + def test_empty_package_name(self, requirement_factory, package_names) -> None: """Test requirement with empty package name. Edge case: Empty string as name. """ - req = Requirement(name="") + req = requirement_factory(name=package_names["empty"]) result = req.to_string() assert result == "" - def test_package_name_with_special_characters(self) -> None: + @pytest.mark.unit + def test_package_name_with_special_characters( + self, requirement_factory, package_names + ) -> None: """Test package name with special characters. Edge case: Names with dots, dashes, underscores. """ - req = Requirement(name="my-package.name_v2") + req = requirement_factory(name=package_names["special_chars"]) result = req.to_string() assert result == "my-package.name_v2" - def test_very_long_package_name(self) -> None: + @pytest.mark.unit + def test_very_long_package_name(self, requirement_factory, package_names) -> None: """Test requirement with very long package name. Edge case: Extremely long names should be handled. """ - long_name = "package-" * 50 + "name" - req = Requirement(name=long_name) + long_name = package_names["long"] + req = requirement_factory(name=long_name) result = req.to_string() assert result == long_name - def test_spec_with_wildcards(self) -> None: + @pytest.mark.unit + def test_spec_with_wildcards(self, requirement_factory, spec_factory) -> None: """Test version specifier with wildcards. Edge case: Wildcard versions like ==2.*. """ - req = Requirement(name="requests", specs=[("==", "2.*")]) + req = requirement_factory(name="requests", specs=spec_factory["wildcard"]) result = req.to_string() assert result == "requests==2.*" - def test_spec_with_local_version(self) -> None: + @pytest.mark.unit + def test_spec_with_local_version( + self, requirement_factory, version_factory + ) -> None: """Test version specifier with local identifier. Edge case: PEP 440 local versions like 1.0+local. """ - req = Requirement(name="requests", specs=[("==", "2.28.0+local")]) + req = requirement_factory( + name="requests", specs=[("==", version_factory["local"])] + ) result = req.to_string() assert result == "requests==2.28.0+local" - def test_spec_with_epoch(self) -> None: + @pytest.mark.unit + def test_spec_with_epoch(self, requirement_factory, version_factory) -> None: """Test version specifier with epoch. Edge case: PEP 440 epochs like 1!2.0.0. """ - req = Requirement(name="requests", specs=[("==", "1!2.0.0")]) + req = requirement_factory( + name="requests", specs=[("==", version_factory["epoch"])] + ) result = req.to_string() assert result == "requests==1!2.0.0" - def test_marker_with_complex_expression(self) -> None: + @pytest.mark.unit + def test_marker_with_complex_expression( + self, requirement_factory, marker_factory + ) -> None: """Test requirement with complex marker expression. Edge case: Multiple conditions in markers. """ - req = Requirement( + req = requirement_factory( name="requests", - markers='python_version >= "3.7" and sys_platform == "linux" and platform_machine == "x86_64"', + markers=marker_factory["complex"], ) result = req.to_string() @@ -696,92 +1087,113 @@ def test_marker_with_complex_expression(self) -> None: assert 'sys_platform == "linux"' in result assert 'platform_machine == "x86_64"' in result - def test_marker_with_or_condition(self) -> None: + @pytest.mark.unit + def test_marker_with_or_condition( + self, requirement_factory, marker_factory + ) -> None: """Test requirement with OR marker expression. Edge case: Markers with or operator. """ - req = Requirement( + req = requirement_factory( name="requests", - markers='sys_platform == "win32" or sys_platform == "darwin"', + markers=marker_factory["or_condition"], ) result = req.to_string() assert 'sys_platform == "win32" or sys_platform == "darwin"' in result - def test_url_with_git_protocol(self) -> None: + @pytest.mark.unit + def test_url_with_git_protocol(self, requirement_factory, url_factory) -> None: """Test URL with git+ protocol. Edge case: VCS URLs. """ - req = Requirement( + req = requirement_factory( name="mypackage", - url="git+https://github.com/user/repo.git@main#egg=mypackage", + url=url_factory["git_https"], ) result = req.to_string() assert "git+https://github.com/user/repo.git@main#egg=mypackage" in result - def test_url_with_ssh(self) -> None: + @pytest.mark.unit + def test_url_with_ssh(self, requirement_factory, url_factory) -> None: """Test URL with SSH protocol. Edge case: SSH-based VCS URLs. """ - req = Requirement( - name="mypackage", url="git+ssh://git@github.com/user/repo.git" - ) + req = requirement_factory(name="mypackage", url=url_factory["git_ssh"]) result = req.to_string() assert "git+ssh://git@github.com/user/repo.git" in result - def test_url_with_branch_and_subdirectory(self) -> None: + @pytest.mark.unit + def test_url_with_branch_and_subdirectory( + self, requirement_factory, url_factory + ) -> None: """Test URL with branch and subdirectory. Edge case: Complex VCS URL with path. """ - req = Requirement( + req = requirement_factory( name="mypackage", - url="git+https://github.com/user/repo.git@feature-branch#subdirectory=packages/mypackage", + url=url_factory["git_subdirectory"], ) result = req.to_string() + assert "feature-branch" in result assert "subdirectory=packages/mypackage" in result - def test_comment_with_special_characters(self) -> None: + @pytest.mark.unit + def test_comment_with_special_characters( + self, requirement_factory, comment_factory + ) -> None: """Test comment with special characters. Edge case: Comments with unicode, symbols. """ - req = Requirement( - name="requests", comment="Critical! ⚠️ Don't update (see issue #123)" + req = requirement_factory( + name="requests", comment=comment_factory["special_chars"] ) result = req.to_string(include_comment=True) assert "Critical! ⚠️ Don't update (see issue #123)" in result - def test_comment_with_hash_symbol(self) -> None: + @pytest.mark.unit + def test_comment_with_hash_symbol( + self, requirement_factory, comment_factory + ) -> None: """Test comment containing # symbol. Edge case: Hash symbols within comment text. """ - req = Requirement(name="requests", comment="See issue #123 and PR #456") + req = requirement_factory( + name="requests", comment=comment_factory["hash_symbols"] + ) result = req.to_string(include_comment=True) assert "# See issue #123 and PR #456" in result - def test_multiple_extras_ordering(self) -> None: + @pytest.mark.unit + def test_multiple_extras_ordering( + self, requirement_factory, extras_factory + ) -> None: """Test extras maintain insertion order. Edge case: Order of extras should be preserved. """ - req = Requirement(name="requests", extras=["z-extra", "a-extra", "m-extra"]) + req = requirement_factory(name="requests", extras=extras_factory["ordered"]) result = req.to_string() assert result == "requests[z-extra,a-extra,m-extra]" - def test_hash_with_different_algorithms(self) -> None: + @pytest.mark.unit + def test_hash_with_different_algorithms( + self, requirement_factory, spec_factory, hash_factory + ) -> None: """Test hashes with different algorithms. Edge case: Multiple hash algorithms (sha256, sha512, md5). """ - req = Requirement( + req = requirement_factory( name="requests", - specs=[("==", "2.28.0")], - hashes=["sha256:abc123", "sha512:def456ghi789", "md5:xyz890"], + specs=spec_factory["pinned"], + hashes=hash_factory["different_algorithms"], ) result = req.to_string(include_hashes=True) @@ -789,131 +1201,149 @@ def test_hash_with_different_algorithms(self) -> None: assert "--hash=sha512:def456ghi789" in result assert "--hash=md5:xyz890" in result - def test_very_long_comment(self) -> None: + @pytest.mark.unit + def test_very_long_comment(self, requirement_factory, comment_factory) -> None: """Test requirement with very long comment. Edge case: Comments can be arbitrarily long. """ - long_comment = "This is a very long comment " * 20 - req = Requirement(name="requests", comment=long_comment) + long_comment = comment_factory["long"] + req = requirement_factory(name="requests", comment=long_comment) result = req.to_string(include_comment=True) - assert long_comment in result - def test_zero_line_number(self) -> None: + @pytest.mark.unit + def test_zero_line_number(self, requirement_factory) -> None: """Test requirement with line number 0. Edge case: Zero is valid line number (default). """ - req = Requirement(name="requests", line_number=0) + req = requirement_factory(name="requests", line_number=0) assert req.line_number == 0 - def test_large_line_number(self) -> None: + @pytest.mark.unit + def test_large_line_number(self, requirement_factory) -> None: """Test requirement with very large line number. Edge case: Large files can have high line numbers. """ - req = Requirement(name="requests", line_number=999999) + req = requirement_factory(name="requests", line_number=999999) assert req.line_number == 999999 - def test_raw_line_with_whitespace(self) -> None: + @pytest.mark.unit + def test_raw_line_with_whitespace(self, requirement_factory) -> None: """Test raw_line preserves whitespace. Edge case: Original line might have leading/trailing space. """ - req = Requirement(name="requests", raw_line=" requests>=2.0.0 # comment ") - assert req.raw_line == " requests>=2.0.0 # comment " + req = requirement_factory( + name="requests", raw_line=" requests>=2.0.0 # comment " + ) + assert req.raw_line == " requests>=2.0.0 # comment " - def test_operator_variations(self) -> None: + @pytest.mark.unit + def test_operator_variations(self, requirement_factory, all_operators) -> None: """Test all valid PEP 440 operators. Edge case: All comparison operators should work. """ - operators = ["==", "!=", ">=", "<=", ">", "<", "~=", "==="] - - for op in operators: - req = Requirement(name="requests", specs=[(op, "2.0.0")]) + for op in all_operators: + req = requirement_factory(name="requests", specs=[(op, "2.0.0")]) result = req.to_string() assert f"requests{op}2.0.0" in result - def test_compatible_release_operator(self) -> None: + @pytest.mark.unit + def test_compatible_release_operator(self, requirement_factory) -> None: """Test compatible release operator ~=. Edge case: Tilde equal operator for compatible releases. """ - req = Requirement(name="requests", specs=[("~=", "2.28")]) + req = requirement_factory(name="requests", specs=[("~=", "2.28")]) result = req.to_string() assert result == "requests~=2.28" - def test_arbitrary_equality_operator(self) -> None: + @pytest.mark.unit + def test_arbitrary_equality_operator(self, requirement_factory) -> None: """Test arbitrary equality operator ===. Edge case: Triple equals for string matching. """ - req = Requirement(name="requests", specs=[("===", "2.28.0-local")]) + req = requirement_factory(name="requests", specs=[("===", "2.28.0-local")]) result = req.to_string() assert result == "requests===2.28.0-local" - def test_update_version_with_prerelease(self) -> None: + @pytest.mark.unit + def test_update_version_with_prerelease( + self, requirement_factory, spec_factory, version_factory + ) -> None: """Test updating to pre-release version. Edge case: Pre-release versions like 3.0.0a1. """ - req = Requirement(name="requests", specs=[("==", "2.28.0")]) - result = req.update_version("3.0.0a1") - assert ">=3.0.0a1" in result + req = requirement_factory(name="requests", specs=spec_factory["pinned"]) + result = req.update_version(version_factory["prerelease"]) + assert "==3.0.0a1" in result - def test_update_version_with_dev_version(self) -> None: + @pytest.mark.unit + def test_update_version_with_dev_version( + self, requirement_factory, spec_factory, version_factory + ) -> None: """Test updating to development version. Edge case: Dev versions like 3.0.0.dev1. """ - req = Requirement(name="requests", specs=[("==", "2.28.0")]) - result = req.update_version("3.0.0.dev1") - assert ">=3.0.0.dev1" in result + req = requirement_factory(name="requests", specs=spec_factory["pinned"]) + result = req.update_version(version_factory["dev"]) + assert "==3.0.0.dev1" in result - def test_empty_specs_list_to_string(self) -> None: + @pytest.mark.unit + def test_empty_specs_list_to_string(self, simple_requirement) -> None: """Test to_string with explicitly empty specs list. Edge case: Empty list should produce name only. """ - req = Requirement(name="requests", specs=[]) - result = req.to_string() + result = simple_requirement.to_string() assert result == "requests" - def test_empty_extras_list_to_string(self) -> None: + @pytest.mark.unit + def test_empty_extras_list_to_string(self, requirement_factory) -> None: """Test to_string with explicitly empty extras list. Edge case: Empty list should not add brackets. """ - req = Requirement(name="requests", extras=[]) + req = requirement_factory(name="requests", extras=[]) result = req.to_string() assert result == "requests" assert "[" not in result - def test_empty_hashes_list_to_string(self) -> None: + @pytest.mark.unit + def test_empty_hashes_list_to_string(self, requirement_factory) -> None: """Test to_string with explicitly empty hashes list. Edge case: Empty list should not add --hash entries. """ - req = Requirement(name="requests", hashes=[]) + req = requirement_factory(name="requests", hashes=[]) result = req.to_string(include_hashes=True) assert result == "requests" assert "--hash=" not in result +@pytest.mark.unit class TestIntegrationScenarios: """Integration tests for real-world requirement scenarios.""" - def test_typical_pinned_requirement(self) -> None: + @pytest.mark.unit + def test_typical_pinned_requirement( + self, requirement_factory, spec_factory, hash_factory, version_factory + ) -> None: """Test typical pinned requirement with hash. Integration: Common pattern for reproducible installs. """ - req = Requirement( + req = requirement_factory( name="requests", - specs=[("==", "2.28.0")], - hashes=["sha256:abc123def456"], + specs=spec_factory["pinned"], + hashes=hash_factory["single_sha256"], line_number=15, raw_line="requests==2.28.0 --hash=sha256:abc123def456", ) @@ -924,20 +1354,23 @@ def test_typical_pinned_requirement(self) -> None: assert "--hash=sha256:abc123def456" in result # Test version update - updated = req.update_version("2.31.0") - assert ">=2.31.0" in updated + updated = req.update_version(version_factory["updated"]) + assert "==2.31.0" in updated assert "--hash=" not in updated # Hashes removed - def test_development_dependency_workflow(self) -> None: + @pytest.mark.unit + def test_development_dependency_workflow( + self, requirement_factory, marker_factory, comment_factory, version_factory + ) -> None: """Test development dependency with markers and comment. Integration: Dev dependency with platform markers. """ - req = Requirement( + req = requirement_factory( name="pytest", specs=[(">=", "7.0.0")], - markers='python_version >= "3.8"', - comment="Testing framework", + markers=marker_factory["python_version_38"], + comment=comment_factory["testing"], line_number=25, ) @@ -949,20 +1382,23 @@ def test_development_dependency_workflow(self) -> None: # Update version updated = req.update_version("7.4.0") - assert ">=7.4.0" in updated + assert "==7.4.0" in updated assert "# Testing framework" in updated - def test_editable_local_package_workflow(self) -> None: + @pytest.mark.unit + def test_editable_local_package_workflow( + self, requirement_factory, url_factory, extras_factory, comment_factory + ) -> None: """Test editable local package installation. Integration: Common development workflow. """ - req = Requirement( + req = requirement_factory( name="myproject", - url=".", + url=url_factory["local"], editable=True, - extras=["dev", "test"], - comment="Local development", + extras=extras_factory["dev"], + comment=comment_factory["local_dev"], line_number=1, ) @@ -971,17 +1407,20 @@ def test_editable_local_package_workflow(self) -> None: assert ".[dev,test]" in result assert "# Local development" in result - def test_vcs_requirement_with_branch(self) -> None: + @pytest.mark.unit + def test_vcs_requirement_with_branch( + self, requirement_factory, url_factory, marker_factory, comment_factory + ) -> None: """Test VCS requirement with specific branch. Integration: Installing from git repository. """ - req = Requirement( + req = requirement_factory( name="my-lib", - url="git+https://github.com/user/my-lib.git@develop", + url=url_factory["git_branch"], editable=False, - markers='sys_platform != "win32"', - comment="Latest develop branch", + markers=marker_factory["not_windows"], + comment=comment_factory["develop_branch"], ) result = req.to_string() @@ -989,16 +1428,24 @@ def test_vcs_requirement_with_branch(self) -> None: assert '; sys_platform != "win32"' in result assert "# Latest develop branch" in result - def test_requirement_with_all_operators(self) -> None: + @pytest.mark.unit + def test_requirement_with_all_operators( + self, + requirement_factory, + spec_factory, + extras_factory, + comment_factory, + version_factory, + ) -> None: """Test requirement using multiple operators. Integration: Complex version constraints. """ - req = Requirement( + req = requirement_factory( name="django", - specs=[(">=", "3.2"), ("<", "5.0"), ("!=", "4.0")], - extras=["bcrypt"], - comment="Avoid Django 4.0 due to breaking changes", + specs=spec_factory["complex"], + extras=extras_factory["django"], + comment=comment_factory["breaking_changes"], ) result = req.to_string() @@ -1007,20 +1454,23 @@ def test_requirement_with_all_operators(self) -> None: # Update should replace all specs updated = req.update_version("4.2.0") - assert ">=4.2.0" in updated + assert "==4.2.0" in updated assert "<5.0" not in updated assert "!=4.0" not in updated - def test_security_constrained_requirement(self) -> None: + @pytest.mark.unit + def test_security_constrained_requirement( + self, requirement_factory, hash_factory, comment_factory + ) -> None: """Test requirement with security-related constraints. Integration: Security fix with exclusions. """ - req = Requirement( + req = requirement_factory( name="pillow", specs=[(">=", "9.0.0"), ("!=", "9.1.0"), ("!=", "9.1.1")], - comment="Exclude vulnerable versions (CVE-2023-XXXXX)", - hashes=["sha256:hash1", "sha256:hash2"], + comment=comment_factory["cve"], + hashes=hash_factory["security"], line_number=50, ) @@ -1029,57 +1479,76 @@ def test_security_constrained_requirement(self) -> None: assert "CVE-2023-XXXXX" in result assert "--hash=sha256:hash1" in result - def test_platform_specific_requirement(self) -> None: + @pytest.mark.unit + def test_platform_specific_requirement( + self, requirement_factory, marker_factory, comment_factory + ) -> None: """Test requirement specific to certain platforms. Integration: Platform-conditional dependency. """ - req = Requirement( + req = requirement_factory( name="pywin32", specs=[(">=", "300")], - markers='sys_platform == "win32"', - comment="Windows-specific", + markers=marker_factory["windows"], + comment=comment_factory["windows"], ) result = req.to_string() assert "pywin32>=300" in result assert '; sys_platform == "win32"' in result - def test_requirement_update_preserves_context(self) -> None: + @pytest.mark.unit + def test_requirement_update_preserves_context( + self, + requirement_factory, + spec_factory, + extras_factory, + marker_factory, + comment_factory, + ) -> None: """Test version update preserves all context. Integration: Full update workflow maintaining metadata. """ - original = Requirement( + original = requirement_factory( name="flask", - specs=[(">=", "2.0.0"), ("<", "3.0.0")], - extras=["async"], - markers='python_version >= "3.8"', - comment="Web framework", + specs=spec_factory["range"], + extras=extras_factory["flask"], + markers=marker_factory["python_version_38"], + comment=comment_factory["web_framework"], line_number=10, - raw_line='flask[async]>=2.0.0,<3.0.0 ; python_version >= "3.8" # Web framework', + raw_line='flask[async]>=2.0.0,<3.0.0 ; python_version >= "3.8" # Web framework', ) # Update version updated_str = original.update_version("2.3.0") # Verify preservation - assert "flask[async]>=2.3.0" in updated_str + assert "flask[async]==2.3.0" in updated_str assert 'python_version >= "3.8"' in updated_str assert "# Web framework" in updated_str assert "<3.0.0" not in updated_str - def test_roundtrip_string_consistency(self) -> None: + @pytest.mark.unit + def test_roundtrip_string_consistency( + self, + requirement_factory, + spec_factory, + extras_factory, + marker_factory, + comment_factory, + ) -> None: """Test to_string output can represent requirement. Integration: String rendering should be consistent. """ - req = Requirement( + req = requirement_factory( name="numpy", - specs=[(">=", "1.20.0"), ("<", "2.0.0")], - extras=["dev"], - markers='python_version >= "3.9"', - comment="Scientific computing", + specs=spec_factory["range"], + extras=extras_factory["numpy"], + markers=marker_factory["python_version_39"], + comment=comment_factory["scientific"], ) # Render twice @@ -1088,8 +1557,7 @@ def test_roundtrip_string_consistency(self) -> None: # Should be identical assert first == second - # Should contain all components - assert "numpy[dev]>=1.20.0,<2.0.0" in first + assert "numpy[dev]>=2.0.0,<3.0.0" in first assert 'python_version >= "3.9"' in first assert "# Scientific computing" in first diff --git a/tests/test_utils/test_console.py b/tests/test_utils/test_console.py index 2dc27e7..fb5301e 100644 --- a/tests/test_utils/test_console.py +++ b/tests/test_utils/test_console.py @@ -1,46 +1,34 @@ -"""Unit tests for depkeeper.utils.console module. - -This test suite provides comprehensive coverage of console output utilities, -including theme configuration, output functions, table rendering, user interaction, -and edge cases for environment-based configuration. - -Test Coverage: -- Console initialization and lifecycle -- Color detection based on environment variables -- Success/error/warning message printing -- Table rendering with various configurations -- User confirmation prompts -- Console reconfiguration -- Thread safety of singleton console -- Edge cases for None/empty inputs -""" - from __future__ import annotations import sys import threading from unittest.mock import MagicMock, patch -from typing import Any, Dict, List, Generator +from typing import Any, Dict, Generator, List import pytest from rich.table import Table from rich.console import Console from depkeeper.utils.console import ( - _should_use_color, + DEPKEEPER_THEME, _get_console, - reconfigure_console, - print_success, - print_error, - print_warning, - print_table, + _should_use_color, + colorize_update_type, confirm, get_raw_console, - colorize_update_type, - DEPKEEPER_THEME, + print_error, + print_success, + print_table, + print_warning, + reconfigure_console, ) +# ============================================================================== +# Fixtures +# ============================================================================== + + @pytest.fixture(autouse=True) def reset_console() -> Generator[None, None, None]: """Reset console singleton before and after each test. @@ -57,12 +45,31 @@ def reset_console() -> Generator[None, None, None]: def clean_env(monkeypatch: pytest.MonkeyPatch) -> None: """Clean environment variables that affect console behavior. - Removes NO_COLOR and CI variables to ensure consistent test state. + Removes NO_COLOR variable to ensure consistent test state. """ monkeypatch.delenv("NO_COLOR", raising=False) - monkeypatch.delenv("CI", raising=False) +@pytest.fixture +def mock_tty(clean_env: None) -> Generator[None, None, None]: + """Mock stdout as a TTY with isatty() returning True.""" + with patch.object(sys.stdout, "isatty", return_value=True): + yield + + +@pytest.fixture +def mock_non_tty(clean_env: None) -> Generator[None, None, None]: + """Mock stdout as non-TTY with isatty() returning False.""" + with patch.object(sys.stdout, "isatty", return_value=False): + yield + + +# ============================================================================== +# Theme Configuration Tests +# ============================================================================== + + +@pytest.mark.unit class TestThemeConfiguration: """Tests for DEPKEEPER_THEME configuration.""" @@ -81,80 +88,87 @@ def test_theme_has_required_styles(self) -> None: ] for style_name in required_styles: - assert style_name in DEPKEEPER_THEME.styles + assert style_name in DEPKEEPER_THEME.styles, f"Missing style: {style_name}" assert DEPKEEPER_THEME.styles[style_name] is not None - def test_theme_style_values(self) -> None: + @pytest.mark.parametrize( + "style_name,expected_value", + [ + ("success", "bold green"), + ("error", "bold red"), + ("warning", "bold yellow"), + ("info", "bold cyan"), + ("dim", "dim"), + ("highlight", "bold magenta"), + ], + ids=["success", "error", "warning", "info", "dim", "highlight"], + ) + def test_theme_style_values(self, style_name: str, expected_value: str) -> None: """Test theme styles have expected color/formatting values. Verifies specific style attributes match the documented theme. """ - theme_dict = { - "success": "bold green", - "error": "bold red", - "warning": "bold yellow", - "info": "bold cyan", - "dim": "dim", - "highlight": "bold magenta", - } + actual_style = str(DEPKEEPER_THEME.styles[style_name]) + # The string representation may include "Style(...)" wrapper + assert expected_value in actual_style or actual_style == expected_value + - for style_name, expected_value in theme_dict.items(): - actual_style = str(DEPKEEPER_THEME.styles[style_name]) - assert expected_value in actual_style or actual_style == expected_value +# ============================================================================== +# Color Detection Tests +# ============================================================================== +@pytest.mark.unit class TestShouldUseColor: """Tests for _should_use_color environment detection.""" - def test_no_color_env_disables_color(self, monkeypatch: pytest.MonkeyPatch) -> None: + @pytest.mark.parametrize( + "no_color_value", + ["1", "true", "TRUE", "anything", "yes", ""], + ids=[ + "one", + "true-lower", + "true-upper", + "arbitrary-value", + "yes", + "empty-string", + ], + ) + def test_no_color_env_disables_color( + self, monkeypatch: pytest.MonkeyPatch, no_color_value: str + ) -> None: """Test NO_COLOR environment variable disables colored output. - Per NO_COLOR spec (https://no-color.org/), any value should disable color. - """ - monkeypatch.setenv("NO_COLOR", "1") - assert _should_use_color() is False - - # Any non-empty value should disable color - monkeypatch.setenv("NO_COLOR", "true") - assert _should_use_color() is False - - monkeypatch.setenv("NO_COLOR", "anything") - assert _should_use_color() is False - - def test_ci_env_disables_color(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Test CI environment variable disables colored output. - - CI environments typically don't support ANSI color codes. + Per NO_COLOR spec (https://no-color.org/), any value (including empty) + should disable color. """ - monkeypatch.setenv("CI", "true") - assert _should_use_color() is False + monkeypatch.setenv("NO_COLOR", no_color_value) + # Arrange & Act + result = _should_use_color() - monkeypatch.setenv("CI", "1") - assert _should_use_color() is False + # Assert + assert result is False, f"NO_COLOR={no_color_value!r} should disable color" - def test_both_no_color_and_ci_set(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Test NO_COLOR takes precedence when both are set. - - Edge case: Both environment variables set simultaneously. - """ - monkeypatch.setenv("NO_COLOR", "1") - monkeypatch.setenv("CI", "true") - assert _should_use_color() is False - - def test_tty_detection( + def test_no_color_unset_checks_tty( self, monkeypatch: pytest.MonkeyPatch, clean_env: None ) -> None: - """Test color is enabled for TTY, disabled for non-TTY. + """Test color detection falls back to TTY check when NO_COLOR is unset. - When no env vars are set, uses stdout.isatty() to detect terminal. + When NO_COLOR is not set, should use stdout.isatty() to detect terminal. """ - # Mock TTY + # Arrange & Act - TTY with patch.object(sys.stdout, "isatty", return_value=True): - assert _should_use_color() is True + result_tty = _should_use_color() + + # Assert + assert result_tty is True, "Should enable color for TTY" - # Mock non-TTY (pipe, redirect) + # Arrange & Act - non-TTY with patch.object(sys.stdout, "isatty", return_value=False): - assert _should_use_color() is False + result_non_tty = _should_use_color() + + # Assert + assert result_non_tty is False, "Should disable color for non-TTY" def test_isatty_raises_attribute_error( self, monkeypatch: pytest.MonkeyPatch, clean_env: None @@ -163,8 +177,14 @@ def test_isatty_raises_attribute_error( Edge case: Some file-like objects don't have isatty(). """ + # Arrange with patch.object(sys, "stdout", spec=[]): # No isatty attribute - assert _should_use_color() is False + + # Act + result = _should_use_color() + + # Assert + assert result is False, "Should disable color when isatty() unavailable" def test_isatty_raises_os_error( self, monkeypatch: pytest.MonkeyPatch, clean_env: None @@ -173,24 +193,39 @@ def test_isatty_raises_os_error( Edge case: Some environments raise errors when checking TTY. """ + # Arrange mock_stdout = MagicMock() mock_stdout.isatty.side_effect = OSError("Not a terminal") with patch.object(sys, "stdout", mock_stdout): - assert _should_use_color() is False + # Act + result = _should_use_color() - def test_empty_no_color_enables_color( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Test empty NO_COLOR variable still disables color. + # Assert + assert result is False, "Should disable color when isatty() raises OSError" + + def test_no_color_priority_over_tty(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test NO_COLOR takes precedence over TTY detection. - Edge case: NO_COLOR="" should still disable color per spec. + Even when stdout is a TTY, NO_COLOR should disable color. """ - monkeypatch.setenv("NO_COLOR", "") - # Empty string is truthy in env vars - should still disable - assert _should_use_color() is False + # Arrange + monkeypatch.setenv("NO_COLOR", "1") + + with patch.object(sys.stdout, "isatty", return_value=True): + # Act + result = _should_use_color() + + # Assert + assert result is False, "NO_COLOR should override TTY detection" +# ============================================================================== +# Console Singleton Tests +# ============================================================================== + + +@pytest.mark.unit class TestGetConsole: """Tests for _get_console singleton management.""" @@ -267,6 +302,7 @@ def get_console_thread() -> None: assert all(console is results[0] for console in results) +@pytest.mark.unit class TestReconfigureConsole: """Tests for reconfigure_console reset functionality.""" @@ -326,6 +362,7 @@ def reconfigure_thread() -> None: assert isinstance(console, Console) +@pytest.mark.unit class TestPrintSuccess: """Tests for print_success message output.""" @@ -384,6 +421,7 @@ def test_message_with_rich_markup(self) -> None: ) +@pytest.mark.unit class TestPrintError: """Tests for print_error message output.""" @@ -420,6 +458,7 @@ def test_empty_message(self) -> None: mock_print.assert_called_once_with("[ERROR] ", style="error") +@pytest.mark.unit class TestPrintWarning: """Tests for print_warning message output.""" @@ -446,6 +485,7 @@ def test_custom_prefix(self) -> None: mock_print.assert_called_once_with("⚠ Caution", style="warning") +@pytest.mark.unit class TestPrintTable: """Tests for print_table structured output.""" @@ -625,6 +665,7 @@ def test_all_options_combined(self) -> None: assert table_arg.show_lines is True +@pytest.mark.unit class TestConfirm: """Tests for confirm user interaction.""" @@ -766,6 +807,7 @@ def test_confirm_prompt_format_default_false(self) -> None: assert "[y/N]" in call_args +@pytest.mark.unit class TestGetRawConsole: """Tests for get_raw_console accessor.""" @@ -791,6 +833,7 @@ def test_returns_same_instance_multiple_calls(self) -> None: assert console1 is console2 +@pytest.mark.unit class TestColorizeUpdateType: """Tests for colorize_update_type Rich markup helper.""" @@ -881,6 +924,7 @@ def test_colorize_preserves_original_string(self) -> None: assert "major" not in result.replace("[red]", "").replace("[/red]", "") +@pytest.mark.integration class TestIntegration: """Integration tests combining multiple console features.""" @@ -953,6 +997,7 @@ def test_reconfigure_affects_subsequent_calls( assert get_raw_console() is console2 +@pytest.mark.unit class TestEdgeCases: """Additional edge case tests.""" @@ -975,9 +1020,9 @@ def test_unicode_characters(self) -> None: Edge case: Emoji and international characters should work. """ with patch.object(Console, "print") as mock_print: - print_success("✓ 成功 🎉") + print_success("✓ 成功 ��") - assert "✓ 成功 🎉" in mock_print.call_args[0][0] + assert "✓ 成功 ��" in mock_print.call_args[0][0] def test_table_with_unicode_data(self) -> None: """Test print_table handles Unicode in data. @@ -1029,3 +1074,516 @@ def test_confirm_with_unicode_prompt(self) -> None: with patch.object(Console, "print"): result = confirm("続けますか?") # "Continue?" in Japanese assert result is True + + +# ============================================================================== +# Additional Parametrized Tests +# ============================================================================== + + +@pytest.mark.unit +class TestPrintFunctionsParametrized: + """Parametrized tests for all print functions.""" + + @pytest.mark.parametrize( + "func,message,style", + [ + (print_success, "Success message", "success"), + (print_error, "Error message", "error"), + (print_warning, "Warning message", "warning"), + ], + ids=["print_success", "print_error", "print_warning"], + ) + def test_print_functions_basic(self, func: Any, message: str, style: str) -> None: + """Test all print functions with basic messages. + + Parametrized test covering success/error/warning functions. + """ + # Act + with patch.object(Console, "print") as mock_print: + func(message) + + # Assert + assert mock_print.call_count == 1 + assert style in str(mock_print.call_args) + assert message in mock_print.call_args[0][0] + + @pytest.mark.parametrize( + "prefix,message", + [ + ("✓", "Test passed"), + ("DONE", "Completed successfully"), + ("", "No prefix"), + ("��", "Celebration"), + ("[INFO]", "Information"), + ], + ids=["checkmark", "done", "empty-prefix", "emoji", "info-prefix"], + ) + def test_print_success_various_prefixes(self, prefix: str, message: str) -> None: + """Test print_success with various prefix and message combinations.""" + # Act + with patch.object(Console, "print") as mock_print: + print_success(message, prefix=prefix) + + # Assert + mock_print.assert_called_once_with(f"{prefix} {message}", style="success") + + +# ============================================================================== +# Additional Table Edge Cases +# ============================================================================== + + +@pytest.mark.unit +class TestPrintTableAdvanced: + """Advanced table rendering edge cases.""" + + def test_table_single_row(self) -> None: + """Test print_table with single row.""" + # Arrange + data = [{"name": "Alice", "age": "30"}] + + # Act + with patch.object(Console, "print") as mock_print: + print_table(data) + + # Assert + assert mock_print.call_count == 1 + + def test_table_single_column(self) -> None: + """Test print_table with single column.""" + # Arrange + data = [{"name": "Alice"}, {"name": "Bob"}, {"name": "Charlie"}] + + # Act + with patch.object(Console, "print") as mock_print: + print_table(data) + + # Assert + table_arg = mock_print.call_args[0][0] + assert len(table_arg.columns) == 1 + + def test_table_with_many_rows(self) -> None: + """Test print_table with many rows. + + Edge case: Tables with many rows should work efficiently. + """ + # Arrange + data = [{"id": str(i), "value": f"val{i}"} for i in range(1000)] + + # Act + with patch.object(Console, "print") as mock_print: + print_table(data) + + # Assert + assert mock_print.call_count == 1 + + def test_table_with_mixed_types(self) -> None: + """Test print_table with mixed data types. + + Edge case: Rows with different value types should be converted to strings. + """ + # Arrange + data = [ + {"name": "Alice", "age": 30, "active": True}, + {"name": "Bob", "age": None, "active": False}, + ] + + # Act + with patch.object(Console, "print") as mock_print: + print_table(data) + + # Assert + assert mock_print.call_count == 1 + + @pytest.mark.parametrize( + "value,expected_contains", + [ + (30, "30"), + (95.5, "95.5"), + (None, "None"), + (True, "True"), + (False, "False"), + ], + ids=["int", "float", "none", "bool-true", "bool-false"], + ) + def test_table_value_conversion(self, value: Any, expected_contains: str) -> None: + """Test print_table converts various types to strings.""" + # Arrange + data = [{"name": "Test", "value": value}] + + # Act + with patch.object(Console, "print") as mock_print: + print_table(data) + + # Assert + assert mock_print.call_count == 1 + + +# ============================================================================== +# Confirm Advanced Tests +# ============================================================================== + + +@pytest.mark.unit +class TestConfirmAdvanced: + """Advanced confirm interaction tests.""" + + @pytest.mark.parametrize( + "response,expected", + [ + ("y", True), + ("yes", True), + ("Y", True), + ("YES", True), + ("Yes", True), + ("n", False), + ("no", False), + ("N", False), + ("NO", False), + ("No", False), + ], + ids=[ + "y-lower", + "yes-lower", + "y-upper", + "yes-upper", + "yes-mixed", + "n-lower", + "no-lower", + "n-upper", + "no-upper", + "no-mixed", + ], + ) + def test_confirm_all_valid_responses(self, response: str, expected: bool) -> None: + """Test confirm with all valid yes/no variations.""" + # Act + with patch("builtins.input", return_value=response): + result = confirm("Proceed?") + + # Assert + assert result is expected + + @pytest.mark.parametrize( + "response,default,expected", + [ + ("", True, True), + ("", False, False), + ("maybe", True, True), + ("maybe", False, False), + ("123", True, True), + ("xyz", False, False), + ], + ids=[ + "empty-default-true", + "empty-default-false", + "maybe-default-true", + "maybe-default-false", + "numeric-default-true", + "invalid-default-false", + ], + ) + def test_confirm_invalid_inputs_use_default( + self, response: str, default: bool, expected: bool + ) -> None: + """Test confirm falls back to default for invalid inputs.""" + # Act + with patch("builtins.input", return_value=response): + result = confirm("Proceed?", default=default) + + # Assert + assert result is expected + + def test_confirm_multiple_prompts(self) -> None: + """Test multiple consecutive confirm calls.""" + # Act & Assert + with patch("builtins.input", side_effect=["y", "n", "yes", "no"]): + assert confirm("First?") is True + assert confirm("Second?") is False + assert confirm("Third?") is True + assert confirm("Fourth?") is False + + +# ============================================================================== +# Thread Safety Tests +# ============================================================================== + + +@pytest.mark.unit +class TestThreadSafety: + """Comprehensive thread safety tests.""" + + def test_concurrent_console_access(self) -> None: + """Test concurrent access to console from multiple threads.""" + # Arrange + reconfigure_console() + results: List[Console] = [] + lock = threading.Lock() + + def access_thread() -> None: + console = _get_console() + with lock: + results.append(console) + + # Act + threads = [threading.Thread(target=access_thread) for _ in range(50)] + + for thread in threads: + thread.start() + + for thread in threads: + thread.join() + + # Assert + assert len(results) == 50 + assert all(console is results[0] for console in results) + + def test_concurrent_print_operations(self) -> None: + """Test concurrent print operations are safe.""" + # Arrange + results: List[bool] = [] + lock = threading.Lock() + + def print_thread(msg: str) -> None: + with patch.object(Console, "print"): + print_success(msg) + print_error(msg) + print_warning(msg) + with lock: + results.append(True) + + # Act + threads = [ + threading.Thread(target=print_thread, args=(f"Message {i}",)) + for i in range(30) + ] + + for thread in threads: + thread.start() + + for thread in threads: + thread.join() + + # Assert + assert len(results) == 30 + + +# ============================================================================== +# Security and Safety Tests +# ============================================================================== + + +@pytest.mark.unit +class TestSecurityAndSafety: + """Security and safety considerations per security instructions.""" + + def test_no_code_execution_in_table_data(self) -> None: + """Test print_table doesn't execute code in data values. + + SECURITY_NOTE: Ensure data values are safely rendered as strings. + """ + # Arrange - Potentially dangerous string representations + data = [ + {"cmd": "__import__('os').system('echo pwned')"}, + {"cmd": "eval('1+1')"}, + {"cmd": "exec('import sys')"}, + ] + + # Act & Assert - Should just render as strings, not execute + with patch.object(Console, "print") as mock_print: + print_table(data) + + # Verify no exception and table was printed + assert mock_print.call_count == 1 + + def test_no_code_execution_in_messages(self) -> None: + """Test print functions don't execute code in message strings. + + SECURITY_NOTE: Message strings should be safe to print. + """ + # Arrange + dangerous = 'eval(\'__import__("os").system("echo pwned")\')' + + # Act & Assert + with patch.object(Console, "print") as mock_print: + print_success(dangerous) + print_error(dangerous) + print_warning(dangerous) + + # Should print safely without executing + assert mock_print.call_count == 3 + + def test_input_sanitization_in_confirm(self) -> None: + """Test confirm properly handles potentially problematic input. + + SECURITY_NOTE: User input should be safely processed. + """ + # Arrange - Various potentially problematic inputs + inputs = [ + "\x00", # Null byte + "\x1b[31m", # ANSI escape + "y\0n", # Embedded null + "y" * 10000, # Very long input + "yes\nno", # Embedded newline + ] + + # Act & Assert + for inp in inputs: + with patch("builtins.input", return_value=inp): + with patch.object(Console, "print"): + # Should handle safely without crashing + result = confirm("Test?", default=False) + assert isinstance(result, bool) + + +# ============================================================================== +# Error Handling Tests +# ============================================================================== + + +@pytest.mark.unit +class TestErrorHandling: + """Test error handling and edge cases.""" + + def test_colorize_with_whitespace(self) -> None: + """Test colorize_update_type with leading/trailing whitespace.""" + # Act & Assert - Should not match due to whitespace + assert colorize_update_type(" major") == " major" + assert colorize_update_type("major ") == "major " + assert colorize_update_type(" minor ") == " minor " + + +# ============================================================================== +# Integration Tests - Real World Scenarios +# ============================================================================== + + +@pytest.mark.integration +class TestRealWorldScenarios: + """Integration tests for real-world usage patterns.""" + + def test_update_workflow_complete(self, mock_tty: None) -> None: + """Test complete update workflow with all console features. + + Integration test: Simulates real CLI update workflow. + """ + # Arrange + updates = [ + { + "package": "requests", + "current": "2.28.0", + "latest": "2.31.0", + "type": colorize_update_type("minor"), + }, + { + "package": "numpy", + "current": "1.24.0", + "latest": "1.26.0", + "type": colorize_update_type("major"), + }, + ] + + # Act & Assert - Full workflow + with patch.object(Console, "print"): + # Initial message + print_success("Checking for updates...") + + # Display table + print_table( + updates, + title="Available Updates", + headers=["package", "current", "latest", "type"], + caption="2 updates found", + ) + + # Warning + print_warning("Major updates may contain breaking changes") + + # Confirmation + with patch("builtins.input", return_value="y"): + proceed = confirm("Apply updates?", default=False) + assert proceed is True + + # Success + print_success("Updates applied successfully", prefix="✓") + + def test_error_recovery_workflow(self) -> None: + """Test error display and recovery workflow.""" + # Act & Assert + with patch.object(Console, "print"): + print_error("Failed to connect to PyPI") + print_warning("Retrying with different mirror...") + print_success("Connected successfully") + + def test_reconfiguration_during_execution( + self, monkeypatch: pytest.MonkeyPatch, mock_tty: None + ) -> None: + """Test runtime reconfiguration affects subsequent operations.""" + # Arrange - Start with color + with patch.object(Console, "print") as mock_print: + print_success("Initial message") + initial_calls = mock_print.call_count + + # Act - Disable color + monkeypatch.setenv("NO_COLOR", "1") + reconfigure_console() + + # Assert - New console has no color + console = _get_console() + assert console.no_color is True + + with patch.object(Console, "print") as mock_print: + print_success("After reconfigure") + assert mock_print.call_count >= 1 + + +# ============================================================================== +# Performance and Stress Tests +# ============================================================================== + + +@pytest.mark.unit +@pytest.mark.slow +class TestPerformance: + """Performance and stress tests.""" + + def test_large_table_rendering(self) -> None: + """Test rendering large tables efficiently.""" + # Arrange - 10000 rows + data = [ + {"id": str(i), "name": f"user{i}", "value": f"value{i}"} + for i in range(10000) + ] + + # Act + with patch.object(Console, "print") as mock_print: + print_table(data) + + # Assert + assert mock_print.call_count == 1 + + def test_very_long_messages(self) -> None: + """Test handling of very long message strings.""" + # Arrange + long_message = "x" * 1000000 # 1MB string + + # Act + with patch.object(Console, "print") as mock_print: + print_success(long_message) + + # Assert + assert mock_print.call_count == 1 + assert long_message in mock_print.call_args[0][0] + + def test_many_sequential_operations(self) -> None: + """Test many sequential console operations.""" + # Act + with patch.object(Console, "print") as mock_print: + for i in range(1000): + print_success(f"Message {i}") + print_error(f"Error {i}") + print_warning(f"Warning {i}") + + # Assert + assert mock_print.call_count == 3000 diff --git a/tests/test_utils/test_filesystem.py b/tests/test_utils/test_filesystem.py index 6351c6b..b57d0eb 100644 --- a/tests/test_utils/test_filesystem.py +++ b/tests/test_utils/test_filesystem.py @@ -1,20 +1,3 @@ -"""Unit tests for depkeeper.utils.filesystem module. - -This test suite provides comprehensive coverage of filesystem utilities, -including file reading/writing, atomic operations, backup/restore, -file discovery, path validation, and edge cases. - -Test Coverage: -- File validation and existence checks -- Safe file reading with size limits -- Atomic file writing with temp files -- Backup creation and restoration -- Requirements file discovery -- Path validation and security -- Error handling and rollback -- Edge cases (permissions, encoding, symlinks, etc.) -""" - from __future__ import annotations import os @@ -40,6 +23,28 @@ from depkeeper.exceptions import FileOperationError +def _can_create_symlinks() -> bool: + """Check if the current environment supports symlink creation. + + On Windows, symlinks require admin privileges or developer mode. + Returns False if symlink creation fails. + """ + import tempfile + + try: + with tempfile.TemporaryDirectory() as tmpdir: + target = Path(tmpdir) / "target.txt" + link = Path(tmpdir) / "link.txt" + target.write_text("test") + link.symlink_to(target) + return True + except (OSError, NotImplementedError): + return False + + +SYMLINKS_SUPPORTED = _can_create_symlinks() + + @pytest.fixture def temp_dir(tmp_path: Path) -> Generator[Path, None, None]: """Create a temporary directory for testing. @@ -94,6 +99,7 @@ def requirements_structure(temp_dir: Path) -> Path: return temp_dir +@pytest.mark.unit class TestValidatedFile: """Tests for _validated_file internal helper.""" @@ -143,6 +149,7 @@ def test_accepts_nonexistent_when_allowed(self, temp_dir: Path) -> None: assert result.is_absolute() + @pytest.mark.skipif(not SYMLINKS_SUPPORTED, reason="Symlinks not supported") def test_resolves_symlink(self, temp_file: Path, temp_dir: Path) -> None: """Test _validated_file resolves symlinks. @@ -175,6 +182,7 @@ def test_resolves_relative_path(self, temp_file: Path) -> None: os.chdir(original_cwd) +@pytest.mark.unit class TestAtomicWrite: """Tests for _atomic_write internal helper.""" @@ -283,7 +291,7 @@ def test_fsync_called(self, temp_dir: Path) -> None: def test_unicode_content(self, tmp_path: Path) -> None: target = tmp_path / "unicode.txt" - content = "Unicode test: ✓ α β γ 🚀" + content = "Unicode test: ✓ α β γ ��" safe_write_file(target, content) @@ -302,7 +310,23 @@ def test_large_content(self, temp_dir: Path) -> None: assert target.read_text(encoding="utf-8") == content + def test_cleanup_failure_logged(self, temp_dir: Path) -> None: + """Test _atomic_write logs warning when temp file cleanup fails. + Edge case: If atomic write fails AND cleanup fails, should log warning. + """ + target = temp_dir / "file.txt" + + # Create a scenario where both replace and unlink fail + with patch.object(Path, "replace", side_effect=OSError("Replace failed")): + with patch.object(Path, "unlink", side_effect=OSError("Unlink failed")): + with pytest.raises(FileOperationError) as exc_info: + _atomic_write(target, "content") + + assert "atomic write failed" in str(exc_info.value).lower() + + +@pytest.mark.unit class TestCreateBackupInternal: """Tests for _create_backup_internal helper.""" @@ -366,6 +390,7 @@ def test_raises_on_nonexistent_file(self, temp_dir: Path) -> None: assert exc_info.value.operation == "backup" +@pytest.mark.unit class TestRestoreBackupInternal: """Tests for _restore_backup_internal helper.""" @@ -412,6 +437,7 @@ def test_raises_on_missing_backup(self, temp_dir: Path) -> None: assert exc_info.value.operation == "restore" +@pytest.mark.unit class TestSafeReadFile: """Tests for safe_read_file public API.""" @@ -501,7 +527,7 @@ def test_handles_unicode_content(self, temp_dir: Path) -> None: Edge case: Should handle emoji and international text. """ file_path = temp_dir / "unicode.txt" - content = "Hello 世界 🌍" + content = "Hello 世界 ��" file_path.write_text(content, encoding="utf-8") result = safe_read_file(file_path) @@ -534,6 +560,7 @@ def test_handles_binary_decode_error(self, temp_dir: Path) -> None: assert exc_info.value.operation == "read" +@pytest.mark.unit class TestSafeWriteFile: """Tests for safe_write_file public API.""" @@ -620,7 +647,7 @@ def test_unicode_content(self, temp_dir: Path) -> None: Edge case: Should write emoji and international text correctly. """ target = temp_dir / "unicode.txt" - content = "Hello 世界 🌍" + content = "Hello 世界 ��" safe_write_file(target, content, create_backup=False) @@ -635,6 +662,30 @@ def test_overwrites_existing_content(self, temp_file: Path) -> None: assert temp_file.read_text(encoding="utf-8") == "replacement" + def test_restore_failure_silently_handled(self, temp_file: Path) -> None: + """Test safe_write_file silently handles restore failures. + + Edge case: If write fails and restore also fails, should raise original error. + """ + # Make write fail and restore also fail + with patch( + "depkeeper.utils.filesystem._atomic_write", + side_effect=FileOperationError( + "Write failed", file_path=str(temp_file), operation="write" + ), + ): + with patch( + "depkeeper.utils.filesystem._restore_backup_internal", + side_effect=OSError("Restore failed"), + ): + with pytest.raises(FileOperationError) as exc_info: + safe_write_file(temp_file, "new content") + + assert "write failed" in str(exc_info.value).lower() + + # Original file should still exist + assert temp_file.exists() + def test_creates_parent_directories(self, temp_dir: Path) -> None: """Test safe_write_file creates missing parent directories. @@ -648,6 +699,7 @@ def test_creates_parent_directories(self, temp_dir: Path) -> None: assert target.parent.exists() +@pytest.mark.unit class TestCreateBackup: """Tests for create_backup public API.""" @@ -693,6 +745,7 @@ def test_accepts_string_path(self, temp_file: Path) -> None: assert backup.exists() +@pytest.mark.unit class TestRestoreBackup: """Tests for restore_backup public API.""" @@ -762,6 +815,7 @@ def test_accepts_string_paths(self, temp_file: Path) -> None: assert temp_file.read_text(encoding="utf-8") == "test content" +@pytest.mark.unit class TestFindRequirementsFiles: """Tests for find_requirements_files discovery.""" @@ -878,6 +932,7 @@ def test_accepts_string_path(self, requirements_structure: Path) -> None: assert len(files) > 0 +@pytest.mark.unit class TestValidatePath: """Tests for validate_path security and validation.""" @@ -960,6 +1015,32 @@ def test_accepts_string_path(self, temp_file: Path) -> None: assert result.is_absolute() + def test_relative_base_dir(self, temp_dir: Path) -> None: + """Test validate_path handles relative base_dir. + + Should resolve relative base_dir to absolute path. + """ + original_cwd = Path.cwd() + try: + # Change to temp directory + import os + + os.chdir(temp_dir) + + # Create a file in temp dir + test_file = temp_dir / "test.txt" + test_file.write_text("test") + + # Use relative base_dir + result = validate_path(test_file, base_dir=".") + + assert result.is_absolute() + assert result == test_file.resolve() + finally: + import os + + os.chdir(original_cwd) + def test_handles_nonexistent_paths(self, temp_dir: Path) -> None: """Test validate_path works with non-existent paths. @@ -971,6 +1052,7 @@ def test_handles_nonexistent_paths(self, temp_dir: Path) -> None: assert result.is_absolute() + @pytest.mark.skipif(not SYMLINKS_SUPPORTED, reason="Symlinks not supported") def test_symlink_resolution(self, temp_file: Path, temp_dir: Path) -> None: """Test validate_path resolves symlinks. @@ -984,6 +1066,7 @@ def test_symlink_resolution(self, temp_file: Path, temp_dir: Path) -> None: assert result.is_absolute() +@pytest.mark.unit class TestCreateTimestampedBackup: """Tests for create_timestamped_backup public API.""" @@ -1059,6 +1142,18 @@ def test_raises_on_directory(self, temp_dir: Path) -> None: assert "cannot backup invalid file" in str(exc_info.value).lower() + def test_copy_failure_raises_error(self, temp_file: Path) -> None: + """Test create_timestamped_backup raises error when copy fails. + + Edge case: Should raise FileOperationError when shutil.copy2 fails. + """ + with patch("shutil.copy2", side_effect=OSError("Copy failed")): + with pytest.raises(FileOperationError) as exc_info: + create_timestamped_backup(temp_file) + + assert exc_info.value.operation == "backup" + assert "failed to create backup" in str(exc_info.value).lower() + def test_accepts_string_path(self, temp_file: Path) -> None: """Test accepts string paths. @@ -1069,6 +1164,7 @@ def test_accepts_string_path(self, temp_file: Path) -> None: assert backup.exists() +@pytest.mark.integration class TestEdgeCases: """Additional edge cases and integration tests.""" @@ -1099,13 +1195,53 @@ def test_write_read_cycle(self, temp_dir: Path) -> None: Integration test: Full write/read cycle. """ file_path = temp_dir / "cycle.txt" - content = "Test content with 🌍 unicode" + content = "Test content with �� unicode" safe_write_file(file_path, content, create_backup=False) result = safe_read_file(file_path) assert result == content + def test_cross_platform_path_handling(self, temp_dir: Path) -> None: + """Test path handling works across different platforms. + + Cross-platform: Paths should work on Windows, Linux, macOS. + """ + # Test with nested directories + nested = temp_dir / "a" / "b" / "c" / "file.txt" + + safe_write_file(nested, "content", create_backup=False) + + assert nested.exists() + assert safe_read_file(nested) == "content" + + def test_special_characters_in_content(self, temp_dir: Path) -> None: + """Test files with special characters and unicode. + + Cross-platform: Unicode should work on all platforms. + """ + file_path = temp_dir / "unicode.txt" + content = "Hello 世界 �� Привет مرحبا" + + safe_write_file(file_path, content, create_backup=False) + result = safe_read_file(file_path) + + assert result == content + + def test_line_ending_preservation(self, temp_dir: Path) -> None: + """Test line endings are consistent across platforms. + + Uses newline='\\n' in atomic_write to ensure LF line endings. + """ + file_path = temp_dir / "lines.txt" + content = "line1\\nline2\\nline3\\n" + + safe_write_file(file_path, content, create_backup=False) + result = safe_read_file(file_path) + + assert result == content + assert "\\r\\n" not in result # Should use LF, not CRLF + def test_backup_restore_cycle(self, temp_file: Path) -> None: """Test backup then restore preserves content. @@ -1120,19 +1256,6 @@ def test_backup_restore_cycle(self, temp_file: Path) -> None: assert temp_file.read_text(encoding="utf-8") == original - def test_very_long_filename(self, temp_dir: Path) -> None: - """Test handles very long filenames. - - Edge case: Long but valid filenames should work. - """ - # Most filesystems limit to 255 bytes - long_name = "a" * 200 + ".txt" - file_path = temp_dir / long_name - - safe_write_file(file_path, "content", create_backup=False) - - assert file_path.exists() - def test_special_characters_in_filename(self, temp_dir: Path) -> None: """Test handles special characters in filenames. diff --git a/tests/test_utils/test_http.py b/tests/test_utils/test_http.py index 98337c3..37a8058 100644 --- a/tests/test_utils/test_http.py +++ b/tests/test_utils/test_http.py @@ -1,21 +1,3 @@ -"""Unit tests for depkeeper.utils.http module. - -This test suite provides comprehensive coverage of the HTTPClient class, -including edge cases, error handling, retry logic, rate limiting, and -concurrency control. - -Test Coverage: -- Client initialization and configuration -- Async context manager lifecycle -- Rate limiting enforcement -- Retry logic with exponential backoff -- HTTP status code handling (2xx, 4xx, 5xx) -- Network error recovery -- JSON parsing and validation -- Batch request processing -- Concurrency control -""" - from __future__ import annotations import json @@ -46,6 +28,7 @@ def http_client() -> Generator[HTTPClient, None, None]: asyncio.get_event_loop().run_until_complete(client.close()) +@pytest.mark.unit class TestHTTPClientInit: """Tests for HTTPClient initialization and configuration.""" @@ -127,6 +110,7 @@ def test_edge_case_negative_rate_limit(self) -> None: assert client.rate_limit_delay == -1.0 +@pytest.mark.unit class TestHTTPClientContextManager: """Tests for HTTPClient async context manager protocol.""" @@ -195,6 +179,7 @@ async def test_multiple_context_manager_entries(self) -> None: assert first_client is not second_client +@pytest.mark.unit class TestHTTPClientEnsureClient: """Tests for HTTPClient._ensure_client internal method.""" @@ -245,6 +230,7 @@ async def test_ensure_client_enables_http2(self) -> None: await client.close() +@pytest.mark.unit class TestHTTPClientClose: """Tests for HTTPClient.close cleanup method.""" @@ -291,6 +277,7 @@ async def test_close_multiple_times(self) -> None: assert client._client is None +@pytest.mark.unit class TestHTTPClientRateLimit: """Tests for HTTPClient._rate_limit rate limiting mechanism.""" @@ -390,6 +377,7 @@ async def test_rate_limit_with_negative_delay(self) -> None: assert elapsed < 0.05 +@pytest.mark.unit class TestHTTPClientRequestWithRetry: """Tests for HTTPClient._request_with_retry core retry logic.""" @@ -852,6 +840,7 @@ async def test_redirect_status_codes(self) -> None: assert response.status_code == 200 +@pytest.mark.unit class TestHTTPClientGet: """Tests for HTTPClient.get convenience method.""" @@ -905,6 +894,7 @@ async def test_get_with_params(self) -> None: assert "headers" in call_kwargs +@pytest.mark.unit class TestHTTPClientPost: """Tests for HTTPClient.post convenience method.""" @@ -962,6 +952,7 @@ async def test_post_with_data(self) -> None: assert mock_request.call_count == 3 +@pytest.mark.unit class TestHTTPClientGetJson: """Tests for HTTPClient.get_json JSON parsing method.""" @@ -1073,6 +1064,7 @@ async def test_get_json_nested_structure(self) -> None: assert data["info"]["meta"]["version"] == "1.0" +@pytest.mark.unit class TestHTTPClientBatchGetJson: """Tests for HTTPClient.batch_get_json concurrent fetch method.""" @@ -1276,6 +1268,7 @@ async def mock_get_json(url: str, **kwargs: Any) -> Dict[str, Any]: assert len(results) == 50 +@pytest.mark.unit class TestHTTPClientConcurrency: """Tests for HTTPClient concurrency control and semaphore.""" @@ -1376,6 +1369,8 @@ async def mock_request(method: str, url: str, **kwargs: Any) -> MagicMock: assert concurrent_count[0] == 0 +@pytest.mark.integration +@pytest.mark.network class TestHTTPClientIntegration: """Integration tests combining multiple features.""" diff --git a/tests/test_utils/test_logger.py b/tests/test_utils/test_logger.py index 79071ec..6fa002b 100644 --- a/tests/test_utils/test_logger.py +++ b/tests/test_utils/test_logger.py @@ -1,20 +1,3 @@ -"""Unit tests for depkeeper.utils.logger module. - -This test suite provides comprehensive coverage of the logging utilities, -including formatter behavior, logger configuration, color support, thread -safety, and library-safe defaults. - -Test Coverage: -- ColoredFormatter color application and detection -- setup_logging configuration and idempotency -- get_logger namespace handling and fallback behavior -- Thread safety of configuration -- Environment variable handling (NO_COLOR, CI) -- Stream handling and output redirection -- Logger hierarchy and propagation -- Cleanup and disable functionality -""" - from __future__ import annotations import io @@ -75,6 +58,7 @@ def captured_stream() -> io.StringIO: return io.StringIO() +@pytest.mark.unit class TestColoredFormatter: """Tests for ColoredFormatter ANSI color formatting.""" @@ -307,6 +291,7 @@ def test_format_with_exception_info(self) -> None: assert "ValueError: Test error" in result +@pytest.mark.unit class TestSetupLogging: """Tests for setup_logging configuration function.""" @@ -531,6 +516,7 @@ def test_setup_multiple_log_levels( assert "Critical" in output +@pytest.mark.unit class TestGetLogger: """Tests for get_logger factory function.""" @@ -671,14 +657,15 @@ def test_get_logger_use_dunder_name(self, clean_logger_state: None) -> None: Common usage pattern: get_logger(__name__) should work correctly. """ # Simulate a module name - module_name = "mypackage.mymodule" + module_name = "depkeeper.utils.http" logger = get_logger(module_name) - assert logger.name == f"depkeeper.{module_name}" + assert logger.name == "depkeeper.utils.http" +@pytest.mark.unit class TestIsLoggingConfigured: - """Tests for is_logging_configured state function.""" + """Tests for is_logging_configured state query function.""" def test_not_configured_initially(self, clean_logger_state: None) -> None: """Test is_logging_configured returns False initially. @@ -713,6 +700,7 @@ def test_not_configured_after_disable( assert is_logging_configured() is False +@pytest.mark.unit class TestDisableLogging: """Tests for disable_logging cleanup function.""" @@ -833,6 +821,7 @@ def disable_in_thread() -> None: assert isinstance(logger.handlers[0], logging.NullHandler) +@pytest.mark.integration class TestLoggingIntegration: """Integration tests combining multiple logging features.""" diff --git a/tests/test_utils/test_version_utils.py b/tests/test_utils/test_version_utils.py index 52494b1..54c555f 100644 --- a/tests/test_utils/test_version_utils.py +++ b/tests/test_utils/test_version_utils.py @@ -1,24 +1,7 @@ -"""Unit tests for depkeeper.utils.version module. - -This test suite provides comprehensive coverage of version comparison utilities, -including edge cases, PEP 440 compliance, error handling, and semantic versioning -classification. - -Test Coverage: -- Version update type classification (major, minor, patch) -- New installation detection -- Version downgrade detection -- Same version handling -- Invalid version handling -- PEP 440 compliance (pre-release, post-release, dev, local versions) -- Edge cases (None values, malformed versions, single-digit versions) -- Normalization behavior -""" - from __future__ import annotations import pytest -from packaging.version import InvalidVersion, Version +from packaging.version import Version from depkeeper.utils.version_utils import ( get_update_type, @@ -27,6 +10,7 @@ ) +@pytest.mark.unit class TestGetUpdateType: """Tests for get_update_type main classification function.""" @@ -178,6 +162,7 @@ def test_patch_downgrade(self) -> None: assert result == "downgrade" +@pytest.mark.unit class TestGetUpdateTypePEP440: """Tests for PEP 440 version format handling.""" @@ -290,6 +275,7 @@ def test_implicit_zero_versions(self) -> None: assert get_update_type("1.0", "1.0.1") == "patch" +@pytest.mark.unit class TestGetUpdateTypeEdgeCases: """Tests for edge cases and unusual version formats.""" @@ -364,6 +350,7 @@ def test_four_component_versions(self) -> None: assert result in ("update", "patch", "same") +@pytest.mark.unit class TestClassifyUpgrade: """Tests for _classify_upgrade internal function.""" @@ -448,7 +435,10 @@ def test_classify_zero_to_one_major(self) -> None: assert result == "major" +@pytest.mark.unit class TestNormalizeRelease: + """Tests for _normalize_release internal helper function.""" + """Tests for _normalize_release internal function.""" def test_normalize_full_version(self) -> None: @@ -554,6 +544,7 @@ def test_normalize_four_component_version(self) -> None: assert result == (1, 2, 3) +@pytest.mark.integration class TestGetUpdateTypeIntegration: """Integration tests combining various version scenarios."""