redfetch 1.3.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.
redfetch/meta.py ADDED
@@ -0,0 +1,561 @@
1
+ """Version checking, self-update, and uninstall."""
2
+
3
+ # Standard
4
+ import os
5
+ import platform
6
+ import subprocess
7
+ import sys
8
+ import textwrap
9
+ from pathlib import Path
10
+
11
+ # Third-party
12
+ import httpx
13
+ from packaging import version
14
+
15
+ # Rich library
16
+ from rich.console import Console
17
+ from rich.panel import Panel
18
+ from rich.text import Text
19
+ from rich.prompt import Confirm
20
+
21
+ # Local
22
+ from redfetch.__about__ import __version__
23
+ from redfetch import config
24
+ from diskcache import Cache
25
+
26
+
27
+ def _get_pypi_url() -> str:
28
+ """Pick PyPI JSON URL, favouring `REDFETCH_PYPI_URL` if set."""
29
+ env_url = os.getenv("REDFETCH_PYPI_URL")
30
+ if env_url:
31
+ return env_url
32
+ if "dev" in __version__:
33
+ return "https://test.pypi.org/pypi/redfetch/json"
34
+ return "https://pypi.org/pypi/redfetch/json"
35
+
36
+
37
+ PYPI_URL = _get_pypi_url()
38
+
39
+ console = Console()
40
+
41
+
42
+ def get_current_version():
43
+ return __version__
44
+
45
+
46
+ _UPDATE_CACHE_TTL_SECONDS = 2 * 60 * 60 # 2 hours
47
+
48
+ _meta_cache = None
49
+
50
+
51
+ def _get_meta_cache():
52
+ """Lazy-load disk-backed cache under the config directory."""
53
+ cache_dir = getattr(config, 'config_dir', None) or os.getenv('REDFETCH_CONFIG_DIR')
54
+ if not cache_dir:
55
+ cache_dir = os.getcwd()
56
+ api_cache_dir = os.path.join(cache_dir, '.cache')
57
+ os.makedirs(api_cache_dir, exist_ok=True)
58
+ return Cache(api_cache_dir)
59
+
60
+
61
+ def clear_pypi_cache() -> None:
62
+ """Clear cached PyPI metadata."""
63
+ global _meta_cache
64
+ if _meta_cache is None:
65
+ _meta_cache = _get_meta_cache()
66
+ try:
67
+ _meta_cache.clear()
68
+ finally:
69
+ try:
70
+ _meta_cache.close()
71
+ except Exception:
72
+ pass
73
+ _meta_cache = None
74
+
75
+
76
+ def fetch_latest_version_cached():
77
+ """Fetch latest PyPI version with a 2-hour disk-backed cache."""
78
+ global _meta_cache
79
+ if _meta_cache is None:
80
+ _meta_cache = _get_meta_cache()
81
+ cache_key = f"pypi_latest:{PYPI_URL}"
82
+ cached = _meta_cache.get(cache_key)
83
+ if cached is not None:
84
+ return cached
85
+ latest = fetch_latest_version_from_pypi()
86
+ _meta_cache.set(cache_key, latest, expire=_UPDATE_CACHE_TTL_SECONDS)
87
+ return latest
88
+
89
+
90
+ def fetch_latest_version_from_pypi():
91
+ response = httpx.get(PYPI_URL, timeout=10.0)
92
+ response.raise_for_status()
93
+ data = response.json()
94
+ # On TestPyPI, prefer the highest available release (including pre-releases)
95
+ if "test.pypi.org" in PYPI_URL:
96
+ releases = list(data.get("releases", {}).keys())
97
+ if releases:
98
+ releases.sort(key=version.parse)
99
+ return releases[-1]
100
+ # Default: whatever PyPI reports as the latest stable version
101
+ return data["info"]["version"]
102
+
103
+
104
+ def get_executable_path():
105
+ executable_path = os.environ.get('PYAPP')
106
+ return executable_path
107
+
108
+
109
+ def detect_installation_method():
110
+ """Detect how the package was installed."""
111
+ try:
112
+ # Check for PYAPP first
113
+ if os.getenv('PYAPP'):
114
+ return 'pyapp'
115
+
116
+ # Get the package location
117
+ package_location = Path(__file__).parent.absolute()
118
+
119
+ location_str = str(package_location)
120
+ parts_lower = {part.lower() for part in package_location.parts}
121
+
122
+ # Check for pipx
123
+ if 'pipx' in location_str:
124
+ return 'pipx'
125
+
126
+ # uv paths contain ".../uv/.../tools/..."
127
+ if 'uv' in parts_lower and 'tools' in parts_lower:
128
+ return 'uv'
129
+
130
+ # Default to pip
131
+ return 'pip'
132
+ except Exception:
133
+ return 'pip'
134
+
135
+
136
+ def get_update_command():
137
+ """Get the appropriate update command based on installation method."""
138
+ method = detect_installation_method()
139
+
140
+ # Add TestPyPI index URL to commands if using TestPyPI
141
+ is_test_pypi = "test.pypi.org" in PYPI_URL
142
+
143
+ commands = {
144
+ 'pip': [
145
+ sys.executable, '-m', 'pip', 'install', '--upgrade',
146
+ '--index-url', 'https://test.pypi.org/simple/',
147
+ '--extra-index-url', 'https://pypi.org/simple/',
148
+ 'redfetch'
149
+ ] if is_test_pypi else [
150
+ sys.executable, '-m', 'pip', 'install', '--upgrade', 'redfetch'
151
+ ],
152
+ 'pipx': [
153
+ 'pipx', 'upgrade', 'redfetch', '--pip-args',
154
+ '--index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/'
155
+ ] if is_test_pypi else [
156
+ 'pipx', 'upgrade', 'redfetch'
157
+ ],
158
+ 'uv': [
159
+ 'uv', 'tool', 'upgrade', 'redfetch',
160
+ '--index-url', 'https://test.pypi.org/simple/',
161
+ '--extra-index-url', 'https://pypi.org/simple/'
162
+ ] if is_test_pypi else [
163
+ 'uv', 'tool', 'upgrade', 'redfetch'
164
+ ],
165
+ 'pyapp': None # Handle separately with self_update()
166
+ }
167
+
168
+ return commands.get(method)
169
+
170
+
171
+ def check_for_update():
172
+ current_version = get_current_version()
173
+
174
+ try:
175
+ latest_version = fetch_latest_version_cached()
176
+
177
+ if version.parse(latest_version) > version.parse(current_version):
178
+ version_info = Panel(
179
+ Text.assemble(
180
+ ("An update for redfetch is available! 🚡\n\n", "bold green"),
181
+ ("Local version: ", "dim"),
182
+ (f"{current_version}\n", "cyan"),
183
+ ("Latest version: ", "dim"),
184
+ (f"{latest_version}", "cyan bold")
185
+ ),
186
+ title="Update Available",
187
+ expand=False
188
+ )
189
+ console.print(version_info)
190
+
191
+ # Handle PYAPP separately
192
+ if os.getenv('PYAPP'):
193
+ if Confirm.ask("Would you like to update now?"):
194
+ return self_update()
195
+ else:
196
+ console.print("[yellow]Update skipped. You can manually update later.[/yellow]")
197
+ return False
198
+
199
+ # Get the appropriate update command
200
+ update_command = get_update_command()
201
+ if not update_command:
202
+ console.print("[red]Could not determine update method.[/red]")
203
+ return False
204
+
205
+ command_panel = Panel(
206
+ Text(" ".join(update_command), style="bold cyan"),
207
+ title="Update Command",
208
+ expand=False
209
+ )
210
+ console.print(command_panel)
211
+
212
+ if Confirm.ask("Would you like to run this command to update?"):
213
+ return pip_update_redfetch(update_command, latest_version)
214
+ else:
215
+ console.print("[yellow]Update skipped. You can manually update later.[/yellow]")
216
+ except Exception as e:
217
+ console.print(f"[bold red]Error checking for updates:[/bold red] {e}")
218
+ return False
219
+
220
+
221
+ def pip_update_redfetch(update_command, latest_version):
222
+ try:
223
+ console.print(f"\n[bold]Updating redfetch to version {latest_version}...[/bold]\n")
224
+
225
+ # Run the update command and let it print directly to console
226
+ result = subprocess.run(update_command)
227
+ returncode = result.returncode
228
+
229
+ if returncode == 0:
230
+ console.print("\n[bold green]redfetch has been successfully updated. 🫎[/bold green]")
231
+ console.print("[yellow]Please run redfetch again to use the updated version.[/yellow]")
232
+ sys.exit(0)
233
+ else:
234
+ console.print("\n[bold red]Update failed. See output above for details.[/bold red]")
235
+ sys.exit(1)
236
+ except Exception as e:
237
+ console.print(f"[bold red]Error during update process:[/bold red] {e}")
238
+ sys.exit(1)
239
+
240
+
241
+ def self_update():
242
+ """Update with PYAPP."""
243
+ try:
244
+ console.print("[bold]Performing self-update...[/bold]")
245
+
246
+ current_version = get_current_version()
247
+ latest_version = fetch_latest_version_from_pypi()
248
+ console.print(f"Current version: {current_version}")
249
+ console.print(f"Latest version: {latest_version}")
250
+
251
+ executable_path = get_executable_path()
252
+ update_command = [executable_path, 'self', 'update']
253
+
254
+ # Start the update process in a new console and exit the current one
255
+ subprocess.Popen(
256
+ update_command,
257
+ creationflags=subprocess.CREATE_NEW_CONSOLE
258
+ )
259
+
260
+ # Exit the current process to allow the update to proceed
261
+ sys.exit(0)
262
+
263
+ except Exception as e:
264
+ console.print(f"[bold red]Error during self-update process:[/bold red] {e}")
265
+ sys.exit(1)
266
+
267
+
268
+ def self_remove():
269
+ """Remove with PYAPP."""
270
+ try:
271
+ console.print("[bold]Performing self-uninstall...[/bold]")
272
+
273
+ executable_path = get_executable_path()
274
+ console.print(f"[debug]Executable path: {executable_path}[/debug]")
275
+
276
+ if not executable_path:
277
+ console.print("[bold red]Executable path not found. Exiting self-remove.[/bold red]")
278
+ return
279
+
280
+ # Create a batch script to handle the uninstallation
281
+ batch_script = textwrap.dedent(f"""
282
+ @echo off
283
+ timeout /t 2 > nul
284
+ "{executable_path}" self remove
285
+ if %errorlevel% neq 0 (
286
+ echo Uninstallation failed. Press any key to exit.
287
+ pause > nul
288
+ exit /b 1
289
+ )
290
+ echo Uninstallation successful. Cleaning up...
291
+ del "{executable_path}"
292
+ if exist "{executable_path}" (
293
+ echo Failed to delete the executable. You may need to delete it manually.
294
+ ) else (
295
+ echo Executable deleted successfully.
296
+ )
297
+ echo Cleanup complete. Press any key to exit.
298
+ pause > nul
299
+ (goto) 2>nul & del "%~f0"
300
+ """).strip()
301
+
302
+ batch_file_path = os.path.join(os.path.dirname(executable_path), "uninstall.bat")
303
+ with open(batch_file_path, 'w') as batch_file:
304
+ batch_file.write(batch_script)
305
+
306
+ console.print(f"[debug]Batch script created at: {batch_file_path}[/debug]")
307
+
308
+ # Run the batch script in a new console
309
+ subprocess.Popen(
310
+ ['cmd.exe', '/c', 'start', batch_file_path],
311
+ creationflags=subprocess.CREATE_NEW_CONSOLE
312
+ )
313
+
314
+ # Exit the current process to allow the uninstall to proceed
315
+ sys.exit(0)
316
+
317
+ except Exception as e:
318
+ console.print(f"[bold red]Error during self-uninstall process:[/bold red] {e}")
319
+ input("Press Enter to close this window...")
320
+ sys.exit(1)
321
+
322
+
323
+ def _release_disk_caches() -> None:
324
+ """Ensure all disk caches are closed before deleting directories."""
325
+ errors: list[str] = []
326
+
327
+ try:
328
+ clear_pypi_cache()
329
+ except Exception as exc:
330
+ errors.append(f"PyPI cache: {exc}")
331
+
332
+ try:
333
+ from redfetch import auth
334
+ except Exception as exc:
335
+ errors.append(f"Auth cache import: {exc}")
336
+ else:
337
+ try:
338
+ auth.clear_disk_cache()
339
+ except Exception as exc:
340
+ errors.append(f"Disk cache: {exc}")
341
+
342
+ try:
343
+ from redfetch import net
344
+ except Exception as exc:
345
+ errors.append(f"Manifest cache import: {exc}")
346
+ else:
347
+ try:
348
+ net.clear_manifest_cache()
349
+ except Exception as exc:
350
+ errors.append(f"Manifest cache: {exc}")
351
+
352
+ if errors:
353
+ raise RuntimeError("; ".join(errors))
354
+
355
+
356
+ def uninstall():
357
+ """Guide the user through the uninstallation process."""
358
+ # Import the logout function from auth module
359
+ from .auth import logout
360
+
361
+ config.initialize_config()
362
+
363
+ console.print("\n[bold]Uninstallation Process:[/bold]")
364
+
365
+ # Call the logout function to clear stored credentials
366
+ logout()
367
+
368
+ config.remove_breadcrumb()
369
+
370
+ # Get executable path and installation method
371
+ executable_path = get_executable_path()
372
+ install_method = detect_installation_method()
373
+
374
+ # Inform the user of directories that may contain data
375
+ console.print("\n[bold]Manual Cleanup Instructions:[/bold]\n")
376
+
377
+ environments = ['DEFAULT', 'LIVE', 'TEST', 'EMU'] # List of environments to check
378
+ printed_paths = set() # To avoid duplicates
379
+ existing_paths = set() # Collect existing paths
380
+
381
+ def should_print_path(path):
382
+ """Determine if the path should be printed, avoiding nested paths."""
383
+ path = os.path.abspath(path)
384
+ for printed_path in printed_paths:
385
+ try:
386
+ if os.path.commonpath([path, printed_path]) == printed_path:
387
+ return False
388
+ except ValueError:
389
+ # Paths on different drives; can't have a common path
390
+ continue
391
+ return True
392
+
393
+ for env in environments:
394
+ env_settings = config.settings.from_env(env)
395
+
396
+ # Get download folder
397
+ download_folder = env_settings.get('DOWNLOAD_FOLDER')
398
+ if download_folder and os.path.exists(download_folder):
399
+ download_folder = os.path.normpath(download_folder)
400
+ if should_print_path(download_folder):
401
+ existing_paths.add(download_folder)
402
+ printed_paths.add(download_folder)
403
+
404
+ # Get EQPath
405
+ eq_path = env_settings.get('EQPATH')
406
+ if eq_path:
407
+ eq_path = os.path.normpath(os.path.join(eq_path, "maps"))
408
+ if os.path.exists(eq_path) and should_print_path(eq_path):
409
+ existing_paths.add(eq_path)
410
+ printed_paths.add(eq_path)
411
+
412
+ # Special resources
413
+ special_resources = env_settings.get('SPECIAL_RESOURCES', {})
414
+ for resource_id, resource_info in special_resources.items():
415
+ # Get paths from special resources
416
+ custom_path = resource_info.get('custom_path', '')
417
+ default_path = resource_info.get('default_path', '')
418
+
419
+ paths = set()
420
+
421
+ if custom_path:
422
+ paths.add(os.path.normpath(custom_path))
423
+ if default_path and download_folder:
424
+ paths.add(os.path.normpath(os.path.join(download_folder, default_path)))
425
+
426
+ for path in paths:
427
+ if os.path.exists(path) and should_print_path(path):
428
+ existing_paths.add(path)
429
+ printed_paths.add(path)
430
+
431
+ # Also inform about the configuration directory
432
+ config_dir = os.environ.get('REDFETCH_CONFIG_DIR', '')
433
+ if config_dir and os.path.exists(config_dir):
434
+ # Delete configuration files
435
+ files_to_delete = [
436
+ os.path.join(config_dir, '.env'),
437
+ os.path.join(config_dir, 'settings.local.toml')
438
+ ]
439
+
440
+ # Add any .db files from config root (legacy location)
441
+ db_files = [f for f in os.listdir(config_dir) if f.endswith('.db')]
442
+ files_to_delete.extend([os.path.join(config_dir, f) for f in db_files])
443
+
444
+ for file_path in files_to_delete:
445
+ if os.path.exists(file_path):
446
+ try:
447
+ os.remove(file_path)
448
+ except Exception as e:
449
+ console.print(f"[red]Failed to delete {file_path}: {e}[/red]")
450
+
451
+ # Delete the entire .cache directory
452
+ cache_dir = os.path.join(config_dir, '.cache')
453
+ if os.path.isdir(cache_dir):
454
+ try:
455
+ _release_disk_caches()
456
+ except Exception as e:
457
+ console.print(f"[red]Failed to release cache handles: {e}[/red]")
458
+ try:
459
+ import shutil
460
+ shutil.rmtree(cache_dir)
461
+ except Exception as e:
462
+ console.print(f"[red]Failed to delete cache directory: {e}[/red]")
463
+ # Provide extra context for common Windows multi-user / shared-dir scenarios
464
+ winerror = getattr(e, "winerror", None)
465
+ if os.name == "nt" and winerror == 32:
466
+ console.print(
467
+ "[yellow]Windows reports that the cache is in use by another process. "
468
+ "This often happens when another redfetch instance is still running, "
469
+ "or when multiple Windows user accounts share the same redfetch folder "
470
+ "(for example under C:\\Users\\Public\\redfetch).[/yellow]"
471
+ )
472
+
473
+ if should_print_path(config_dir):
474
+ existing_paths.add(config_dir)
475
+ printed_paths.add(config_dir)
476
+
477
+ if existing_paths:
478
+ console.print("The following directories may contain files downloaded by redfetch:")
479
+ for path in sorted(existing_paths):
480
+ console.print(f" - [cyan]{path}[/cyan]")
481
+
482
+ # Generate OS-specific commands to remove the directories
483
+ commands = generate_removal_commands(existing_paths)
484
+ write_commands_to_file(commands, existing_paths)
485
+ else:
486
+ console.print("[green]No existing directories found that need manual cleanup.[/green]\n")
487
+
488
+ if executable_path:
489
+ # Ask the user if they want to proceed with self-uninstall
490
+ if Confirm.ask("Would you like to uninstall redfetch's little python environment?"):
491
+ # Now, perform self-remove
492
+ self_remove()
493
+ else:
494
+ console.print("[yellow]Uninstallation canceled.[/yellow]")
495
+ sys.exit(0)
496
+ else:
497
+ # If executable_path is not set, guide the user to uninstall via pip or pipx
498
+ console.print("\n[bold]To uninstall redfetch, please run the following command:[/bold]")
499
+ if install_method == 'pipx':
500
+ console.print(" [cyan]pipx uninstall redfetch[/cyan]")
501
+ elif install_method == 'uv':
502
+ console.print(" [cyan]uv tool uninstall redfetch[/cyan]")
503
+ else:
504
+ console.print(" [cyan]pip uninstall redfetch[/cyan]")
505
+ # Optionally, exit the program
506
+ sys.exit(0)
507
+
508
+
509
+ def generate_removal_commands(paths):
510
+ """Generate OS-specific commands to remove the given directories."""
511
+ def deepest_first(path: str) -> tuple[int, str]:
512
+ depth = path.replace("\\", "/").rstrip("/").count("/")
513
+ return (-depth, path.casefold())
514
+
515
+ system = platform.system()
516
+ if system == 'Windows':
517
+ # Generate PowerShell commands
518
+ console.print("[bold]These directories may be removed manually after you make sure there's nothing you need from them, you can do so by running the following PowerShell commands:[/bold]\n")
519
+ commands = []
520
+ for path in sorted(paths, key=deepest_first):
521
+ escaped_path = path.replace("'", "''")
522
+ command = f"Remove-Item -LiteralPath '{escaped_path}' -Recurse -Force"
523
+ commands.append(command)
524
+ console.print(f" {command}")
525
+ else:
526
+ # Assuming Unix-like system
527
+ console.print("[bold]You can remove these directories by running the following commands in your terminal:[/bold]\n")
528
+ commands = []
529
+ for path in sorted(paths, key=deepest_first):
530
+ escaped_path = path.replace("'", "'\\''")
531
+ command = f"rm -rf '{escaped_path}'"
532
+ commands.append(command)
533
+ console.print(f" {command}")
534
+ console.print("\n[bold yellow]These directories must be removed manually.[/bold yellow]")
535
+ return commands
536
+
537
+
538
+ def write_commands_to_file(commands, paths):
539
+ """Write the removal commands and additional information to a text file and open it on Windows."""
540
+ # Only write and open the file on Windows
541
+ if platform.system() == 'Windows':
542
+ file_path = os.path.join(os.path.expanduser("~"), "redfetch_removal_commands.txt")
543
+ with open(file_path, 'w') as file:
544
+ file.write("Manual Cleanup Instructions:\n")
545
+ file.write("The following directories may contain files downloaded by redfetch. You can remove them manually if you want:\n")
546
+ for path in sorted(paths):
547
+ file.write(f" - {path}\n")
548
+ file.write("\nMake sure there's nothing you want in them. When ready to delete, you can use:\n\n")
549
+
550
+ for command in commands:
551
+ file.write(command + '\n')
552
+
553
+ # Automatically open the file with the default text editor
554
+ try:
555
+ os.startfile(file_path)
556
+ except Exception as e:
557
+ console.print(f"[red]Failed to open the file: {e}[/red]")
558
+ console.print(f"Please open the file manually: [cyan]{file_path}[/cyan]")
559
+ else:
560
+ # On non-Windows systems, the important information is already printed to the console
561
+ console.print("[yellow]After that, you can remove the redfetch package.[/yellow]")