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/config.py ADDED
@@ -0,0 +1,483 @@
1
+ # standard
2
+ import json
3
+ import os
4
+ import platform
5
+ import re
6
+ import shutil
7
+
8
+ # third-party
9
+ import tomlkit
10
+ from dynaconf import Dynaconf, Validator, ValidationError
11
+ from platformdirs import user_config_dir, user_data_dir
12
+
13
+ # Parent Category to folder
14
+ CATEGORY_MAP = {
15
+ 8: "macros",
16
+ 11: "plugins",
17
+ 25: "lua"
18
+ }
19
+
20
+ # Resource to MQ version
21
+ VANILLA_MAP = {
22
+ 1974: "LIVE",
23
+ 2218: "TEST",
24
+ 60: "EMU"
25
+ }
26
+
27
+ MYSEQ_MAP = {
28
+ 151: "LIVE",
29
+ 164: "TEST"
30
+ }
31
+
32
+ # to make settings.local.toml easier to read, names are added in comments
33
+ RESOURCE_NAMES = {
34
+ "1974": "Very Vanilla MQ Live",
35
+ "2218": "Very Vanilla MQ Test",
36
+ "60": "Very Vanilla MQ Emu",
37
+ "4": "KissAssist",
38
+ "2539": "Lua Event Manager",
39
+ "151": "MySEQ Live",
40
+ "164": "MySEQ Test",
41
+ "153": "Brewall's EverQuest Maps",
42
+ "303": "Good's EverQuest Maps",
43
+ "2318": "guildclicky",
44
+ "2174": "buttonmaster",
45
+ "2062": "alertmaster",
46
+ "3040": "rgmercs",
47
+ "2196": "lootly",
48
+ "2088": "boxhud",
49
+ "2391": "scriber",
50
+ "3001": "bazaar / auction helper",
51
+ "2937": "skill skillup: spells and others",
52
+ "2675": "lootnscoot",
53
+ "973": "Ninjadvloot.inc",
54
+ }
55
+
56
+ BREADCRUMB_FILENAME = "last_command.json"
57
+ DEFAULT_CONFIG_DIR = user_config_dir("redfetch", "RedGuides")
58
+
59
+ script_dir = os.path.dirname(os.path.abspath(__file__))
60
+ os.environ['REDFETCH_SCRIPT_DIR'] = script_dir
61
+
62
+ # Populated by initialize_config()
63
+ config_dir = None
64
+ env_file_path = None
65
+ settings = None
66
+
67
+
68
+ def normalize_and_create_path(path):
69
+ if not path:
70
+ raise ValidationError("Path is not set.")
71
+ normalized_path = os.path.normpath(path)
72
+ if not os.path.exists(normalized_path):
73
+ try:
74
+ os.makedirs(normalized_path, exist_ok=True)
75
+ print(f"Created directory: {normalized_path}")
76
+ except OSError as e:
77
+ raise ValidationError(f"Failed to create the directory '{normalized_path}': {e}")
78
+ return normalized_path
79
+
80
+
81
+ def normalize_category_paths(data):
82
+ """Normalize and validate absolute paths in CATEGORY_PATHS."""
83
+ if not isinstance(data, dict):
84
+ return data
85
+ valid_names = set(CATEGORY_MAP.values())
86
+ for key, value in list(data.items()):
87
+ if key not in valid_names:
88
+ raise ValidationError(
89
+ f"Unknown category '{key}' in CATEGORY_PATHS. "
90
+ f"Valid categories: {', '.join(sorted(valid_names))}"
91
+ )
92
+ if isinstance(value, str) and value:
93
+ normalized = os.path.normpath(value)
94
+ data[key] = normalized
95
+ return data
96
+
97
+
98
+ def normalize_paths_in_dict(data, parent_key=None):
99
+ """Dynaconf validator for SPECIAL_RESOURCE paths."""
100
+ if isinstance(data, dict):
101
+ for key, value in data.items():
102
+ if isinstance(value, dict):
103
+ normalize_paths_in_dict(value, parent_key=key)
104
+ elif isinstance(value, list):
105
+ for index, item in enumerate(value):
106
+ normalize_paths_in_dict(item, parent_key=key)
107
+ elif key in ['default_path', 'custom_path'] and isinstance(value, str):
108
+ normalized_value = os.path.normpath(value) if value else value
109
+ data[key] = normalized_value
110
+ elif isinstance(data, list):
111
+ for index, item in enumerate(data):
112
+ normalize_paths_in_dict(item, parent_key=parent_key)
113
+ return data
114
+
115
+
116
+ def initialize_config():
117
+ """Initialize configuration settings."""
118
+ from redfetch.config_firstrun import first_run_setup
119
+
120
+ global config_dir, env_file_path, settings # Declare globals to modify them
121
+
122
+ # Perform first-run setup
123
+ config_dir = first_run_setup()
124
+ os.environ['REDFETCH_CONFIG_DIR'] = config_dir
125
+
126
+ # Data dir: Linux default uses XDG data dir (~/.local/share), else same as config
127
+ is_linux_default = platform.system() == "Linux" and config_dir == DEFAULT_CONFIG_DIR
128
+ data_dir = user_data_dir("redfetch", "RedGuides") if is_linux_default else config_dir
129
+ os.makedirs(data_dir, exist_ok=True)
130
+ os.environ['REDFETCH_DATA_DIR'] = data_dir
131
+
132
+ # Path to the .env file
133
+ env_file_path = os.path.join(config_dir, '.env')
134
+
135
+ # Check if the .env file exists
136
+ if not os.path.exists(env_file_path):
137
+ # If not, create it and set the default environment to 'LIVE'
138
+ atomic_write_text(env_file_path, 'REDFETCH_ENV=LIVE\n')
139
+ print(f".env file created with default environment set to 'LIVE' at {env_file_path}")
140
+
141
+ # Initialize Dynaconf settings
142
+ settings = Dynaconf(
143
+ envvar_prefix="REDFETCH",
144
+ settings_files=[
145
+ os.path.join(script_dir, 'settings.toml'),
146
+ os.path.join(config_dir, 'settings.local.toml')
147
+ ],
148
+ load_dotenv=True,
149
+ dotenv_path=env_file_path,
150
+ dotenv_override=True,
151
+ env_switcher="REDFETCH_ENV",
152
+ merge_enabled=True,
153
+ lazy_load=True,
154
+ environments=True,
155
+ validate_on_update=True,
156
+ validators=[
157
+ Validator("DOWNLOAD_FOLDER", cast=normalize_and_create_path),
158
+ # Separate validator for EQPATH to avoid triggering eqgame.exe check
159
+ Validator("EQPATH", default=None, cast=lambda x: os.path.normpath(x) if x else None),
160
+ Validator("SPECIAL_RESOURCES", cast=normalize_paths_in_dict),
161
+ Validator("CATEGORY_PATHS", default={}, cast=normalize_category_paths)
162
+ ]
163
+ )
164
+
165
+ write_breadcrumb()
166
+
167
+ # Return the settings object for potential use
168
+ return settings
169
+
170
+
171
+ def _resolve_redfetch_executable():
172
+ """PYAPP will give a path when built with PYAPP_PASS_LOCATION=1"""
173
+ pyapp = os.environ.get("PYAPP")
174
+ if pyapp and "redfetch" in os.path.basename(pyapp).lower() and os.path.exists(pyapp):
175
+ return os.path.abspath(pyapp)
176
+
177
+ cmd = shutil.which("redfetch")
178
+ if cmd:
179
+ return os.path.abspath(cmd)
180
+
181
+ return None
182
+
183
+
184
+ def atomic_write_text(path: str, text: str) -> None:
185
+ """Write UTF-8 text to `path` via a temp file + os.replace() so readers never see a partial write."""
186
+ directory = os.path.dirname(path)
187
+ if directory:
188
+ os.makedirs(directory, exist_ok=True)
189
+ tmp_path = f"{path}.tmp"
190
+ with open(tmp_path, "w", encoding="utf-8") as f:
191
+ f.write(text)
192
+ os.replace(tmp_path, path)
193
+
194
+
195
+ def atomic_write_json(path: str, data) -> None:
196
+ """Atomically write `data` as UTF-8 JSON (ensure_ascii=False keeps non-ASCII paths/titles verbatim)."""
197
+ atomic_write_text(path, json.dumps(data, ensure_ascii=False, indent=2))
198
+
199
+
200
+ def write_breadcrumb() -> None:
201
+ """A breadcrumb in the user config dir to track the most recently used redfetch binary's location."""
202
+ try:
203
+ program = _resolve_redfetch_executable()
204
+ if program is None:
205
+ return
206
+
207
+ breadcrumb_path = os.path.join(DEFAULT_CONFIG_DIR, BREADCRUMB_FILENAME)
208
+ atomic_write_json(breadcrumb_path, {"program": program})
209
+ except Exception:
210
+ pass
211
+
212
+
213
+ def remove_breadcrumb() -> None:
214
+ breadcrumb_path = os.path.join(DEFAULT_CONFIG_DIR, BREADCRUMB_FILENAME)
215
+ try:
216
+ os.remove(breadcrumb_path)
217
+ except FileNotFoundError:
218
+ pass
219
+
220
+
221
+ def switch_environment(new_env):
222
+ """Switch the environment and update the settings."""
223
+ if settings is None:
224
+ raise RuntimeError("Configuration has not been initialized. Call initialize_config() first.")
225
+
226
+ # Update the .env file first
227
+ write_env_to_file(new_env)
228
+
229
+ # Set the Dynaconf environment so subsequent `from_env` calls use the new env
230
+ settings.setenv(new_env)
231
+
232
+ # Keep a simple attribute around for convenience (used throughout the app)
233
+ settings.ENV = new_env
234
+
235
+ # Re-validate settings after environment switch
236
+ try:
237
+ settings.validators.validate()
238
+ print(f"Server type: {new_env}")
239
+ except ValidationError as e:
240
+ print(f"Validation error after switching to {new_env}: {e}")
241
+
242
+ return settings
243
+
244
+
245
+ def select_environment_in_memory(new_env):
246
+ """Select `new_env` for this process only, without persisting to the .env file."""
247
+ if settings is None:
248
+ raise RuntimeError("Configuration has not been initialized. Call initialize_config() first.")
249
+
250
+ settings.setenv(new_env)
251
+ settings.ENV = new_env
252
+
253
+ try:
254
+ settings.validators.validate()
255
+ except ValidationError as e:
256
+ print(f"Validation error after selecting {new_env}: {e}")
257
+
258
+ return settings
259
+
260
+
261
+ def ensure_config_file_exists(file_path):
262
+ """Ensure the configuration file exists."""
263
+ if not os.path.exists(file_path):
264
+ atomic_write_text(file_path, tomlkit.dumps({}))
265
+ print(f"Created new configuration file: {file_path}")
266
+
267
+
268
+ def load_config(file_path):
269
+ """Load the TOML configuration file, creating an empty document if it doesn't exist."""
270
+ if not os.path.exists(file_path):
271
+ return tomlkit.document()
272
+ try:
273
+ with open(file_path, 'r', encoding='utf-8') as f:
274
+ return tomlkit.parse(f.read())
275
+ except Exception as e:
276
+ raise ValidationError(f"Error loading config file {file_path}: {e}")
277
+
278
+
279
+ def _annotate_special_resource_comments(toml_text: str) -> str:
280
+ """Add a `# friendly-name` comment above each known SPECIAL_RESOURCES section."""
281
+ section_pattern = re.compile(r"^\[(?:DEFAULT|LIVE|TEST|EMU)\.SPECIAL_RESOURCES\.(\d+)\]\s*$")
282
+
283
+ new_lines = []
284
+ for line in toml_text.splitlines():
285
+ match = section_pattern.match(line)
286
+ if match:
287
+ friendly_name = RESOURCE_NAMES.get(match.group(1))
288
+ if friendly_name and not (new_lines and new_lines[-1] == f"# {friendly_name}"):
289
+ new_lines.append(f"# {friendly_name}")
290
+ new_lines.append(line)
291
+
292
+ ending = "\n" if toml_text.endswith("\n") else ""
293
+ return "\n".join(new_lines) + ending
294
+
295
+
296
+ # Header for settings.local.toml, which redfetch rewrites on every save.
297
+ SETTINGS_LOCAL_HEADER = (
298
+ "# Managed by redfetch: stores only your changes from settings.toml defaults.\n"
299
+ "# Editable by hand, but redfetch rewrites on save, so comments may be dropped\n"
300
+ "# and values matching a default are removed. See settings.toml for all options.\n"
301
+ )
302
+
303
+ # Path-valued keys, compared with path-aware equality (slash vs backslash).
304
+ _PATH_LIKE_KEYS = {"EQPATH", "DOWNLOAD_FOLDER", "custom_path", "default_path"}
305
+
306
+ _MISSING = object()
307
+ _base_settings_cache = None
308
+
309
+
310
+ def _base_settings():
311
+ """Cached Dynaconf view of the bundled settings.toml defaults (no overrides)."""
312
+ global _base_settings_cache
313
+ if _base_settings_cache is None:
314
+ _base_settings_cache = Dynaconf(
315
+ settings_files=[os.path.join(script_dir, "settings.toml")],
316
+ environments=True,
317
+ merge_enabled=True,
318
+ env_switcher="REDFETCH_ENV",
319
+ )
320
+ return _base_settings_cache
321
+
322
+
323
+ def _to_plain(data):
324
+ """Convert a tomlkit document/table (or any nested mapping) to plain dicts/lists."""
325
+ if hasattr(data, "unwrap"):
326
+ return data.unwrap()
327
+ if isinstance(data, dict):
328
+ return {k: _to_plain(v) for k, v in data.items()}
329
+ if isinstance(data, list):
330
+ return [_to_plain(v) for v in data]
331
+ return data
332
+
333
+
334
+ def _base_lookup(base, key):
335
+ """Fetch key from base defaults, tolerating Dynaconf case folding."""
336
+ if not isinstance(base, dict):
337
+ return _MISSING
338
+ if key in base:
339
+ return base[key]
340
+ lowered = key.lower()
341
+ for bkey, bval in base.items():
342
+ if isinstance(bkey, str) and bkey.lower() == lowered:
343
+ return bval
344
+ return _MISSING
345
+
346
+
347
+ def _equals_default(value, default, key):
348
+ """True if value matches the default (and can be dropped)."""
349
+ if value == default:
350
+ return True
351
+ if (
352
+ key in _PATH_LIKE_KEYS
353
+ and isinstance(value, str) and value
354
+ and isinstance(default, str) and default
355
+ ):
356
+ return os.path.normpath(value) == os.path.normpath(default)
357
+ return False
358
+
359
+
360
+ def _prune_branch(local, base):
361
+ """Recursively drop leaves equal to their default and tables left empty."""
362
+ for key in list(local.keys()):
363
+ value = local[key]
364
+ base_value = _base_lookup(base, key)
365
+ if isinstance(value, dict):
366
+ _prune_branch(value, base_value if isinstance(base_value, dict) else {})
367
+ if not value:
368
+ del local[key]
369
+ elif base_value is not _MISSING and _equals_default(value, base_value, key):
370
+ del local[key]
371
+
372
+
373
+ def _prune_to_deltas(data):
374
+ """Drop entries equal to the defaults, leaving only deltas.
375
+
376
+ Each top-level table is an environment (LIVE/TEST/EMU/DEFAULT), compared
377
+ against the same environment's defaults from the bundled settings.toml.
378
+ """
379
+ base = _base_settings()
380
+ for env in list(data.keys()):
381
+ if not isinstance(data[env], dict):
382
+ continue
383
+ try:
384
+ base_env = base.from_env(env).as_dict()
385
+ except Exception:
386
+ continue # defaults unresolvable; keep env verbatim
387
+ _prune_branch(data[env], base_env)
388
+ if not data[env]:
389
+ del data[env]
390
+
391
+
392
+ def save_config(file_path, config_data):
393
+ """Regenerate settings.local.toml, keeping only deltas from the defaults.
394
+
395
+ Accepts a tomlkit document or a plain dict.
396
+ """
397
+ data = _to_plain(config_data)
398
+ _prune_to_deltas(data)
399
+
400
+ body = _annotate_special_resource_comments(tomlkit.dumps(data)).strip("\n")
401
+
402
+ if body:
403
+ toml_text = f"{SETTINGS_LOCAL_HEADER}\n{body}\n"
404
+ else:
405
+ toml_text = SETTINGS_LOCAL_HEADER
406
+ atomic_write_text(file_path, toml_text)
407
+
408
+
409
+ def update_setting(setting_path, setting_value, env=None):
410
+ """Update a specific setting in the settings.local.toml file and in memory,
411
+ optionally within a specific environment."""
412
+ if settings is None or config_dir is None:
413
+ raise RuntimeError("Configuration has not been initialized. Call initialize_config() first.")
414
+
415
+ config_file = os.path.join(config_dir, 'settings.local.toml')
416
+ ensure_config_file_exists(config_file)
417
+ config_data = load_config(config_file)
418
+
419
+ # Use the specified environment or, if None, the current environment
420
+ env = env or settings.current_env
421
+
422
+ # Ensure the environment exists in the configuration
423
+ if env not in config_data:
424
+ config_data[env] = tomlkit.table()
425
+
426
+ # Navigate to the correct setting based on the path within the specified environment
427
+ current_data = config_data[env]
428
+ for key in setting_path[:-1]:
429
+ if key not in current_data:
430
+ current_data[key] = tomlkit.table()
431
+ current_data = current_data[key]
432
+
433
+ # Debugging output
434
+ config_key = '.'.join(setting_path)
435
+ print(f"Updating config key: {config_key}")
436
+ print(f"Old Value: {current_data.get(setting_path[-1], 'Not set')}")
437
+
438
+ # Convert 'true'/'false' strings to Boolean values
439
+ if isinstance(setting_value, str) and setting_value.lower() in ('true', 'false'):
440
+ setting_value = setting_value.lower() == 'true'
441
+
442
+ # None means "unset", TOML can't store None, so remove the key
443
+ if setting_value is None:
444
+ current_data.pop(setting_path[-1], None)
445
+ else:
446
+ current_data[setting_path[-1]] = setting_value
447
+
448
+ # Update the environment using from_env to target the correct environment
449
+ settings.from_env(env).set(config_key, setting_value)
450
+ # Update general settings object to keep it in sync
451
+ settings.set(config_key, setting_value)
452
+
453
+ print(f"New Value: {setting_value}")
454
+
455
+ save_config(config_file, config_data)
456
+ settings.reload()
457
+
458
+ print("Configuration saved.")
459
+
460
+
461
+ def write_env_to_file(new_env):
462
+ """Update the environment setting in the .env file."""
463
+ if env_file_path is None:
464
+ raise RuntimeError("Configuration has not been initialized. Call initialize_config() first.")
465
+
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))