Skip to content

Commit 07f85e9

Browse files
authored
Merge branch 'main' into enh/reports-graphics
2 parents 2d0ad78 + 573b879 commit 07f85e9

20 files changed

Lines changed: 514 additions & 204 deletions

.github/workflows/ci.yml

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: CI - Lint
1+
name: CI - Lint and Type Check
22

33
on:
44
push:
@@ -17,21 +17,21 @@ jobs:
1717
- name: Checkout repository
1818
uses: actions/checkout@v6
1919

20-
- name: Set up uv with Python
20+
- name: Set up uv
2121
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57
2222
with:
2323
python-version: "3.14"
2424
enable-cache: true
2525

26-
- name: Sync dependencies (locked, dev)
26+
- name: Sync dependencies
2727
run: |
2828
uv sync --locked --dev
2929
30-
- name: Run ruff (as in pre-commit)
30+
- name: Lint
3131
run: |
3232
uv run ruff check --exclude tests .
3333
34-
- name: Run ruff format (check)
34+
- name: Format
3535
run: |
3636
uv run ruff format --check --exclude tests .
3737
@@ -41,3 +41,25 @@ jobs:
4141
# run: |
4242
# uv run pytest
4343

44+
type-check:
45+
name: Type Check
46+
runs-on: ubuntu-latest
47+
48+
steps:
49+
- name: Checkout repository
50+
uses: actions/checkout@v6
51+
52+
- name: Set up uv
53+
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57
54+
with:
55+
python-version: "3.14"
56+
enable-cache: true
57+
58+
- name: Sync dependencies
59+
run: |
60+
uv sync --locked --dev
61+
62+
- name: Type check
63+
run: |
64+
uv run ty check src
65+

.pre-commit-config.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,11 @@ repos:
1010
- id: ruff-format
1111
- id: ruff-check
1212
args: ["--fix", "--exclude", "tests"]
13+
14+
- repo: local
15+
hooks:
16+
- id: ty-check
17+
name: ty check (src)
18+
entry: uv run ty check src
19+
language: system
20+
pass_filenames: false

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ dev = [
3737
"ruff>=0.1.0",
3838
# "mypy>=1.8.0",
3939
"pre-commit>=3.6",
40+
"ty>=0.0.30",
41+
"pandas-stubs>=3.0.0.260204",
4042
]
4143
build = [
4244
"pyinstaller>=6.15.0",
@@ -121,3 +123,6 @@ strict_optional = true
121123
[[tool.mypy.overrides]]
122124
module = "PySide6.*"
123125
ignore_missing_imports = true
126+
127+
[tool.ty.rules]
128+
unresolved-attribute = "ignore"

src/clinical_dbs_annotator/__main__.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111
import PySide6.QtSvg # noqa: F401 - required to enable SVG rendering in QSS
1212
from PySide6.QtWidgets import QApplication
1313

14-
from .logging_config import setup_logging
14+
from .logging_config import setup_bootstrap_logging, setup_logging
1515
from .utils import get_theme_manager
1616
from .views import WizardWindow
1717

18+
logger = logging.getLogger(__name__)
19+
1820

1921
def main() -> int:
2022
"""
@@ -23,23 +25,28 @@ def main() -> int:
2325
Returns:
2426
Exit code (0 for success)
2527
"""
26-
app = QApplication(sys.argv)
28+
setup_bootstrap_logging()
29+
try:
30+
app = QApplication(sys.argv)
2731

28-
app.setApplicationName("Clinical DBS Annotator")
29-
app.setOrganizationName("BML")
32+
app.setApplicationName("Clinical DBS Annotator")
33+
app.setOrganizationName("BML")
3034

31-
setup_logging(app)
35+
setup_logging(app)
3236

33-
theme_manager = get_theme_manager()
34-
try:
35-
theme_manager.apply_theme(theme_manager.get_current_theme(), app)
36-
except Exception as e:
37-
logging.warning("Could not load theme: %s", e)
37+
theme_manager = get_theme_manager()
38+
try:
39+
theme_manager.apply_theme(theme_manager.get_current_theme(), app)
40+
except Exception:
41+
logger.exception("Could not load current theme")
3842

39-
window = WizardWindow(app)
40-
window.show()
43+
window = WizardWindow(app)
44+
window.show()
4145

42-
return app.exec()
46+
return app.exec()
47+
except Exception:
48+
logger.critical("Fatal startup failure", exc_info=True)
49+
return 1
4350

4451

4552
if __name__ == "__main__":

src/clinical_dbs_annotator/controllers/wizard_controller.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -476,12 +476,17 @@ def undo_last_session_entry(self, view) -> None:
476476

477477
# Read the TSV file and filter out rows with the last block_id
478478
file_path = self.session_data.file_path
479+
if file_path is None:
480+
QMessageBox.warning(
481+
view, "Error", "No file path is associated with this session."
482+
)
483+
return
479484
rows_to_keep = []
480485
rows_to_delete = []
481486

482487
with open(file_path, newline="", encoding="utf-8") as f:
483488
reader = csv.DictReader(f, delimiter="\t")
484-
fieldnames = reader.fieldnames
489+
fieldnames = list(reader.fieldnames or [])
485490

486491
for row in reader:
487492
block_id = row.get("block_id", "")
@@ -528,11 +533,11 @@ def close_session(self, parent) -> None:
528533
parent,
529534
"Confirm Close Session",
530535
"Are you sure you want to close the current session? The session will be saved before closing.",
531-
QMessageBox.Ok | QMessageBox.Cancel,
532-
QMessageBox.Cancel,
536+
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
537+
QMessageBox.StandardButton.Cancel,
533538
)
534539

