subjectdb 1.0.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.
Files changed (89) hide show
  1. subjectdb/__init__.py +13 -0
  2. subjectdb/__main__.py +7 -0
  3. subjectdb/app.py +305 -0
  4. subjectdb/db.py +1172 -0
  5. subjectdb/docs/databases.md +315 -0
  6. subjectdb/docs/images/add-details.png +0 -0
  7. subjectdb/docs/images/add-errors.png +0 -0
  8. subjectdb/docs/images/add-existing.png +0 -0
  9. subjectdb/docs/images/add-person.png +0 -0
  10. subjectdb/docs/images/add-project.png +0 -0
  11. subjectdb/docs/images/add-saved.png +0 -0
  12. subjectdb/docs/images/delete-confirm.png +0 -0
  13. subjectdb/docs/images/edit-dialog.png +0 -0
  14. subjectdb/docs/images/folder-dialog.png +0 -0
  15. subjectdb/docs/images/hero.png +0 -0
  16. subjectdb/docs/images/import-file.png +0 -0
  17. subjectdb/docs/images/import-preview.png +0 -0
  18. subjectdb/docs/images/project-basics.png +0 -0
  19. subjectdb/docs/images/project-diseases.png +0 -0
  20. subjectdb/docs/images/projects-page.png +0 -0
  21. subjectdb/docs/images/researcher.png +0 -0
  22. subjectdb/docs/images/settings.png +0 -0
  23. subjectdb/docs/images/subjects-page.png +0 -0
  24. subjectdb/docs/images/subjects-search.png +0 -0
  25. subjectdb/docs/images/upgrade-dialog.png +0 -0
  26. subjectdb/docs/images/window-tour.png +0 -0
  27. subjectdb/docs/tutorial.md +345 -0
  28. subjectdb/excel.py +469 -0
  29. subjectdb/gui/__init__.py +1 -0
  30. subjectdb/gui/assets/fonts/Inter-Bold.ttf +0 -0
  31. subjectdb/gui/assets/fonts/Inter-Medium.ttf +0 -0
  32. subjectdb/gui/assets/fonts/Inter-Regular.ttf +0 -0
  33. subjectdb/gui/assets/fonts/Inter-SemiBold.ttf +0 -0
  34. subjectdb/gui/assets/fonts/OFL.txt +92 -0
  35. subjectdb/gui/assets/icons/LICENSE +43 -0
  36. subjectdb/gui/assets/icons/book-open.svg +16 -0
  37. subjectdb/gui/assets/icons/calendar.svg +18 -0
  38. subjectdb/gui/assets/icons/check.svg +15 -0
  39. subjectdb/gui/assets/icons/chevron-down.svg +15 -0
  40. subjectdb/gui/assets/icons/chevron-left.svg +15 -0
  41. subjectdb/gui/assets/icons/chevron-right.svg +15 -0
  42. subjectdb/gui/assets/icons/chevron-up.svg +15 -0
  43. subjectdb/gui/assets/icons/circle-alert.svg +17 -0
  44. subjectdb/gui/assets/icons/circle-check.svg +16 -0
  45. subjectdb/gui/assets/icons/circle-help.svg +17 -0
  46. subjectdb/gui/assets/icons/circle-x.svg +17 -0
  47. subjectdb/gui/assets/icons/copy.svg +16 -0
  48. subjectdb/gui/assets/icons/database.svg +17 -0
  49. subjectdb/gui/assets/icons/download.svg +17 -0
  50. subjectdb/gui/assets/icons/external-link.svg +17 -0
  51. subjectdb/gui/assets/icons/file-spreadsheet.svg +20 -0
  52. subjectdb/gui/assets/icons/file-up.svg +18 -0
  53. subjectdb/gui/assets/icons/folder-kanban.svg +18 -0
  54. subjectdb/gui/assets/icons/folder-open.svg +15 -0
  55. subjectdb/gui/assets/icons/folder.svg +15 -0
  56. subjectdb/gui/assets/icons/info.svg +17 -0
  57. subjectdb/gui/assets/icons/log-out.svg +17 -0
  58. subjectdb/gui/assets/icons/mail.svg +16 -0
  59. subjectdb/gui/assets/icons/pencil.svg +16 -0
  60. subjectdb/gui/assets/icons/plus.svg +16 -0
  61. subjectdb/gui/assets/icons/rotate-ccw.svg +16 -0
  62. subjectdb/gui/assets/icons/search.svg +16 -0
  63. subjectdb/gui/assets/icons/settings.svg +16 -0
  64. subjectdb/gui/assets/icons/tags.svg +17 -0
  65. subjectdb/gui/assets/icons/trash-2.svg +19 -0
  66. subjectdb/gui/assets/icons/triangle-alert.svg +17 -0
  67. subjectdb/gui/assets/icons/upload.svg +17 -0
  68. subjectdb/gui/assets/icons/user-plus.svg +18 -0
  69. subjectdb/gui/assets/icons/users.svg +18 -0
  70. subjectdb/gui/assets/icons/x.svg +16 -0
  71. subjectdb/gui/assets/logo.svg +18 -0
  72. subjectdb/gui/dialogs.py +372 -0
  73. subjectdb/gui/help.py +216 -0
  74. subjectdb/gui/import_page.py +235 -0
  75. subjectdb/gui/main_window.py +413 -0
  76. subjectdb/gui/projects_page.py +606 -0
  77. subjectdb/gui/subject_form.py +802 -0
  78. subjectdb/gui/subjects_page.py +345 -0
  79. subjectdb/gui/theme.py +335 -0
  80. subjectdb/gui/widgets.py +823 -0
  81. subjectdb/upgrade.py +411 -0
  82. subjectdb/vocabulary.py +525 -0
  83. subjectdb-1.0.0.dist-info/METADATA +101 -0
  84. subjectdb-1.0.0.dist-info/RECORD +89 -0
  85. subjectdb-1.0.0.dist-info/WHEEL +4 -0
  86. subjectdb-1.0.0.dist-info/entry_points.txt +3 -0
  87. subjectdb-1.0.0.dist-info/licenses/LICENSE +29 -0
  88. subjectdb-1.0.0.dist-info/licenses/src/subjectdb/gui/assets/fonts/OFL.txt +92 -0
  89. subjectdb-1.0.0.dist-info/licenses/src/subjectdb/gui/assets/icons/LICENSE +43 -0
