redfetch 1.1.2__tar.gz → 1.2.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 (58) hide show
  1. {redfetch-1.1.2 → redfetch-1.2.0}/.github/workflows/pipeline.yml +24 -14
  2. {redfetch-1.1.2 → redfetch-1.2.0}/CHANGELOG.md +9 -0
  3. {redfetch-1.1.2 → redfetch-1.2.0}/PKG-INFO +17 -4
  4. {redfetch-1.1.2 → redfetch-1.2.0}/README.md +12 -0
  5. {redfetch-1.1.2 → redfetch-1.2.0}/pyproject.toml +3 -2
  6. redfetch-1.2.0/src/redfetch/__about__.py +24 -0
  7. redfetch-1.2.0/src/redfetch/api.py +135 -0
  8. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/auth.py +235 -64
  9. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/config.py +31 -8
  10. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/config_firstrun.py +2 -3
  11. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/download.py +74 -89
  12. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/listener.py +16 -29
  13. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/main.py +48 -55
  14. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/meta.py +10 -11
  15. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/navmesh.py +2 -3
  16. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/net.py +5 -5
  17. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/processes.py +2 -0
  18. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/push.py +24 -31
  19. redfetch-1.2.0/src/redfetch/runtime_errors.py +96 -0
  20. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/special.py +7 -29
  21. redfetch-1.2.0/src/redfetch/store.py +505 -0
  22. redfetch-1.2.0/src/redfetch/sync.py +240 -0
  23. redfetch-1.2.0/src/redfetch/sync_discovery.py +339 -0
  24. redfetch-1.2.0/src/redfetch/sync_executor.py +162 -0
  25. redfetch-1.2.0/src/redfetch/sync_planner.py +194 -0
  26. redfetch-1.2.0/src/redfetch/sync_remote.py +179 -0
  27. redfetch-1.2.0/src/redfetch/sync_types.py +337 -0
  28. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/terminal_ui.py +61 -59
  29. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/terminal_ui.tcss +34 -3
  30. redfetch-1.2.0/src/redfetch/utils.py +149 -0
  31. redfetch-1.2.0/tests/test_cancellation_e2e.py +123 -0
  32. redfetch-1.2.0/tests/test_licensed_resources_filtering.py +238 -0
  33. redfetch-1.2.0/tests/test_path_reset.py +187 -0
  34. {redfetch-1.1.2 → redfetch-1.2.0}/tests/test_publish_message_file_fallback.py +2 -0
  35. {redfetch-1.1.2 → redfetch-1.2.0}/tests/test_special_resources_e2e.py +36 -15
  36. redfetch-1.2.0/tests/test_sync_pipeline_regressions.py +465 -0
  37. redfetch-1.2.0/tests/test_sync_queue_rules.py +344 -0
  38. redfetch-1.1.2/src/redfetch/__about__.py +0 -34
  39. redfetch-1.1.2/src/redfetch/api.py +0 -354
  40. redfetch-1.1.2/src/redfetch/models.py +0 -47
  41. redfetch-1.1.2/src/redfetch/store.py +0 -597
  42. redfetch-1.1.2/src/redfetch/sync.py +0 -447
  43. redfetch-1.1.2/src/redfetch/utils.py +0 -236
  44. redfetch-1.1.2/tests/test_cancellation_e2e.py +0 -172
  45. redfetch-1.1.2/tests/test_licensed_resources_filtering.py +0 -87
  46. redfetch-1.1.2/tests/test_path_reset.py +0 -138
  47. {redfetch-1.1.2 → redfetch-1.2.0}/.gitignore +0 -0
  48. {redfetch-1.1.2 → redfetch-1.2.0}/LICENSE +0 -0
  49. {redfetch-1.1.2 → redfetch-1.2.0}/redfetch.ico +0 -0
  50. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/ResourceInstallBtn.js +0 -0
  51. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/__init__.py +0 -0
  52. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/desktop_shortcut.py +0 -0
  53. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/detecteq.py +0 -0
  54. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/settings.toml +0 -0
  55. {redfetch-1.1.2 → redfetch-1.2.0}/src/redfetch/unloadmq.py +0 -0
  56. {redfetch-1.1.2 → redfetch-1.2.0}/tests/test_first_run_setup.py +0 -0
  57. {redfetch-1.1.2 → redfetch-1.2.0}/tests/test_is_special_or_dependency.py +0 -0
  58. {redfetch-1.1.2 → redfetch-1.2.0}/tests/test_uninstall_e2e.py +0 -0
@@ -27,13 +27,13 @@ jobs:
27
27
  package-version: ${{ steps.get_version.outputs.version }}
28
28
  steps:
29
29
  - name: Checkout Code
30
- uses: actions/checkout@v4
30
+ uses: actions/checkout@v6
31
31
  with:
