redfetch 1.3.0__py3-none-any.whl
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/__about__.py +24 -0
- redfetch/__init__.py +0 -0
- redfetch/api.py +134 -0
- redfetch/auth.py +573 -0
- redfetch/config.py +483 -0
- redfetch/config_firstrun.py +455 -0
- redfetch/desktop_shortcut.py +81 -0
- redfetch/detecteq.py +116 -0
- redfetch/download.py +312 -0
- redfetch/listener.py +216 -0
- redfetch/main.py +744 -0
- redfetch/meta.py +561 -0
- redfetch/navmesh.py +371 -0
- redfetch/net.py +109 -0
- redfetch/processes.py +118 -0
- redfetch/push.py +246 -0
- redfetch/redfetch.ico +0 -0
- redfetch/runtime_errors.py +96 -0
- redfetch/settings.toml +303 -0
- redfetch/special.py +81 -0
- redfetch/store.py +505 -0
- redfetch/sync.py +261 -0
- redfetch/sync_discovery.py +352 -0
- redfetch/sync_executor.py +164 -0
- redfetch/sync_planner.py +196 -0
- redfetch/sync_remote.py +182 -0
- redfetch/sync_types.py +348 -0
- redfetch/terminal_ui.py +2318 -0
- redfetch/terminal_ui.tcss +569 -0
- redfetch/unloadmq.py +148 -0
- redfetch/update_status.py +62 -0
- redfetch/utils.py +256 -0
- redfetch-1.3.0.dist-info/METADATA +257 -0
- redfetch-1.3.0.dist-info/RECORD +37 -0
- redfetch-1.3.0.dist-info/WHEEL +4 -0
- redfetch-1.3.0.dist-info/entry_points.txt +2 -0
- redfetch-1.3.0.dist-info/licenses/LICENSE +674 -0
redfetch/navmesh.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NavMesh sync module - downloads Nav mesh files from mqmesh.com
|
|
3
|
+
"""
|
|
4
|
+
import os
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import asyncio
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
import httpx
|
|
10
|
+
import aiosqlite
|
|
11
|
+
|
|
12
|
+
from redfetch import config
|
|
13
|
+
from redfetch.sync_types import SyncEventCallback
|
|
14
|
+
from redfetch.utils import get_vvmq_path
|
|
15
|
+
from redfetch.download import download_file_async
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
NAVMESH_MANIFEST_URL = "https://mqmesh.com/updater.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class NavMeshFile:
|
|
23
|
+
"""Represents a navmesh file to potentially download."""
|
|
24
|
+
zone_name: str # e.g., "gfaydark"
|
|
25
|
+
filename: str # e.g., "gfaydark.navmesh"
|
|
26
|
+
download_url: str # Full URL
|
|
27
|
+
remote_hash: str # MD5 from manifest
|
|
28
|
+
local_hash: str | None = None # MD5 of local file (if exists)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def needs_download(self) -> bool:
|
|
32
|
+
return self.local_hash is None or self.local_hash != self.remote_hash
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_navmesh_directory() -> str | None:
|
|
36
|
+
"""Get the navmesh installation directory based on current environment."""
|
|
37
|
+
vvmq_path = get_vvmq_path()
|
|
38
|
+
if not vvmq_path:
|
|
39
|
+
return None
|
|
40
|
+
return os.path.join(vvmq_path, "resources", "MQ2Nav")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_navmesh_opt_in() -> bool | None:
|
|
44
|
+
"""Get the navmesh opt-in setting for the current environment."""
|
|
45
|
+
try:
|
|
46
|
+
return config.settings.from_env(config.settings.ENV).get("NAVMESH_OPT_IN", None)
|
|
47
|
+
except Exception:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_navmesh_enabled(override: bool | None = None) -> bool:
|
|
52
|
+
"""Check if navmesh sync is enabled for the current environment."""
|
|
53
|
+
if override is not None:
|
|
54
|
+
return override
|
|
55
|
+
opt_in = get_navmesh_opt_in()
|
|
56
|
+
return opt_in is True # Only True if explicitly set to True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_protected_navmeshes() -> list[str]:
|
|
60
|
+
"""Get list of protected navmesh filenames (case-insensitive) for current env."""
|
|
61
|
+
try:
|
|
62
|
+
protected = config.settings.from_env(config.settings.ENV).PROTECTED_FILES_BY_RESOURCE.get("navmesh", [])
|
|
63
|
+
return [f.lower() for f in protected]
|
|
64
|
+
except Exception:
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def fetch_manifest_cached(db_path: str, client: httpx.AsyncClient) -> tuple[dict, bool]:
|
|
69
|
+
"""Fetch the navmesh manifest with HTTP caching."""
|
|
70
|
+
current_env = config.settings.ENV
|
|
71
|
+
|
|
72
|
+
# Load cached headers from DB
|
|
73
|
+
cached_etag = None
|
|
74
|
+
cached_last_modified = None
|
|
75
|
+
cached_manifest_json = None
|
|
76
|
+
|
|
77
|
+
async with aiosqlite.connect(db_path, timeout=30.0) as conn:
|
|
78
|
+
async with conn.execute(
|
|
79
|
+
"SELECT etag, last_modified, manifest_json FROM navmesh_cache WHERE env = ?",
|
|
80
|
+
(current_env,)
|
|
81
|
+
) as cur:
|
|
82
|
+
row = await cur.fetchone()
|
|
83
|
+
if row:
|
|
84
|
+
cached_etag, cached_last_modified, cached_manifest_json = row
|
|
85
|
+
|
|
86
|
+
# Build request headers for conditional GET
|
|
87
|
+
headers = {}
|
|
88
|
+
if cached_etag:
|
|
89
|
+
headers["If-None-Match"] = cached_etag
|
|
90
|
+
if cached_last_modified:
|
|
91
|
+
headers["If-Modified-Since"] = cached_last_modified
|
|
92
|
+
|
|
93
|
+
response = await client.get(NAVMESH_MANIFEST_URL, headers=headers)
|
|
94
|
+
|
|
95
|
+
if response.status_code == 304:
|
|
96
|
+
# Not Modified - use cached manifest
|
|
97
|
+
if cached_manifest_json:
|
|
98
|
+
return json.loads(cached_manifest_json), False
|
|
99
|
+
# Fallback: if we got 304 but have no cached manifest, fetch fresh
|
|
100
|
+
response = await client.get(NAVMESH_MANIFEST_URL)
|
|
101
|
+
|
|
102
|
+
response.raise_for_status()
|
|
103
|
+
|
|
104
|
+
# Parse new manifest
|
|
105
|
+
manifest = response.json()
|
|
106
|
+
|
|
107
|
+
# Save new caching headers and manifest to DB
|
|
108
|
+
new_etag = response.headers.get("ETag")
|
|
109
|
+
new_last_modified = response.headers.get("Last-Modified")
|
|
110
|
+
|
|
111
|
+
async with aiosqlite.connect(db_path, timeout=30.0) as conn:
|
|
112
|
+
await conn.execute(
|
|
113
|
+
"""
|
|
114
|
+
INSERT INTO navmesh_cache (env, etag, last_modified, manifest_json)
|
|
115
|
+
VALUES (?, ?, ?, ?)
|
|
116
|
+
ON CONFLICT(env) DO UPDATE SET
|
|
117
|
+
etag = excluded.etag,
|
|
118
|
+
last_modified = excluded.last_modified,
|
|
119
|
+
manifest_json = excluded.manifest_json
|
|
120
|
+
""",
|
|
121
|
+
(current_env, new_etag, new_last_modified, json.dumps(manifest))
|
|
122
|
+
)
|
|
123
|
+
await conn.commit()
|
|
124
|
+
|
|
125
|
+
return manifest, True
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def get_local_navmesh_state(db_path: str, navmesh_dir: str) -> dict[str, str]:
|
|
129
|
+
""" Get hash map of local navmesh file """
|
|
130
|
+
result: dict[str, str] = {}
|
|
131
|
+
|
|
132
|
+
if not os.path.exists(navmesh_dir):
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
# Scan local files
|
|
136
|
+
local_files: list[tuple[str, int, int]] = [] # (filename, size, mtime_ns)
|
|
137
|
+
try:
|
|
138
|
+
for entry in os.scandir(navmesh_dir):
|
|
139
|
+
if entry.is_file() and entry.name.endswith(".navmesh"):
|
|
140
|
+
stat = entry.stat()
|
|
141
|
+
local_files.append((entry.name, stat.st_size, stat.st_mtime_ns))
|
|
142
|
+
except OSError:
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
if not local_files:
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
# Load cached hashes from DB
|
|
149
|
+
cached_records: dict[str, tuple[str, int, int]] = {} # filename -> (hash, size, mtime_ns)
|
|
150
|
+
async with aiosqlite.connect(db_path, timeout=30.0) as conn:
|
|
151
|
+
async with conn.execute(
|
|
152
|
+
"SELECT filename, md5_hash, file_size, mtime_ns FROM navmesh_files"
|
|
153
|
+
) as cur:
|
|
154
|
+
async for row in cur:
|
|
155
|
+
filename, md5_hash, file_size, mtime_ns = row
|
|
156
|
+
cached_records[filename] = (md5_hash, file_size, mtime_ns)
|
|
157
|
+
|
|
158
|
+
# Process each local file
|
|
159
|
+
to_update: list[tuple[str, str, int, int]] = [] # (filename, hash, size, mtime_ns)
|
|
160
|
+
|
|
161
|
+
for filename, size, mtime_ns in local_files:
|
|
162
|
+
cached = cached_records.get(filename)
|
|
163
|
+
if cached and cached[1] == size and cached[2] == mtime_ns:
|
|
164
|
+
# File unchanged, use cached hash
|
|
165
|
+
result[filename] = cached[0]
|
|
166
|
+
else:
|
|
167
|
+
# File changed or new, re-hash it
|
|
168
|
+
file_path = os.path.join(navmesh_dir, filename)
|
|
169
|
+
file_hash = _hash_file(file_path)
|
|
170
|
+
if file_hash:
|
|
171
|
+
result[filename] = file_hash
|
|
172
|
+
to_update.append((filename, file_hash, size, mtime_ns))
|
|
173
|
+
|
|
174
|
+
# Update DB with new/changed hashes
|
|
175
|
+
if to_update:
|
|
176
|
+
async with aiosqlite.connect(db_path, timeout=30.0) as conn:
|
|
177
|
+
await conn.executemany(
|
|
178
|
+
"""
|
|
179
|
+
INSERT INTO navmesh_files (filename, md5_hash, file_size, mtime_ns)
|
|
180
|
+
VALUES (?, ?, ?, ?)
|
|
181
|
+
ON CONFLICT(filename) DO UPDATE SET
|
|
182
|
+
md5_hash = excluded.md5_hash,
|
|
183
|
+
file_size = excluded.file_size,
|
|
184
|
+
mtime_ns = excluded.mtime_ns
|
|
185
|
+
""",
|
|
186
|
+
to_update
|
|
187
|
+
)
|
|
188
|
+
await conn.commit()
|
|
189
|
+
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _hash_file(file_path: str) -> str | None:
|
|
194
|
+
"""Compute MD5 hash of a file."""
|
|
195
|
+
try:
|
|
196
|
+
hasher = hashlib.md5()
|
|
197
|
+
with open(file_path, "rb") as f:
|
|
198
|
+
while chunk := f.read(262_144):
|
|
199
|
+
hasher.update(chunk)
|
|
200
|
+
return hasher.hexdigest().lower()
|
|
201
|
+
except OSError:
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def download_navmesh_file(
|
|
206
|
+
client: httpx.AsyncClient,
|
|
207
|
+
nm: NavMeshFile,
|
|
208
|
+
navmesh_dir: str,
|
|
209
|
+
db_path: str
|
|
210
|
+
) -> bool:
|
|
211
|
+
"""Download a single navmesh file."""
|
|
212
|
+
file_path = os.path.join(navmesh_dir, nm.filename)
|
|
213
|
+
|
|
214
|
+
ok = await download_file_async(
|
|
215
|
+
client,
|
|
216
|
+
nm.download_url,
|
|
217
|
+
file_path,
|
|
218
|
+
expected_md5=nm.remote_hash
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if ok:
|
|
222
|
+
# Update local cache in DB
|
|
223
|
+
try:
|
|
224
|
+
stat = os.stat(file_path)
|
|
225
|
+
file_size = stat.st_size
|
|
226
|
+
mtime_ns = stat.st_mtime_ns
|
|
227
|
+
async with aiosqlite.connect(db_path, timeout=30.0) as conn:
|
|
228
|
+
await conn.execute(
|
|
229
|
+
"""
|
|
230
|
+
INSERT INTO navmesh_files (filename, md5_hash, file_size, mtime_ns)
|
|
231
|
+
VALUES (?, ?, ?, ?)
|
|
232
|
+
ON CONFLICT(filename) DO UPDATE SET
|
|
233
|
+
md5_hash = excluded.md5_hash,
|
|
234
|
+
file_size = excluded.file_size,
|
|
235
|
+
mtime_ns = excluded.mtime_ns
|
|
236
|
+
""",
|
|
237
|
+
(nm.filename, nm.remote_hash, file_size, mtime_ns)
|
|
238
|
+
)
|
|
239
|
+
await conn.commit()
|
|
240
|
+
except Exception as e:
|
|
241
|
+
print(f"Warning: Failed to update navmesh cache for {nm.filename}: {e}")
|
|
242
|
+
|
|
243
|
+
return ok
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
async def sync_navmeshes(
|
|
247
|
+
db_path: str,
|
|
248
|
+
headers: dict,
|
|
249
|
+
on_event: SyncEventCallback | None = None,
|
|
250
|
+
override: bool | None = None,
|
|
251
|
+
) -> bool:
|
|
252
|
+
""" Main entry point for navmesh sync. """
|
|
253
|
+
if not is_navmesh_enabled(override):
|
|
254
|
+
return True # Nothing to do, not an error
|
|
255
|
+
|
|
256
|
+
print("Checking navmesh files...")
|
|
257
|
+
|
|
258
|
+
navmesh_dir = get_navmesh_directory()
|
|
259
|
+
if not navmesh_dir:
|
|
260
|
+
print("navmesh sync skipped: VanillaMQ path not configured")
|
|
261
|
+
return True
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
os.makedirs(navmesh_dir, exist_ok=True)
|
|
265
|
+
except OSError as e:
|
|
266
|
+
print(f"navmesh sync error: Could not create directory {navmesh_dir}: {e}")
|
|
267
|
+
return True # Don't fail the whole sync for navmesh issues
|
|
268
|
+
|
|
269
|
+
protected_meshes = get_protected_navmeshes()
|
|
270
|
+
|
|
271
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
272
|
+
try:
|
|
273
|
+
# Tier 1: Fetch manifest (may be cached); always validate local state
|
|
274
|
+
manifest, _ = await fetch_manifest_cached(db_path, client)
|
|
275
|
+
|
|
276
|
+
# Tier 2: Get local file state (using cached hashes where possible)
|
|
277
|
+
local_state = await get_local_navmesh_state(db_path, navmesh_dir)
|
|
278
|
+
|
|
279
|
+
# Build download list
|
|
280
|
+
to_download: list[NavMeshFile] = []
|
|
281
|
+
zones = manifest.get("zones", {})
|
|
282
|
+
|
|
283
|
+
for zone_name, zone_data in zones.items():
|
|
284
|
+
mesh_info = zone_data.get("files", {}).get("mesh", {})
|
|
285
|
+
if not mesh_info:
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
filename = f"{zone_name}.navmesh"
|
|
289
|
+
download_url = mesh_info.get("link", "")
|
|
290
|
+
remote_hash = mesh_info.get("hash", "").lower()
|
|
291
|
+
|
|
292
|
+
if not download_url or not remote_hash:
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
# Check if file is protected (only skip if file already exists)
|
|
296
|
+
file_path = os.path.join(navmesh_dir, filename)
|
|
297
|
+
if filename.lower() in protected_meshes and os.path.exists(file_path):
|
|
298
|
+
print(f"navmesh: Skipping protected mesh {filename}")
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
nm = NavMeshFile(
|
|
302
|
+
zone_name=zone_name,
|
|
303
|
+
filename=filename,
|
|
304
|
+
download_url=download_url,
|
|
305
|
+
remote_hash=remote_hash,
|
|
306
|
+
local_hash=local_state.get(filename),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if nm.needs_download:
|
|
310
|
+
to_download.append(nm)
|
|
311
|
+
|
|
312
|
+
if not to_download:
|
|
313
|
+
print(f"All {len(zones)} navmesh files up-to-date.")
|
|
314
|
+
return True
|
|
315
|
+
|
|
316
|
+
print(f"navmesh: {len(to_download)} files to download out of {len(zones)} total")
|
|
317
|
+
|
|
318
|
+
# Notify progress bar of additional items
|
|
319
|
+
if on_event:
|
|
320
|
+
on_event(("add_total", len(to_download), None))
|
|
321
|
+
|
|
322
|
+
# Download in parallel (batch size 5 to respect server limits)
|
|
323
|
+
BATCH_SIZE = 5
|
|
324
|
+
failed = 0
|
|
325
|
+
|
|
326
|
+
for i in range(0, len(to_download), BATCH_SIZE):
|
|
327
|
+
batch = to_download[i : i + BATCH_SIZE]
|
|
328
|
+
tasks = []
|
|
329
|
+
for nm in batch:
|
|
330
|
+
print(f"navmesh: Downloading {nm.filename}...")
|
|
331
|
+
tasks.append(download_navmesh_file(client, nm, navmesh_dir, db_path))
|
|
332
|
+
|
|
333
|
+
results = await asyncio.gather(*tasks)
|
|
334
|
+
|
|
335
|
+
# Process results
|
|
336
|
+
for nm, ok in zip(batch, results):
|
|
337
|
+
if ok:
|
|
338
|
+
if on_event:
|
|
339
|
+
on_event(("done", nm.filename, "downloaded"))
|
|
340
|
+
else:
|
|
341
|
+
if on_event:
|
|
342
|
+
on_event(("done", nm.filename, "error"))
|
|
343
|
+
failed += 1
|
|
344
|
+
|
|
345
|
+
if failed:
|
|
346
|
+
print(f"navmesh: {failed} meshes failed to download")
|
|
347
|
+
# Invalidate manifest cache so next run re-checks everything
|
|
348
|
+
async with aiosqlite.connect(db_path, timeout=30.0) as conn:
|
|
349
|
+
await conn.execute(
|
|
350
|
+
"DELETE FROM navmesh_cache WHERE env = ?",
|
|
351
|
+
(config.settings.ENV,)
|
|
352
|
+
)
|
|
353
|
+
await conn.commit()
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
print("All navmesh files downloaded successfully")
|
|
357
|
+
return True
|
|
358
|
+
|
|
359
|
+
except (httpx.HTTPError, json.JSONDecodeError) as e:
|
|
360
|
+
print(f"navmesh sync warning: Error during navmesh sync: {e}")
|
|
361
|
+
try:
|
|
362
|
+
async with aiosqlite.connect(db_path, timeout=30.0) as conn:
|
|
363
|
+
await conn.execute(
|
|
364
|
+
"DELETE FROM navmesh_cache WHERE env = ?",
|
|
365
|
+
(config.settings.ENV,),
|
|
366
|
+
)
|
|
367
|
+
await conn.commit()
|
|
368
|
+
except Exception as cache_err:
|
|
369
|
+
print(f"Warning: Failed to clear navmesh manifest cache after error: {cache_err}")
|
|
370
|
+
return False
|
|
371
|
+
|
redfetch/net.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Async HTTP utilities with retry and simple caching."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
import httpx
|
|
6
|
+
from tenacity import (
|
|
7
|
+
retry,
|
|
8
|
+
stop_after_attempt,
|
|
9
|
+
wait_exponential,
|
|
10
|
+
retry_if_exception_type,
|
|
11
|
+
)
|
|
12
|
+
from cachetools import TTLCache
|
|
13
|
+
from diskcache import Cache
|
|
14
|
+
from redfetch import config
|
|
15
|
+
|
|
16
|
+
BASE_URL = os.environ.get("REDFETCH_BASE_URL", "https://www.redguides.com/community")
|
|
17
|
+
# Manifest endpoint provided by the "Redbot - API Extensions" addon
|
|
18
|
+
MANIFEST_URL = f"{BASE_URL}/resources-manifest"
|
|
19
|
+
|
|
20
|
+
# Manifest cache: 60 seconds TTL
|
|
21
|
+
_MANIFEST_TTL_SECONDS = 60
|
|
22
|
+
_manifest_cache: TTLCache = TTLCache(maxsize=1, ttl=_MANIFEST_TTL_SECONDS) # In-memory
|
|
23
|
+
_manifest_disk_cache: Optional[Cache] = None # Lazy-loaded disk cache
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_manifest_disk_cache() -> Cache:
|
|
27
|
+
"""Lazy-load disk cache after config is initialized."""
|
|
28
|
+
global _manifest_disk_cache
|
|
29
|
+
if _manifest_disk_cache is None:
|
|
30
|
+
cache_dir = getattr(config, "config_dir", None) or os.getenv("REDFETCH_CONFIG_DIR")
|
|
31
|
+
if not cache_dir:
|
|
32
|
+
cache_dir = os.getcwd()
|
|
33
|
+
api_cache_dir = os.path.join(cache_dir, ".cache")
|
|
34
|
+
_manifest_disk_cache = Cache(api_cache_dir)
|
|
35
|
+
return _manifest_disk_cache
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def clear_manifest_cache() -> None:
|
|
39
|
+
"""Clear and close both in-memory and disk manifest caches."""
|
|
40
|
+
global _manifest_disk_cache
|
|
41
|
+
_manifest_cache.clear()
|
|
42
|
+
if _manifest_disk_cache is not None:
|
|
43
|
+
try:
|
|
44
|
+
_manifest_disk_cache.clear()
|
|
45
|
+
finally:
|
|
46
|
+
try:
|
|
47
|
+
_manifest_disk_cache.close()
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
_manifest_disk_cache = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@retry(
|
|
54
|
+
stop=stop_after_attempt(3),
|
|
55
|
+
wait=wait_exponential(multiplier=0.5, min=0.5, max=4),
|
|
56
|
+
retry=retry_if_exception_type(httpx.RequestError),
|
|
57
|
+
reraise=True,
|
|
58
|
+
)
|
|
59
|
+
async def get_json(client: httpx.AsyncClient, url: str, params: Optional[Dict[str, Any]] = None) -> dict:
|
|
60
|
+
"""GET JSON with retry on transient network errors."""
|
|
61
|
+
response = await client.get(url, params=params, timeout=10.0)
|
|
62
|
+
response.raise_for_status()
|
|
63
|
+
return response.json()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def fetch_manifest_cached(client: httpx.AsyncClient) -> dict:
|
|
67
|
+
"""Fetch manifest with a 60-second cache."""
|
|
68
|
+
manifest = _manifest_cache.get("manifest")
|
|
69
|
+
if manifest:
|
|
70
|
+
return manifest
|
|
71
|
+
|
|
72
|
+
disk_cache = _get_manifest_disk_cache()
|
|
73
|
+
manifest = disk_cache.get("manifest")
|
|
74
|
+
if manifest:
|
|
75
|
+
_manifest_cache["manifest"] = manifest
|
|
76
|
+
return manifest
|
|
77
|
+
|
|
78
|
+
manifest = await get_json(client, MANIFEST_URL)
|
|
79
|
+
_manifest_cache["manifest"] = manifest
|
|
80
|
+
disk_cache.set("manifest", manifest, expire=_MANIFEST_TTL_SECONDS)
|
|
81
|
+
return manifest
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _is_mq_down_async(client: httpx.AsyncClient) -> bool:
|
|
85
|
+
"""Return True if MQ is down for current env."""
|
|
86
|
+
url = "https://www.redguides.com/update/ready.json"
|
|
87
|
+
try:
|
|
88
|
+
data = await get_json(client, url)
|
|
89
|
+
|
|
90
|
+
# Get the current environment from config settings and convert to lowercase
|
|
91
|
+
current_env = config.settings.ENV.lower()
|
|
92
|
+
|
|
93
|
+
# Check if the current environment exists in the Status dictionary (case-insensitive)
|
|
94
|
+
for env, status in data["Status"].items():
|
|
95
|
+
if env.lower() == current_env:
|
|
96
|
+
return status.lower() != "yes"
|
|
97
|
+
|
|
98
|
+
print(f"Warning: Environment {current_env} not found in status JSON.")
|
|
99
|
+
return True # Assume down if environment not found
|
|
100
|
+
except (httpx.HTTPStatusError, httpx.RequestError, KeyError, ValueError) as e:
|
|
101
|
+
print(f"Error fetching or parsing status: {e}")
|
|
102
|
+
return True # Assume down if there's an error
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def is_mq_down() -> bool:
|
|
106
|
+
"""Return True if MQ is down for current env."""
|
|
107
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
108
|
+
return await _is_mq_down_async(client)
|
|
109
|
+
|
redfetch/processes.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Helpers for managing external processes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from typing import List, Sequence, Tuple
|
|
8
|
+
|
|
9
|
+
import psutil
|
|
10
|
+
|
|
11
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if IS_WINDOWS:
|
|
15
|
+
from .unloadmq import force_remote_unload
|
|
16
|
+
else: # skip if not on windows
|
|
17
|
+
def force_remote_unload() -> None:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _normalized_executables(folder_path: str) -> List[str]:
|
|
22
|
+
folder = os.path.normpath(os.path.abspath(folder_path))
|
|
23
|
+
if not os.path.isdir(folder):
|
|
24
|
+
return []
|
|
25
|
+
return [
|
|
26
|
+
os.path.normcase(os.path.join(folder, entry))
|
|
27
|
+
for entry in os.listdir(folder)
|
|
28
|
+
if entry.lower().endswith(".exe")
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def are_executables_running_in_folder(folder_path: str) -> List[Tuple[int, str]]:
|
|
33
|
+
"""Return running executables located within ``folder_path``."""
|
|
34
|
+
if not IS_WINDOWS:
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
running: List[Tuple[int, str]] = []
|
|
38
|
+
try:
|
|
39
|
+
exec_paths = _normalized_executables(folder_path)
|
|
40
|
+
if not exec_paths:
|
|
41
|
+
return running
|
|
42
|
+
|
|
43
|
+
for proc in psutil.process_iter(["pid", "exe"]):
|
|
44
|
+
try:
|
|
45
|
+
exe_path = proc.info.get("exe")
|
|
46
|
+
if not exe_path or not os.path.isfile(exe_path):
|
|
47
|
+
continue
|
|
48
|
+
normalized = os.path.normcase(os.path.normpath(exe_path))
|
|
49
|
+
if normalized in exec_paths:
|
|
50
|
+
print(f"Process '{exe_path}' (PID {proc.pid}) is currently running.")
|
|
51
|
+
running.append((proc.pid, exe_path))
|
|
52
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
53
|
+
continue
|
|
54
|
+
return running
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
print(f"An error occurred while checking running processes: {exc}")
|
|
57
|
+
return running
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def terminate_executables_in_folder(folder_path: str) -> None:
|
|
61
|
+
"""Terminate running executables inside ``folder_path`` and unload MacroQuest."""
|
|
62
|
+
if not IS_WINDOWS:
|
|
63
|
+
print("Terminating executables is only supported on Windows platforms.")
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
running = are_executables_running_in_folder(folder_path)
|
|
67
|
+
for pid, exe_path in running:
|
|
68
|
+
try:
|
|
69
|
+
proc = psutil.Process(pid)
|
|
70
|
+
proc.terminate()
|
|
71
|
+
proc.wait(timeout=5)
|
|
72
|
+
print(f"Terminated process '{exe_path}' (PID {pid}).")
|
|
73
|
+
except psutil.NoSuchProcess:
|
|
74
|
+
pass
|
|
75
|
+
except (psutil.AccessDenied, psutil.ZombieProcess) as err:
|
|
76
|
+
print(f"Could not terminate process: {err}")
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
force_remote_unload()
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
print(f"Error unloading MacroQuest: {exc}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run_executable(folder_path: str, executable_name: str, args: Sequence[str] | None = None) -> bool:
|
|
85
|
+
"""Launch ``executable_name`` located in ``folder_path`` with optional arguments."""
|
|
86
|
+
if not IS_WINDOWS:
|
|
87
|
+
raise RuntimeError("Running executables is only supported on Windows.")
|
|
88
|
+
|
|
89
|
+
if not folder_path:
|
|
90
|
+
raise ValueError(f"Folder path not set for {executable_name}")
|
|
91
|
+
|
|
92
|
+
executable_path = os.path.join(folder_path, executable_name)
|
|
93
|
+
if not os.path.isfile(executable_path):
|
|
94
|
+
raise FileNotFoundError(f"{executable_name} not found in the specified folder.")
|
|
95
|
+
|
|
96
|
+
subprocess.Popen([executable_path, *(args or [])], cwd=folder_path)
|
|
97
|
+
print(f"{executable_name} started successfully.")
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run_command(command: "str | Sequence[str]", cwd: str | None = None) -> bool:
|
|
102
|
+
"""Launch a command that may be resolved through PATH."""
|
|
103
|
+
if isinstance(command, str):
|
|
104
|
+
if not command.strip():
|
|
105
|
+
raise ValueError("No command to run.")
|
|
106
|
+
popen_arg: "str | list[str]" = command
|
|
107
|
+
display = command
|
|
108
|
+
else:
|
|
109
|
+
argv = list(command)
|
|
110
|
+
if not argv:
|
|
111
|
+
raise ValueError("No command to run.")
|
|
112
|
+
popen_arg = argv
|
|
113
|
+
display = subprocess.list2cmdline(argv)
|
|
114
|
+
|
|
115
|
+
subprocess.Popen(popen_arg, cwd=cwd)
|
|
116
|
+
print(f"Started: {display}")
|
|
117
|
+
return True
|
|
118
|
+
|