pipu-cli 0.1.dev7__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/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
@@ -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