32
32
  fetch-depth: 0 # Ensure full Git history is available for versioning
33
33
  fetch-tags: true
34
34
 
35
35
  - name: Set Up Python
36
- uses: actions/setup-python@v5
36
+ uses: actions/setup-python@v6
37
37
  with:
38
38
  python-version: '3.x'
39
39
 
@@ -85,7 +85,7 @@ jobs:
85
85
  project-version: ${{ steps.set_version.outputs.version }}
86
86
  steps:
87
87
  - name: Checkout Code
88
- uses: actions/checkout@v4
88
+ uses: actions/checkout@v6
89
89
  with:
90
90
  fetch-depth: 0 # Ensure full Git history is available for versioning
91
91
 
@@ -110,7 +110,7 @@ jobs:
110
110
  Get-Content $env:GITHUB_ENV
111
111
 
112
112
  - name: Set Up Python
113
- uses: actions/setup-python@v5
113
+ uses: actions/setup-python@v6
114
114
  with:
115
115
  python-version: '3.x'
116
116
 
@@ -185,16 +185,26 @@ jobs:
185
185
  --set-icon "redfetch.ico"
186
186
 
187
187
  - name: Sign Executable
188
- uses: dlemstra/code-sign-action@v1
189
- with:
190
- certificate: '${{ secrets.CERTIFICATE_PFX }}'
191
- password: '${{ secrets.CERTIFICATE_PFX_PASSWORD }}'
192
- files: |
193
- redfetch.exe
194
- description: 'redfetch executable'
188
+ shell: pwsh
189
+ run: |
190
+ $certPath = Join-Path $env:RUNNER_TEMP "cert.pfx"
191
+ [IO.File]::WriteAllBytes($certPath, [Convert]::FromBase64String("${{ secrets.CERTIFICATE_PFX }}"))
192
+
193
+ $signtool = Get-ChildItem -Path "${env:ProgramFiles(x86)}\Windows Kits" -Recurse -Filter "signtool.exe" |
194
+ Where-Object { $_.FullName -match "\\x64\\" } |
195
+ Sort-Object LastWriteTime -Descending |
196
+ Select-Object -First 1 -ExpandProperty FullName
197
+
198
+ if (-not $signtool) { throw "signtool.exe not found" }
199
+ Write-Host "Using signtool: $signtool"
200
+
201
+ & $signtool sign /f $certPath /p "${{ secrets.CERTIFICATE_PFX_PASSWORD }}" /fd sha256 /tr "http://timestamp.digicert.com" /td sha256 /v redfetch.exe
202
+ & $signtool verify /pa /v redfetch.exe
203
+
204
+ Remove-Item $certPath
195
205
 
196
206
  - name: Upload Executable Artifact
197
- uses: actions/upload-artifact@v4
207
+ uses: actions/upload-artifact@v7
198
208
  with:
199
209
  name: redfetch.exe
200
210
  path: redfetch.exe
@@ -205,9 +215,9 @@ jobs:
205
215
  runs-on: ubuntu-latest
206
216
  steps:
207
217
  - name: Checkout Code
208
- uses: actions/checkout@v4
218
+ uses: actions/checkout@v6
209
219
  - name: Download Executable Artifact
210
- uses: actions/download-artifact@v4
220
+ uses: actions/download-artifact@v8
211
221
  with:
212
222
  name: redfetch.exe
213
223
  - name: Redguides Publish
@@ -5,6 +5,15 @@ 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.2.0] - 2026-04-05
9
+
10
+ ### Added
11
+ - Custom category directories: override where `lua`, `macros`, or `plugins` install via `CATEGORY_PATHS` in `settings.local.toml`.
12
+ - Clearer messages for users on why certain resources are skipped or blocked.
13
+
14
+ ### Changed
15
+ - Rewrote the sync pipeline with separate discovery, planning, and execution stages.
16
+
8
17
  ## [1.1.2] - 2026-02-16
9
18
 
10
19
  ### Fixed
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redfetch
3
- Version: 1.1.2
3
+ Version: 1.2.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/ab2c7322507ec27526aed8b90ef1ca15a8bcab8f.zip
10
+ Project-URL: Source_archive, https://github.com/RedGuides/redfetch/archive/35e27c4778b6d07b5be189be22877ad5bb541d27.zip
11
11
  Author-email: Redbot <ask@redguides.com>
12
12
  License-Expression: GPL-3.0-or-later
13
13
  License-File: LICENSE
@@ -30,12 +30,13 @@ Requires-Dist: md2bbcode
30
30
  Requires-Dist: packaging~=26.0
31
31
  Requires-Dist: platformdirs~=4.5
32
32
  Requires-Dist: psutil~=7.2
33
+ Requires-Dist: pydantic~=2.12
33
34
  Requires-Dist: pyperclip~=1.11