535-
if reply == QMessageBox.Ok:
540+
if reply == QMessageBox.StandardButton.Ok:
536541
self.session_data.close_file()
537542
parent.close()
538543

src/clinical_dbs_annotator/logging_config.py

Lines changed: 76 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import faulthandler
12
import logging
23
import platform
34
import sys
45
import threading
56
from logging.handlers import RotatingFileHandler
67
from pathlib import Path
8+
from types import TracebackType
79

810
from PySide6.QtCore import (
911
QMessageLogContext,
@@ -17,10 +19,69 @@
1719

1820
_configured = False
1921
_log_file_path: Path | None = None
22+
_crash_log_file = None
23+
24+
25+
def _safe_exc_info(
26+
exc_type: type[BaseException],
27+
exc_value: BaseException | None,
28+
exc_traceback: TracebackType | None,
29+
) -> (
30+
tuple[type[BaseException], BaseException, TracebackType | None]
31+
| tuple[None, None, None]
32+
):
33+
"""Normalize exception tuple for logging APIs that require non-optional exception values."""
34+
if exc_value is None:
35+
return (None, None, None)
36+
return (exc_type, exc_value, exc_traceback)
37+
38+
39+
def _install_exception_hooks() -> None:
40+
def exc_hook(
41+
exc_type: type[BaseException], exc: BaseException, tb: TracebackType | None
42+
) -> None:
43+
logging.getLogger("uncaught").critical(
44+
"Uncaught exception",
45+
exc_info=(exc_type, exc, tb),
46+
)
47+
48+
sys.excepthook = exc_hook
49+
50+
def thread_exc_hook(args: threading.ExceptHookArgs) -> None:
51+
logging.getLogger("uncaught").critical(
52+
"Uncaught thread exception",
53+
exc_info=_safe_exc_info(args.exc_type, args.exc_value, args.exc_traceback),
54+
)
55+
56+
threading.excepthook = thread_exc_hook
57+
58+
def unraisable_hook(args: sys.UnraisableHookArgs) -> None:
59+
logging.getLogger("uncaught").error(
60+
"Unraisable exception in %r",
61+
args.object,
62+
exc_info=_safe_exc_info(args.exc_type, args.exc_value, args.exc_traceback),
63+
)
64+
65+
sys.unraisablehook = unraisable_hook
66+
67+
68+
def setup_bootstrap_logging() -> None:
69+
root = logging.getLogger()
70+
if root.handlers:
71+
_install_exception_hooks()
72+
return
73+
74+
fmt = logging.Formatter("%(asctime)s ¦ %(levelname)s ¦ %(name)s ¦ %(message)s")
75+
sh = logging.StreamHandler(sys.stderr)
76+
sh.setLevel(logging.INFO)
77+
sh.setFormatter(fmt)
78+
root.setLevel(logging.DEBUG)
79+
root.addHandler(sh)
80+
_install_exception_hooks()
2081

2182

2283
def setup_logging(_app: QApplication) -> Path:
23-
global _configured, _log_file_path
84+
global _configured, _log_file_path, _crash_log_file
2485
if _configured:
2586
assert _log_file_path is not None
2687
return _log_file_path
@@ -53,21 +114,7 @@ def setup_logging(_app: QApplication) -> Path:
53114
sh.setFormatter(fmt)
54115
root.addHandler(sh)
55116

56-
def exc_hook(exc_type, exc, tb):
57-
logging.getLogger("uncaught").critical(
58-
"Uncaught exception",
59-
exc_info=(exc_type, exc, tb),
60-
)
61-
62-
sys.excepthook = exc_hook
63-
64-
def thread_exc_hook(args: threading.ExceptHookArgs) -> None:
65-
logging.getLogger("uncaught").critical(
66-
"Uncaught thread exception",
67-
exc_info=(args.exc_type, args.exc_value, args.exc_traceback),
68-
)
69-
70-
threading.excepthook = thread_exc_hook
117+
_install_exception_hooks()
71118

72119
def qt_handler(mode: QtMsgType, context: QMessageLogContext, message: str) -> None:
73120
if mode == QtMsgType.QtDebugMsg:
@@ -86,6 +133,19 @@ def qt_handler(mode: QtMsgType, context: QMessageLogContext, message: str) -> No
86133
logging.getLogger("qt").log(level, "%s%s", message, suffix)
87134

88135
qInstallMessageHandler(qt_handler)
136+
_app.aboutToQuit.connect(
137+
lambda: logging.getLogger("clinical_dbs_annotator").info("Application shutdown")
138+
)
139+
140+
crash_log_path = log_dir / "clinical-dbs-annotator-crash.log"
141+
try:
142+
_crash_log_file = open(crash_log_path, "a", encoding="utf-8")
143+
faulthandler.enable(file=_crash_log_file, all_threads=True)
144+
except Exception:
145+
logging.getLogger("clinical_dbs_annotator").exception(
146+
"Failed to enable faulthandler with crash log %s",
147+
crash_log_path,
148+
)
89149

90150
logging.getLogger("clinical_dbs_annotator").info(
91151
"Started v%s Python %s | %s | log=%s",

0 commit comments

Comments
 (0)