Skip to content

Commit 0df2940

Browse files
committed
centralizing error reporting
1 parent fd807be commit 0df2940

7 files changed

Lines changed: 180 additions & 74 deletions

File tree

src/redfetch/api.py

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ async def get_api_headers():
3030
if not user_id:
3131
user_id = await fetch_user_id_from_api(api_key)
3232
if not user_id:
33-
raise Exception("Unable to retrieve user ID using the provided API key.")
33+
raise RuntimeError("Unable to retrieve user ID using the provided API key.")
3434
headers['XF-Api-User'] = str(user_id)
3535
return headers
3636

@@ -50,10 +50,7 @@ async def get_api_headers():
5050
redirect_uri = os.environ.get("REDFETCH_OAUTH_REDIRECT_URI") or (settings.get("OAUTH_REDIRECT_URI") if settings else None) or "http://127.0.0.1:62897/"
5151

5252
if not client_id:
53-
raise Exception(
54-
"OAuth refresh token is present but OAUTH_CLIENT_ID is not configured. "
55-
"Set REDFETCH_OAUTH_CLIENT_ID (or OAUTH_CLIENT_ID in settings.local.toml) and try again."
56-
)
53+
raise RuntimeError("OAuth client is not configured.")
5754

5855
refreshed = await asyncio.to_thread(
5956
auth.refresh_token,
@@ -66,12 +63,12 @@ async def get_api_headers():
6663
if access_token:
6764
return {"Authorization": f"Bearer {access_token}"}
6865

69-
raise Exception("OAuth token refresh failed. Please run `redfetch logout` and authorize again.")
66+
raise RuntimeError("OAuth token refresh failed. Please run `redfetch logout` and authorize again.")
7067

7168
# Access token exists but is expired/missing expiry and there's no refresh token available.
72-
raise Exception("OAuth access token is expired and no refresh token is available. Please authorize again.")
69+
raise RuntimeError("OAuth access token is expired and no refresh token is available. Please authorize again.")
7370

74-
raise Exception(
71+
raise RuntimeError(
7572
"Not authenticated. Set REDGUIDES_API_KEY (and optionally REDGUIDES_USER_ID), "
7673
"or authorize via OAuth."
7774
)
@@ -336,7 +333,7 @@ async def get_username():
336333
username = await fetch_username(api_key)
337334
if username != "Unknown":
338335
return username
339-
raise Exception("Unable to retrieve username using the provided API key.")
336+
raise RuntimeError("Unable to retrieve username using the provided API key.")
340337

341338
# Prefer OAuth bearer token if present (including refresh-only sessions)
342339
access_token = keyring.get_password(KEYRING_SERVICE_NAME, "access_token")
@@ -349,6 +346,6 @@ async def get_username():
349346
set_username(me["username"])
350347
set_user_id(me["user_id"])
351348
return me["username"]
352-
raise Exception("Unable to retrieve username using the stored OAuth token.")
349+
raise RuntimeError("Unable to retrieve username using the stored OAuth token.")
353350

354-
raise Exception("Username not found. Set REDGUIDES_API_KEY or authorize via OAuth.")
351+
raise RuntimeError("Username not found. Set REDGUIDES_API_KEY or authorize via OAuth.")

src/redfetch/auth.py

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import base64
1010
import hashlib
1111
import os
12-
import sys
1312
import time
1413
import webbrowser
1514
from datetime import datetime, timedelta
@@ -91,7 +90,7 @@ def do_GET(self):
9190
self.wfile.write(b"Authorization successful. You can close this tab.")
9291

9392

94-
def first_authorization(client_id: str, client_secret: str | None, *, scope: str, redirect_uri: str) -> bool:
93+
def first_authorization(client_id: str, client_secret: str | None, *, scope: str, redirect_uri: str) -> None:
9594
"""Perform auth via browser and cache tokens.
9695
9796
Uses Authorization Code + PKCE (S256) as required by XF for public clients.
@@ -145,14 +144,16 @@ def first_authorization(client_id: str, client_secret: str | None, *, scope: str
145144
headers = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}
146145
response = httpx.post(TOKEN_ENDPOINT, headers=headers, data=payload, timeout=10.0)
147146
if not response.is_success:
148-
print("Failed to retrieve tokens.")
149-
print(response.text)
150-
return False
147+
details = response.text.strip()
148+
if details:
149+
raise RuntimeError(f"Failed to retrieve tokens.\n{details}")
150+
raise RuntimeError("Failed to retrieve tokens.")
151151

152152
token_data = response.json()
153153
if token_data.get("error"):
154-
print(f"OAuth token error: {token_data.get('error')} {token_data.get('error_description', '')}".strip())
155-
return False
154+
raise RuntimeError(
155+
f"OAuth token error: {token_data.get('error')} {token_data.get('error_description', '')}".strip()
156+
)
156157

157158
# Step 5: Cache tokens and basic user info
158159
store_tokens_in_keyring(token_data)
@@ -164,8 +165,6 @@ def first_authorization(client_id: str, client_secret: str | None, *, scope: str
164165
except Exception:
165166
pass
166167

167-
return True
168-
169168

170169
def _cache_user_info(access_token: str | None) -> None:
171170
"""Fetch /api/me and cache username/user_id (best-effort)."""
@@ -195,11 +194,7 @@ def authorize():
195194
redirect_uri = _get_setting("OAUTH_REDIRECT_URI", DEFAULT_REDIRECT_URI)
196195

197196
if not client_id:
198-
print("OAuth client is not configured.")
199-
print("Set one of the following:")
200-
print(" - Environment variable: REDFETCH_OAUTH_CLIENT_ID")
201-
print(" - Or add to your settings.local.toml: OAUTH_CLIENT_ID = \"...\"")
202-
sys.exit(1)
197+
raise RuntimeError("OAuth client is not configured.")
203198

204199
data = get_cached_tokens()
205200

@@ -216,9 +211,7 @@ def authorize():
216211

217212
# Fall back to interactive authorization
218213
print("Performing full authorization...")
219-
if not first_authorization(client_id, client_secret, scope=scope, redirect_uri=redirect_uri):
220-
print("Authorization failed.")
221-
sys.exit(1)
214+
first_authorization(client_id, client_secret, scope=scope, redirect_uri=redirect_uri)
222215

223216

224217
def _port_from_redirect_uri(redirect_uri: str) -> int:
@@ -372,16 +365,18 @@ def initialize_keyring():
372365
# Attempt to use the keyring to trigger any potential errors
373366
keyring.get_password('test_service', 'test_user')
374367
except (NoKeyringError, ModuleNotFoundError):
375-
print("No suitable keyring backend found, probably because you're not on Windows.")
376-
print("Please install `keyrings.alt` by running:")
377-
print(" pip install keyrings.alt")
378-
print("Then restart the application.")
379-
sys.exit(1)
368+
raise RuntimeError(
369+
"No suitable keyring backend found, probably because you're not on Windows.\n\n"
370+
"Please install `keyrings.alt` by running:\n"
371+
" pip install keyrings.alt\n\n"
372+
"Then restart the application."
373+
)
380374
except Exception as e:
381375
# Catch any other exceptions that may occur and handle them gracefully
382-
print(f"An error occurred while initializing keyring: {e}")
383-
print("Please ensure that a suitable keyring backend is available.")
384-
sys.exit(1)
376+
raise RuntimeError(
377+
f"An error occurred while initializing keyring: {e}\n\n"
378+
"Please ensure that a suitable keyring backend is available."
379+
) from e
385380

386381

387382
if __name__ == "__main__":

src/redfetch/config_firstrun.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
# Standard
22
import os
33
import platform
4-
import sys
54
import json
65

76
# Third-party
@@ -300,7 +299,7 @@ def first_run_setup():
300299
elif any(word in response for word in ["no", "nope", "nah", "nay"]) or response == "n":
301300
console.print("\n[bold red]The wizards point to the sky with their longest finger ... \"BEGONE!\"[/bold red]")
302301
Prompt.ask("\nPress Enter to continue")
303-
sys.exit(1)
302+
raise SystemExit(1)
304303
elif any(phrase in response for phrase in ["xyzzy", "plugh", "hello sailor", "mailbox", "east", "leave house", "grue"]):
305304
console.print(
306305
"\nAs you utter the ancient words, the wizards eyes widen."
@@ -318,7 +317,7 @@ def first_run_setup():
318317
else:
319318
console.print("\n[bold red]The wizards shake their heads sadly, \"Your riddle eludes us. Perhaps you should go east.\"[/bold red]")
320319
Prompt.ask("\nPress Enter to continue")
321-
sys.exit(1)
320+
raise SystemExit(1)
322321

323322
config_dir = setup_directories()
324323
create_first_run_flag(default_config_dir, config_dir)

src/redfetch/main.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from redfetch import push
2323
from redfetch import sync as sync_pipeline
2424
from redfetch import store
25+
from redfetch.runtime_errors import exit_with_fatal_error
2526

2627

2728
app = typer.Typer(
@@ -640,11 +641,18 @@ def root(
640641
# ============================================================================
641642

642643
def main():
643-
# Launch TUI when no arguments are provided
644-
if len(sys.argv) == 1:
645-
run_tui()
646-
return
647-
app()
644+
try:
645+
# Launch TUI when no arguments are provided
646+
if len(sys.argv) == 1:
647+
run_tui()
648+
return
649+
app()
650+
except typer.Exit:
651+
raise
652+
except KeyboardInterrupt:
653+
raise
654+
except Exception as exc:
655+
exit_with_fatal_error(exc)
648656

649657

650658
if __name__ == "__main__":

src/redfetch/push.py

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
import asyncio
33
import httpx
44
import keepachangelog
5+
import typer
56
from md2bbcode.main import process_readme
67

78
from redfetch import api
89
from redfetch.net import BASE_URL
910
from redfetch import auth
10-
import sys
1111

1212
XF_API_URL = f'{BASE_URL}/api'
1313
URI_MESSAGE = f'{XF_API_URL}/resource-updates'
@@ -220,32 +220,27 @@ def handle_cli(args):
220220

221221
if not any([args.description, args.version, args.message, args.file]):
222222
print("At least one option (--description, --version, --message, or --file) must be specified.")
223-
sys.exit(1)
223+
raise typer.Exit(code=1)
224224

225225
if args.message and not args.version:
226226
print("The --message option requires --version to be specified.")
227-
sys.exit(1)
228-
229-
try:
230-
# Ensure the user is authorized
231-
auth.initialize_keyring()
232-
auth.authorize()
233-
234-
# Blocking call is fine here; push is a short-lived CLI operation.
235-
resource = asyncio.run(api.get_resource_details(args.resource_id))
236-
resource_id = resource['resource_id']
237-
238-
if args.description:
239-
update_description(resource_id, args.description, domain=args.domain)
240-
241-
if args.version and args.message:
242-
message = generate_version_message(args)
243-
version_info = {'version_string': args.version, 'message': message}
244-
update_resource(resource_id, version_info, args.file)
245-
elif args.file:
246-
# Allow publishing a version with a file but no changelog message.
247-
add_xf_attachment(resource_id, args.file, args.version)
248-
249-
except Exception as e:
250-
print(f"An error occurred: {e}")
251-
sys.exit(1)
227+
raise typer.Exit(code=1)
228+
229+
# Ensure the user is authorized
230+
auth.initialize_keyring()
231+
auth.authorize()
232+
233+
# Blocking call is fine here; push is a short-lived CLI operation.
234+
resource = asyncio.run(api.get_resource_details(args.resource_id))
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)

src/redfetch/runtime_errors.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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)

0 commit comments

Comments
 (0)