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/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
+