redfetch 1.3.0__tar.gz → 1.4.0__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 (63) hide show
  1. {redfetch-1.3.0 → redfetch-1.4.0}/.github/workflows/pipeline.yml +28 -12
  2. {redfetch-1.3.0 → redfetch-1.4.0}/.gitignore +4 -0
  3. redfetch-1.4.0/AGENTS.md +21 -0
  4. {redfetch-1.3.0 → redfetch-1.4.0}/PKG-INFO +5 -5
  5. {redfetch-1.3.0 → redfetch-1.4.0}/pyproject.toml +3 -3
  6. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/__about__.py +2 -2
  7. redfetch-1.4.0/src/redfetch/api.py +50 -0
  8. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/config.py +3 -19
  9. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/download.py +199 -36
  10. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/listener.py +14 -4
  11. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/main.py +54 -157
  12. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/navmesh.py +32 -98
  13. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/net.py +1 -10
  14. redfetch-1.4.0/src/redfetch/post_update.py +141 -0
  15. redfetch-1.4.0/src/redfetch/processes.py +290 -0
  16. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/runtime_errors.py +1 -3
  17. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/store.py +11 -15
  18. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/sync.py +74 -29
  19. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/sync_discovery.py +17 -53
  20. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/sync_planner.py +1 -3
  21. redfetch-1.4.0/src/redfetch/sync_remote.py +135 -0
  22. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/sync_types.py +33 -4
  23. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/terminal_ui.py +347 -315
  24. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/terminal_ui.tcss +21 -71
  25. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/utils.py +56 -2
  26. {redfetch-1.3.0 → redfetch-1.4.0}/tests/test_check.py +2 -2
  27. redfetch-1.4.0/tests/test_close_gating.py +787 -0
  28. redfetch-1.4.0/tests/test_fetch_sync_info.py +67 -0
  29. redfetch-1.4.0/tests/test_licensed_resources_filtering.py +83 -0
  30. redfetch-1.4.0/tests/test_process_lock.py +48 -0
  31. redfetch-1.4.0/tests/test_remote_snapshot.py +97 -0
  32. redfetch-1.4.0/tests/test_staged_extraction.py +340 -0
  33. {redfetch-1.3.0 → redfetch-1.4.0}/tests/test_sync_queue_rules.py +29 -8
  34. redfetch-1.3.0/src/redfetch/api.py +0 -134
  35. redfetch-1.3.0/src/redfetch/processes.py +0 -118
  36. redfetch-1.3.0/src/redfetch/sync_remote.py +0 -182
  37. redfetch-1.3.0/src/redfetch/unloadmq.py +0 -148
  38. redfetch-1.3.0/tests/test_breadcrumb.py +0 -56
  39. redfetch-1.3.0/tests/test_cancellation_e2e.py +0 -123
  40. redfetch-1.3.0/tests/test_licensed_resources_filtering.py +0 -320
  41. redfetch-1.3.0/tests/test_publish_message_file_fallback.py +0 -158
  42. redfetch-1.3.0/tests/test_uninstall_e2e.py +0 -233
  43. {redfetch-1.3.0 → redfetch-1.4.0}/CHANGELOG.md +0 -0
  44. {redfetch-1.3.0 → redfetch-1.4.0}/LICENSE +0 -0
  45. {redfetch-1.3.0 → redfetch-1.4.0}/README.md +0 -0
  46. {redfetch-1.3.0 → redfetch-1.4.0}/redfetch.ico +0 -0
  47. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/__init__.py +0 -0
  48. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/auth.py +0 -0
  49. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/config_firstrun.py +0 -0
  50. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/desktop_shortcut.py +0 -0
  51. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/detecteq.py +0 -0
  52. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/meta.py +0 -0
  53. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/push.py +0 -0
  54. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/settings.toml +0 -0
  55. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/special.py +0 -0
  56. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/sync_executor.py +0 -0
  57. {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/update_status.py +0 -0
  58. {redfetch-1.3.0 → redfetch-1.4.0}/tests/test_env_selection.py +0 -0
  59. {redfetch-1.3.0 → redfetch-1.4.0}/tests/test_first_run_setup.py +0 -0
  60. {redfetch-1.3.0 → redfetch-1.4.0}/tests/test_is_special_or_dependency.py +0 -0
  61. {redfetch-1.3.0 → redfetch-1.4.0}/tests/test_path_reset.py +0 -0
  62. {redfetch-1.3.0 → redfetch-1.4.0}/tests/test_settings_local_deltas.py +0 -0
  63. {redfetch-1.3.0 → redfetch-1.4.0}/tests/test_sync_pipeline_regressions.py +0 -0
@@ -60,6 +60,13 @@ jobs:
60
60
  run: |
61
61
  hatch build
62
62
 
63
+ - name: Upload Python distribution
64
+ if: github.ref == 'refs/heads/develop'
65
+ uses: actions/upload-artifact@v7
66
+ with:
67
+ name: python-dist
68
+ path: dist/
69
+
63
70
  - name: Get Version
64
71
  id: get_version
65
72
  run: |
@@ -89,22 +96,26 @@ jobs:
89
96
  with:
90
97
  fetch-depth: 0 # Ensure full Git history is available for versioning
91
98
 
99
+ - name: Download Python distribution
100
+ if: github.ref == 'refs/heads/develop'
101
+ uses: actions/download-artifact@v8
102
+ with:
103
+ name: python-dist
104
+ path: dist
105
+
92
106
  - name: Set Environment Variables
93
107
  id: set_env_vars
94
108
  shell: pwsh
95
109
  run: |
96
- # Set static environment variables
97
- "PYAPP_PROJECT_NAME=redfetch" >> $env:GITHUB_ENV
98
110
  "PYAPP_EXEC_MODULE=redfetch.main" >> $env:GITHUB_ENV
99
111
  "PYAPP_PASS_LOCATION=1" >> $env:GITHUB_ENV
100
112
 
101
- # Conditionally set PYAPP_PIP_EXTRA_ARGS based on the branch or tag
102
113
  if ($env:GITHUB_REF -eq 'refs/heads/develop') {
103
- $pipArgs = "--index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/"
114
+ "PYAPP_EMBED_WHEEL=1" >> $env:GITHUB_ENV
104
115
  } else {
105
- $pipArgs = ""
116
+ "PYAPP_PROJECT_NAME=redfetch" >> $env:GITHUB_ENV
106
117
  }
107
- "PYAPP_PIP_EXTRA_ARGS=$pipArgs" >> $env:GITHUB_ENV
118
+ "PYAPP_PIP_EXTRA_ARGS=" >> $env:GITHUB_ENV
108
119
 
109
120
  # Output the values for verification
110
121
  Get-Content $env:GITHUB_ENV
@@ -145,18 +156,23 @@ jobs:
145
156
 
146
157
  - name: Build PyApp Executable
147
158
  run: |
148
- $env:PYAPP_PROJECT_NAME = "${{ env.PYAPP_PROJECT_NAME }}"
159
+ if ($env:PYAPP_EMBED_WHEEL -eq '1') {
160
+ # Develop embeds the built wheel so its deps resolve from PyPI rather than TestPyPI
161
+ $wheel = Get-ChildItem -Path "$env:GITHUB_WORKSPACE\dist\*.whl" | Select-Object -First 1
162
+ if (-not $wheel) { throw "No redfetch wheel found in dist/" }
163
+ $env:PYAPP_PROJECT_PATH = $wheel.FullName
164
+ Remove-Item Env:PYAPP_PROJECT_NAME -ErrorAction SilentlyContinue
165
+ Remove-Item Env:PYAPP_PROJECT_VERSION -ErrorAction SilentlyContinue
166
+ }
167
+
149
168
  $env:PYAPP_EXEC_MODULE = "${{ env.PYAPP_EXEC_MODULE }}"
150
169
  $env:PYAPP_PASS_LOCATION = "${{ env.PYAPP_PASS_LOCATION }}"
151
170
  $env:PYAPP_PIP_EXTRA_ARGS = "${{ env.PYAPP_PIP_EXTRA_ARGS }}"
152
- $env:PYAPP_PROJECT_VERSION = "${{ env.PYAPP_PROJECT_VERSION }}"
153
-
154
- Write-Host "PYAPP_PROJECT_NAME: $env:PYAPP_PROJECT_NAME"
171
+
155
172
  Write-Host "PYAPP_EXEC_MODULE: $env:PYAPP_EXEC_MODULE"
156
173
  Write-Host "PYAPP_PASS_LOCATION: $env:PYAPP_PASS_LOCATION"
157
174
  Write-Host "PYAPP_PIP_EXTRA_ARGS: $env:PYAPP_PIP_EXTRA_ARGS"
158
- Write-Host "PYAPP_PROJECT_VERSION: $env:PYAPP_PROJECT_VERSION"
159
-
175
+
160
176
  cargo build --release
161
177
  working-directory: .\pyapp-latest
162
178
  shell: pwsh
@@ -7,6 +7,10 @@ __pycache__/
7
7
  #virtual environments
8
8
  env/
9
9
  venv/
10
+ .venv/
11
+
12
+ # uv (project is hatch-managed)
13
+ uv.lock
10
14
 
11
15
  # Downloaded files
12
16
  Downloads/
@@ -0,0 +1,21 @@
1
+ # AGENTS.md
2
+
3
+ **redfetch** — a CLI/TUI for downloading and publishing MacroQuest and EverQuest scripts and software via the RedGuides API.
4
+
5
+ ## Running tests
6
+
7
+ Use `hatch test` — not `pytest`, `uv`, or an ad-hoc venv
8
+
9
+ ```sh
10
+ hatch test # full suite
11
+ hatch test tests/test_check.py # single file
12
+ ```
13
+ ## Libraries
14
+
15
+ - [Textual](https://textual.textualize.io/) - TUI
16
+ - [Hatch](https://hatch.pypa.io/latest/) - Build system
17
+ - [PYPA](https://www.pypa.io/en/latest/specifications/) - Package index
18
+
19
+ ## Conventions
20
+
21
+ - Use the `dev` environment (`hatch shell dev`) for development
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redfetch
3
- Version: 1.3.0
3
+ Version: 1.4.0
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/aaf2d42ab82d45abeadfafa943e2b61abba2cb60.zip
10
+ Project-URL: Source_archive, https://github.com/RedGuides/redfetch/archive/ccf5cf558b6e6f261fab4079e837d5cb47e7975d.zip
11
11
  Author-email: Redbot <ask@redguides.com>
12
12
  License-Expression: GPL-3.0-or-later
13
13
  License-File: LICENSE
@@ -21,9 +21,10 @@ Requires-Python: >=3.12
21
21
  Requires-Dist: aiofiles~=25.1
22
22
  Requires-Dist: aiohttp~=3.14
23
23
  Requires-Dist: aiosqlite~=0.22
24
- Requires-Dist: cachetools~=7.1
25
24
  Requires-Dist: diskcache~=5.6
26
- Requires-Dist: dynaconf<3.3,~=3.2
25
+ Requires-Dist: dynaconf>=3.3.2,~=3.3
26
+ Requires-Dist: filelock~=3.29
27
+ Requires-Dist: hishel[async,httpx]~=1.3
27
28
  Requires-Dist: httpx[http2]~=0.28
28
29
  Requires-Dist: keepachangelog~=2.0
29
30
  Requires-Dist: keyring~=25.7
@@ -32,7 +33,6 @@ Requires-Dist: packaging~=26.2
32
33
  Requires-Dist: platformdirs~=4.10
33
34
  Requires-Dist: psutil~=7.2
34
35
  Requires-Dist: pydantic~=2.13
35
- Requires-Dist: pyperclip~=1.11
36
36
  Requires-Dist: pywin32; sys_platform == 'win32'
37
37
  Requires-Dist: rich~=15.0
38
38
  Requires-Dist: tenacity~=9.1
@@ -21,9 +21,10 @@ dependencies = [
21
21
  "aiofiles~=25.1",
22
22
  "aiosqlite~=0.22",
23
23
  "aiohttp~=3.14",
24
- "cachetools~=7.1",
25
24
  "diskcache~=5.6",
26
- "dynaconf~=3.2,<3.3", # 3.3 has a bug
25
+ "dynaconf~=3.3,>=3.3.2", # (pr 1409 fixed RecursionError)
26
+ "filelock~=3.29",
27
+ "hishel[httpx,async]~=1.3",
27
28
  "httpx[http2]~=0.28",
28
29
  "keepachangelog~=2.0",
29
30
  "keyring~=25.7",
@@ -32,7 +33,6 @@ dependencies = [
32
33
  "pydantic~=2.13",
33
34
  "platformdirs~=4.10",
34
35
  "psutil~=7.2",
35
- "pyperclip~=1.11",
36
36
  "rich~=15.0",
37
37
  "tenacity~=9.1",
38
38
  "textual~=8.2",
@@ -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.3.0'
22
- __version_tuple__ = version_tuple = (1, 3, 0)
21
+ __version__ = version = '1.4.0'
22
+ __version_tuple__ = version_tuple = (1, 4, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,50 @@
1
+ """Resource API client: fetch_*() takes a client, get_*() is self-contained."""
2
+
3
+ import httpx
4
+ from redfetch import net
5
+ from redfetch.sync_types import SyncInfo
6
+
7
+ BASE_URL = net.BASE_URL
8
+
9
+
10
+ async def fetch_sync_info(client: httpx.AsyncClient) -> SyncInfo:
11
+ """Per-user sync info"""
12
+ url = f'{BASE_URL}/api/rgsync'
13
+ is_level_2 = is_moderator = False
14
+ watched: set[str] = set()
15
+ licenses: list[dict] = []
16
+ page = 1
17
+ while True:
18
+ data = await net.get_json(client, url, params={'page': page})
19
+ is_level_2 = bool(data.get('is_level_2', False))
20
+ is_moderator = bool(data.get('is_moderator', False))
21
+ watched.update(str(rid) for rid in data.get('watched', []))
22
+ licenses.extend(data.get('licenses', []))
23
+ if not data.get('pagination', {}).get('has_more', False):
24
+ break
25
+ page += 1
26
+
27
+ licensed_ids = {
28
+ str(lic['resource_id']) for lic in licenses if lic.get('resource_id') is not None
29
+ }
30
+ return SyncInfo(
31
+ is_level_2=is_level_2,
32
+ is_moderator=is_moderator,
33
+ watched=watched,
34
+ licensed_ids=licensed_ids,
35
+ licenses=licenses,
36
+ )
37
+
38
+
39
+ async def get_sync_info(headers: dict) -> SyncInfo:
40
+ """fetch_sync_info for callers that hold headers but not a client."""
41
+ async with httpx.AsyncClient(headers=headers, http2=True, timeout=30.0) as client:
42
+ return await fetch_sync_info(client)
43
+
44
+
45
+ async def get_resource_details(resource_id: int, headers: dict) -> dict:
46
+ """Retrieve details of a specific resource from the API."""
47
+ url = f'{BASE_URL}/api/resources/{resource_id}'
48
+ async with httpx.AsyncClient(headers=headers, http2=True, timeout=30.0) as client:
49
+ data = await net.get_json(client, url)
50
+ return data['resource']
@@ -8,6 +8,7 @@ import shutil
8
8
  # third-party
9
9
  import tomlkit
10
10
  from dynaconf import Dynaconf, Validator, ValidationError
11
+ from dynaconf.loaders import env_loader
11
12
  from platformdirs import user_config_dir, user_data_dir
12
13
 
13
14
  # Parent Category to folder
@@ -459,25 +460,8 @@ def update_setting(setting_path, setting_value, env=None):
459
460
 
460
461
 
461
462
  def write_env_to_file(new_env):
462
- """Update the environment setting in the .env file."""
463
+ """Persist the selected environment to the .env file; dynaconf reads it back via env_switcher."""
463
464
  if env_file_path is None:
464
465
  raise RuntimeError("Configuration has not been initialized. Call initialize_config() first.")
465
466
 
466
- # Read the existing content of the .env file
467
- with open(env_file_path, 'r') as file:
468
- lines = file.readlines()
469
-
470
- # Update the environment line
471
- updated = False
472
- for i, line in enumerate(lines):
473
- if line.startswith('REDFETCH_ENV='):
474
- lines[i] = f'REDFETCH_ENV={new_env}\n'
475
- updated = True
476
- break
477
-
478
- # If the environment line was not found, add it
479
- if not updated:
480
- lines.append(f'REDFETCH_ENV={new_env}\n')
481
-
482
- # Write the updated content back to the .env file
483
- atomic_write_text(env_file_path, ''.join(lines))
467
+ env_loader.write(env_file_path, {"REDFETCH_ENV": new_env})
@@ -1,8 +1,12 @@
1
1
  # standard
2
2
  import os
3
3
  import shutil
4
- import time
5
4
  import hashlib
5
+ import stat
6
+ import sys
7
+ import time
8
+ import zlib
9
+ from pathlib import Path
6
10
  from zipfile import ZipFile, is_zipfile
7
11
  import asyncio
8
12
 
@@ -206,6 +210,7 @@ def extract_and_discard_zip(zip_path, extract_to, resource_id, should_flatten=Fa
206
210
 
207
211
  if protected_files is None:
208
212
  protected_files = config.settings.from_env(config.settings.ENV).PROTECTED_FILES_BY_RESOURCE.get(resource_id, [])
213
+ sweep_stale_swap_files(extract_to)
209
214
  if should_flatten:
210
215
  extract_flattened(zip_ref, extract_to, protected_files)
211
216
  else:
@@ -218,6 +223,7 @@ def extract_and_discard_zip(zip_path, extract_to, resource_id, should_flatten=Fa
218
223
  def extract_flattened(zip_ref, extract_to, protected_files):
219
224
  print(f"Flattening extraction to {extract_to}")
220
225
  protected_files_lower = {f.lower() for f in protected_files}
226
+ pairs = []
221
227
  for member in zip_ref.infolist():
222
228
  filename = os.path.basename(member.filename)
223
229
  if not filename:
@@ -228,14 +234,16 @@ def extract_flattened(zip_ref, extract_to, protected_files):
228
234
  print(f"Skipping protected file {filename}")
229
235
  continue
230
236
  if is_safe_path(extract_to, normalized_path):
231
- extract_zip_member(zip_ref, member, normalized_path)
237
+ pairs.append((member, normalized_path))
232
238
  else:
233
239
  print(f"Skipping unsafe file {member.filename}")
240
+ _extract_members_staged(zip_ref, pairs)
234
241
 
235
242
 
236
243
  def extract_with_structure(zip_ref, extract_to, protected_files):
237
244
  print(f"Extracting with structure to {extract_to}")
238
245
  protected_files_lower = {f.lower() for f in protected_files}
246
+ pairs = []
239
247
  for member in zip_ref.infolist():
240
248
  target_path = os.path.join(extract_to, member.filename)
241
249
  normalized_path = os.path.normpath(target_path)
@@ -248,45 +256,200 @@ def extract_with_structure(zip_ref, extract_to, protected_files):
248
256
  if member.is_dir():
249
257
  os.makedirs(normalized_path, exist_ok=True)
250
258
  continue
251
- extract_zip_member(zip_ref, member, normalized_path)
259
+ pairs.append((member, normalized_path))
260
+ _extract_members_staged(zip_ref, pairs)
252
261
 
253
262
 
254
- def extract_zip_member(zip_ref, member, target_path):
255
- os.makedirs(os.path.dirname(target_path), exist_ok=True)
256
- max_retries = 3
257
- retry_delay = 10 # seconds
263
+ def _extract_members_staged(zip_ref, pairs) -> None:
264
+ """Stage every member to a sibling ``.rfnew``, then swap all in."""
265
+ # duplicate targets, last member wins
266
+ fold = str.lower if sys.platform == "darwin" else os.path.normcase
267
+ deduped = {fold(target): (member, target) for member, target in pairs}
268
+ staged = [] # (tmp_path, target_path)
269
+ try:
270
+ for member, target_path in deduped.values():
271
+ os.makedirs(os.path.dirname(target_path), exist_ok=True)
272
+ if _member_matches_disk(zip_ref, member, target_path):
273
+ continue
274
+ # sibling temp: same volume, so the swap is an atomic rename
275
+ tmp_path = target_path + ".rfnew"
276
+ try:
277
+ with zip_ref.open(member) as source, open(tmp_path, 'wb') as target:
278
+ shutil.copyfileobj(source, target)
279
+ except BaseException:
280
+ _remove_if_exists(tmp_path)
281
+ raise
282
+ staged.append((tmp_path, target_path))
283
+ except BaseException as e: # cancellation must clean up too
284
+ for tmp_path, _ in staged:
285
+ _remove_if_exists(tmp_path)
286
+ if isinstance(e, Exception):
287
+ print(f"Extraction failed while staging; no files were changed: {e}")
288
+ raise
258
289
 
259
- for attempt in range(max_retries):
290
+ failed = []
291
+ for tmp_path, target_path in staged:
260
292
  try:
261
- with zip_ref.open(member) as source, open(target_path, 'wb') as target:
262
- shutil.copyfileobj(source, target)
263
- return # Successful extraction, exit the function
293
+ displaced = _swap_into_place(tmp_path, target_path)
264
294
  except PermissionError:
265
- file_name = os.path.basename(target_path)
266
- folder_path = os.path.dirname(target_path)
267
-
268
- error_msg = [
269
- f"\nPermission Error: Unable to extract {file_name}",
270
- "\nThis could be because:",
271
- "1. The file is currently in use by another program (e.g., MacroQuest, EQBCS)",
272
- "2. You don't have write permissions for this location",
273
- "\nPossible solutions:",
274
- "1. Close all EverQuest-related programs (MacroQuest, EQBCS, etc.)",
275
- f"2. Change the installation directory in settings to a location you own",
276
- f"3. Manually set write permissions on: {folder_path}",
277
- ]
278
-
279
- if attempt < max_retries - 1:
280
- error_msg.append(f"\nRetrying in {retry_delay} seconds... (Attempt {attempt + 1} of {max_retries})")
281
- print("\n".join(error_msg))
282
- time.sleep(retry_delay)
283
- else:
284
- error_msg.append("\nMaximum retry attempts reached. Please resolve the permission issue and try again.")
285
- print("\n".join(error_msg))
286
- raise PermissionError(f"Failed to extract {file_name} after {max_retries} attempts.")
287
- except Exception as e:
288
- print(f"Unexpected error while extracting {os.path.basename(target_path)}: {str(e)}")
289
- raise
295
+ _report_locked_target(target_path)
296
+ failed.append(target_path)
297
+ continue
298
+ except OSError as e: # e.g. AV quarantined the .rfnew; don't abort the rest
299
+ print(f"Could not update {os.path.basename(target_path)}: {e}")
300
+ _remove_if_exists(tmp_path)
301
+ failed.append(target_path)
302
+ continue
303
+ if displaced:
304
+ print(f"Staged update for {os.path.basename(target_path)}; applies on next launch.")
305
+ if failed:
306
+ names = ", ".join(os.path.basename(path) for path in failed)
307
+ raise OSError(f"Could not update file(s): {names}")
308
+
309
+
310
+ def _swap_into_place(tmp_path: str, target_path: str) -> bool:
311
+ """On Windows, files that are in use (like loaded executables) can't be overwritten directly.
312
+ Instead, we rename the locked file out of the way and put the new one in its place."""
313
+ try:
314
+ os.replace(tmp_path, target_path)
315
+ return False
316
+ except PermissionError:
317
+ pass
318
+
319
+ if os.path.isdir(target_path):
320
+ # a directory shadowing a file member
321
+ _remove_if_exists(tmp_path)
322
+ raise IsADirectoryError(f"Target is a directory: {target_path}")
323
+
324
+ # a read-only target fails the same way a locked one does
325
+ cleared_readonly = _clear_readonly(target_path)
326
+ if cleared_readonly:
327
+ try:
328
+ os.replace(tmp_path, target_path)
329
+ return False
330
+ except PermissionError:
331
+ pass
332
+
333
+ old_path = _displacement_path(target_path)
334
+ try:
335
+ os.replace(target_path, old_path)
336
+ except PermissionError:
337
+ if cleared_readonly:
338
+ _restore_readonly(target_path)
339
+ _remove_if_exists(tmp_path)
340
+ raise
341
+ _mark_fresh(old_path) # rename kept the old mtime; a concurrent sweep would eat it
342
+ try:
343
+ os.replace(tmp_path, target_path)
344
+ except BaseException:
345
+ # roll back so the target never goes missing
346
+ try:
347
+ os.replace(old_path, target_path)
348
+ except OSError:
349
+ pass
350
+ else:
351
+ if cleared_readonly:
352
+ _restore_readonly(target_path)
353
+ _remove_if_exists(tmp_path)
354
+ raise
355
+ return True
356
+
357
+
358
+ def _displacement_path(target_path: str) -> str:
359
+ """Pick a free name for the displaced old file."""
360
+ for n in range(1000):
361
+ candidate = f"{target_path}.rfold{n or ''}"
362
+ _remove_if_exists(candidate)
363
+ if not os.path.exists(candidate):
364
+ return candidate
365
+ return candidate # 1000 stuck generations: let the swap fail loudly
366
+
367
+
368
+ def _member_matches_disk(zip_ref, member, target_path: str) -> bool:
369
+ """checks if the file on disk matches the zip member, to skip the swap"""
370
+ try:
371
+ if not os.path.isfile(target_path) or os.path.getsize(target_path) != member.file_size:
372
+ return False
373
+ crc = 0
374
+ with open(target_path, 'rb') as existing:
375
+ for chunk in iter(lambda: existing.read(262_144), b""):
376
+ crc = zlib.crc32(chunk, crc)
377
+ return crc == member.CRC
378
+ except OSError:
379
+ return False
380
+
381
+
382
+ def _clear_readonly(path: str) -> bool:
383
+ """True if *path* was read-only and the attribute was cleared."""
384
+ try:
385
+ if os.access(path, os.W_OK):
386
+ return False
387
+ os.chmod(path, stat.S_IWRITE)
388
+ return True
389
+ except OSError:
390
+ return False
391
+
392
+
393
+ def _restore_readonly(path: str) -> None:
394
+ """Put back a read-only bit we cleared for a swap that then failed."""
395
+ try:
396
+ os.chmod(path, stat.S_IREAD)
397
+ except OSError:
398
+ pass
399
+
400
+
401
+ def _mark_fresh(path: str) -> None:
402
+ """Stamp now so the mtime sweep guard sees an in-flight file, not stale debris."""
403
+ try:
404
+ os.utime(path)
405
+ except OSError:
406
+ pass
407
+
408
+
409
+ def _remove_if_exists(path: str) -> None:
410
+ try:
411
+ os.remove(path)
412
+ except PermissionError:
413
+ # the read-only bit survives displacement renames and blocks deletion
414
+ try:
415
+ os.chmod(path, stat.S_IWRITE)
416
+ os.remove(path)
417
+ except OSError:
418
+ pass
419
+ except OSError:
420
+ pass
421
+
422
+
423
+ _STALE_DEBRIS_AGE = 3600 # seconds
424
+
425
+
426
+ def sweep_stale_swap_files(directory: str) -> None:
427
+ """Recursively remove .rfnew/.rfold debris left by prior runs."""
428
+ cutoff = time.time() - _STALE_DEBRIS_AGE
429
+ root = Path(directory)
430
+ # rglob does not interpret glob wildcards within directory names, e.g. "Very[Vanilla]MQ" is matched literally
431
+ for pattern in ("*.rfnew", "*.rfold*"):
432
+ for path in root.rglob(pattern):
433
+ try:
434
+ if os.path.getmtime(path) >= cutoff:
435
+ continue
436
+ except OSError:
437
+ continue
438
+ _remove_if_exists(str(path))
439
+
440
+
441
+ def _report_locked_target(target_path: str) -> None:
442
+ """Friendly report for swap failure."""
443
+ file_name = os.path.basename(target_path)
444
+ folder_path = os.path.dirname(target_path)
445
+ print("\n".join([
446
+ f"\nPermission Error: Unable to update {file_name}",
447
+ "\nThe file is held open by another program and can't be replaced.",
448
+ "\nPossible solutions:",
449
+ "1. Close the program holding it open (e.g. one keeping a database file open)",
450
+ "2. Change the installation directory in settings to a location you own",
451
+ f"3. Manually set write permissions on: {folder_path}",
452
+ ]))
290
453
 
291
454
 
292
455
  def delete_zip_file(zip_path):
@@ -89,9 +89,14 @@ async def handle_download(request: web.Request) -> web.Response:
89
89
  try:
90
90
  resource_id_str = str(resource_id)
91
91
  db_path = store.get_db_path(db_name)
92
- success = await sync.run_sync(db_path, headers, resource_ids=[resource_id_str])
93
- if success:
92
+ outcome = await sync.run_sync(db_path, headers, resource_ids=[resource_id_str])
93
+ if outcome.success:
94
94
  return web.json_response({"success": True, "message": "Download completed successfully."})
95
+ if outcome.status == "busy":
96
+ return web.json_response(
97
+ {"success": False, "message": "Another update is already in progress; please retry shortly."},
98
+ status=503, headers={"Retry-After": "5"},
99
+ )
95
100
  return web.json_response({"success": False, "message": "Download failed due to internal error."}, status=500)
96
101
  except Exception as e:
97
102
  log.error("Error during download: %s", e, exc_info=True)
@@ -105,11 +110,16 @@ async def handle_download_watched(request: web.Request) -> web.Response:
105
110
 
106
111
  try:
107
112
  db_path = store.get_db_path(db_name)
108
- success = await sync.run_sync(db_path, headers)
109
- if success:
113
+ outcome = await sync.run_sync(db_path, headers)
114
+ if outcome.success:
110
115
  return web.json_response(
111
116
  {"success": True, "message": "All watched resources downloaded successfully."}
112
117
  )
118
+ if outcome.status == "busy":
119
+ return web.json_response(
120
+ {"success": False, "message": "Another update is already in progress; please retry shortly."},
121
+ status=503, headers={"Retry-After": "5"},
122
+ )
113
123
  return web.json_response(
114
124
  {"success": False, "message": "Download of one or more resources failed."}, status=500
115
125
  )