subjectdb/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """SubjectDB: register study participants across research projects.
2
+
3
+ The package is split in two layers:
4
+
5
+ * ``vocabulary``, ``db``, ``upgrade`` and ``excel`` hold all rules and all
6
+ database work. They never import Qt, so they can be tested and scripted alone.
7
+ * ``gui`` is the PySide6 interface. It never talks to SQLite directly; every
8
+ read and write goes through :class:`subjectdb.db.Registry`.
9
+
10
+ ``app`` glues both together (command line, start-up checks, main window).
11
+ """
12
+
13
+ __version__ = "1.0.0"
subjectdb/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow ``python -m subjectdb`` (researcher) and ``python -m subjectdb --pi`` (PI)."""
2
+
3
+ import sys
4
+
5
+ from subjectdb.app import main
6
+
7
+ sys.exit(main())
subjectdb/app.py ADDED
@@ -0,0 +1,305 @@
1
+ """Start-up of SubjectDB: command line, installation check, Qt application, error handling.
2
+
3
+ subjectdb researcher mode subjectdb-pi PI mode
4
+ --data-dir PATH use this data folder. Otherwise: $SUBJECTDB_DATA_DIR, then the folder
5
+ remembered from last time, then a dialog asks ("Where are your databases?")
6
+ --check print an installation check without opening a window: Python, SQLite,
7
+ Qt, the display, missing system libraries, access to each database file
8
+ --version print the version and stop
9
+
10
+ Qt is imported only when a window is needed, so --check and --version work even when the Qt
11
+ libraries cannot be loaded. Unexpected errors are logged to a file and shown in a dialog.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import logging
18
+ import logging.handlers
19
+ import os
20
+ import platform
21
+ import re
22
+ import shutil
23
+ import subprocess
24
+ import sys
25
+ import traceback
26
+ from pathlib import Path
27
+
28
+ from . import __version__
29
+
30
+ LOG = logging.getLogger("subjectdb")
31
+ MIN_SQLITE = (3, 27)
32
+ LIBS = Path.home() / "subjectdb-libs" # where the no-admin fix unpacks missing libraries
33
+ LAUNCHER = """#!/bin/sh
34
+ export LD_LIBRARY_PATH="$HOME/subjectdb-libs/usr/lib/x86_64-linux-gnu${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}"
35
+ exec {command} "$@\""""
36
+ # Debian package that provides a library Qt's X11 plugin may need (for the no-admin fix).
37
+ DEBIAN_PACKAGES = {
38
+ "libxcb-cursor.so.0": "libxcb-cursor0", "libxkbcommon-x11.so.0": "libxkbcommon-x11-0",
39
+ "libxkbcommon.so.0": "libxkbcommon0", "libxcb-icccm.so.4": "libxcb-icccm4", "libxcb-image.so.0": "libxcb-image0",
40
+ "libxcb-keysyms.so.1": "libxcb-keysyms1", "libxcb-render-util.so.0": "libxcb-render-util0",
41
+ "libxcb-shape.so.0": "libxcb-shape0", "libxcb-randr.so.0": "libxcb-randr0", "libxcb-xkb.so.1": "libxcb-xkb1",
42
+ "libxcb-xinerama.so.0": "libxcb-xinerama0", "libEGL.so.1": "libegl1", "libGL.so.1": "libgl1",
43
+ "libOpenGL.so.0": "libopengl0", "libfontconfig.so.1": "libfontconfig1", "libdbus-1.so.3": "libdbus-1-3",
44
+ }
45
+
46
+
47
+ def parse_args(argv: list[str] | None) -> argparse.Namespace:
48
+ parser = argparse.ArgumentParser(prog="subjectdb", description="Register study participants across projects.")
49
+ parser.add_argument("--pi", action="store_true", help="start in PI mode (same as the subjectdb-pi command)")
50
+ parser.add_argument("--data-dir", type=Path, help="folder with the three database files")
51
+ parser.add_argument("--check", action="store_true", help="check the installation and print what to fix")
52
+ parser.add_argument("--version", action="version", version=f"SubjectDB {__version__}")
53
+ return parser.parse_args(argv)
54
+
55
+
56
+ def main(argv: list[str] | None = None) -> int:
57
+ """Entry point of ``subjectdb`` (researcher mode; --pi switches to PI mode)."""
58
+ return run(argv, pi=False)
59
+
60
+
61
+ def main_pi(argv: list[str] | None = None) -> int:
62
+ """Entry point of ``subjectdb-pi``."""
63
+ return run(argv, pi=True)
64
+
65
+
66
+ def run(argv: list[str] | None, *, pi: bool) -> int:
67
+ args = parse_args(argv)
68
+ pi = pi or args.pi
69
+ if args.check:
70
+ return check(args.data_dir, pi)
71
+ try:
72
+ from PySide6.QtWidgets import QApplication
73
+ except ImportError as exc:
74
+ print(f"SubjectDB cannot load Qt: {exc}\nRun subjectdb --check to see what is missing.", file=sys.stderr)
75
+ return 1
76
+ app = QApplication.instance() or QApplication(sys.argv[:1])
77
+ app.setApplicationName("SubjectDB")
78
+ app.setOrganizationName("SubjectDB")
79
+ app.setApplicationVersion(__version__)
80
+ log_file = setup_logging()
81
+ install_excepthook(log_file)
82
+ from .gui import theme
83
+ from .gui.main_window import environment_folder, run_window
84
+
85
+ theme.apply(app)
86
+ return run_window(pi, args.data_dir.expanduser() if args.data_dir else environment_folder())
87
+
88
+
89
+ # --- Logging and unexpected errors --------------------------------------------------------------
90
+
91
+
92
+ def log_folder() -> Path:
93
+ from PySide6.QtCore import QStandardPaths
94
+
95
+ folder = Path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppLocalDataLocation))
96
+ return folder if str(folder) not in ("", ".") else Path.home() / ".subjectdb"
97
+
98
+
99
+ def setup_logging() -> Path:
100
+ """Errors go to <user data folder>/subjectdb.log (never personal data: only technical details)."""
101
+ folder = log_folder()
102
+ folder.mkdir(parents=True, exist_ok=True)
103
+ path = folder / "subjectdb.log"
104
+ handler = logging.handlers.RotatingFileHandler(path, maxBytes=1_000_000, backupCount=2, encoding="utf-8")
105
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
106
+ LOG.addHandler(handler)
107
+ LOG.setLevel(logging.INFO)
108
+ LOG.info("SubjectDB %s started (Python %s)", __version__, platform.python_version())
109
+ return path
110
+
111
+
112
+ def install_excepthook(log_file: Path) -> None:
113
+ """Show unexpected errors in a dialog (and log them) instead of dying silently."""
114
+
115
+ def hook(kind, exc, tb):
116
+ if issubclass(kind, KeyboardInterrupt):
117
+ sys.__excepthook__(kind, exc, tb)
118
+ return
119
+ LOG.error("Unexpected error\n%s", "".join(traceback.format_exception(kind, exc, tb)))
120
+ try:
121
+ from PySide6.QtWidgets import QApplication
122
+
123
+ from . import db
124
+ from .gui import widgets
125
+
126
+ widgets.inform(QApplication.activeWindow(), "Something went wrong",
127
+ f"{db.friendly_error(exc)}<br><br>Your data is safe: a save either happens completely or "
128
+ f"not at all. Technical details are in <b>{log_file}</b>.", tone="danger")
129
+ except Exception: # the dialog itself failed: at least print
130
+ traceback.print_exception(kind, exc, tb)
131
+
132
+ sys.excepthook = hook
133
+
134
+
135
+ # --- subjectdb --check --------------------------------------------------------------------------
136
+
137
+
138
+ class _Report:
139
+ """Collects the lines of the installation check; the exit code is the number of failures (max 1)."""
140
+
141
+ def __init__(self):
142
+ self.failures = 0
143
+
144
+ def line(self, status: str, text: str, fix: str = "") -> None:
145
+ if status or text:
146
+ print(f" {status:<5} {text}")
147
+ for fix_line in fix.splitlines():
148
+ print(f" {fix_line}")
149
+ if status == "FAIL":
150
+ self.failures += 1
151
+
152
+
153
+ def _missing_libraries(pyside_dir: Path) -> list[str] | None:
154
+ """Libraries that Qt's X11 plugin cannot find (ldd), or None when that cannot be checked."""
155
+ plugin = pyside_dir / "Qt" / "plugins" / "platforms" / "libqxcb.so"
156
+ if not plugin.is_file() or not shutil.which("ldd"):
157
+ return None
158
+ try:
159
+ result = subprocess.run(["ldd", str(plugin)], capture_output=True, text=True, timeout=20, check=False)
160
+ except (OSError, subprocess.SubprocessError):
161
+ return None
162
+ return sorted({m.group(1) for m in re.finditer(r"^\s*(\S+) => not found", result.stdout, re.M)})
163
+
164
+
165
+ def _library_fix(missing: list[str], pi: bool) -> str:
166
+ """How to get the missing libraries: from IT, or unpacked into ~/subjectdb-libs without admin rights."""
167
+ packages = sorted({DEBIAN_PACKAGES[lib] for lib in missing if lib in DEBIAN_PACKAGES})
168
+ command = Path(sys.executable).parent / ("subjectdb-pi" if pi else "subjectdb")
169
+ lines = []
170
+ if packages:
171
+ lines += [f"Ask IT to install: {' '.join(packages)}", "Or, without admin rights:",
172
+ f" cd /tmp && apt-get download {' '.join(packages)}",
173
+ *(f" dpkg -x {package}_*.deb ~/subjectdb-libs" for package in packages),
174
+ "then start SubjectDB (and this check) through this launcher script:",
175
+ *(f" {line}" for line in LAUNCHER.format(command=command).splitlines())]
176
+ lines += [f"For {lib}: ask IT which Debian package provides it (apt-file search {lib})."
177
+ for lib in missing if lib not in DEBIAN_PACKAGES]
178
+ if LIBS.is_dir() and str(LIBS) not in os.environ.get("LD_LIBRARY_PATH", ""):
179
+ lines.append("~/subjectdb-libs exists, but this run did not use it: start it through the launcher script.")
180
+ return "\n".join(lines)
181
+
182
+
183
+ def _folder_to_check(cli: Path | None) -> tuple[Path | None, str]:
184
+ if cli:
185
+ return cli.expanduser(), "from --data-dir"
186
+ if os.environ.get("SUBJECTDB_DATA_DIR"):
187
+ return Path(os.environ["SUBJECTDB_DATA_DIR"]).expanduser(), "from SUBJECTDB_DATA_DIR"
188
+ try:
189
+ from PySide6.QtCore import QSettings
190
+
191
+ remembered = QSettings(QSettings.Format.IniFormat, QSettings.Scope.UserScope, "SubjectDB",
192
+ "SubjectDB").value("data_folder")
193
+ if remembered:
194
+ return Path(str(remembered)), "remembered from the last start"
195
+ except ImportError:
196
+ pass
197
+ return None, ""
198
+
199
+
200
+ def check(data_dir: Path | None, pi: bool) -> int:
201
+ """Print what works and what to fix. Needs no display; never changes anything."""
202
+ report = _Report()
203
+ print(f"SubjectDB {__version__} – installation check{' (PI mode)' if pi else ''}\n")
204
+ version = sys.version_info
205
+ report.line("OK" if version >= (3, 10) else "FAIL", f"Python {platform.python_version()} ({sys.executable})",
206
+ "" if version >= (3, 10) else "SubjectDB needs Python 3.10 or newer: python3.11 -m venv …")
207
+ from . import db
208
+
209
+ sqlite = tuple(int(x) for x in db.sqlite_version().split(".")[:2])
210
+ report.line("OK" if sqlite >= MIN_SQLITE else "FAIL", f"SQLite {db.sqlite_version()}",
211
+ "" if sqlite >= MIN_SQLITE else "SQLite 3.27 or newer is needed.")
212
+ try:
213
+ import openpyxl
214
+
215
+ report.line("OK", f"openpyxl {openpyxl.__version__} (Excel files)")
216
+ except ImportError:
217
+ report.line("FAIL", "openpyxl is missing", "pip install openpyxl")
218
+ try:
219
+ import PySide6
220
+ from PySide6.QtCore import qVersion
221
+
222
+ report.line("OK", f"PySide6 {PySide6.__version__} (Qt {qVersion()})")
223
+ pyside_dir = Path(PySide6.__file__).parent
224
+ except ImportError as exc:
225
+ report.line("FAIL", f"PySide6 cannot be loaded: {exc}", "pip install 'PySide6-Essentials>=6.8'")
226
+ pyside_dir = None
227
+ if pyside_dir:
228
+ try:
229
+ from PySide6 import QtGui, QtWidgets # noqa: F401 (loads libGL, fontconfig, …)
230
+
231
+ report.line("OK", "Qt graphics libraries load")
232
+ except ImportError as exc:
233
+ lib = re.search(r"(lib[\w.+-]+\.so[\d.]*)", str(exc))
234
+ package = DEBIAN_PACKAGES.get(lib.group(1), "") if lib else ""
235
+ report.line("FAIL", f"Qt graphics libraries: {exc}",
236
+ f"Ask IT to install {package}, or see 'Missing libraries' in databases.md." if package else
237
+ "See 'Missing libraries' in databases.md.")
238
+ if sys.platform.startswith("linux"):
239
+ display = os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")
240
+ report.line("OK" if display else "FAIL", f"Display: {display}" if display else "No display found",
241
+ "" if display else "Connect with ssh -X user@server (or -Y), or use the lab's remote desktop.")
242
+ missing = _missing_libraries(pyside_dir) if pyside_dir else None
243
+ if pyside_dir and missing is None:
244
+ report.line("WARN", "Qt's X11 plugin could not be checked (ldd or the plugin file is missing).")
245
+ elif missing:
246
+ for lib in missing:
247
+ report.line("FAIL", f"Qt needs {lib}, which is not installed.")
248
+ report.line("", "", _library_fix(missing, pi))
249
+ elif pyside_dir:
250
+ report.line("OK", "Qt's X11 plugin finds all its libraries")
251
+ folder, source = _folder_to_check(data_dir)
252
+ print()
253
+ if folder is None:
254
+ report.line("WARN", "No data folder given, so the access to the databases was not checked.",
255
+ "Run the check again with --data-dir /path/to/the/database/folder")
256
+ else:
257
+ _check_folder(report, folder, source, pi)
258
+ if report.failures:
259
+ print(f"\n{report.failures} problem{'s' if report.failures > 1 else ''} found: see the lines marked FAIL.")
260
+ else:
261
+ print("\nAll checks passed." if folder else "\nNo problems found, but the data folder was not checked.")
262
+ return 1 if report.failures else 0
263
+
264
+
265
+ def _check_folder(report: _Report, folder: Path, source: str, pi: bool) -> None:
266
+ from . import db
267
+
268
+ if not folder.is_dir():
269
+ report.line("FAIL", f"Data folder {folder} ({source}) does not exist.",
270
+ "Check the path, or choose another one.")
271
+ return
272
+ state = db.inspect_folder(folder)
273
+ report.line("OK", f"Data folder {folder} ({source})")
274
+ if state.no_access:
275
+ report.line("FAIL", "You may not look inside this folder.", db.NO_ACCESS)
276
+ return
277
+ report.line("OK" if state.folder_writable else "FAIL", "The folder is writable (SQLite writes a journal file next "
278
+ "to the databases)" if state.folder_writable else "You cannot create files in this folder.",
279
+ "" if state.folder_writable else "Ask your PI: the folder needs group write permission (chmod g+w).")
280
+ for name in db.DB_FILES:
281
+ if not state.present[name]:
282
+ report.line("WARN" if name == db.GENERAL_DB else "FAIL", f"{name} is missing",
283
+ "The PI app creates it at its next save." if name == db.GENERAL_DB else "Wrong folder?")
284
+ continue
285
+ if not state.readable[name]:
286
+ report.line("FAIL", f"{name}: you may not read it",
287
+ "A permission problem, not a damaged file: ask your PI to fix the file's group and mode "
288
+ "(see “Permissions” in databases.md). Do not restore a backup.")
289
+ continue
290
+ version = state.versions[name]
291
+ status = "read and write" if state.writable[name] else "read only for you"
292
+ if name == db.PROJECT_DB and not state.writable[name]:
293
+ report.line("FAIL" if pi else "OK", f"{name}: {status}" + ("" if pi else " (normal for researchers)"),
294
+ "PI mode needs write access to this file." if pi else "")
295
+ elif not state.writable[name] and name != db.PROJECT_DB:
296
+ report.line("FAIL", f"{name}: {status}", "Ask your PI to give the lab group write access (chmod g+w).")
297
+ else:
298
+ report.line("OK", f"{name}: {status}")
299
+ if name in state.errors: # the sentence says what to do (restore only for a damaged file)
300
+ report.line("WARN" if name == db.GENERAL_DB else "FAIL", f"{name} cannot be opened",
301
+ state.errors[name] + (" The PI app rebuilds this copy." if name == db.GENERAL_DB else ""))
302
+ elif version == 0:
303
+ report.line("WARN", f"{name} was made by the old app", "Open the PI app once to upgrade it.")
304
+ elif version is not None and version > db.SCHEMA_VERSION:
305
+ report.line("FAIL", f"{name} is from a newer SubjectDB", db.UPDATE_HINT)