depkeeper 0.1.0.dev2__tar.gz → 0.1.0.dev3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {depkeeper-0.1.0.dev2 → depkeeper-0.1.0.dev3}/PKG-INFO +6 -4
  2. {depkeeper-0.1.0.dev2 → depkeeper-0.1.0.dev3}/README.md +1 -1
  3. {depkeeper-0.1.0.dev2 → depkeeper-0.1.0.dev3}/depkeeper/__init__.py +1 -1
  4. {depkeeper-0.1.0.dev2 → depkeeper-0.1.0.dev3}/depkeeper/__version__.py +1 -1
  5. depkeeper-0.1.0.dev3/depkeeper/commands/__init__.py +0 -0
  6. depkeeper-0.1.0.dev3/depkeeper/commands/check.py +608 -0
  7. depkeeper-0.1.0.dev3/depkeeper/commands/update.py +684 -0
  8. depkeeper-0.1.0.dev3/depkeeper/core/__init__.py +27 -0
  9. depkeeper-0.1.0.dev3/depkeeper/core/checker.py +458 -0
  10. depkeeper-0.1.0.dev3/depkeeper/core/data_store.py +645 -0
  11. depkeeper-0.1.0.dev3/depkeeper/core/dependency_analyzer.py +1011 -0
  12. depkeeper-0.1.0.dev3/depkeeper/core/parser.py +1138 -0
  13. depkeeper-0.1.0.dev3/depkeeper/models/__init__.py +23 -0
  14. depkeeper-0.1.0.dev3/depkeeper/models/conflict.py +151 -0
  15. depkeeper-0.1.0.dev3/depkeeper/models/package.py +371 -0
  16. depkeeper-0.1.0.dev3/depkeeper/models/requirement.py +148 -0
  17. depkeeper-0.1.0.dev3/depkeeper/utils/__init__.py +100 -0
  18. depkeeper-0.1.0.dev3/depkeeper/utils/console.py +238 -0
  19. depkeeper-0.1.0.dev3/depkeeper/utils/filesystem.py +324 -0
  20. depkeeper-0.1.0.dev3/depkeeper/utils/http.py +267 -0
  21. depkeeper-0.1.0.dev3/depkeeper/utils/logger.py +146 -0
  22. depkeeper-0.1.0.dev3/depkeeper/utils/version_utils.py +92 -0
  23. {depkeeper-0.1.0.dev2 → depkeeper-0.1.0.dev3}/depkeeper.egg-info/PKG-INFO +6 -4
  24. depkeeper-0.1.0.dev3/depkeeper.egg-info/SOURCES.txt +34 -0
  25. {depkeeper-0.1.0.dev2 → depkeeper-0.1.0.dev3}/depkeeper.egg-info/requires.txt +2 -0
  26. {depkeeper-0.1.0.dev2 → depkeeper-0.1.0.dev3}/pyproject.toml +8 -5
  27. depkeeper-0.1.0.dev2/depkeeper.egg-info/SOURCES.txt +0 -16
  28. {depkeeper-0.1.0.dev2 → depkeeper-0.1.0.dev3}/LICENSE +0 -0
  29. {depkeeper-0.1.0.dev2 → depkeeper-0.1.0.dev3}/depkeeper/__main__.py +0 -0
  30. {depkeeper-0.1.0.dev2 → depkeeper-0.1.0.dev3}/depkeeper/cli.py +0 -0
  31. {depkeeper-0.1.0.dev2 → depkeeper-0.1.0.dev3}/depkeeper/constants.py +0 -0
  32. {depkeeper-0.1.0.dev2 → depkeeper-0.1.0.dev3}/depkeeper/context.py +0 -0
  33. {depkeeper-0.1.0.dev2 → depkeeper-0.1.0.dev3}/depkeeper/exceptions.py +0 -0
  34. {depkeeper-0.1.0.dev2 → depkeeper-0.1.0.dev3}/depkeeper.egg-info/dependency_links.txt +0 -0
  35. {depkeeper-0.1.0.dev2 → depkeeper-0.1.0.dev3}/depkeeper.egg-info/entry_points.txt +0 -0
  36. {depkeeper-0.1.0.dev2 → depkeeper-0.1.0.dev3}/depkeeper.egg-info/top_level.txt +0 -0
  37. {depkeeper-0.1.0.dev2 → depkeeper-0.1.0.dev3}/setup.cfg +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: depkeeper