34
35
  Requires-Dist: pywin32; sys_platform == 'win32'
35
36
  Requires-Dist: rich~=14.3
36
37
  Requires-Dist: tenacity~=9.1
37
- Requires-Dist: textual-fspicker~=0.6
38
- Requires-Dist: textual~=7.5
38
+ Requires-Dist: textual-fspicker~=1.0
39
+ Requires-Dist: textual~=8.1
39
40
  Requires-Dist: tomlkit~=0.14
40
41
  Requires-Dist: typer~=0.21
41
42
  Description-Content-Type: text/markdown
@@ -172,6 +173,18 @@ If there are local files you don't want overwritten by a resource, you can add t
172
173
  navmesh = ["befallen.navmesh", "innothuleb.navmesh"]
173
174
  ```
174
175
 
176
+ ### Custom category directories
177
+
178
+ If you share `lua`, `macros`, or `plugins` directories across multiple MQ environments, you can override where an entire category is installed. Add a `CATEGORY_PATHS` section to your `settings.local.toml`:
179
+
180
+ ```toml
181
+ [DEFAULT.CATEGORY_PATHS]
182
+ lua = 'D:\\shared\\lua'
183
+ macros = 'D:\\shared\\macros'
184
+ ```
185
+
186
+ Absolute paths are used as-is. Relative paths are joined to `DOWNLOAD_FOLDER`. You can set this globally in `[DEFAULT]` or per-environment (`[LIVE.CATEGORY_PATHS]`, `[TEST.CATEGORY_PATHS]`, etc.).
187
+
175
188
  ## Tinkerers
176
189
 
177
190
  If you self-compile MacroQuest or use a discord friend's copy, you can still keep your scripts and plugins in sync with redfetch by opting out of Very Vanilla:
@@ -130,6 +130,18 @@ If there are local files you don't want overwritten by a resource, you can add t
130
130
  navmesh = ["befallen.navmesh", "innothuleb.navmesh"]
131
131
  ```
132
132
 
133
+ ### Custom category directories
134
+
135
+ If you share `lua`, `macros`, or `plugins` directories across multiple MQ environments, you can override where an entire category is installed. Add a `CATEGORY_PATHS` section to your `settings.local.toml`:
136
+
137
+ ```toml
138
+ [DEFAULT.CATEGORY_PATHS]
139
+ lua = 'D:\\shared\\lua'
140
+ macros = 'D:\\shared\\macros'
141
+ ```
142
+
143
+ Absolute paths are used as-is. Relative paths are joined to `DOWNLOAD_FOLDER`. You can set this globally in `[DEFAULT]` or per-environment (`[LIVE.CATEGORY_PATHS]`, `[TEST.CATEGORY_PATHS]`, etc.).
144
+
133
145
  ## Tinkerers
134
146
 
135
147
  If you self-compile MacroQuest or use a discord friend's copy, you can still keep your scripts and plugins in sync with redfetch by opting out of Very Vanilla:
