# 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."""