redfetch 1.4.2__tar.gz → 1.4.4__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.
- {redfetch-1.4.2 → redfetch-1.4.4}/.github/workflows/pipeline.yml +3 -3
- {redfetch-1.4.2 → redfetch-1.4.4}/CHANGELOG.md +10 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/PKG-INFO +2 -2
- redfetch-1.4.4/pyapp_launcher.py +99 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/__about__.py +2 -2
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/auth.py +1 -1
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/config.py +6 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/config_firstrun.py +25 -1
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/download.py +11 -1
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/main.py +3 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/net.py +10 -1
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/terminal_ui.py +31 -9
- redfetch-1.4.4/tests/test_oauth_401_recovery.py +94 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/tests/test_staged_extraction.py +49 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/.gitignore +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/AGENTS.md +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/LICENSE +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/README.md +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/pyproject.toml +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/redfetch.ico +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/__init__.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/api.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/desktop_shortcut.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/detecteq.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/listener.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/meta.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/navmesh.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/post_update.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/processes.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/push.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/runtime_errors.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/settings.toml +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/shortcuts.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/special.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/store.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/sync.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/sync_discovery.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/sync_executor.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/sync_planner.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/sync_remote.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/sync_types.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/terminal_ui.tcss +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/update_status.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/src/redfetch/utils.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/tests/cli_reference.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/tests/test_autologin_eqpath.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/tests/test_check.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/tests/test_close_gating.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/tests/test_env_selection.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/tests/test_fetch_sync_info.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/tests/test_first_run_setup.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/tests/test_is_special_or_dependency.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/tests/test_licensed_resources_filtering.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/tests/test_path_reset.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/tests/test_process_lock.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/tests/test_remote_snapshot.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/tests/test_self_heal_eqpath.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/tests/test_settings_local_deltas.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/tests/test_shortcuts.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/tests/test_sync_pipeline_regressions.py +0 -0
- {redfetch-1.4.2 → redfetch-1.4.4}/tests/test_sync_queue_rules.py +0 -0
|
@@ -107,7 +107,7 @@ jobs:
|
|
|
107
107
|
id: set_env_vars
|
|
108
108
|
shell: pwsh
|
|
109
109
|
run: |
|
|
110
|
-
"
|
|
110
|
+
"PYAPP_EXEC_SCRIPT=$env:GITHUB_WORKSPACE\pyapp_launcher.py" >> $env:GITHUB_ENV
|
|
111
111
|
"PYAPP_PASS_LOCATION=1" >> $env:GITHUB_ENV
|
|
112
112
|
|
|
113
113
|
if ($env:GITHUB_REF -eq 'refs/heads/develop') {
|
|
@@ -165,11 +165,11 @@ jobs:
|
|
|
165
165
|
Remove-Item Env:PYAPP_PROJECT_VERSION -ErrorAction SilentlyContinue
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
$env:
|
|
168
|
+
$env:PYAPP_EXEC_SCRIPT = "${{ env.PYAPP_EXEC_SCRIPT }}"
|
|
169
169
|
$env:PYAPP_PASS_LOCATION = "${{ env.PYAPP_PASS_LOCATION }}"
|
|
170
170
|
$env:PYAPP_PIP_EXTRA_ARGS = "${{ env.PYAPP_PIP_EXTRA_ARGS }}"
|
|
171
171
|
|
|
172
|
-
Write-Host "
|
|
172
|
+
Write-Host "PYAPP_EXEC_SCRIPT: $env:PYAPP_EXEC_SCRIPT"
|
|
173
173
|
Write-Host "PYAPP_PASS_LOCATION: $env:PYAPP_PASS_LOCATION"
|
|
174
174
|
Write-Host "PYAPP_PIP_EXTRA_ARGS: $env:PYAPP_PIP_EXTRA_ARGS"
|
|
175
175
|
|
|
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.4.4] - 2026-07-18
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- A rejected OAuth token will no longer assume you're level 1.
|
|
12
|
+
|
|
13
|
+
## [1.4.3] - 2026-07-06
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- A failed install will attempt to repair itself, rather than closing immediately.
|
|
17
|
+
|
|
8
18
|
## [1.4.2] - 2026-07-06
|
|
9
19
|
|
|
10
20
|
### Fixed
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: redfetch
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.4
|
|
4
4
|
Summary: Download and publish EverQuest scripts and software using the RedGuides API
|
|
5
5
|
Project-URL: Homepage, https://www.redguides.com
|
|
6
6
|
Project-URL: Documentation, https://www.redguides.com/community/resources/redfetch.3177/
|
|
7
7
|
Project-URL: Repository, https://github.com/RedGuides/redfetch
|
|
8
8
|
Project-URL: Issues, https://github.com/RedGuides/redfetch/issues
|
|
9
9
|
Project-URL: Changelog, https://github.com/RedGuides/redfetch/blob/main/CHANGELOG.md
|
|
10
|
-
Project-URL: Source_archive, https://github.com/RedGuides/redfetch/archive/
|
|
10
|
+
Project-URL: Source_archive, https://github.com/RedGuides/redfetch/archive/d7f95d7be3adeb113e9846081bae4f5764514b72.zip
|
|
11
11
|
Author-email: Redbot <ask@redguides.com>
|
|
12
12
|
License-Expression: GPL-3.0-or-later
|
|
13
13
|
License-File: LICENSE
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""PyApp entry point for redfetch.exe: run the app, and self-heal a broken install.
|
|
2
|
+
|
|
3
|
+
PyApp treats half-installed environments as complete, so if something fails or
|
|
4
|
+
interrupts install, it stays broken unless someone opens a terminal and runs `self restore`.
|
|
5
|
+
This wrapper runs `self restore` automatically.
|
|
6
|
+
|
|
7
|
+
Embedded via PYAPP_EXEC_SCRIPT. Stdlib-only, and must not import redfetch at module
|
|
8
|
+
scope, since redfetch is exactly what may be missing!
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _import_entrypoint():
|
|
15
|
+
from redfetch.main import main
|
|
16
|
+
return main
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Repair runs after we exit, in a detached script: on Windows `self restore` can't
|
|
20
|
+
# delete the install dir while our python.exe is still running from inside it.
|
|
21
|
+
_REPAIR_BAT = r"""@echo off
|
|
22
|
+
title redfetch - finishing setup
|
|
23
|
+
echo.
|
|
24
|
+
echo redfetch setup didn't finish!
|
|
25
|
+
echo Trying to finish it now, this may take a few minutes.
|
|
26
|
+
echo Please don't close this window.
|
|
27
|
+
echo.
|
|
28
|
+
timeout /t 3 /nobreak > nul
|
|
29
|
+
"<EXE>" self restore
|
|
30
|
+
if %errorlevel% neq 0 goto redfetch_failed
|
|
31
|
+
echo.
|
|
32
|
+
echo All set -- starting redfetch...
|
|
33
|
+
set "REDFETCH_SELFHEAL=1"
|
|
34
|
+
"<EXE>"
|
|
35
|
+
goto redfetch_cleanup
|
|
36
|
+
:redfetch_failed
|
|
37
|
+
echo.
|
|
38
|
+
echo Automatic repair didn't complete. You can repair manually by running:
|
|
39
|
+
echo "<EXE>" self restore
|
|
40
|
+
echo.
|
|
41
|
+
pause
|
|
42
|
+
:redfetch_cleanup
|
|
43
|
+
(goto) 2>nul & del "%~f0"
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _schedule_repair() -> "int | None":
|
|
48
|
+
"""Schedule `self restore` + relaunch; return 0 if scheduled, else None."""
|
|
49
|
+
import os
|
|
50
|
+
|
|
51
|
+
# PYAPP is always set (to "1" when PASS_LOCATION is off), so require a real path.
|
|
52
|
+
exe = os.environ.get("PYAPP")
|
|
53
|
+
if sys.platform != "win32" or not exe or not os.path.exists(exe):
|
|
54
|
+
return None
|
|
55
|
+
if os.environ.get("REDFETCH_SELFHEAL"): # already tried once this chain
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
import subprocess
|
|
59
|
+
import tempfile
|
|
60
|
+
|
|
61
|
+
script = _REPAIR_BAT.replace("<EXE>", exe.replace("%", "%%")) # %% so cmd won't expand the path
|
|
62
|
+
path = None
|
|
63
|
+
try:
|
|
64
|
+
fd, path = tempfile.mkstemp(prefix="redfetch-repair-", suffix=".bat")
|
|
65
|
+
with os.fdopen(fd, "w") as handle:
|
|
66
|
+
handle.write(script)
|
|
67
|
+
subprocess.Popen(
|
|
68
|
+
["cmd.exe", "/c", path],
|
|
69
|
+
creationflags=subprocess.CREATE_NEW_CONSOLE,
|
|
70
|
+
close_fds=True,
|
|
71
|
+
)
|
|
72
|
+
except Exception:
|
|
73
|
+
if path:
|
|
74
|
+
try:
|
|
75
|
+
os.unlink(path)
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
78
|
+
return None
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main() -> int:
|
|
83
|
+
try:
|
|
84
|
+
entrypoint = _import_entrypoint()
|
|
85
|
+
except ImportError:
|
|
86
|
+
code = _schedule_repair()
|
|
87
|
+
if code is None:
|
|
88
|
+
raise
|
|
89
|
+
print("redfetch needs to finish setting up; opening a repair window...",
|
|
90
|
+
file=sys.stderr)
|
|
91
|
+
return code
|
|
92
|
+
|
|
93
|
+
sys.argv[0] = "redfetch" # so typer's --help shows "redfetch", not this script's path
|
|
94
|
+
entrypoint() # errors past here are app logic, not install problems -> don't self-heal
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
raise SystemExit(main())
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '1.4.
|
|
22
|
-
__version_tuple__ = version_tuple = (1, 4,
|
|
21
|
+
__version__ = version = '1.4.4'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 4, 4)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -499,7 +499,7 @@ async def get_api_headers():
|
|
|
499
499
|
if access_token:
|
|
500
500
|
return {"Authorization": f"Bearer {access_token}"}
|
|
501
501
|
|
|
502
|
-
raise RuntimeError("OAuth token refresh failed.
|
|
502
|
+
raise RuntimeError("OAuth token refresh failed. Restart redfetch to authorize again.")
|
|
503
503
|
|
|
504
504
|
raise RuntimeError("OAuth access token is expired and no refresh token is available. Please authorize again.")
|
|
505
505
|
|
|
@@ -493,3 +493,9 @@ def write_env_to_file(new_env):
|
|
|
493
493
|
raise RuntimeError("Configuration has not been initialized. Call initialize_config() first.")
|
|
494
494
|
|
|
495
495
|
env_loader.write(env_file_path, {"REDFETCH_ENV": new_env})
|
|
496
|
+
|
|
497
|
+
# Env changed -> re-shows server/config banner.
|
|
498
|
+
try:
|
|
499
|
+
os.remove(os.path.join(os.path.dirname(env_file_path), ".banner_shown"))
|
|
500
|
+
except OSError:
|
|
501
|
+
pass
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import os
|
|
5
5
|
import platform
|
|
6
6
|
import json
|
|
7
|
+
import time
|
|
7
8
|
|
|
8
9
|
# Third-party
|
|
9
10
|
from platformdirs import user_config_dir
|
|
@@ -21,6 +22,25 @@ from redfetch.detecteq import find_everquest_uninstall_location
|
|
|
21
22
|
|
|
22
23
|
console = Console()
|
|
23
24
|
|
|
25
|
+
# server type and config dir banner
|
|
26
|
+
BANNER_COOLDOWN = 12 * 3600
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _banner_due(config_dir):
|
|
30
|
+
"""True unless the banner was shown within the cooldown window."""
|
|
31
|
+
marker = os.path.join(config_dir, ".banner_shown")
|
|
32
|
+
try:
|
|
33
|
+
return (time.time() - os.path.getmtime(marker)) > BANNER_COOLDOWN
|
|
34
|
+
except OSError:
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _mark_banner_shown(config_dir):
|
|
39
|
+
try:
|
|
40
|
+
open(os.path.join(config_dir, ".banner_shown"), "w").close()
|
|
41
|
+
except OSError:
|
|
42
|
+
pass
|
|
43
|
+
|
|
24
44
|
|
|
25
45
|
# zork-like prompt
|
|
26
46
|
class CustomPrompt(PromptBase):
|
|
@@ -260,7 +280,11 @@ def first_run_setup():
|
|
|
260
280
|
if pypi_url_override:
|
|
261
281
|
notice_lines.append(f"[bold yellow]REDFETCH_PYPI_URL:[/bold yellow] {pypi_url_override}")
|
|
262
282
|
|
|
263
|
-
|
|
283
|
+
# Env overrides always spam the status/config banner.
|
|
284
|
+
has_overrides = len(notice_lines) > 2
|
|
285
|
+
if has_overrides or _banner_due(config_dir):
|
|
286
|
+
console.print(Panel("\n".join(notice_lines), expand=False))
|
|
287
|
+
_mark_banner_shown(config_dir)
|
|
264
288
|
return config_dir
|
|
265
289
|
else:
|
|
266
290
|
console.print("[bold red]Environment file (.env) not found. Rerunning setup.[/bold red]")
|
|
@@ -104,7 +104,17 @@ async def download_file_async(
|
|
|
104
104
|
if tmp_path is None:
|
|
105
105
|
return False
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
try:
|
|
108
|
+
displaced = _swap_into_place(tmp_path, file_path)
|
|
109
|
+
except PermissionError:
|
|
110
|
+
_report_locked_target(file_path)
|
|
111
|
+
return False
|
|
112
|
+
except OSError as e:
|
|
113
|
+
print(f"Could not update {os.path.basename(file_path)}: {e}")
|
|
114
|
+
_remove_if_exists(tmp_path)
|
|
115
|
+
return False
|
|
116
|
+
if displaced:
|
|
117
|
+
print(f"Staged update for {os.path.basename(file_path)}; applies on next launch.")
|
|
108
118
|
print(f"Downloaded file {file_path}")
|
|
109
119
|
return True
|
|
110
120
|
finally:
|
|
@@ -539,6 +539,9 @@ def config_show_command(server: Optional[Env] = typer.Option(None, "--server", "
|
|
|
539
539
|
if not panel_lines:
|
|
540
540
|
panel_lines.append("[dim]No paths are currently configured or opted in for this environment.[/dim]")
|
|
541
541
|
|
|
542
|
+
# Lead with the server type so users always know which env they're on.
|
|
543
|
+
panel_lines.insert(0, f"[bold yellow]Server type:[/bold yellow] {current_env}")
|
|
544
|
+
|
|
542
545
|
console.print(Panel("\n".join(panel_lines), expand=False))
|
|
543
546
|
|
|
544
547
|
# Optional: Show full settings dict with a label
|
|
@@ -54,8 +54,17 @@ def clear_manifest_cache() -> None:
|
|
|
54
54
|
reraise=True,
|
|
55
55
|
)
|
|
56
56
|
async def get_json(client: httpx.AsyncClient, url: str, params: Optional[Dict[str, Any]] = None) -> dict:
|
|
57
|
-
"""GET JSON with retry on transient network errors."""
|
|
57
|
+
"""GET JSON with retry on transient network errors and one rejected OAuth token."""
|
|
58
58
|
response = await client.get(url, params=params, timeout=10.0)
|
|
59
|
+
if response.status_code == 401:
|
|
60
|
+
scheme, _, token = response.request.headers.get("Authorization", "").partition(" ")
|
|
61
|
+
if scheme.lower() == "bearer" and token:
|
|
62
|
+
# The server rejected a token our local expiry still trusts.
|
|
63
|
+
from redfetch import auth # lazy: auth imports this module
|
|
64
|
+
|
|
65
|
+
auth.set_token_expiry("0")
|
|
66
|
+
client.headers.update(await auth.get_api_headers())
|
|
67
|
+
response = await client.get(url, params=params, timeout=10.0)
|
|
59
68
|
response.raise_for_status()
|
|
60
69
|
return response.json()
|
|
61
70
|
|
|
@@ -8,6 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
from itertools import cycle
|
|
9
9
|
|
|
10
10
|
# third-party
|
|
11
|
+
import httpx
|
|
11
12
|
from dynaconf import ValidationError
|
|
12
13
|
from textual_fspicker import SelectDirectory
|
|
13
14
|
from rich.console import detect_legacy_windows
|
|
@@ -314,10 +315,12 @@ class FetchTab(ScrollableContainer):
|
|
|
314
315
|
app: "Redfetch" = self.app # type: ignore[assignment]
|
|
315
316
|
if not app.username:
|
|
316
317
|
return # keep the compose default until identity resolves
|
|
317
|
-
if app.is_level_2:
|
|
318
|
+
if app.is_level_2 is True:
|
|
318
319
|
greeting = f"[italic]Hail, [bold]{app.username}![/bold][/italic]"
|
|
319
|
-
|
|
320
|
+
elif app.is_level_2 is False:
|
|
320
321
|
greeting = f"Hey {app.username}, you're level 1 😞"
|
|
322
|
+
else:
|
|
323
|
+
greeting = f"Hey [bold]{app.username}[/bold]!"
|
|
321
324
|
self.query_one("#welcome_label", Label).update(greeting)
|
|
322
325
|
|
|
323
326
|
def _recompute(self) -> None:
|
|
@@ -911,13 +914,15 @@ class AccountTab(ScrollableContainer):
|
|
|
911
914
|
|
|
912
915
|
def _refresh_account_state(self) -> None:
|
|
913
916
|
app: "Redfetch" = self.app # type: ignore[assignment]
|
|
914
|
-
self.query_one("#btn_ding", Button).display =
|
|
917
|
+
self.query_one("#btn_ding", Button).display = app.is_level_2 is not True
|
|
915
918
|
if not app.username:
|
|
916
919
|
return # keep the "Loading..." compose default until the level check resolves
|
|
917
|
-
if app.is_level_2:
|
|
920
|
+
if app.is_level_2 is True:
|
|
918
921
|
text = f"[italic][bold]{app.username}, thank you for being level 2[/bold][/italic] 💛"
|
|
919
|
-
|
|
922
|
+
elif app.is_level_2 is False:
|
|
920
923
|
text = f"Hey {app.username}, you're level 1 😞 some resources won't be downloaded."
|
|
924
|
+
else:
|
|
925
|
+
text = f"Hey {app.username}, we couldn't verify your account level."
|
|
921
926
|
self.query_one("#account_label", Label).update(text)
|
|
922
927
|
|
|
923
928
|
#
|
|
@@ -1023,7 +1028,7 @@ class Redfetch(App):
|
|
|
1023
1028
|
current_env: reactive[str] = reactive(config.settings.ENV)
|
|
1024
1029
|
# User account identity and permissions: set reactively by background workers, observed by AccountTab for live updates
|
|
1025
1030
|
username: reactive[str] = reactive("")
|
|
1026
|
-
is_level_2: reactive[bool] = reactive(
|
|
1031
|
+
is_level_2: reactive[bool | None] = reactive(None)
|
|
1027
1032
|
# Startup update check: None = unknown, int = resources to be fetched
|
|
1028
1033
|
update_count: reactive[int | None] = reactive(None)
|
|
1029
1034
|
# Transient post-sync flash on the Easy Update button
|
|
@@ -1919,7 +1924,11 @@ class Redfetch(App):
|
|
|
1919
1924
|
@work
|
|
1920
1925
|
async def load_startup_status(self):
|
|
1921
1926
|
"""Set the account level, the update badge, and print an update summary at startup."""
|
|
1922
|
-
|
|
1927
|
+
try:
|
|
1928
|
+
username = await auth.get_username()
|
|
1929
|
+
except RuntimeError:
|
|
1930
|
+
print("Couldn't verify your RedGuides account right now.")
|
|
1931
|
+
return
|
|
1923
1932
|
|
|
1924
1933
|
try:
|
|
1925
1934
|
headers = await auth.get_api_headers()
|
|
@@ -1956,8 +1965,21 @@ class Redfetch(App):
|
|
|
1956
1965
|
if os.environ.get("REDFETCH_CRASH_TEST") == "ding":
|
|
1957
1966
|
raise RuntimeError("Intentional crash test from _check_ding_level_worker.")
|
|
1958
1967
|
|
|
1959
|
-
|
|
1960
|
-
|
|
1968
|
+
try:
|
|
1969
|
+
headers = await auth.get_api_headers()
|
|
1970
|
+
sync_info = await api.get_sync_info(headers)
|
|
1971
|
+
except RuntimeError as exc:
|
|
1972
|
+
# Auth is unrecoverable this session (e.g. token refresh failed).
|
|
1973
|
+
self.notify(str(exc), severity="error", timeout=10)
|
|
1974
|
+
return
|
|
1975
|
+
except (httpx.HTTPStatusError, httpx.RequestError):
|
|
1976
|
+
self.notify(
|
|
1977
|
+
"Couldn't check your account level right now.",
|
|
1978
|
+
severity="error",
|
|
1979
|
+
timeout=10,
|
|
1980
|
+
)
|
|
1981
|
+
return
|
|
1982
|
+
|
|
1961
1983
|
if sync_info.is_level_2:
|
|
1962
1984
|
# User is now level 2! AccountTab + FetchTab react to the reactives below.
|
|
1963
1985
|
self.username = self.username or await auth.get_username()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Regression tests: a bearer 401 invalidates the local token expiry and retries once."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from unittest.mock import AsyncMock, patch
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from redfetch import net
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _run_get_json(handler, headers):
|
|
13
|
+
async def go():
|
|
14
|
+
transport = httpx.MockTransport(handler)
|
|
15
|
+
async with httpx.AsyncClient(headers=headers, transport=transport) as client:
|
|
16
|
+
return await net.get_json(client, "https://example.com/api/rgsync")
|
|
17
|
+
return asyncio.run(go())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_bearer_401_invalidates_expiry_and_retries_with_fresh_headers():
|
|
21
|
+
requests: list[httpx.Request] = []
|
|
22
|
+
|
|
23
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
24
|
+
requests.append(request)
|
|
25
|
+
if len(requests) == 1:
|
|
26
|
+
return httpx.Response(401, request=request)
|
|
27
|
+
return httpx.Response(200, json={"ok": True}, request=request)
|
|
28
|
+
|
|
29
|
+
fresh_headers = AsyncMock(return_value={"Authorization": "Bearer replacement"})
|
|
30
|
+
with (
|
|
31
|
+
patch("redfetch.auth.set_token_expiry") as set_expiry,
|
|
32
|
+
patch("redfetch.auth.get_api_headers", new=fresh_headers),
|
|
33
|
+
):
|
|
34
|
+
result = _run_get_json(handler, {"Authorization": "Bearer rejected"})
|
|
35
|
+
|
|
36
|
+
assert result == {"ok": True}
|
|
37
|
+
assert [r.headers["Authorization"] for r in requests] == [
|
|
38
|
+
"Bearer rejected",
|
|
39
|
+
"Bearer replacement",
|
|
40
|
+
]
|
|
41
|
+
# Zeroing the expiry is what routes get_api_headers() into its refresh path,
|
|
42
|
+
# and makes authorize() fall through to full auth on the next launch.
|
|
43
|
+
set_expiry.assert_called_once_with("0")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_second_401_raises_without_further_retries():
|
|
47
|
+
requests: list[httpx.Request] = []
|
|
48
|
+
|
|
49
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
50
|
+
requests.append(request)
|
|
51
|
+
return httpx.Response(401, request=request)
|
|
52
|
+
|
|
53
|
+
fresh_headers = AsyncMock(return_value={"Authorization": "Bearer replacement"})
|
|
54
|
+
with (
|
|
55
|
+
patch("redfetch.auth.set_token_expiry"),
|
|
56
|
+
patch("redfetch.auth.get_api_headers", new=fresh_headers),
|
|
57
|
+
pytest.raises(httpx.HTTPStatusError) as exc_info,
|
|
58
|
+
):
|
|
59
|
+
_run_get_json(handler, {"Authorization": "Bearer rejected"})
|
|
60
|
+
|
|
61
|
+
assert exc_info.value.response.status_code == 401
|
|
62
|
+
assert len(requests) == 2
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_failed_refresh_propagates_runtime_error():
|
|
66
|
+
"""When get_api_headers() can't refresh, its RuntimeError reaches the caller."""
|
|
67
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
68
|
+
return httpx.Response(401, request=request)
|
|
69
|
+
|
|
70
|
+
failed = AsyncMock(side_effect=RuntimeError("OAuth token refresh failed."))
|
|
71
|
+
with (
|
|
72
|
+
patch("redfetch.auth.set_token_expiry"),
|
|
73
|
+
patch("redfetch.auth.get_api_headers", new=failed),
|
|
74
|
+
pytest.raises(RuntimeError, match="refresh failed"),
|
|
75
|
+
):
|
|
76
|
+
_run_get_json(handler, {"Authorization": "Bearer rejected"})
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_api_key_401_does_not_attempt_oauth_refresh():
|
|
80
|
+
requests: list[httpx.Request] = []
|
|
81
|
+
|
|
82
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
83
|
+
requests.append(request)
|
|
84
|
+
return httpx.Response(401, request=request)
|
|
85
|
+
|
|
86
|
+
fresh_headers = AsyncMock()
|
|
87
|
+
with (
|
|
88
|
+
patch("redfetch.auth.get_api_headers", new=fresh_headers),
|
|
89
|
+
pytest.raises(httpx.HTTPStatusError),
|
|
90
|
+
):
|
|
91
|
+
_run_get_json(handler, {"XF-Api-Key": "invalid"})
|
|
92
|
+
|
|
93
|
+
assert len(requests) == 1
|
|
94
|
+
fresh_headers.assert_not_awaited()
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Tests for two-phase staged extraction: stage every member, then swap all in."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import os
|
|
4
5
|
import stat
|
|
5
6
|
import sys
|
|
@@ -184,6 +185,54 @@ def test_readonly_target_updated_in_place(tmp_path, monkeypatch):
|
|
|
184
185
|
assert not list(dest.rglob("*.rfold*"))
|
|
185
186
|
|
|
186
187
|
|
|
188
|
+
def test_readonly_target_single_file_download(tmp_path, monkeypatch):
|
|
189
|
+
# non-zip downloads must share the staged-swap read-only handling
|
|
190
|
+
# simulate Windows semantics (replace onto RO dst fails) so posix CI pins this too
|
|
191
|
+
target = tmp_path / "zones.navmesh"
|
|
192
|
+
target.write_bytes(b"old")
|
|
193
|
+
os.chmod(target, stat.S_IREAD)
|
|
194
|
+
|
|
195
|
+
real_replace = os.replace
|
|
196
|
+
|
|
197
|
+
def readonly_replace(src, dst):
|
|
198
|
+
if os.path.exists(dst) and not os.access(dst, os.W_OK):
|
|
199
|
+
raise PermissionError("read-only destination")
|
|
200
|
+
return real_replace(src, dst)
|
|
201
|
+
|
|
202
|
+
monkeypatch.setattr(download.os, "replace", readonly_replace)
|
|
203
|
+
|
|
204
|
+
async def fake_stream(resp, file_path, hasher):
|
|
205
|
+
tmp = file_path + ".dl"
|
|
206
|
+
with open(tmp, "wb") as f:
|
|
207
|
+
f.write(b"new")
|
|
208
|
+
return tmp
|
|
209
|
+
|
|
210
|
+
monkeypatch.setattr(download, "_stream_to_tempfile", fake_stream)
|
|
211
|
+
|
|
212
|
+
class FakeResp:
|
|
213
|
+
headers = {}
|
|
214
|
+
|
|
215
|
+
def raise_for_status(self):
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
class FakeStream:
|
|
219
|
+
async def __aenter__(self):
|
|
220
|
+
return FakeResp()
|
|
221
|
+
|
|
222
|
+
async def __aexit__(self, *exc):
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
class FakeClient:
|
|
226
|
+
def stream(self, *args, **kwargs):
|
|
227
|
+
return FakeStream()
|
|
228
|
+
|
|
229
|
+
ok = asyncio.run(download.download_file_async(FakeClient(), "http://x", str(target)))
|
|
230
|
+
|
|
231
|
+
assert ok is True
|
|
232
|
+
assert target.read_bytes() == b"new"
|
|
233
|
+
assert not list(tmp_path.rglob("*.rfold*"))
|
|
234
|
+
|
|
235
|
+
|
|
187
236
|
def test_remove_if_exists_clears_readonly(tmp_path, monkeypatch):
|
|
188
237
|
# displaced .rfold files keep the read-only bit; sweep must still delete them.
|
|
189
238
|
# simulate Windows semantics (unlink of RO file fails) so posix CI pins this too
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|