3
- Version: 0.1.0.dev2
3
+ Version: 0.1.0.dev3
4
4
  Summary: Modern Python dependency management for requirements.txt files
5
- Author: depkeeper Contributors
5
+ Author: Rahul Kaushal
6
6
  License: Apache-2.0
7
7
  Project-URL: Homepage, https://github.com/rahulkaushal04/depkeeper
8
- Project-URL: Documentation, https://docs.depkeeper.dev
8
+ Project-URL: Documentation, https://rahulkaushal04.github.io/depkeeper/
9
9
  Project-URL: Repository, https://github.com/rahulkaushal04/depkeeper
10
10
  Project-URL: Bug Tracker, https://github.com/rahulkaushal04/depkeeper/issues
11
11
  Project-URL: Changelog, https://github.com/rahulkaushal04/depkeeper/blob/main/CHANGELOG.md
@@ -48,6 +48,8 @@ Provides-Extra: docs
48
48
  Requires-Dist: mkdocs>=1.6.1; extra == "docs"
49
49
  Requires-Dist: mkdocs-material>=9.7.1; extra == "docs"
50
50
  Requires-Dist: mkdocstrings[python]>=0.26.1; extra == "docs"
51
+ Requires-Dist: pymdown-extensions>=10.15; extra == "docs"
52
+ Requires-Dist: mkdocs-minify-plugin>=0.8.0; extra == "docs"
51
53
 
52
54
  # depkeeper
53
55
 
@@ -154,7 +156,7 @@ Options:
154
156
 
155
157
  ## Documentation
156
158
 