@@ -28,13 +28,14 @@ dependencies = [
28
28
  "keyring~=25.6",
29
29
  "md2bbcode",
30
30
  "packaging~=26.0",
31
+ "pydantic~=2.12",
31
32
  "platformdirs~=4.5",
32
33
  "psutil~=7.2",
33
34
  "pyperclip~=1.11",
34
35
  "rich~=14.3",
35
36
  "tenacity~=9.1",
36
- "textual~=7.5",
37
- "textual-fspicker~=0.6",
37
+ "textual~=8.1",
38
+ "textual-fspicker~=1.0",
38
39
  "tomlkit~=0.14",
39
40
  "typer~=0.21",
40
41
  "pywin32; sys_platform == 'win32'"
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '1.2.0'
22
+ __version_tuple__ = version_tuple = (1, 2, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,135 @@
1
+ """Resource API client: fetch_*() takes a client, get_*() is self-contained."""
2
+ import asyncio
3
+
4
+ import httpx
5
+ from pydantic import BaseModel
6
+
7
+ from redfetch import auth
8
+ from redfetch import net
9
+ from redfetch.sync_types import RemoteStatus
10
+
11
+ BASE_URL = net.BASE_URL
12
+
13
+
14
+ class ResourceRecord(BaseModel):
15
+ """Result of a single-resource API check: status plus optional payload or error."""
16
+ resource_id: str
17
+ status: RemoteStatus
18
+ resource: dict | None = None
19
+ error: str | None = None
20
+
21
+
22
+ async def fetch_watched_page(client: httpx.AsyncClient, page: int) -> tuple[list, int]:
23
+ """Fetch a single page of watched resources."""
24
+ url = f'{BASE_URL}/api/rgwatched'
25
+ try:
26
+ data = await net.get_json(client, url, params={'page': page})
27
+ last_page = data['pagination']['last_page']
28
+ items = data.get('resources', [])
29
+ return items, last_page
30
+ except Exception as e:
31
+ print(f"Error fetching watched resources page {page}: {e}")
32
+ return [], 0
33
+
34
+
35
+ async def fetch_licenses_page(client: httpx.AsyncClient, page: int) -> tuple[list, int]:
36
+ """Fetch a single page of user licenses."""
37
+ url = f'{BASE_URL}/api/user-licenses'
38
+ try:
39
+ data = await net.get_json(client, url, params={'page': page})
40
+ last_page = data['pagination']['last_page']
41
+ items = data.get('licenses', [])
42
+ return items, last_page
43
+ except Exception as e:
44
+ print(f"Error fetching licenses page {page}: {e}")
45
+ return [], 0
46
+
47
+
48
+ async def fetch_resource_record(client: httpx.AsyncClient, resource_id: str) -> ResourceRecord:
49
+ """Fetch a single resource and classify its availability."""
50
+ url = f'{BASE_URL}/api/resources/{resource_id}'
51
+ try:
52
+ data = await net.get_json(client, url)
53
+ resource = data['resource']
54
+ current_files = resource.get('current_files') or []
55
+ if not resource.get('can_download', False):
56
+ status: RemoteStatus = 'access_denied'
57
+ elif len(current_files) == 0:
58
+ status = 'no_files'
59
+ elif len(current_files) > 1:
60
+ status = 'multiple_files'
61
+ else:
62
+ status = 'downloadable'
63
+ return ResourceRecord(resource_id=resource_id, status=status, resource=resource)
64
+ except httpx.HTTPStatusError as e:
65
+ status = 'not_found' if e.response.status_code == 404 else 'fetch_error'
66
+ return ResourceRecord(resource_id=resource_id, status=status, error=str(e))
67
+ except Exception as e:
68
+ return ResourceRecord(resource_id=resource_id, status='fetch_error', error=str(e))
69
+
70
+
71
+ async def fetch_resource_records_batch(client: httpx.AsyncClient, resource_ids: list[str]) -> list[ResourceRecord]:
72
+ """Fetch multiple resource records concurrently."""
73
+ if not resource_ids:
74
+ return []
75
+ return list(await asyncio.gather(
76
+ *(fetch_resource_record(client, rid) for rid in resource_ids)
77
+ ))
78
+
79
+
80
+ async def get_resource_details(resource_id: int, headers: dict) -> dict:
81
+ """Retrieve details of a specific resource from the API."""
82
+ url = f'{BASE_URL}/api/resources/{resource_id}'
83
+ async with httpx.AsyncClient(headers=headers, http2=True, timeout=30.0) as client:
84
+ response = await client.get(url)
85
+ response.raise_for_status()
86
+ return response.json()['resource']
87
+
88
+
89
+ async def fetch_watched_resources(client: httpx.AsyncClient) -> list:
90
+ """Fetch all watched resources with concurrent pagination."""
91
+ items, total_pages = await fetch_watched_page(client, 1)
92
+ if total_pages <= 1:
93
+ return items
94
+
95
+ coros = [fetch_watched_page(client, p) for p in range(2, total_pages + 1)]
96
+ page_results = await asyncio.gather(*coros)
97
+
98
+ for page_items, _ in page_results:
99
+ items.extend(page_items)
100
+
101
+ return items
102
+
103
+
104
+ async def fetch_licenses(client: httpx.AsyncClient) -> list:
105
+ """Fetch all user licenses with concurrent pagination."""
106
+ items, total_pages = await fetch_licenses_page(client, 1)
107
+ if total_pages <= 1:
108
+ return items
109
+ coros = [fetch_licenses_page(client, p) for p in range(2, total_pages + 1)]
110
+ page_results = await asyncio.gather(*coros)
111
+
112
+ for page_items, _ in page_results:
113
+ items.extend(page_items)
114
+
115
+ return items
116
+
117
+
118
+ _KISS_CACHE_TTL_SECONDS = 60
119
+
120
+
121
+ async def is_kiss_downloadable(headers, force_refresh: bool = False):
122
+ """Check for level 2 access by checking KISS."""
123
+ cache = auth.get_disk_cache()
124
+ cache_key = "kiss"
125
+
126
+ if not force_refresh:
127
+ cached = cache.get(cache_key)
128
+ if cached is not None:
129
+ return bool(cached)
130
+
131
+ async with httpx.AsyncClient(headers=headers, http2=True) as client:
132
+ record = await fetch_resource_record(client, "4")
133
+ result = record.status == "downloadable"
134
+ cache.set(cache_key, bool(result), expire=_KISS_CACHE_TTL_SECONDS)
135
+ return result