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/__about__.py +24 -0
- redfetch/__init__.py +0 -0
- redfetch/api.py +134 -0
- redfetch/auth.py +573 -0
- redfetch/config.py +483 -0
- redfetch/config_firstrun.py +455 -0
- redfetch/desktop_shortcut.py +81 -0
- redfetch/detecteq.py +116 -0
- redfetch/download.py +312 -0
- redfetch/listener.py +216 -0
- redfetch/main.py +744 -0
- redfetch/meta.py +561 -0
- redfetch/navmesh.py +371 -0
- redfetch/net.py +109 -0
- redfetch/processes.py +118 -0
- redfetch/push.py +246 -0
- redfetch/redfetch.ico +0 -0
- redfetch/runtime_errors.py +96 -0
- redfetch/settings.toml +303 -0
- redfetch/special.py +81 -0
- redfetch/store.py +505 -0
- redfetch/sync.py +261 -0
- redfetch/sync_discovery.py +352 -0
- redfetch/sync_executor.py +164 -0
- redfetch/sync_planner.py +196 -0
- redfetch/sync_remote.py +182 -0
- redfetch/sync_types.py +348 -0
- redfetch/terminal_ui.py +2318 -0
- redfetch/terminal_ui.tcss +569 -0
- redfetch/unloadmq.py +148 -0
- redfetch/update_status.py +62 -0
- redfetch/utils.py +256 -0
- redfetch-1.3.0.dist-info/METADATA +257 -0
- redfetch-1.3.0.dist-info/RECORD +37 -0
- redfetch-1.3.0.dist-info/WHEEL +4 -0
- redfetch-1.3.0.dist-info/entry_points.txt +2 -0
- redfetch-1.3.0.dist-info/licenses/LICENSE +674 -0
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]")
|