pipu-cli 0.1.dev6__py3-none-any.whl → 0.2.0__py3-none-any.whl
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.
- pipu_cli/__init__.py +2 -2
- pipu_cli/cache.py +316 -0
- pipu_cli/cli.py +867 -812
- pipu_cli/config.py +7 -58
- pipu_cli/config_file.py +80 -0
- pipu_cli/output.py +99 -0
- pipu_cli/package_management.py +1145 -0
- pipu_cli/pretty.py +286 -0
- pipu_cli/requirements.py +100 -0
- pipu_cli/rollback.py +110 -0
- pipu_cli-0.2.0.dist-info/METADATA +422 -0
- pipu_cli-0.2.0.dist-info/RECORD +16 -0
- pipu_cli/common.py +0 -4
- pipu_cli/internals.py +0 -819
- pipu_cli/package_constraints.py +0 -2286
- pipu_cli/thread_safe.py +0 -243
- pipu_cli/ui/__init__.py +0 -51
- pipu_cli/ui/apps.py +0 -1460
- pipu_cli/ui/constants.py +0 -19
- pipu_cli/ui/modal_dialogs.py +0 -1375
- pipu_cli/ui/table_widgets.py +0 -345
- pipu_cli/utils.py +0 -169
- pipu_cli-0.1.dev6.dist-info/METADATA +0 -517
- pipu_cli-0.1.dev6.dist-info/RECORD +0 -19
- {pipu_cli-0.1.dev6.dist-info → pipu_cli-0.2.0.dist-info}/WHEEL +0 -0
- {pipu_cli-0.1.dev6.dist-info → pipu_cli-0.2.0.dist-info}/entry_points.txt +0 -0
- {pipu_cli-0.1.dev6.dist-info → pipu_cli-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {pipu_cli-0.1.dev6.dist-info → pipu_cli-0.2.0.dist-info}/top_level.txt +0 -0
pipu_cli/pretty.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Pretty printing functions for pipu CLI."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from rich.prompt import Confirm, Prompt
|
|
8
|
+
|
|
9
|
+
from pipu_cli.package_management import UpgradePackageInfo, UpgradedPackage, BlockedPackageInfo
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConsoleStream:
|
|
13
|
+
"""A stream adapter that writes to a Rich Console.
|
|
14
|
+
|
|
15
|
+
This class implements the write/flush protocol expected by
|
|
16
|
+
package_management.OutputStream, allowing pip output to be
|
|
17
|
+
displayed through Rich's console.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, console: Console) -> None:
|
|
21
|
+
"""Initialize with a Rich console instance.
|
|
22
|
+
|
|
23
|
+
:param console: Rich Console to write output to
|
|
24
|
+
"""
|
|
25
|
+
self.console = console
|
|
26
|
+
|
|
27
|
+
def write(self, text: str) -> None:
|
|
28
|
+
"""Write text to the console if non-empty.
|
|
29
|
+
|
|
30
|
+
:param text: Text to write
|
|
31
|
+
"""
|
|
32
|
+
if text and text.strip():
|
|
33
|
+
self.console.print(text, end="")
|
|
34
|
+
|
|
35
|
+
def flush(self) -> None:
|
|
36
|
+
"""Flush the stream (no-op for console)."""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def print_upgradable_packages_table(
|
|
41
|
+
packages: List[UpgradePackageInfo],
|
|
42
|
+
console: Optional[Console] = None
|
|
43
|
+
) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Print a table of upgradable packages with version information.
|
|
46
|
+
|
|
47
|
+
:param packages: List of UpgradePackageInfo objects to display
|
|
48
|
+
:param console: Optional Rich console instance (creates new one if not provided)
|
|
49
|
+
"""
|
|
50
|
+
if console is None:
|
|
51
|
+
console = Console()
|
|
52
|
+
|
|
53
|
+
if not packages:
|
|
54
|
+
console.print("[yellow]No packages need upgrading.[/yellow]")
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
# Filter to only upgradable packages
|
|
58
|
+
upgradable = [pkg for pkg in packages if pkg.upgradable]
|
|
59
|
+
|
|
60
|
+
if not upgradable:
|
|
61
|
+
console.print("[yellow]No packages can be upgraded (all blocked by constraints).[/yellow]")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
# Create table
|
|
65
|
+
num_upgradable = len(upgradable)
|
|
66
|
+
table = Table(title=f"[bold]{num_upgradable} Package(s) Available for Upgrade[/bold]")
|
|
67
|
+
table.add_column("Package", style="cyan", no_wrap=True)
|
|
68
|
+
table.add_column("Current", style="magenta")
|
|
69
|
+
table.add_column("Latest", style="green")
|
|
70
|
+
table.add_column("Editable", style="yellow")
|
|
71
|
+
|
|
72
|
+
for pkg in upgradable:
|
|
73
|
+
editable_mark = "Yes" if pkg.is_editable else ""
|
|
74
|
+
table.add_row(
|
|
75
|
+
pkg.name,
|
|
76
|
+
str(pkg.version),
|
|
77
|
+
str(pkg.latest_version),
|
|
78
|
+
editable_mark
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
console.print(table)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def print_blocked_packages_table(
|
|
85
|
+
packages: List[BlockedPackageInfo],
|
|
86
|
+
console: Optional[Console] = None
|
|
87
|
+
) -> None:
|
|
88
|
+
"""
|
|
89
|
+
Print a table of blocked packages with reasons.
|
|
90
|
+
|
|
91
|
+
:param packages: List of BlockedPackageInfo objects to display
|
|
92
|
+
:param console: Optional Rich console instance
|
|
93
|
+
"""
|
|
94
|
+
if console is None:
|
|
95
|
+
console = Console()
|
|
96
|
+
|
|
97
|
+
if not packages:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
num_blocked = len(packages)
|
|
101
|
+
table = Table(title=f"[bold yellow]{num_blocked} Package(s) Blocked by Constraints[/bold yellow]")
|
|
102
|
+
table.add_column("Package", style="cyan", no_wrap=True)
|
|
103
|
+
table.add_column("Current", style="magenta")
|
|
104
|
+
table.add_column("Available", style="green")
|
|
105
|
+
table.add_column("Blocked By", style="red")
|
|
106
|
+
|
|
107
|
+
for pkg in packages:
|
|
108
|
+
blocked_by = ", ".join(pkg.blocked_by[:2]) # Show first 2 reasons
|
|
109
|
+
if len(pkg.blocked_by) > 2:
|
|
110
|
+
blocked_by += f" (+{len(pkg.blocked_by) - 2} more)"
|
|
111
|
+
|
|
112
|
+
table.add_row(
|
|
113
|
+
pkg.name,
|
|
114
|
+
str(pkg.version),
|
|
115
|
+
str(pkg.latest_version),
|
|
116
|
+
blocked_by
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
console.print(table)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def print_upgrade_results(
|
|
123
|
+
results: List[UpgradedPackage],
|
|
124
|
+
console: Optional[Console] = None
|
|
125
|
+
) -> None:
|
|
126
|
+
"""
|
|
127
|
+
Print a summary of package upgrade results.
|
|
128
|
+
|
|
129
|
+
:param results: List of UpgradedPackage objects with upgrade status
|
|
130
|
+
:param console: Optional Rich console instance (creates new one if not provided)
|
|
131
|
+
"""
|
|
132
|
+
if console is None:
|
|
133
|
+
console = Console()
|
|
134
|
+
|
|
135
|
+
if not results:
|
|
136
|
+
console.print("[yellow]No packages were processed.[/yellow]")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
# Separate successful and failed upgrades
|
|
140
|
+
successful = [pkg for pkg in results if pkg.upgraded]
|
|
141
|
+
failed = [pkg for pkg in results if not pkg.upgraded]
|
|
142
|
+
|
|
143
|
+
# Print success summary
|
|
144
|
+
if successful:
|
|
145
|
+
num_successful = len(successful)
|
|
146
|
+
console.print(f"\n[bold green]Successfully upgraded {num_successful} package(s):[/bold green]")
|
|
147
|
+
for pkg in successful:
|
|
148
|
+
prev_ver = str(pkg.previous_version)
|
|
149
|
+
curr_ver = str(pkg.version)
|
|
150
|
+
console.print(f" - {pkg.name}: {prev_ver} -> {curr_ver}")
|
|
151
|
+
|
|
152
|
+
# Print failure summary
|
|
153
|
+
if failed:
|
|
154
|
+
num_failed = len(failed)
|
|
155
|
+
console.print(f"\n[bold yellow]{num_failed} package(s) could not be upgraded:[/bold yellow]")
|
|
156
|
+
|
|
157
|
+
table = Table(show_header=True, header_style="bold yellow")
|
|
158
|
+
table.add_column("Package", style="cyan")
|
|
159
|
+
table.add_column("Current Version", style="magenta")
|
|
160
|
+
table.add_column("Reason", style="dim")
|
|
161
|
+
|
|
162
|
+
for pkg in failed:
|
|
163
|
+
table.add_row(
|
|
164
|
+
pkg.name,
|
|
165
|
+
str(pkg.version),
|
|
166
|
+
"Blocked by runtime constraints"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
console.print(table)
|
|
170
|
+
|
|
171
|
+
# Overall summary
|
|
172
|
+
console.print()
|
|
173
|
+
if failed:
|
|
174
|
+
num_successful = len(successful)
|
|
175
|
+
num_total = len(results)
|
|
176
|
+
console.print(f"[bold]Summary:[/bold] {num_successful}/{num_total} packages upgraded successfully")
|
|
177
|
+
else:
|
|
178
|
+
console.print("[bold green]All packages upgraded successfully![/bold green]")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _parse_selection(selection: str, max_index: int) -> List[int]:
|
|
182
|
+
"""Parse a selection string supporting ranges and comma-separated values.
|
|
183
|
+
|
|
184
|
+
Examples:
|
|
185
|
+
"1,2,3" -> [0, 1, 2]
|
|
186
|
+
"1-3" -> [0, 1, 2]
|
|
187
|
+
"1-3, 5" -> [0, 1, 2, 4]
|
|
188
|
+
"1, 3-5, 7" -> [0, 2, 3, 4, 6]
|
|
189
|
+
|
|
190
|
+
:param selection: User input string
|
|
191
|
+
:param max_index: Maximum valid index (1-based)
|
|
192
|
+
:returns: List of 0-based indices
|
|
193
|
+
:raises ValueError: If selection cannot be parsed
|
|
194
|
+
"""
|
|
195
|
+
indices = set()
|
|
196
|
+
parts = selection.split(',')
|
|
197
|
+
|
|
198
|
+
for part in parts:
|
|
199
|
+
part = part.strip()
|
|
200
|
+
if not part:
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
if '-' in part:
|
|
204
|
+
# Handle range like "1-3"
|
|
205
|
+
range_parts = part.split('-')
|
|
206
|
+
if len(range_parts) != 2:
|
|
207
|
+
raise ValueError(f"Invalid range: {part}")
|
|
208
|
+
start = int(range_parts[0].strip())
|
|
209
|
+
end = int(range_parts[1].strip())
|
|
210
|
+
if start > end:
|
|
211
|
+
start, end = end, start
|
|
212
|
+
for i in range(start, end + 1):
|
|
213
|
+
if 1 <= i <= max_index:
|
|
214
|
+
indices.add(i - 1) # Convert to 0-based
|
|
215
|
+
else:
|
|
216
|
+
# Handle single number
|
|
217
|
+
num = int(part)
|
|
218
|
+
if 1 <= num <= max_index:
|
|
219
|
+
indices.add(num - 1) # Convert to 0-based
|
|
220
|
+
|
|
221
|
+
return sorted(indices)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def select_packages_interactively(
|
|
225
|
+
packages: List[UpgradePackageInfo],
|
|
226
|
+
console: Console
|
|
227
|
+
) -> List[UpgradePackageInfo]:
|
|
228
|
+
"""Allow user to interactively select which packages to upgrade.
|
|
229
|
+
|
|
230
|
+
:param packages: Available packages to choose from
|
|
231
|
+
:param console: Rich console for output
|
|
232
|
+
:returns: Selected packages
|
|
233
|
+
"""
|
|
234
|
+
console.print("\n[bold]Select packages to upgrade:[/bold]")
|
|
235
|
+
console.print("[dim](Enter numbers, ranges, or 'all'. Examples: 1,3,5 or 1-3 or 1-3,5)[/dim]\n")
|
|
236
|
+
|
|
237
|
+
for idx, pkg in enumerate(packages, 1):
|
|
238
|
+
console.print(f" {idx}. {pkg.name}: {pkg.version} -> {pkg.latest_version}")
|
|
239
|
+
|
|
240
|
+
console.print()
|
|
241
|
+
selection = Prompt.ask("Selection", default="all")
|
|
242
|
+
|
|
243
|
+
if selection.lower() == "all":
|
|
244
|
+
selected = packages
|
|
245
|
+
else:
|
|
246
|
+
try:
|
|
247
|
+
indices = _parse_selection(selection, len(packages))
|
|
248
|
+
selected = [packages[i] for i in indices]
|
|
249
|
+
|
|
250
|
+
if not selected:
|
|
251
|
+
console.print("[yellow]No valid packages selected, using all packages.[/yellow]")
|
|
252
|
+
selected = packages
|
|
253
|
+
except (ValueError, IndexError):
|
|
254
|
+
console.print("[yellow]Invalid selection, using all packages.[/yellow]")
|
|
255
|
+
selected = packages
|
|
256
|
+
|
|
257
|
+
# Show confirmation table with selected packages highlighted
|
|
258
|
+
selected_names = {pkg.name for pkg in selected}
|
|
259
|
+
num_selected = len(selected)
|
|
260
|
+
|
|
261
|
+
console.print()
|
|
262
|
+
table = Table(title=f"[bold]{num_selected} Package(s) Selected for Upgrade[/bold]")
|
|
263
|
+
table.add_column("", style="bold green", no_wrap=True, width=3)
|
|
264
|
+
table.add_column("Package", style="cyan", no_wrap=True)
|
|
265
|
+
table.add_column("Current", style="magenta")
|
|
266
|
+
table.add_column("Latest", style="green")
|
|
267
|
+
|
|
268
|
+
for pkg in packages:
|
|
269
|
+
is_selected = pkg.name in selected_names
|
|
270
|
+
marker = "[green]\u2713[/green]" if is_selected else ""
|
|
271
|
+
style = "" if is_selected else "dim"
|
|
272
|
+
table.add_row(
|
|
273
|
+
marker,
|
|
274
|
+
f"[{style}]{pkg.name}[/{style}]" if style else pkg.name,
|
|
275
|
+
f"[{style}]{pkg.version}[/{style}]" if style else str(pkg.version),
|
|
276
|
+
f"[{style}]{pkg.latest_version}[/{style}]" if style else str(pkg.latest_version),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
console.print(table)
|
|
280
|
+
|
|
281
|
+
# Ask for confirmation
|
|
282
|
+
if not Confirm.ask("\nProceed with upgrade?", default=True):
|
|
283
|
+
console.print("[yellow]Upgrade cancelled.[/yellow]")
|
|
284
|
+
return []
|
|
285
|
+
|
|
286
|
+
return selected
|
pipu_cli/requirements.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Requirements file management for pipu."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Dict
|
|
6
|
+
|
|
7
|
+
from pipu_cli.package_management import UpgradedPackage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_requirements_file(path: Path) -> Dict[str, str]:
|
|
11
|
+
"""Parse a requirements.txt file.
|
|
12
|
+
|
|
13
|
+
:param path: Path to requirements file
|
|
14
|
+
:returns: Dict mapping package names to their lines
|
|
15
|
+
"""
|
|
16
|
+
packages = {}
|
|
17
|
+
|
|
18
|
+
if not path.exists():
|
|
19
|
+
return packages
|
|
20
|
+
|
|
21
|
+
with open(path, 'r') as f:
|
|
22
|
+
for line in f:
|
|
23
|
+
line = line.strip()
|
|
24
|
+
|
|
25
|
+
# Skip comments and empty lines
|
|
26
|
+
if not line or line.startswith('#'):
|
|
27
|
+
continue
|
|
28
|
+
|
|
29
|
+
# Skip options like -r, -e, etc.
|
|
30
|
+
if line.startswith('-'):
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
# Extract package name (before any specifier)
|
|
34
|
+
match = re.match(r'^([a-zA-Z0-9_-]+)', line)
|
|
35
|
+
if match:
|
|
36
|
+
pkg_name = match.group(1).lower()
|
|
37
|
+
packages[pkg_name] = line
|
|
38
|
+
|
|
39
|
+
return packages
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def update_requirements_file(
|
|
43
|
+
path: Path,
|
|
44
|
+
upgraded_packages: List[UpgradedPackage],
|
|
45
|
+
pin_versions: bool = True
|
|
46
|
+
) -> int:
|
|
47
|
+
"""Update a requirements file with upgraded package versions.
|
|
48
|
+
|
|
49
|
+
:param path: Path to requirements file
|
|
50
|
+
:param upgraded_packages: List of upgraded packages
|
|
51
|
+
:param pin_versions: Whether to pin exact versions (default: True)
|
|
52
|
+
:returns: Number of packages updated
|
|
53
|
+
"""
|
|
54
|
+
if not path.exists():
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
# Read current file
|
|
58
|
+
with open(path, 'r') as f:
|
|
59
|
+
lines = f.readlines()
|
|
60
|
+
|
|
61
|
+
# Build map of upgraded packages
|
|
62
|
+
upgraded_map = {
|
|
63
|
+
pkg.name.lower(): pkg
|
|
64
|
+
for pkg in upgraded_packages
|
|
65
|
+
if pkg.upgraded
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
updated_count = 0
|
|
69
|
+
new_lines = []
|
|
70
|
+
|
|
71
|
+
for line in lines:
|
|
72
|
+
stripped = line.strip()
|
|
73
|
+
|
|
74
|
+
# Keep comments and empty lines as-is
|
|
75
|
+
if not stripped or stripped.startswith('#') or stripped.startswith('-'):
|
|
76
|
+
new_lines.append(line)
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
# Extract package name
|
|
80
|
+
match = re.match(r'^([a-zA-Z0-9_-]+)', stripped)
|
|
81
|
+
if match:
|
|
82
|
+
pkg_name = match.group(1).lower()
|
|
83
|
+
|
|
84
|
+
if pkg_name in upgraded_map:
|
|
85
|
+
pkg = upgraded_map[pkg_name]
|
|
86
|
+
if pin_versions:
|
|
87
|
+
new_line = f"{pkg.name}=={pkg.version}\n"
|
|
88
|
+
else:
|
|
89
|
+
new_line = f"{pkg.name}>={pkg.version}\n"
|
|
90
|
+
new_lines.append(new_line)
|
|
91
|
+
updated_count += 1
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
new_lines.append(line)
|
|
95
|
+
|
|
96
|
+
# Write updated file
|
|
97
|
+
with open(path, 'w') as f:
|
|
98
|
+
f.writelines(new_lines)
|
|
99
|
+
|
|
100
|
+
return updated_count
|
pipu_cli/rollback.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Rollback functionality for pipu."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from pipu_cli.package_management import UpgradedPackage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
ROLLBACK_DIR = Path.home() / ".pipu" / "rollback"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def save_state(packages: List[Dict[str, str]], description: str = "") -> Path:
|
|
17
|
+
"""Save current package state for potential rollback.
|
|
18
|
+
|
|
19
|
+
:param packages: List of dicts with 'name' and 'version' keys
|
|
20
|
+
:param description: Optional description of the state
|
|
21
|
+
:returns: Path to saved state file
|
|
22
|
+
"""
|
|
23
|
+
ROLLBACK_DIR.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
|
|
25
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
26
|
+
state_file = ROLLBACK_DIR / f"state_{timestamp}.json"
|
|
27
|
+
|
|
28
|
+
state = {
|
|
29
|
+
"timestamp": timestamp,
|
|
30
|
+
"description": description,
|
|
31
|
+
"packages": packages
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
with open(state_file, 'w') as f:
|
|
35
|
+
json.dump(state, f, indent=2)
|
|
36
|
+
|
|
37
|
+
return state_file
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_latest_state() -> Optional[Dict[str, Any]]:
|
|
41
|
+
"""Get the most recent saved state.
|
|
42
|
+
|
|
43
|
+
:returns: State dictionary or None if no states saved
|
|
44
|
+
"""
|
|
45
|
+
if not ROLLBACK_DIR.exists():
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
state_files = sorted(ROLLBACK_DIR.glob("state_*.json"), reverse=True)
|
|
49
|
+
|
|
50
|
+
if not state_files:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
with open(state_files[0], 'r') as f:
|
|
54
|
+
return json.load(f)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def rollback_to_state(state: Dict[str, Any], dry_run: bool = False) -> List[str]:
|
|
58
|
+
"""Rollback packages to a saved state.
|
|
59
|
+
|
|
60
|
+
:param state: State dictionary from get_latest_state()
|
|
61
|
+
:param dry_run: If True, only show what would be done
|
|
62
|
+
:returns: List of packages that were rolled back
|
|
63
|
+
"""
|
|
64
|
+
packages = state.get("packages", [])
|
|
65
|
+
rolled_back = []
|
|
66
|
+
|
|
67
|
+
for pkg in packages:
|
|
68
|
+
name = pkg["name"]
|
|
69
|
+
version = pkg["version"]
|
|
70
|
+
|
|
71
|
+
if dry_run:
|
|
72
|
+
rolled_back.append(f"{name}=={version}")
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
subprocess.run(
|
|
77
|
+
[sys.executable, '-m', 'pip', 'install', f'{name}=={version}'],
|
|
78
|
+
check=True,
|
|
79
|
+
capture_output=True
|
|
80
|
+
)
|
|
81
|
+
rolled_back.append(f"{name}=={version}")
|
|
82
|
+
except subprocess.CalledProcessError:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
return rolled_back
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def list_states() -> List[Dict[str, Any]]:
|
|
89
|
+
"""List all saved states.
|
|
90
|
+
|
|
91
|
+
:returns: List of state summaries
|
|
92
|
+
"""
|
|
93
|
+
if not ROLLBACK_DIR.exists():
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
states = []
|
|
97
|
+
for state_file in sorted(ROLLBACK_DIR.glob("state_*.json"), reverse=True):
|
|
98
|
+
try:
|
|
99
|
+
with open(state_file, 'r') as f:
|
|
100
|
+
state = json.load(f)
|
|
101
|
+
states.append({
|
|
102
|
+
"file": state_file.name,
|
|
103
|
+
"timestamp": state.get("timestamp", "unknown"),
|
|
104
|
+
"description": state.get("description", ""),
|
|
105
|
+
"package_count": len(state.get("packages", []))
|
|
106
|
+
})
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
return states
|