Coverage for src / mafw / devtools / dependencies / compare.py: 100%
142 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-28 13:34 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-28 13:34 +0000
1# Copyright 2026 European Union
2# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
3# SPDX-License-Identifier: EUPL-1.2
4"""
5Dependency comparison engine for MAFw lockfiles.
7This module provides data models and rendering functions for comparing
8dependency lockfiles across Python versions. It supports JSON, markdown,
9and Rich terminal output formats.
10"""
12from __future__ import annotations
14import datetime
15import json
16from dataclasses import dataclass, field
17from pathlib import Path
18from typing import Any, Literal
20from rich.console import Console
21from rich.panel import Panel
22from rich.table import Table
24from mafw.devtools import DevtoolsError
27@dataclass(frozen=True, slots=True)
28class PackageChange:
29 """A single dependency change between reference and latest lockfiles.
31 Each instance represents one package that was added, removed, or updated
32 between the reference and the latest resolved dependency set.
34 :param package_name: Normalized (lowercase) package name.
35 :param change_type: Classification of the change (ADDED, REMOVED, UPDATED).
36 :param reference_version: Version in the reference file, ``None`` for ADDED entries.
37 :param new_version: Version in the latest file, ``None`` for REMOVED entries.
38 """
40 package_name: str
41 change_type: Literal['ADDED', 'REMOVED', 'UPDATED']
42 reference_version: str | None
43 new_version: str | None
46@dataclass(frozen=True, slots=True)
47class VersionComparisonResult:
48 """Comparison result for a single Python version.
50 Holds all detected dependency changes for a given Python interpreter version.
52 :param python_version: Python version string (e.g. ``"3.12"``).
53 :param changes: Sorted list of package changes for this version.
54 """
56 python_version: str
57 changes: list[PackageChange] = field(default_factory=list)
59 @property
60 def has_changes(self) -> bool:
61 """Return ``True`` if there is at least one dependency change."""
62 return len(self.changes) > 0
65def compare_packages(
66 latest_packages: dict[str, dict[str, Any]],
67 reference_packages: dict[str, dict[str, Any]],
68) -> list[PackageChange]:
69 """Compare two package dictionaries and classify differences.
71 Both dictionaries are expected to be keyed by **lowercase** package name.
72 The function classifies each difference into one of three categories:
74 - **ADDED**: package present in *latest* but absent from *reference*.
75 - **REMOVED**: package present in *reference* but absent from *latest*.
76 - **UPDATED**: package present in both but with a different version or marker.
78 :param latest_packages: Packages from the freshly compiled lockfile, keyed
79 by lowercase name.
80 :type latest_packages: dict[str, dict[str, Any]]
81 :param reference_packages: Packages from the reference lockfile, keyed by
82 lowercase name.
83 :type reference_packages: dict[str, dict[str, Any]]
84 :return: List of :class:`PackageChange` entries sorted alphabetically by
85 package name.
86 :rtype: list[PackageChange]
87 """
88 changes: list[PackageChange] = []
90 latest_names = set(latest_packages.keys())
91 reference_names = set(reference_packages.keys())
93 # Packages only in latest → ADDED
94 for name in sorted(latest_names - reference_names):
95 pkg = latest_packages[name]
96 changes.append(
97 PackageChange(
98 package_name=name,
99 change_type='ADDED',
100 reference_version=None,
101 new_version=pkg.get('version', 'unknown'),
102 )
103 )
105 # Packages only in reference → REMOVED
106 for name in sorted(reference_names - latest_names):
107 pkg = reference_packages[name]
108 changes.append(
109 PackageChange(
110 package_name=name,
111 change_type='REMOVED',
112 reference_version=pkg.get('version', 'unknown'),
113 new_version=None,
114 )
115 )
117 # Packages in both → check for version or marker differences
118 for name in sorted(latest_names & reference_names):
119 latest_pkg = latest_packages[name]
120 ref_pkg = reference_packages[name]
121 if latest_pkg.get('version') != ref_pkg.get('version') or latest_pkg.get('marker') != ref_pkg.get('marker'):
122 changes.append(
123 PackageChange(
124 package_name=name,
125 change_type='UPDATED',
126 reference_version=ref_pkg.get('version', 'unknown'),
127 new_version=latest_pkg.get('version', 'unknown'),
128 )
129 )
131 return changes
134def render_comparison_json(results: list[VersionComparisonResult]) -> str:
135 """Render comparison results as a JSON document string.
137 Produces a JSON object with:
139 - ``timestamp``: ISO 8601 generation time.
140 - ``python_versions``: ordered list of all Python versions tested.
141 - ``results``: dictionary keyed by Python version, each value being a list
142 of change entries (empty list when no differences exist for that version).
144 Each change entry contains ``package_name``, ``change_type``,
145 ``reference_version`` (null for ADDED), and ``new_version`` (null for
146 REMOVED).
148 :param results: Comparison results for each Python version.
149 :type results: list[VersionComparisonResult]
150 :return: Formatted JSON string with 2-space indentation.
151 :rtype: str
152 """
153 # Collect all Python versions in the order they were compared.
154 python_versions = [r.python_version for r in results]
156 # Build the per-version results dictionary.
157 results_dict: dict[str, list[dict[str, str | None]]] = {}
158 for result in results:
159 entries: list[dict[str, str | None]] = []
160 for change in result.changes:
161 entries.append(
162 {
163 'package_name': change.package_name,
164 'change_type': change.change_type,
165 'reference_version': change.reference_version,
166 'new_version': change.new_version,
167 }
168 )
169 results_dict[result.python_version] = entries
171 output = {
172 'timestamp': datetime.datetime.now(tz=datetime.timezone.utc).isoformat(),
173 'python_versions': python_versions,
174 'results': results_dict,
175 }
177 return json.dumps(output, indent=2)
180def render_comparison_markdown(results: list[VersionComparisonResult]) -> str:
181 """Render comparison results as a markdown document string.
183 Produces a structured markdown report with a title, an ISO 8601 timestamp,
184 and per-version sections containing ADDED, REMOVED, and UPDATED tables.
185 Versions with no dependency changes are omitted from the output.
187 :param results: List of comparison results, one per Python version.
188 :type results: list[VersionComparisonResult]
189 :return: Complete markdown document as a string.
190 :rtype: str
191 """
192 lines: list[str] = []
193 timestamp = datetime.datetime.now(tz=datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
195 lines.append('# Dependency Comparison Report')
196 lines.append('')
197 lines.append(f'Generated: {timestamp}')
199 for result in results:
200 # Requirement 9.5: omit versions with no changes.
201 if not result.has_changes:
202 continue
204 lines.append('')
205 lines.append(f'## Python {result.python_version}')
207 # Group changes by type in ADDED → REMOVED → UPDATED order.
208 added = [c for c in result.changes if c.change_type == 'ADDED']
209 removed = [c for c in result.changes if c.change_type == 'REMOVED']
210 updated = [c for c in result.changes if c.change_type == 'UPDATED']
212 if added:
213 lines.append('')
214 lines.append('### Added')
215 lines.append('')
216 lines.append('| Package | Version |')
217 lines.append('|---------|---------|')
218 for change in sorted(added, key=lambda c: c.package_name):
219 lines.append(f'| {change.package_name} | {change.new_version} |')
221 if removed:
222 lines.append('')
223 lines.append('### Removed')
224 lines.append('')
225 lines.append('| Package | Version |')
226 lines.append('|---------|---------|')
227 for change in sorted(removed, key=lambda c: c.package_name):
228 lines.append(f'| {change.package_name} | {change.reference_version} |')
230 if updated:
231 lines.append('')
232 lines.append('### Updated')
233 lines.append('')
234 lines.append('| Package | Reference | Latest |')
235 lines.append('|---------|-----------|--------|')
236 for change in sorted(updated, key=lambda c: c.package_name):
237 lines.append(f'| {change.package_name} | {change.reference_version} | {change.new_version} |')
239 # Ensure trailing newline for well-formed text files.
240 lines.append('')
241 return '\n'.join(lines)
244def render_comparison_rich(
245 results: list[VersionComparisonResult],
246 console: Console,
247) -> None:
248 """Render comparison results to the terminal using Rich panels and tables.
250 Displays a title panel, followed by a section per Python version containing
251 ADDED, REMOVED, and UPDATED subsections in that fixed order. Within each
252 subsection, entries are sorted alphabetically by package name.
254 When no changes are detected across all Python versions, a single
255 informational message is printed instead.
257 :param results: Comparison results for each Python version.
258 :type results: list[VersionComparisonResult]
259 :param console: Rich Console instance for output.
260 :type console: Console
261 """
262 # Requirement 8.2: display a title indicating the purpose.
263 console.print()
264 console.print(Panel('[bold]Dependency Comparison Summary[/bold]', expand=False))
266 # Requirement 8.8: display a message when no changes are detected.
267 if not any(r.has_changes for r in results):
268 console.print()
269 console.print('[green]No dependency changes detected.[/green]')
270 return
272 for result in results:
273 if not result.has_changes:
274 continue
276 # Requirement 8.3: separate section per Python version.
277 console.print()
278 console.print(f'[bold cyan]Python {result.python_version}[/bold cyan]')
280 # Group changes by type in ADDED → REMOVED → UPDATED order (Req 8.4).
281 added = sorted(
282 (c for c in result.changes if c.change_type == 'ADDED'),
283 key=lambda c: c.package_name,
284 )
285 removed = sorted(
286 (c for c in result.changes if c.change_type == 'REMOVED'),
287 key=lambda c: c.package_name,
288 )
289 updated = sorted(
290 (c for c in result.changes if c.change_type == 'UPDATED'),
291 key=lambda c: c.package_name,
292 )
294 # Requirement 8.5: ADDED entries show package name and new version.
295 if added:
296 table = Table(title='Added', show_header=True, header_style='bold green')
297 table.add_column('Package')
298 table.add_column('Version')
299 for change in added:
300 table.add_row(change.package_name, change.new_version or '')
301 console.print(table)
303 # Requirement 8.5: REMOVED entries show package name and reference version.
304 if removed:
305 table = Table(title='Removed', show_header=True, header_style='bold red')
306 table.add_column('Package')
307 table.add_column('Version')
308 for change in removed:
309 table.add_row(change.package_name, change.reference_version or '')
310 console.print(table)
312 # Requirement 8.5: UPDATED entries show package name, ref version, new version.
313 if updated:
314 table = Table(title='Updated', show_header=True, header_style='bold yellow')
315 table.add_column('Package')
316 table.add_column('Reference')
317 table.add_column('Latest')
318 for change in updated:
319 table.add_row(
320 change.package_name,
321 change.reference_version or '',
322 change.new_version or '',
323 )
324 console.print(table)
327# ---------------------------------------------------------------------------
328# Format resolution
329# ---------------------------------------------------------------------------
331EXTENSION_FORMAT_MAP: dict[str, str] = {
332 '.md': 'markdown',
333 '.json': 'json',
334}
335"""Mapping from recognized file extensions to output format identifiers."""
338def resolve_output_format(
339 explicit_format: str | None,
340 output_file: Path | None,
341) -> str:
342 """Determine the effective output format based on CLI arguments.
344 The resolution follows a priority order that avoids ambiguity between an
345 explicitly requested format and the file extension of the output path:
347 1. If *output_file* has a recognized extension **and** *explicit_format* is
348 set:
350 - **Match** (e.g., ``json`` + ``.json``): return the format.
351 - **Conflict** (e.g., ``json`` + ``.md``): raise :class:`click.UsageError`.
353 2. If *output_file* has a recognized extension **and** *explicit_format* is
354 ``None``: infer the format from the extension.
355 3. If *output_file* has an unrecognized extension **and** *explicit_format*
356 is set: return the explicit format.
357 4. Otherwise: return ``"markdown"`` as the default.
359 :param explicit_format: The value of ``--format`` if explicitly provided by
360 the user, or ``None`` when omitted.
361 :type explicit_format: str | None
362 :param output_file: The value of ``--output-file``, or ``None`` when
363 omitted.
364 :type output_file: Path | None
365 :return: The resolved format string (``"markdown"`` or ``"json"``).
366 :rtype: str
367 :raises click.UsageError: When *explicit_format* conflicts with the format
368 inferred from the file extension.
369 """
370 # Determine the inferred format from the output file extension (if any).
371 inferred_format: str | None = None
372 if output_file is not None:
373 extension = output_file.suffix.lower()
374 inferred_format = EXTENSION_FORMAT_MAP.get(extension)
376 # Case 1: recognized extension + explicit format provided.
377 if inferred_format is not None and explicit_format is not None:
378 if explicit_format == inferred_format:
379 # Match — proceed normally.
380 return explicit_format
381 # Conflict — signal the mismatch to the user.
382 raise DevtoolsError(
383 f"The specified format '{explicit_format}' conflicts with the "
384 f"output file extension '{output_file!s}' "
385 f"(implies '{inferred_format}'). "
386 f'Use a matching extension or omit --format.'
387 )
389 # Case 2: recognized extension, no explicit format — infer.
390 if inferred_format is not None:
391 return inferred_format
393 # Case 3: unrecognized (or no) extension, explicit format provided.
394 if explicit_format is not None:
395 return explicit_format
397 # Case 4: fallback — default to markdown.
398 return 'markdown'