redfetch 1.4.3__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.
Files changed (61) hide show
  1. {redfetch-1.4.3 → redfetch-1.4.4}/CHANGELOG.md +5 -0
  2. {redfetch-1.4.3 → redfetch-1.4.4}/PKG-INFO +2 -2
  3. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/__about__.py +2 -2
  4. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/auth.py +1 -1
  5. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/download.py +11 -1
  6. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/net.py +10 -1
  7. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/terminal_ui.py +31 -9
  8. redfetch-1.4.4/tests/test_oauth_401_recovery.py +94 -0
  9. {redfetch-1.4.3 → redfetch-1.4.4}/tests/test_staged_extraction.py +49 -0
  10. {redfetch-1.4.3 → redfetch-1.4.4}/.github/workflows/pipeline.yml +0 -0
  11. {redfetch-1.4.3 → redfetch-1.4.4}/.gitignore +0 -0
  12. {redfetch-1.4.3 → redfetch-1.4.4}/AGENTS.md +0 -0
  13. {redfetch-1.4.3 → redfetch-1.4.4}/LICENSE +0 -0
  14. {redfetch-1.4.3 → redfetch-1.4.4}/README.md +0 -0
  15. {redfetch-1.4.3 → redfetch-1.4.4}/pyapp_launcher.py +0 -0
  16. {redfetch-1.4.3 → redfetch-1.4.4}/pyproject.toml +0 -0
  17. {redfetch-1.4.3 → redfetch-1.4.4}/redfetch.ico +0 -0
  18. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/__init__.py +0 -0
  19. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/api.py +0 -0
  20. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/config.py +0 -0
  21. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/config_firstrun.py +0 -0
  22. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/desktop_shortcut.py +0 -0
  23. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/detecteq.py +0 -0
  24. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/listener.py +0 -0
  25. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/main.py +0 -0
  26. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/meta.py +0 -0
  27. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/navmesh.py +0 -0
  28. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/post_update.py +0 -0
  29. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/processes.py +0 -0
  30. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/push.py +0 -0
  31. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/runtime_errors.py +0 -0
  32. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/settings.toml +0 -0
  33. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/shortcuts.py +0 -0
  34. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/special.py +0 -0
  35. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/store.py +0 -0
  36. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/sync.py +0 -0
  37. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/sync_discovery.py +0 -0
  38. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/sync_executor.py +0 -0
  39. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/sync_planner.py +0 -0
  40. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/sync_remote.py +0 -0
  41. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/sync_types.py +0 -0
  42. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/terminal_ui.tcss +0 -0
  43. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/update_status.py +0 -0
  44. {redfetch-1.4.3 → redfetch-1.4.4}/src/redfetch/utils.py +0 -0
  45. {redfetch-1.4.3 → redfetch-1.4.4}/tests/cli_reference.py +0 -0
  46. {redfetch-1.4.3 → redfetch-1.4.4}/tests/test_autologin_eqpath.py +0 -0
  47. {redfetch-1.4.3 → redfetch-1.4.4}/tests/test_check.py +0 -0
  48. {redfetch-1.4.3 → redfetch-1.4.4}/tests/test_close_gating.py +0 -0
  49. {redfetch-1.4.3 → redfetch-1.4.4}/tests/test_env_selection.py +0 -0
  50. {redfetch-1.4.3 → redfetch-1.4.4}/tests/test_fetch_sync_info.py +0 -0
  51. {redfetch-1.4.3 → redfetch-1.4.4}/tests/test_first_run_setup.py +0 -0
  52. {redfetch-1.4.3 → redfetch-1.4.4}/tests/test_is_special_or_dependency.py +0 -0
  53. {redfetch-1.4.3 → redfetch-1.4.4}/tests/test_licensed_resources_filtering.py +0 -0
  54. {redfetch-1.4.3 → redfetch-1.4.4}/tests/test_path_reset.py +0 -0
  55. {redfetch-1.4.3 → redfetch-1.4.4}/tests/test_process_lock.py +0 -0
  56. {redfetch-1.4.3 → redfetch-1.4.4}/tests/test_remote_snapshot.py +0 -0
  57. {redfetch-1.4.3 → redfetch-1.4.4}/tests/test_self_heal_eqpath.py +0 -0
  58. {redfetch-1.4.3 → redfetch-1.4.4}/tests/test_settings_local_deltas.py +0 -0
  59. {redfetch-1.4.3 → redfetch-1.4.4}/tests/test_shortcuts.py +0 -0
  60. {redfetch-1.4.3 → redfetch-1.4.4}/tests/test_sync_pipeline_regressions.py +0 -0
  61. {redfetch-1.4.3 → redfetch-1.4.4}/tests/test_sync_queue_rules.py +0 -0
@@ -5,6 +5,11 @@ 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
+
8
13
  ## [1.4.3] - 2026-07-06
9
14
 
10
15
  ### Added
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redfetch
3
- Version: 1.4.3
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/9161a88b101d6df6ce9a697736e3fcc57b1c3122.zip
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
@@ -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.3'
22
- __version_tuple__ = version_tuple = (1, 4, 3)
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. Please run `redfetch logout` and authorize again.")
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
 
@@ -104,7 +104,17 @@ async def download_file_async(
104
104
  if tmp_path is None:
105
105
  return False
106
106
 
107
- os.replace(tmp_path, file_path)
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:
@@ -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
- else:
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 = not app.is_level_2
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
- else:
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(False)
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
- username = await auth.get_username()
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
- headers = await auth.get_api_headers()
1960
- sync_info = await api.get_sync_info(headers)
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