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.
- {redfetch-1.3.0 → redfetch-1.4.0}/.github/workflows/pipeline.yml +28 -12
- {redfetch-1.3.0 → redfetch-1.4.0}/.gitignore +4 -0
- redfetch-1.4.0/AGENTS.md +21 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/PKG-INFO +5 -5
- {redfetch-1.3.0 → redfetch-1.4.0}/pyproject.toml +3 -3
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/__about__.py +2 -2
- redfetch-1.4.0/src/redfetch/api.py +50 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/config.py +3 -19
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/download.py +199 -36
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/listener.py +14 -4
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/main.py +54 -157
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/navmesh.py +32 -98
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/net.py +1 -10
- redfetch-1.4.0/src/redfetch/post_update.py +141 -0
- redfetch-1.4.0/src/redfetch/processes.py +290 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/runtime_errors.py +1 -3
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/store.py +11 -15
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/sync.py +74 -29
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/sync_discovery.py +17 -53
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/sync_planner.py +1 -3
- redfetch-1.4.0/src/redfetch/sync_remote.py +135 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/sync_types.py +33 -4
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/terminal_ui.py +347 -315
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/terminal_ui.tcss +21 -71
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/utils.py +56 -2
- {redfetch-1.3.0 → redfetch-1.4.0}/tests/test_check.py +2 -2
- redfetch-1.4.0/tests/test_close_gating.py +787 -0
- redfetch-1.4.0/tests/test_fetch_sync_info.py +67 -0
- redfetch-1.4.0/tests/test_licensed_resources_filtering.py +83 -0
- redfetch-1.4.0/tests/test_process_lock.py +48 -0
- redfetch-1.4.0/tests/test_remote_snapshot.py +97 -0
- redfetch-1.4.0/tests/test_staged_extraction.py +340 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/tests/test_sync_queue_rules.py +29 -8
- redfetch-1.3.0/src/redfetch/api.py +0 -134
- redfetch-1.3.0/src/redfetch/processes.py +0 -118
- redfetch-1.3.0/src/redfetch/sync_remote.py +0 -182
- redfetch-1.3.0/src/redfetch/unloadmq.py +0 -148
- redfetch-1.3.0/tests/test_breadcrumb.py +0 -56
- redfetch-1.3.0/tests/test_cancellation_e2e.py +0 -123
- redfetch-1.3.0/tests/test_licensed_resources_filtering.py +0 -320
- redfetch-1.3.0/tests/test_publish_message_file_fallback.py +0 -158
- redfetch-1.3.0/tests/test_uninstall_e2e.py +0 -233
- {redfetch-1.3.0 → redfetch-1.4.0}/CHANGELOG.md +0 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/LICENSE +0 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/README.md +0 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/redfetch.ico +0 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/__init__.py +0 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/auth.py +0 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/config_firstrun.py +0 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/desktop_shortcut.py +0 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/detecteq.py +0 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/meta.py +0 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/push.py +0 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/settings.toml +0 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/special.py +0 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/sync_executor.py +0 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/src/redfetch/update_status.py +0 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/tests/test_env_selection.py +0 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/tests/test_first_run_setup.py +0 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/tests/test_is_special_or_dependency.py +0 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/tests/test_path_reset.py +0 -0
- {redfetch-1.3.0 → redfetch-1.4.0}/tests/test_settings_local_deltas.py +0 -0
- {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
|
-
|
|
114
|
+
"PYAPP_EMBED_WHEEL=1" >> $env:GITHUB_ENV
|
|
104
115
|
} else {
|
|
105
|
-
|
|
116
|
+
"PYAPP_PROJECT_NAME=redfetch" >> $env:GITHUB_ENV
|
|
106
117
|
}
|
|
107
|
-
"PYAPP_PIP_EXTRA_ARGS
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
175
|
+
|
|
160
176
|
cargo build --release
|
|
161
177
|
working-directory: .\pyapp-latest
|
|
162
178
|
shell: pwsh
|
redfetch-1.4.0/AGENTS.md
ADDED
|
@@ -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
|
+
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/
|
|
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
|
|
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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (1,
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
259
|
+
pairs.append((member, normalized_path))
|
|
260
|
+
_extract_members_staged(zip_ref, pairs)
|
|
252
261
|
|
|
253
262
|
|
|
254
|
-
def
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
290
|
+
failed = []
|
|
291
|
+
for tmp_path, target_path in staged:
|
|
260
292
|
try:
|
|
261
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
)
|