Source code for mafw.devtools.dependencies.compare

#  Copyright 2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Dependency comparison engine for MAFw lockfiles.

This module provides data models and rendering functions for comparing
dependency lockfiles across Python versions. It supports JSON, markdown,
and Rich terminal output formats.
"""

from __future__ import annotations

import datetime
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal

from rich.console import Console
from rich.panel import Panel
from rich.table import Table

from mafw.devtools import DevtoolsError


[docs] @dataclass(frozen=True, slots=True) class PackageChange: """A single dependency change between reference and latest lockfiles. Each instance represents one package that was added, removed, or updated between the reference and the latest resolved dependency set. :param package_name: Normalized (lowercase) package name. :param change_type: Classification of the change (ADDED, REMOVED, UPDATED). :param reference_version: Version in the reference file, ``None`` for ADDED entries. :param new_version: Version in the latest file, ``None`` for REMOVED entries. """ package_name: str change_type: Literal['ADDED', 'REMOVED', 'UPDATED'] reference_version: str | None new_version: str | None
[docs] @dataclass(frozen=True, slots=True) class VersionComparisonResult: """Comparison result for a single Python version. Holds all detected dependency changes for a given Python interpreter version. :param python_version: Python version string (e.g. ``"3.12"``). :param changes: Sorted list of package changes for this version. """ python_version: str changes: list[PackageChange] = field(default_factory=list) @property def has_changes(self) -> bool: """Return ``True`` if there is at least one dependency change.""" return len(self.changes) > 0
[docs] def compare_packages( latest_packages: dict[str, dict[str, Any]], reference_packages: dict[str, dict[str, Any]], ) -> list[PackageChange]: """Compare two package dictionaries and classify differences. Both dictionaries are expected to be keyed by **lowercase** package name. The function classifies each difference into one of three categories: - **ADDED**: package present in *latest* but absent from *reference*. - **REMOVED**: package present in *reference* but absent from *latest*. - **UPDATED**: package present in both but with a different version or marker. :param latest_packages: Packages from the freshly compiled lockfile, keyed by lowercase name. :type latest_packages: dict[str, dict[str, Any]] :param reference_packages: Packages from the reference lockfile, keyed by lowercase name. :type reference_packages: dict[str, dict[str, Any]] :return: List of :class:`PackageChange` entries sorted alphabetically by package name. :rtype: list[PackageChange] """ changes: list[PackageChange] = [] latest_names = set(latest_packages.keys()) reference_names = set(reference_packages.keys()) # Packages only in latest → ADDED for name in sorted(latest_names - reference_names): pkg = latest_packages[name] changes.append( PackageChange( package_name=name, change_type='ADDED', reference_version=None, new_version=pkg.get('version', 'unknown'), ) ) # Packages only in reference → REMOVED for name in sorted(reference_names - latest_names): pkg = reference_packages[name] changes.append( PackageChange( package_name=name, change_type='REMOVED', reference_version=pkg.get('version', 'unknown'), new_version=None, ) ) # Packages in both → check for version or marker differences for name in sorted(latest_names & reference_names): latest_pkg = latest_packages[name] ref_pkg = reference_packages[name] if latest_pkg.get('version') != ref_pkg.get('version') or latest_pkg.get('marker') != ref_pkg.get('marker'): changes.append( PackageChange( package_name=name, change_type='UPDATED', reference_version=ref_pkg.get('version', 'unknown'), new_version=latest_pkg.get('version', 'unknown'), ) ) return changes
[docs] def render_comparison_json(results: list[VersionComparisonResult]) -> str: """Render comparison results as a JSON document string. Produces a JSON object with: - ``timestamp``: ISO 8601 generation time. - ``python_versions``: ordered list of all Python versions tested. - ``results``: dictionary keyed by Python version, each value being a list of change entries (empty list when no differences exist for that version). Each change entry contains ``package_name``, ``change_type``, ``reference_version`` (null for ADDED), and ``new_version`` (null for REMOVED). :param results: Comparison results for each Python version. :type results: list[VersionComparisonResult] :return: Formatted JSON string with 2-space indentation. :rtype: str """ # Collect all Python versions in the order they were compared. python_versions = [r.python_version for r in results] # Build the per-version results dictionary. results_dict: dict[str, list[dict[str, str | None]]] = {} for result in results: entries: list[dict[str, str | None]] = [] for change in result.changes: entries.append( { 'package_name': change.package_name, 'change_type': change.change_type, 'reference_version': change.reference_version, 'new_version': change.new_version, } ) results_dict[result.python_version] = entries output = { 'timestamp': datetime.datetime.now(tz=datetime.UTC).isoformat(), 'python_versions': python_versions, 'results': results_dict, } return json.dumps(output, indent=2)
[docs] def render_comparison_markdown(results: list[VersionComparisonResult]) -> str: """Render comparison results as a markdown document string. Produces a structured markdown report with a title, an ISO 8601 timestamp, and per-version sections containing ADDED, REMOVED, and UPDATED tables. Versions with no dependency changes are omitted from the output. :param results: List of comparison results, one per Python version. :type results: list[VersionComparisonResult] :return: Complete markdown document as a string. :rtype: str """ lines: list[str] = [] timestamp = datetime.datetime.now(tz=datetime.UTC).strftime('%Y-%m-%dT%H:%M:%SZ') lines.append('# Dependency Comparison Report') lines.append('') lines.append(f'Generated: {timestamp}') for result in results: # Requirement 9.5: omit versions with no changes. if not result.has_changes: continue lines.append('') lines.append(f'## Python {result.python_version}') # Group changes by type in ADDED → REMOVED → UPDATED order. added = [c for c in result.changes if c.change_type == 'ADDED'] removed = [c for c in result.changes if c.change_type == 'REMOVED'] updated = [c for c in result.changes if c.change_type == 'UPDATED'] if added: lines.append('') lines.append('### Added') lines.append('') lines.append('| Package | Version |') lines.append('|---------|---------|') for change in sorted(added, key=lambda c: c.package_name): lines.append(f'| {change.package_name} | {change.new_version} |') if removed: lines.append('') lines.append('### Removed') lines.append('') lines.append('| Package | Version |') lines.append('|---------|---------|') for change in sorted(removed, key=lambda c: c.package_name): lines.append(f'| {change.package_name} | {change.reference_version} |') if updated: lines.append('') lines.append('### Updated') lines.append('') lines.append('| Package | Reference | Latest |') lines.append('|---------|-----------|--------|') for change in sorted(updated, key=lambda c: c.package_name): lines.append(f'| {change.package_name} | {change.reference_version} | {change.new_version} |') # Ensure trailing newline for well-formed text files. lines.append('') return '\n'.join(lines)
[docs] def render_comparison_rich( results: list[VersionComparisonResult], console: Console, ) -> None: """Render comparison results to the terminal using Rich panels and tables. Displays a title panel, followed by a section per Python version containing ADDED, REMOVED, and UPDATED subsections in that fixed order. Within each subsection, entries are sorted alphabetically by package name. When no changes are detected across all Python versions, a single informational message is printed instead. :param results: Comparison results for each Python version. :type results: list[VersionComparisonResult] :param console: Rich Console instance for output. :type console: Console """ # Requirement 8.2: display a title indicating the purpose. console.print() console.print(Panel('[bold]Dependency Comparison Summary[/bold]', expand=False)) # Requirement 8.8: display a message when no changes are detected. if not any(r.has_changes for r in results): console.print() console.print('[green]No dependency changes detected.[/green]') return for result in results: if not result.has_changes: continue # Requirement 8.3: separate section per Python version. console.print() console.print(f'[bold cyan]Python {result.python_version}[/bold cyan]') # Group changes by type in ADDED → REMOVED → UPDATED order (Req 8.4). added = sorted( (c for c in result.changes if c.change_type == 'ADDED'), key=lambda c: c.package_name, ) removed = sorted( (c for c in result.changes if c.change_type == 'REMOVED'), key=lambda c: c.package_name, ) updated = sorted( (c for c in result.changes if c.change_type == 'UPDATED'), key=lambda c: c.package_name, ) # Requirement 8.5: ADDED entries show package name and new version. if added: table = Table(title='Added', show_header=True, header_style='bold green') table.add_column('Package') table.add_column('Version') for change in added: table.add_row(change.package_name, change.new_version or '') console.print(table) # Requirement 8.5: REMOVED entries show package name and reference version. if removed: table = Table(title='Removed', show_header=True, header_style='bold red') table.add_column('Package') table.add_column('Version') for change in removed: table.add_row(change.package_name, change.reference_version or '') console.print(table) # Requirement 8.5: UPDATED entries show package name, ref version, new version. if updated: table = Table(title='Updated', show_header=True, header_style='bold yellow') table.add_column('Package') table.add_column('Reference') table.add_column('Latest') for change in updated: table.add_row( change.package_name, change.reference_version or '', change.new_version or '', ) console.print(table)
# --------------------------------------------------------------------------- # Format resolution # --------------------------------------------------------------------------- EXTENSION_FORMAT_MAP: dict[str, str] = { '.md': 'markdown', '.json': 'json', } """Mapping from recognized file extensions to output format identifiers."""
[docs] def resolve_output_format( explicit_format: str | None, output_file: Path | None, ) -> str: """Determine the effective output format based on CLI arguments. The resolution follows a priority order that avoids ambiguity between an explicitly requested format and the file extension of the output path: 1. If *output_file* has a recognized extension **and** *explicit_format* is set: - **Match** (e.g., ``json`` + ``.json``): return the format. - **Conflict** (e.g., ``json`` + ``.md``): raise :class:`click.UsageError`. 2. If *output_file* has a recognized extension **and** *explicit_format* is ``None``: infer the format from the extension. 3. If *output_file* has an unrecognized extension **and** *explicit_format* is set: return the explicit format. 4. Otherwise: return ``"markdown"`` as the default. :param explicit_format: The value of ``--format`` if explicitly provided by the user, or ``None`` when omitted. :type explicit_format: str | None :param output_file: The value of ``--output-file``, or ``None`` when omitted. :type output_file: Path | None :return: The resolved format string (``"markdown"`` or ``"json"``). :rtype: str :raises click.UsageError: When *explicit_format* conflicts with the format inferred from the file extension. """ # Determine the inferred format from the output file extension (if any). inferred_format: str | None = None if output_file is not None: extension = output_file.suffix.lower() inferred_format = EXTENSION_FORMAT_MAP.get(extension) # Case 1: recognized extension + explicit format provided. if inferred_format is not None and explicit_format is not None: if explicit_format == inferred_format: # Match — proceed normally. return explicit_format # Conflict — signal the mismatch to the user. raise DevtoolsError( f"The specified format '{explicit_format}' conflicts with the " f"output file extension '{output_file!s}' " f"(implies '{inferred_format}'). " f'Use a matching extension or omit --format.' ) # Case 2: recognized extension, no explicit format — infer. if inferred_format is not None: return inferred_format # Case 3: unrecognized (or no) extension, explicit format provided. if explicit_format is not None: return explicit_format # Case 4: fallback — default to markdown. return 'markdown'