157
- Full documentation will be available soon at **[https://docs.depkeeper.dev](https://docs.depkeeper.dev)**
159
+ Full documentation will be available soon at **[https://rahulkaushal04.github.io/depkeeper/](https://rahulkaushal04.github.io/depkeeper/)**
158
160
 
159
161
  ---
160
162
 
@@ -103,7 +103,7 @@ Options:
103
103
 
104
104
  ## Documentation
105
105
 
106
- Full documentation will be available soon at **[https://docs.depkeeper.dev](https://docs.depkeeper.dev)**
106
+ Full documentation will be available soon at **[https://rahulkaushal04.github.io/depkeeper/](https://rahulkaushal04.github.io/depkeeper/)**
107
107
 
108
108
  ---
109
109
 
@@ -22,7 +22,7 @@ Programmatic usage::
22
22
  >>> requirements = parser.parse_file("requirements.txt")
23
23
 
24
24
  For more information, see:
25
- https://docs.depkeeper.dev
25
+ https://rahulkaushal04.github.io/depkeeper/
26
26
  """
27
27
 
28
28
  from __future__ import annotations
@@ -9,4 +9,4 @@ allow tools (CLI, packaging, docs) to query the version reliably.
9
9
  from __future__ import annotations
10
10
 
11
11
  #: Current depkeeper version (PEP 440 compliant).
12
- __version__: str = "0.1.0.dev2"
12
+ __version__: str = "0.1.0.dev3"
File without changes
@@ -0,0 +1,608 @@
1
+ """Check command implementation for depkeeper.
2
+
3
+ Analyzes a ``requirements.txt`` file to identify packages with available
4
+ updates, dependency conflicts, and Python version compatibility issues.
5
+
6
+ The command orchestrates three core components:
7
+
8
+ 1. **RequirementsParser** — parses the requirements file into structured
9
+ :class:`Requirement` objects.
10
+ 2. **VersionChecker** — queries PyPI concurrently to fetch latest versions
11
+ and compute recommendations.
12
+ 3. **DependencyAnalyzer** — cross-validates all recommended versions and
13
+ resolves conflicts through iterative downgrading/constraining.
14
+
15
+ All components share a single :class:`PyPIDataStore` instance to guarantee
16
+ that each package's metadata is fetched at most once per invocation.
17
+
18
+ Typical usage::
19
+
20
+ # Show all packages with available updates
21
+ $ depkeeper check requirements.txt --outdated-only
22
+
23
+ # Machine-readable JSON output
24
+ $ depkeeper check --format json > report.json
25
+
26
+ # Enable conflict resolution (adjusts recommendations to be mutually compatible)
27
+ $ depkeeper check --check-conflicts
28
+
29
+ # Strict mode: only use pinned versions, don't infer from constraints
30
+ $ depkeeper check --strict-version-matching
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import sys
36
+ import json
37
+ import click
38
+ import asyncio
39
+ from pathlib import Path
40
+ from typing import Any, Dict, List
41
+
42
+ from depkeeper.models import Package
43
+ from depkeeper.exceptions import DepKeeperError, ParseError
44
+ from depkeeper.context import pass_context, DepKeeperContext
45
+ from depkeeper.core import (
46
+ PyPIDataStore,
47
+ VersionChecker,
48
+ RequirementsParser,
49
+ DependencyAnalyzer,
50
+ ResolutionResult,
51
+ )
52
+ from depkeeper.utils import (
53
+ HTTPClient,
54
+ get_logger,
55
+ print_success,
56
+ print_error,
57
+ print_warning,
58
+ print_table,
59
+ get_raw_console,
60
+ colorize_update_type,
61
+ )
62
+
63
+ logger = get_logger("commands.check")
64
+
65
+
66
+ @click.command()
67
+ @click.argument(
68
+ "file",
69
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
70
+ default="requirements.txt",
71
+ )
72
+ @click.option(
73
+ "--outdated-only",
74
+ is_flag=True,
75
+ help="Show only packages with available updates.",
76
+ )
77
+ @click.option(
78
+ "--format",
79
+ "-f",
80
+ type=click.Choice(["table", "simple", "json"], case_sensitive=False),
81
+ default="table",
82
+ help="Output format.",
83
+ )
84
+ @click.option(
85
+ "--strict-version-matching",
86
+ is_flag=True,
87
+ help="Only use exact version pins, don't infer from constraints.",
88
+ )
89
+ @click.option(
90
+ "--check-conflicts",
91
+ is_flag=True,
92
+ default=True,
93
+ help="Check for dependency conflicts between packages.",
94
+ )
95
+ @pass_context
96
+ def check(
97
+ ctx: DepKeeperContext,
98
+ file: Path,
99
+ outdated_only: bool,
100
+ format: str,
101
+ strict_version_matching: bool,
102
+ check_conflicts: bool,
103
+ ) -> None:
104
+ """Check requirements file for available updates.
105
+
106
+ Parses the specified requirements file, queries PyPI for the latest
107
+ versions, and displays a report of packages that can be updated.
108
+ Optionally performs dependency conflict analysis to ensure recommended
109
+ updates are compatible with each other.
110
+
111
+ When ``--check-conflicts`` is enabled, the command:
112
+
113
+ 1. Fetches initial recommendations for every package.
114
+ 2. Cross-validates all recommendations to detect conflicts (package A's
115
+ dependency on B is incompatible with B's recommended version).
116
+ 3. Iteratively adjusts versions (downgrading sources or constraining
117
+ targets) until a conflict-free set is found or the iteration limit
118
+ is reached.
119
+ 4. Displays the final resolved versions along with any unresolved
120
+ conflicts.
121
+
122
+ Args:
123
+ ctx: Depkeeper context with configuration and verbosity settings.
124
+ file: Path to the requirements file (default: ``requirements.txt``).
125
+ outdated_only: Only display packages with updates or conflicts.
126
+ format: Output format (``table``, ``simple``, or ``json``).
127
+ strict_version_matching: Don't infer current versions from
128
+ constraints like ``>=2.0``; only use exact pins (``==``).
129
+ check_conflicts: Enable cross-package conflict resolution.
130
+
131
+ Exits:
132
+ 0 if all packages are up-to-date, 1 if updates are available or an
133
+ error occurred.
134
+
135
+ Example::
136
+
137
+ >>> # CLI invocation
138
+ $ depkeeper check requirements.txt --check-conflicts --format table
139
+ """
140
+ try:
141
+ has_updates = asyncio.run(
142
+ _check_async(
143
+ ctx,
144
+ file,
145
+ outdated_only,
146
+ format,
147
+ infer_version_from_constraints=not strict_version_matching,
148
+ check_conflicts=check_conflicts,
149
+ )
150
+ )
151
+ sys.exit(1 if has_updates else 0)
152
+
153
+ except DepKeeperError as e:
154
+ print_error(f"{e}")
155
+ sys.exit(1)
156
+ except Exception as e:
157
+ print_error(f"Unexpected error: {e}")
158
+ logger.exception("Error in check command")
159
+ sys.exit(1)
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # Async orchestration
164
+ # ---------------------------------------------------------------------------
165
+
166
+
167
+ async def _check_async(
168
+ ctx: DepKeeperContext,
169
+ file: Path,
170
+ outdated_only: bool,
171
+ format: str,
172
+ infer_version_from_constraints: bool,
173
+ check_conflicts: bool,
174
+ ) -> bool:
175
+ """Async implementation of the check command.
176
+
177
+ Core logic:
178
+
179
+ 1. Parse the requirements file.
180
+ 2. Create a shared :class:`PyPIDataStore` (guarantees each package is
181
+ fetched once).
182
+ 3. Run :class:`VersionChecker` to compute initial recommendations.
183
+ 4. Optionally run :class:`DependencyAnalyzer` to resolve conflicts.
184
+ 5. Filter packages if ``--outdated-only`` is set.
185
+ 6. Display results in the requested format.
186
+
187
+ Args:
188
+ ctx: Depkeeper context.
189
+ file: Path to the requirements file.
190
+ outdated_only: Filter to show only packages needing updates.
191
+ format: Output format (``table``, ``simple``, ``json``).
192
+ infer_version_from_constraints: Infer current version from
193
+ constraints (e.g., ``>=2.0`` → current is ``2.0``).
194
+ check_conflicts: Enable dependency conflict resolution.
195
+
196
+ Returns:
197
+ ``True`` if any package has updates or unresolved conflicts,
198
+ ``False`` if everything is up-to-date.
199
+
200
+ Raises:
201
+ DepKeeperError: Requirements file cannot be parsed or is malformed.
202
+ """
203
+ # Only show progress/status for human-readable formats
204
+ show_progress: bool = format == "table" or ctx.verbose > 0
205
+
206
+ logger.info("Checking %s...", file)
207
+
208
+ # ── Step 1: Parse requirements ────────────────────────────────────
209
+ parser = RequirementsParser()
210
+ try:
211
+ requirements = parser.parse_file(file)
212
+ except ParseError as e:
213
+ raise DepKeeperError(f"Failed to parse {file}: {e}") from e
214
+
215
+ if not requirements:
216
+ if show_progress:
217
+ print_warning("No packages found in requirements file")
218
+ return False
219
+
220
+ logger.info("Found %d package(s)", len(requirements))
221
+
222
+ # ── Step 2: Fetch versions from PyPI (shared data store) ──────────
223
+ async with HTTPClient() as http:
224
+ data_store = PyPIDataStore(http)
225
+
226
+ # Warm the cache with all packages in one concurrent burst
227
+ await data_store.prefetch_packages([req.name for req in requirements])
228
+
229
+ # Use the same data store for version checking and conflict analysis
230
+ checker = VersionChecker(
231
+ data_store=data_store,
232
+ infer_version_from_constraints=infer_version_from_constraints,
233
+ )
234
+ packages = await checker.check_packages(requirements)
235
+
236
+ # ── Step 3: Resolve conflicts (optional) ──────────────────────
237
+ resolution_result = None
238
+ if check_conflicts:
239
+ logger.info("Cross-validating recommended versions...")
240
+ analyzer = DependencyAnalyzer(data_store=data_store)
241
+ resolution_result = await analyzer.resolve_and_annotate_conflicts(packages)
242
+
243
+ if show_progress and resolution_result:
244
+ # Display resolution summary (convergence status, version changes)
245
+ _display_resolution_summary(resolution_result)
246
+
247
+ # ── Step 4: Filter and display ────────────────────────────────────
248
+ if outdated_only:
249
+ packages = [p for p in packages if p.has_update() or p.has_conflicts()]
250
+
251
+ if not packages:
252
+ if show_progress:
253
+ msg = (
254
+ "All packages are up to date!"
255
+ if outdated_only
256
+ else "No packages to display"
257
+ )
258
+ (print_success if outdated_only else print_warning)(msg)
259
+ return False
260
+
261
+ packages_needing_action = sum(1 for p in packages if p.has_update())
262
+
263
+ # Dispatch to the appropriate renderer
264
+ if format == "table":
265
+ _display_table(packages)
266
+ elif format == "simple":
267
+ _display_simple(packages)
268
+ else: # json
269
+ _display_json(packages)
270
+
271
+ # ── Final summary ──────────────────────────────────────────────────
272
+ if show_progress:
273
+ if packages_needing_action > 0:
274
+ print_warning(
275
+ f"\n{packages_needing_action} package(s) have updates available"
276
+ )
277
+ if resolution_result and resolution_result.packages_with_conflicts > 0:
278
+ print_warning(
279
+ f"{resolution_result.packages_with_conflicts} package(s) have "
280
+ "unresolved conflicts — see 'Conflicts' column"
281
+ )
282
+ else:
283
+ print_success("\nAll packages are up to date!")
284
+
285
+ return packages_needing_action > 0
286
+
287
+
288
+ def _display_resolution_summary(result: ResolutionResult) -> None:
289
+ """Print a human-readable summary of the conflict resolution process.
290
+
291
+ Displays:
292
+
293
+ - Total packages analyzed
294
+ - Number of packages with conflicts
295
+ - Number of version changes made during resolution
296
+ - Convergence status (did resolution finish or hit iteration limit?)
297
+ - Details of each version change (original → resolved)
298
+
299
+ Args:
300
+ result: The :class:`ResolutionResult` from the dependency analyzer.
301
+
302
+ Example output::
303
+
304
+ Resolution Summary:
305
+ ==================================================
306
+ Total packages: 15
307
+ Packages with conflicts: 2
308
+ Packages changed: 3
309
+ Converged: Yes (5 iterations)
310
+
311
+ Version changes:
312
+ • flask: 3.0.0 → 2.3.3 (downgraded)
313
+ • werkzeug: 3.0.1 → 2.3.7 (constrained)
314
+ """
315
+ console = get_raw_console()
316
+ console.print("\n[bold]Resolution Summary:[/bold]")
317
+ console.print("=" * 50)
318
+ console.print(f"Total packages: {result.total_packages}")
319
+ console.print(f"Packages with conflicts: {result.packages_with_conflicts}")
320
+
321
+ changed = result.get_changed_packages()
322
+ console.print(f"Packages changed: {len(changed)}")
323
+
324
+ convergence_status = (
325
+ f"Yes ({result.iterations_used} iterations)"
326
+ if result.converged
327
+ else f"No (stopped after {result.iterations_used} iterations)"
328
+ )
329
+ console.print(f"Converged: {convergence_status}")
330
+
331
+ if changed:
332
+ console.print("\n[bold]Version changes:[/bold]")
333
+ for pkg_resolution in changed:
334
+ console.print(
335
+ f" • {pkg_resolution.name}: {pkg_resolution.original} → "
336
+ f"{pkg_resolution.resolved} ({pkg_resolution.status.value})"
337
+ )
338
+
339
+ console.print("") # blank line separator
340
+
341
+
342
+ # ---------------------------------------------------------------------------
343
+ # Display renderers
344
+ # ---------------------------------------------------------------------------
345
+
346
+
347
+ def _display_table(packages: List[Package]) -> None:
348
+ """Render packages as a Rich-formatted table.
349
+
350
+ Creates a visually appealing table with color-coded status indicators,
351
+ version columns, update types, conflict details, and Python
352
+ compatibility information.
353
+
354
+ Status indicators:
355
+
356
+ - ``✓ OK`` (green): Package is up-to-date.
357
+ - ``⬆ OUTDATED`` (yellow): Updates are available.
358
+ - ``⚠ CONFLICT`` (red): Dependency conflicts exist; no safe upgrade.
359
+ - ``⚠ INCOMP`` (red): Recommended version is lower than current
360
+ (would require a downgrade).
361
+ - ``✗ ERROR`` (red): PyPI query failed or package not found.
362
+
363
+ Args:
364
+ packages: List of :class:`Package` objects to display.
365
+
366
+ Example::
367
+
368
+ ┏━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━┓
369
+ ┃ Status ┃ Package ┃ Current ┃ Latest ┃ Update Type ┃
370
+ ┡━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━┩
371
+ │ ✓ OK │ requests │ 2.31.0 │ 2.31.0 │ - │
372
+ │ ⬆ OUTDATED │ flask │ 2.0.0 │ 3.0.0 │ major │
373
+ └────────────┴─────────────┴───────────┴───────────┴──────────────┘
374
+ """
375
+ data = [_create_table_row(pkg) for pkg in packages]
376
+
377
+ column_styles: Dict[str, Dict[str, Any]] = {
378
+ "Status": {"justify": "center", "no_wrap": True, "width": 10},
379
+ "Package": {"style": "bold cyan", "no_wrap": True},
380
+ "Current": {"justify": "center", "style": "dim"},
381
+ "Latest": {"justify": "center", "style": "bold green"},
382
+ "Recommended": {"justify": "center", "style": "bright_cyan"},
383
+ "Update Type": {"justify": "center"},
384
+ "Conflicts": {"justify": "left", "no_wrap": False},
385
+ "Python Support": {"justify": "left", "no_wrap": False},
386
+ }
387
+
388
+ print_table(
389
+ data,
390
+ title="Dependency Status",
391
+ column_styles=column_styles,
392
+ show_row_lines=True,
393
+ )
394
+
395
+
396
+ def _create_table_row(pkg: Package) -> Dict[str, str]:
397
+ """Build a Rich-formatted table row dictionary for a single package.
398
+
399
+ Determines the appropriate status indicator, version displays, and
400
+ conflict information based on the package's state. All logic for
401
+ status determination is delegated to :meth:`Package.get_display_data`.
402
+
403
+ Args:
404
+ pkg: The :class:`Package` to render.
405
+
406
+ Returns:
407
+ A dictionary mapping column names to Rich markup strings.
408
+
409
+ Example::
410
+
411
+ {
412
+ "Status": "[yellow]⬆ OUTDATED[/yellow]",
413
+ "Package": "flask",
414
+ "Current": "2.0.0",
415
+ "Latest": "3.0.0",
416
+ "Recommended": "[bright_cyan]2.3.3[/bright_cyan]",
417
+ "Update Type": "[bold yellow]minor[/bold yellow]",
418
+ "Conflicts": "[dim]-[/dim]",
419
+ "Python Support": ">=3.8",
420
+ }
421
+ """
422
+ python_support = pkg.render_python_compatibility()
423
+
424
+ # ── Error case (PyPI query failed) ────────────────────────────────
425
+ if not pkg.latest_version:
426
+ return {
427
+ "Status": "[red]✗ ERROR[/red]",
428
+ "Package": pkg.name,
429
+ "Current": pkg.current_version or "[dim]-[/dim]",
430
+ "Latest": "[red]error[/red]",
431
+ "Recommended": "[dim]-[/dim]",
432
+ "Update Type": "[dim]-[/dim]",
433
+ "Conflicts": "[dim]-[/dim]",
434
+ "Python Support": "[dim]-[/dim]",
435
+ }
436
+
437
+ # ── Fetch pre-computed display metadata ───────────────────────────
438
+ display = pkg.get_display_data()
439
+
440
+ # Recommended version: only show if it differs from current
441
+ recommended_display = "[dim]-[/dim]"
442
+ if pkg.recommended_version:
443
+ if pkg.current_version and pkg.recommended_version != pkg.current_version:
444
+ recommended_display = (
445
+ f"[bright_cyan]{pkg.recommended_version}[/bright_cyan]"
446
+ )
447
+
448
+ # Conflicts: format as multi-line list of requirements
449
+ conflicts_display = "[dim]-[/dim]"
450
+ if display["has_conflicts"]:
451
+ conflict_lines = [
452
+ f"[red]⚠[/red] {c.source_package} needs {c.required_spec}"
453
+ for c in pkg.conflicts
454
+ ]
455
+ conflicts_display = "\n".join(conflict_lines)
456
+
457
+ # ── Downgrade/incompatible case ───────────────────────────────────
458
+ if display["requires_downgrade"]:
459
+ return {
460
+ "Status": "[red]⚠ INCOMP[/red]",
461
+ "Package": pkg.name,
462
+ "Current": pkg.current_version or "[dim]-[/dim]",
463
+ "Latest": pkg.latest_version,
464
+ "Recommended": recommended_display,
465
+ "Update Type": "[red]downgrade[/red]",
466
+ "Conflicts": conflicts_display,
467
+ "Python Support": python_support,
468
+ }
469
+
470
+ # ── Conflict case ──────────────────────────────────────────────────
471
+ if display["has_conflicts"]:
472
+ if not pkg.has_update():
473
+ # True conflict: no safe upgrade exists
474
+ return {
475
+ "Status": "[red]⚠ CONFLICT[/red]",
476
+ "Package": pkg.name,
477
+ "Current": pkg.current_version or "[dim]-[/dim]",
478
+ "Latest": pkg.latest_version,
479
+ "Recommended": recommended_display,
480
+ "Update Type": "[red]blocked[/red]",
481
+ "Conflicts": conflicts_display,
482
+ "Python Support": python_support,
483
+ }
484
+ else:
485
+ # Conflicts exist but a safe upgrade is available
486
+ colored_type = colorize_update_type(display["update_type"] or "update")
487
+ return {
488
+ "Status": "[yellow]⬆ OUTDATED[/yellow]",
489
+ "Package": pkg.name,
490
+ "Current": pkg.current_version or "[dim]-[/dim]",
491
+ "Latest": pkg.latest_version,
492
+ "Recommended": recommended_display,
493
+ "Update Type": colored_type,
494
+ "Conflicts": conflicts_display,
495
+ "Python Support": python_support,
496
+ }
497
+
498
+ # ── Update available case ──────────────────────────────────────────
499
+ if display["update_available"]:
500
+ colored_type = colorize_update_type(display["update_type"] or "update")
501
+ return {
502
+ "Status": "[yellow]⬆ OUTDATED[/yellow]",
503
+ "Package": pkg.name,
504
+ "Current": pkg.current_version or "[dim]-[/dim]",
505
+ "Latest": pkg.latest_version,
506
+ "Recommended": recommended_display,
507
+ "Update Type": colored_type,
508
+ "Conflicts": conflicts_display,
509
+ "Python Support": python_support,
510
+ }
511
+
512
+ # ── Up-to-date case ────────────────────────────────────────────────
513
+ return {
514
+ "Status": "[green]✓ OK[/green]",
515
+ "Package": pkg.name,
516
+ "Current": pkg.current_version or "[dim]-[/dim]",
517
+ "Latest": pkg.latest_version or "[dim]-[/dim]",
518
+ "Recommended": recommended_display,
519
+ "Update Type": "[dim]-[/dim]",
520
+ "Conflicts": conflicts_display,
521
+ "Python Support": python_support,
522
+ }
523
+
524
+
525
+ def _display_simple(packages: List[Package]) -> None:
526
+ """Render packages in simple, line-based text format.
527
+
528
+ Outputs one line per package showing status, name, current version,
529
+ and latest version. Conflict and Python compatibility details are
530
+ shown on indented lines below each package.
531
+
532
+ Suitable for piping to other tools or for minimal terminal output.
533
+
534
+ Args:
535
+ packages: List of :class:`Package` objects to display.
536
+
537
+ Example::
538
+
539
+ [OUTDATED] flask 2.0.0 → 3.0.0
540
+ ⚠ Conflict: werkzeug requires >=2.0,<3
541
+ Python: installed: >=3.7, latest: >=3.8
542
+ [OK] requests 2.31.0 → 2.31.0
543
+ """
544
+ console = get_raw_console()
545
+
546
+ for pkg in packages:
547
+ # Main line: [STATUS] package_name current → latest
548
+ status, installed, latest, recommended = pkg.get_status_summary()
549
+
550
+ if recommended and recommended != latest:
551
+ # Show recommended version when it differs (conflict resolution)
552
+ console.print(
553
+ f"[{status}] {pkg.name:20} {installed:10} → {latest:10} "
554
+ f"(recommended: {recommended})"
555
+ )
556
+ else:
557
+ console.print(f"[{status}] {pkg.name:20} {installed:10} → {latest:10}")
558
+
559
+ # Indented conflict details
560
+ if pkg.has_conflicts():
561
+ for conflict in pkg.conflicts:
562
+ console.print(
563
+ f" [red]⚠ Conflict:[/red] {conflict.to_display_string()}"
564
+ )
565
+
566
+ # Indented Python version requirements
567
+ current_req = pkg.get_version_python_req("current")
568
+ latest_req = pkg.get_version_python_req("latest")
569
+ if current_req or latest_req:
570
+ req_parts = []
571
+ if current_req:
572
+ req_parts.append(f"installed: {current_req}")
573
+ if latest_req:
574
+ req_parts.append(f"latest: {latest_req}")
575
+ if pkg.has_update():
576
+ recommended_req = pkg.get_version_python_req("recommended")
577
+ if recommended_req:
578
+ req_parts.append(f"recommended: {recommended_req}")
579
+ if req_parts:
580
+ console.print(f" Python: {', '.join(req_parts)}")
581
+
582
+
583
+ def _display_json(packages: List[Package]) -> None:
584
+ """Render packages as formatted JSON for machine consumption.
585
+
586
+ Outputs a JSON array where each element contains complete package
587
+ information: versions, conflicts, metadata, and Python compatibility.
588
+ Suitable for piping to ``jq`` or parsing in scripts.
589
+
590
+ Args:
591
+ packages: List of :class:`Package` objects to serialize.
592
+
593
+ Example::
594
+
595
+ [
596
+ {
597
+ "name": "flask",
598
+ "current_version": "2.0.0",
599
+ "latest_version": "3.0.0",
600
+ "recommended_version": "2.3.3",
601
+ "conflicts": [...],
602
+ "metadata": {...}
603
+ },
604
+ ...
605
+ ]
606
+ """
607
+ data = [pkg.to_json() for pkg in packages]
608
+ print(json.dumps(data, indent=2))