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/push.py ADDED
@@ -0,0 +1,246 @@
1
+ """Publish resource updates to RedGuides."""
2
+
3
+ import os
4
+ import asyncio
5
+ import httpx
6
+ import keepachangelog
7
+ import typer
8
+ from md2bbcode.main import process_readme
9
+
10
+ from redfetch import api
11
+ from redfetch.net import BASE_URL
12
+ from redfetch import auth
13
+
14
+ XF_API_URL = f'{BASE_URL}/api'
15
+ URI_MESSAGE = f'{XF_API_URL}/resource-updates'
16
+ URI_ATTACHMENT = f'{XF_API_URL}/attachments/new-key'
17
+ URI_RESOURCE_VERSIONS = f'{XF_API_URL}/resource-versions'
18
+ MAX_MESSAGE_CHARS = 10_000
19
+
20
+
21
+ def _get_api_headers_blocking() -> dict:
22
+ """Synchronous helper to obtain API headers from the async auth client."""
23
+ return asyncio.run(auth.get_api_headers())
24
+
25
+
26
+ def update_resource_description(resource_id, new_description):
27
+ """Update resource description."""
28
+ url = f"{XF_API_URL}/resources/{resource_id}"
29
+ payload = {'description': new_description}
30
+ headers = _get_api_headers_blocking()
31
+ response = httpx.post(url, headers=headers, data=payload, timeout=30.0)
32
+ response.raise_for_status()
33
+ print("Successfully updated the resource description.")
34
+
35
+
36
+ def add_xf_message(resource_id, msg_title, message):
37
+ """Post a new resource update message."""
38
+ headers = _get_api_headers_blocking()
39
+ form_message = {
40
+ 'resource_id': resource_id,
41
+ 'title': msg_title,
42
+ 'message': message
43
+ }
44
+ response = httpx.post(URI_MESSAGE, headers=headers, data=form_message, timeout=30.0)
45
+ response.raise_for_status()
46
+ print(f"Response: {response.status_code}, {response.text}")
47
+ return response.json()
48
+
49
+
50
+ def add_xf_attachment(resource_id, upfilename, version=None):
51
+ """Upload a file and attach it as a new resource version."""
52
+ headers = _get_api_headers_blocking()
53
+
54
+ # Prepare the data for getting an attachment key and uploading the file
55
+ data = {
56
+ "type": "resource_version",
57
+ "context[resource_id]": resource_id
58
+ }
59
+
60
+ try:
61
+ # Get an attachment key and also upload the file
62
+ with open(upfilename, "rb") as file:
63
+ files = {"attachment": (os.path.basename(upfilename), file, "application/octet-stream")}
64
+ response = httpx.post(URI_ATTACHMENT, headers=headers, data=data, files=files, timeout=60.0)
65
+ response.raise_for_status()
66
+ content = response.json()
67
+ attach_key = content.get("key")
68
+ if attach_key:
69
+ # Now associate the attachment(s) with the resource version
70
+ data_update = {
71
+ "type": "resource_version",
72
+ "resource_id": resource_id,
73
+ "version_attachment_key": attach_key,
74
+ }
75
+ if version:
76
+ data_update["version_string"] = version
77
+ response_update = httpx.post(URI_RESOURCE_VERSIONS, headers=headers, data=data_update, timeout=60.0)
78
+ response_update.raise_for_status()
79
+ print(f"Successfully added attachment for resource {resource_id}")
80
+ else:
81
+ print("[ERROR] No attachment key received from the server.")
82
+ raise RuntimeError("No attachment key received from the server.")
83
+ except httpx.HTTPStatusError as e:
84
+ print(f"HTTP Error: {e.response.status_code} - {e.response.text}")
85
+ raise
86
+ except FileNotFoundError:
87
+ print(f"Error: File '{upfilename}' not found.")
88
+ raise
89
+
90
+
91
+ def update_resource(resource_id, version_info, upfilename=None):
92
+ """Create a version update (message + optional attachment)."""
93
+ message = version_info.get('message', '')
94
+ if message.strip():
95
+ add_xf_message(resource_id, version_info['version_string'], message)
96
+ else:
97
+ print("Warning: No message content provided, skipping update post.")
98
+
99
+ if upfilename:
100
+ add_xf_attachment(resource_id, upfilename, version_info['version_string'])
101
+
102
+
103
+ def convert_markdown_to_bbcode(markdown_text, domain=None):
104
+ """Convert markdown text to BBCode."""
105
+ bbcode_output = process_readme(markdown_text, domain=domain)
106
+ return bbcode_output
107
+
108
+
109
+ def _truncate_text(text: str, max_chars: int, suffix: str = "\n\n(truncated)") -> str:
110
+ """Truncate text to max_chars (including suffix), if needed."""
111
+ if not isinstance(text, str):
112
+ text = str(text)
113
+ if max_chars <= 0 or len(text) <= max_chars:
114
+ return text
115
+
116
+ suffix = suffix or ""
117
+ if len(suffix) >= max_chars:
118
+ return suffix[:max_chars]
119
+
120
+ allowed = max_chars - len(suffix)
121
+ return text[:allowed].rstrip() + suffix
122
+
123
+
124
+ def _try_parse_keepachangelog_dict(changelog_path: str):
125
+ """
126
+ Attempt to parse a keep-a-changelog file.
127
+
128
+ Returns a non-empty dict if it looks like a changelog; otherwise returns None.
129
+ """
130
+ try:
131
+ changes = keepachangelog.to_dict(changelog_path)
132
+ except Exception:
133
+ return None
134
+
135
+ if not isinstance(changes, dict) or not changes:
136
+ return None
137
+
138
+ return changes
139
+
140
+
141
+ def parse_changelog(changelog_path, version, domain=None, changes=None):
142
+ """
143
+ Parses the changelog file and returns the changelog entry for the given version as BBCode.
144
+ """
145
+ # Use keepachangelog to parse the changelog file
146
+ if changes is None:
147
+ changes = keepachangelog.to_dict(changelog_path)
148
+ # Remove 'v' prefix if present
149
+ version_key = version.lstrip('v')
150
+ if version_key in changes:
151
+ # Flatten the change notes into a markdown string
152
+ version_data = changes[version_key]
153
+ markdown_lines = []
154
+ for section, notes in version_data.items():
155
+ if section != 'metadata':
156
+ markdown_lines.append(f"### {section.capitalize()}")
157
+ for note in notes:
158
+ markdown_lines.append(f"- {note}")
159
+ markdown_lines.append("") # Add a newline
160
+ markdown_message = "\n".join(markdown_lines)
161
+ # Convert markdown to BBCode
162
+ bbcode_message = convert_markdown_to_bbcode(markdown_message, domain=domain)
163
+ return bbcode_message
164
+ else:
165
+ raise ValueError(f"Version {version} not found in {changelog_path}")
166
+
167
+
168
+ def generate_version_message(args):
169
+ """
170
+ Build a version message from `--message` (file path or literal string).
171
+
172
+ If `--message` is a keep-a-changelog file, extract the entry for `--version`; otherwise post the
173
+ file contents (markdown is converted to BBCode). Large messages are truncated.
174
+ """
175
+ max_chars = MAX_MESSAGE_CHARS
176
+
177
+ # message can be a literal string, or a path-like string to a real file.
178
+ if args.message and os.path.isfile(args.message):
179
+ message_path = args.message
180
+ ext = os.path.splitext(message_path)[1].lower()
181
+
182
+ if ext == ".md":
183
+ # If it looks like keep-a-changelog, post the entry for --version; otherwise post the file.
184
+ changes = _try_parse_keepachangelog_dict(message_path)
185
+ if changes is not None:
186
+ try:
187
+ message = parse_changelog(message_path, args.version, domain=args.domain, changes=changes)
188
+ except ValueError as e:
189
+ with open(message_path, "r", encoding="utf-8", errors="replace") as f:
190
+ markdown_text = f.read()
191
+ message = convert_markdown_to_bbcode(markdown_text, domain=args.domain)
192
+ print(f"Warning: {e}. Posting full file contents instead.")
193
+ else:
194
+ with open(message_path, "r", encoding="utf-8", errors="replace") as f:
195
+ markdown_text = f.read()
196
+ message = convert_markdown_to_bbcode(markdown_text, domain=args.domain)
197
+ else:
198
+ # Plain text (or other) file: post as-is.
199
+ with open(message_path, "r", encoding="utf-8", errors="replace") as f:
200
+ message = f.read()
201
+
202
+ return _truncate_text(message, max_chars)
203
+
204
+ # If --message is a regular string, use it directly (still guard against extreme length).
205
+ return _truncate_text(args.message, max_chars)
206
+
207
+
208
+ def update_description(resource_id, description_path, domain=None):
209
+ """Read description, convert markdown to BBCode if needed, then update."""
210
+ with open(description_path, 'r') as f:
211
+ new_description = f.read()
212
+ if description_path.lower().endswith('.md'):
213
+ new_description = convert_markdown_to_bbcode(new_description, domain=domain)
214
+ update_resource_description(resource_id, new_description)
215
+
216
+
217
+ def handle_cli(args):
218
+ """Handle the push subcommand using existing push helpers."""
219
+
220
+ if not any([args.description, args.version, args.message, args.file]):
221
+ print("At least one option (--description, --version, --message, or --file) must be specified.")
222
+ raise typer.Exit(code=1)
223
+
224
+ if args.message and not args.version:
225
+ print("The --message option requires --version to be specified.")
226
+ raise typer.Exit(code=1)
227
+
228
+ # Ensure the user is authorized
229
+ auth.initialize_keyring()
230
+ auth.authorize()
231
+
232
+ # Blocking call is fine here; push is a short-lived CLI operation.
233
+ headers = _get_api_headers_blocking()
234
+ resource = asyncio.run(api.get_resource_details(args.resource_id, headers))
235
+ resource_id = resource['resource_id']
236
+
237
+ if args.description:
238
+ update_description(resource_id, args.description, domain=args.domain)
239
+
240
+ if args.version and args.message:
241
+ message = generate_version_message(args)
242
+ version_info = {'version_string': args.version, 'message': message}
243
+ update_resource(resource_id, version_info, args.file)
244
+ elif args.file:
245
+ # Allow publishing a version with a file but no changelog message.
246
+ add_xf_attachment(resource_id, args.file, args.version)
redfetch/redfetch.ico ADDED
Binary file
@@ -0,0 +1,96 @@
1
+ """Shared fatal error helpers for startup/CLI boundaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import traceback
8
+ from typing import NoReturn
9
+
10
+
11
+ def is_windows_pyapp() -> bool:
12
+ """are we using the redfetch.exe executable on windows?"""
13
+ return sys.platform == "win32" and bool(os.getenv("PYAPP"))
14
+
15
+ def normalize_error_message(
16
+ message: str | BaseException | None,
17
+ *,
18
+ fallback: str = "An unexpected error occurred.",
19
+ ) -> str:
20
+ """Make a safe non-empty string."""
21
+ if isinstance(message, BaseException):
22
+ normalized = str(message).strip()
23
+ if normalized:
24
+ return normalized
25
+ return f"{message.__class__.__name__} ({fallback})"
26
+
27
+ if message is None:
28
+ return fallback
29
+
30
+ normalized = str(message).strip()
31
+ if normalized:
32
+ return normalized
33
+ return fallback
34
+
35
+
36
+ def _show_windows_error_dialog(message: str) -> None:
37
+ """Shows a Windows MessageBox."""
38
+ try:
39
+ import ctypes
40
+
41
+ ctypes.windll.user32.MessageBoxW(0, message, "redfetch", 0x10)
42
+ except Exception:
43
+ # Best effort only: CLI stderr output remains the source of truth.
44
+ pass
45
+
46
+
47
+ def _format_error_details(error: BaseException) -> str:
48
+ """Summarize the exception with traceback and details."""
49
+ summary = f"{error.__class__.__name__}: {normalize_error_message(error)}"
50
+ traceback_text = "".join(
51
+ traceback.format_exception(type(error), error, error.__traceback__)
52
+ ).strip()
53
+ if not traceback_text:
54
+ return summary
55
+ return f"{summary}\n\n{traceback_text}"
56
+
57
+
58
+ def display_fatal_error(
59
+ message: str | BaseException | None,
60
+ ) -> str:
61
+ """Print a fatal error and optionally show a Windows MessageBox."""
62
+ normalized_message = normalize_error_message(message)
63
+ error_details = (
64
+ _format_error_details(message)
65
+ if isinstance(message, BaseException)
66
+ else None
67
+ )
68
+
69
+ if is_windows_pyapp():
70
+ if error_details:
71
+ dialog_message = (
72
+ "Tip: Press Ctrl+C to copy this error report.\n\n"
73
+ f"{error_details}"
74
+ )
75
+ print(error_details, file=sys.stderr)
76
+ else:
77
+ dialog_message = normalized_message
78
+ print(normalized_message, file=sys.stderr)
79
+ _show_windows_error_dialog(dialog_message)
80
+ else:
81
+ # CLI behavior: print the raw normalized message without extra wrappers.
82
+ if error_details:
83
+ print(error_details, file=sys.stderr)
84
+ else:
85
+ print(normalized_message, file=sys.stderr)
86
+
87
+ return normalized_message
88
+
89
+
90
+ def exit_with_fatal_error(
91
+ message: str | BaseException | None,
92
+ exit_code: int = 1,
93
+ ) -> NoReturn:
94
+ """Display a fatal error and terminate with a non-zero exit code."""
95
+ display_fatal_error(message)
96
+ raise SystemExit(exit_code)
redfetch/settings.toml ADDED
@@ -0,0 +1,303 @@
1
+ # Hail adventurer! This file is not meant to be edited directly.
2
+ # Instead, you should make a settings.local.toml file, and edit that. It will override anything here.
3
+
4
+ # ========================================
5
+ # DEFAULT SETTINGS USED IN ALL ENVIRONMENTS
6
+ # ========================================
7
+ # These settings are the base configuration for all environments. You can override them with [LIVE], [TEST], or [EMU] sections.
8
+ [DEFAULT]
9
+ DOWNLOAD_FOLDER = "@format {env[REDFETCH_DATA_DIR]}/Downloads"
10
+ EQPATH = ""
11
+
12
+ # ========================================
13
+ # XenForo OAuth2
14
+ # ========================================
15
+ OAUTH_CLIENT_ID = "4558786089326342"
16
+ OAUTH_CLIENT_SECRET = ""
17
+ OAUTH_SCOPE = "user:read resource:read resource:write attachment:write"
18
+ OAUTH_REDIRECT_URI = "http://127.0.0.1:62897/"
19
+
20
+ # ========================================
21
+ # LIVE ENVIRONMENT SETTINGS
22
+ # ========================================
23
+ [LIVE]
24
+ THEME = "textual-dark"
25
+
26
+ [LIVE.PROTECTED_FILES_BY_RESOURCE]
27
+ 1974 = ["CharSelect.cfg", "Zoned.cfg", "MQ2Map.ini", "MQ2MoveUtils.ini"]
28
+
29
+ [LIVE.SPECIAL_RESOURCES.1974]
30
+ default_path = "VanillaMQ_LIVE"
31
+ custom_path = ""
32
+ opt_in = true
33
+
34
+ [LIVE.SPECIAL_RESOURCES.151]
35
+ default_path = "MySEQ_LIVE"
36
+ custom_path = ""
37
+ opt_in = false
38
+
39
+ [LIVE.SPECIAL_RESOURCES.151.dependencies.153]
40
+ subfolder = "maps"
41
+ flatten = true
42
+ opt_in = true
43
+
44
+ [LIVE.SPECIAL_RESOURCES.151.dependencies.1865]
45
+ subfolder = ""
46
+ flatten = false
47
+ opt_in = true
48
+
49
+ [LIVE.SPECIAL_RESOURCES.153]
50
+ default_path = "@format {this.eqpath}/maps"
51
+ custom_path = ""
52
+ opt_in = false
53
+
54
+ [LIVE.SPECIAL_RESOURCES.303]
55
+ default_path = "@format {this.eqpath}/maps"
56
+ custom_path = ""
57
+ opt_in = false
58
+
59
+ # LIVE staff picks
60
+ # kissassist
61
+ [LIVE.SPECIAL_RESOURCES.4]
62
+ opt_in = true
63
+ staff_pick = true
64
+
65
+ # lua event manager
66
+ [LIVE.SPECIAL_RESOURCES.2539]
67
+ opt_in = true
68
+ staff_pick = true
69
+
70
+ # guildclicky
71
+ [LIVE.SPECIAL_RESOURCES.2318]
72
+ opt_in = true
73
+ staff_pick = true
74
+
75
+ # buttonmaster
76
+ [LIVE.SPECIAL_RESOURCES.2174]
77
+ opt_in = true
78
+ staff_pick = true
79
+
80
+ # alertmaster
81
+ [LIVE.SPECIAL_RESOURCES.2062]
82
+ opt_in = true
83
+ staff_pick = true
84
+
85
+ # rgmercs
86
+ [LIVE.SPECIAL_RESOURCES.3040]
87
+ opt_in = true
88
+ staff_pick = true
89
+
90
+ # lootly
91
+ [LIVE.SPECIAL_RESOURCES.2196]
92
+ opt_in = true
93
+ staff_pick = true
94
+
95
+ # boxhud
96
+ [LIVE.SPECIAL_RESOURCES.2088]
97
+ opt_in = true
98
+ staff_pick = true
99
+
100
+ # scriber
101
+ [LIVE.SPECIAL_RESOURCES.2391]
102
+ opt_in = true
103
+ staff_pick = true
104
+
105
+ # bazaar / auction helper
106
+ [LIVE.SPECIAL_RESOURCES.3001]
107
+ opt_in = true
108
+ staff_pick = true
109
+
110
+ # skill skillup: spells and others
111
+ [LIVE.SPECIAL_RESOURCES.2937]
112
+ opt_in = true
113
+ staff_pick = true
114
+
115
+ # Ninjadvloot.inc
116
+ [LIVE.SPECIAL_RESOURCES.973]
117
+ opt_in = true
118
+ staff_pick = true
119
+
120
+ # ========================================
121
+ # TEST ENVIRONMENT SETTINGS
122
+ # ========================================
123
+ [TEST]
124
+ THEME = "gruvbox"
125
+
126
+ [TEST.PROTECTED_FILES_BY_RESOURCE]
127
+ 2218 = ["CharSelect.cfg", "Zoned.cfg", "MQ2Map.ini", "MQ2MoveUtils.ini"]
128
+
129
+ [TEST.SPECIAL_RESOURCES.2218]
130
+ default_path = "VanillaMQ_TEST"
131
+ custom_path = ""
132
+ opt_in = true
133
+
134
+ [TEST.SPECIAL_RESOURCES.164]
135
+ default_path = "MySEQ_TEST"
136
+ custom_path = ""
137
+ opt_in = false
138
+
139
+ [TEST.SPECIAL_RESOURCES.164.dependencies.153]
140
+ subfolder = "maps"
141
+ flatten = true
142
+ opt_in = true
143
+
144
+ [TEST.SPECIAL_RESOURCES.164.dependencies.1865]
145
+ subfolder = ""
146
+ flatten = false
147
+ opt_in = true
148
+
149
+ [TEST.SPECIAL_RESOURCES.153]
150
+ default_path = "@format {this.eqpath}/maps"
151
+ custom_path = ""
152
+ opt_in = false
153
+
154
+ [TEST.SPECIAL_RESOURCES.303]
155
+ default_path = "@format {this.eqpath}/maps"
156
+ custom_path = ""
157
+ opt_in = false
158
+
159
+ # TEST staff picks
160
+ # kissassist
161
+ [TEST.SPECIAL_RESOURCES.4]
162
+ opt_in = true
163
+ staff_pick = true
164
+
165
+ # lua event manager
166
+ [TEST.SPECIAL_RESOURCES.2539]
167
+ opt_in = true
168
+ staff_pick = true
169
+
170
+ # guildclicky
171
+ [TEST.SPECIAL_RESOURCES.2318]
172
+ opt_in = true
173
+ staff_pick = true
174
+
175
+ # buttonmaster
176
+ [TEST.SPECIAL_RESOURCES.2174]
177
+ opt_in = true
178
+ staff_pick = true
179
+
180
+ # alertmaster
181
+ [TEST.SPECIAL_RESOURCES.2062]
182
+ opt_in = true
183
+ staff_pick = true
184
+
185
+ # rgmercs
186
+ [TEST.SPECIAL_RESOURCES.3040]
187
+ opt_in = true
188
+ staff_pick = true
189
+
190
+ # lootly
191
+ [TEST.SPECIAL_RESOURCES.2196]
192
+ opt_in = true
193
+ staff_pick = true
194
+
195
+ # boxhud
196
+ [TEST.SPECIAL_RESOURCES.2088]
197
+ opt_in = true
198
+ staff_pick = true
199
+
200
+ # scriber
201
+ [TEST.SPECIAL_RESOURCES.2391]
202
+ opt_in = true
203
+ staff_pick = true
204
+
205
+ # bazaar / auction helper
206
+ [TEST.SPECIAL_RESOURCES.3001]
207
+ opt_in = true
208
+ staff_pick = true
209
+
210
+ # skill skillup: spells and others
211
+ [TEST.SPECIAL_RESOURCES.2937]
212
+ opt_in = true
213
+ staff_pick = true
214
+
215
+ # Ninjadvloot.inc
216
+ [TEST.SPECIAL_RESOURCES.973]
217
+ opt_in = true
218
+ staff_pick = true
219
+
220
+ # ========================================
221
+ # EMULATOR ENVIRONMENT SETTINGS
222
+ # ========================================
223
+ [EMU]
224
+ THEME = "dracula"
225
+
226
+ [EMU.PROTECTED_FILES_BY_RESOURCE]
227
+ 60 = ["CharSelect.cfg", "Zoned.cfg", "MQ2Map.ini", "MQ2MoveUtils.ini"]
228
+
229
+ [EMU.SPECIAL_RESOURCES.60]
230
+ default_path = "VanillaMQ_EMU"
231
+ custom_path = ""
232
+ opt_in = true
233
+
234
+ [EMU.SPECIAL_RESOURCES.153]
235
+ default_path = "@format {this.eqpath}/maps"
236
+ custom_path = ""
237
+ opt_in = false
238
+
239
+ [EMU.SPECIAL_RESOURCES.303]
240
+ default_path = "@format {this.eqpath}/maps"
241
+ custom_path = ""
242
+ opt_in = false
243
+
244
+ # EMU staff picks
245
+ # kissassist
246
+ [EMU.SPECIAL_RESOURCES.4]
247
+ opt_in = true
248
+ staff_pick = true
249
+
250
+ # lua event manager
251
+ [EMU.SPECIAL_RESOURCES.2539]
252
+ opt_in = true
253
+ staff_pick = true
254
+
255
+ # guildclicky
256
+ [EMU.SPECIAL_RESOURCES.2318]
257
+ opt_in = true
258
+ staff_pick = true
259
+
260
+ # buttonmaster
261
+ [EMU.SPECIAL_RESOURCES.2174]
262
+ opt_in = true
263
+ staff_pick = true
264
+
265
+ # alertmaster
266
+ [EMU.SPECIAL_RESOURCES.2062]
267
+ opt_in = true
268
+ staff_pick = true
269
+
270
+ # rgmercs
271
+ [EMU.SPECIAL_RESOURCES.3040]
272
+ opt_in = true
273
+ staff_pick = true
274
+
275
+ # boxhud
276
+ [EMU.SPECIAL_RESOURCES.2088]
277
+ opt_in = true
278
+ staff_pick = true
279
+
280
+ # scriber
281
+ [EMU.SPECIAL_RESOURCES.2391]
282
+ opt_in = true
283
+ staff_pick = true
284
+
285
+ # bazaar / auction helper
286
+ [EMU.SPECIAL_RESOURCES.3001]
287
+ opt_in = true
288
+ staff_pick = true
289
+
290
+ # skill skillup: spells and others
291
+ [EMU.SPECIAL_RESOURCES.2937]
292
+ opt_in = true
293
+ staff_pick = true
294
+
295
+ # lootnscoot (EMU only)
296
+ [EMU.SPECIAL_RESOURCES.2675]
297
+ opt_in = true
298
+ staff_pick = true
299
+
300
+ # Ninjadvloot.inc
301
+ [EMU.SPECIAL_RESOURCES.973]
302
+ opt_in = true
303
+ staff_pick = true