pineuro 1.0.0__1-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 (74) hide show
  1. pineuro/__init__.py +284 -0
  2. pineuro/atlas_registry.py +49 -0
  3. pineuro/backup.py +210 -0
  4. pineuro/collection_qc.py +377 -0
  5. pineuro/config.py +90 -0
  6. pineuro/display.py +130 -0
  7. pineuro/feedback.py +4177 -0
  8. pineuro/gui/__init__.py +94 -0
  9. pineuro/gui/app.py +702 -0
  10. pineuro/gui/components/__init__.py +142 -0
  11. pineuro/gui/components/activation_plot.py +377 -0
  12. pineuro/gui/components/base_dialog.py +60 -0
  13. pineuro/gui/components/collection_progress.py +304 -0
  14. pineuro/gui/components/csv_poller.py +272 -0
  15. pineuro/gui/components/disk_space_bar.py +154 -0
  16. pineuro/gui/components/feedback_summary.py +486 -0
  17. pineuro/gui/components/manual_masks.py +98 -0
  18. pineuro/gui/components/mask_selection_dialog.py +1759 -0
  19. pineuro/gui/components/pipeline_runner.py +2817 -0
  20. pineuro/gui/components/qc_plot_viewer.py +224 -0
  21. pineuro/gui/components/qc_plots.py +492 -0
  22. pineuro/gui/components/qc_viewer.py +346 -0
  23. pineuro/gui/components/scanner_connection_dialog.py +225 -0
  24. pineuro/gui/components/session_selection_dialog.py +345 -0
  25. pineuro/gui/components/task_mirror.py +113 -0
  26. pineuro/gui/components/unified_attempt_dialog.py +337 -0
  27. pineuro/gui/config_manager.py +500 -0
  28. pineuro/gui/config_snapshot.py +206 -0
  29. pineuro/gui/dispatch.py +103 -0
  30. pineuro/gui/models.py +272 -0
  31. pineuro/gui/screens/__init__.py +6 -0
  32. pineuro/gui/screens/_feedback_tab.py +1198 -0
  33. pineuro/gui/screens/_localizer_tab.py +1081 -0
  34. pineuro/gui/screens/participant_workflow.py +8253 -0
  35. pineuro/gui/screens/study_editor.py +2200 -0
  36. pineuro/gui/styles.py +406 -0
  37. pineuro/gui/utils.py +1092 -0
  38. pineuro/mask_extraction.py +4399 -0
  39. pineuro/preprocessing.py +1605 -0
  40. pineuro/py.typed +0 -0
  41. pineuro/qc.py +4082 -0
  42. pineuro/scanner_setup.py +466 -0
  43. pineuro/script_generator.py +401 -0
  44. pineuro/session.py +2991 -0
  45. pineuro/sham.py +447 -0
  46. pineuro/simulator.py +746 -0
  47. pineuro/storage.py +123 -0
  48. pineuro/tasks/__init__.py +276 -0
  49. pineuro/tasks/ball.py +1334 -0
  50. pineuro/tasks/base.py +661 -0
  51. pineuro/tasks/designs.py +233 -0
  52. pineuro/tasks/factory.py +222 -0
  53. pineuro/tasks/gauge.py +858 -0
  54. pineuro/tasks/localizer_base.py +255 -0
  55. pineuro/tasks/protocol.py +102 -0
  56. pineuro/tasks/rest.py +129 -0
  57. pineuro/tasks/rit.py +1158 -0
  58. pineuro/tasks/rit_experience_sampling.py +1408 -0
  59. pineuro/tasks/rit_triggered_ball.py +1507 -0
  60. pineuro/tasks/self_other.py +525 -0
  61. pineuro/tasks/state.py +492 -0
  62. pineuro/tasks/subprocess_runner.py +438 -0
  63. pineuro/tasks/task_base.py +1495 -0
  64. pineuro/tasks/task_registry.py +611 -0
  65. pineuro/tasks/thermometer.py +1371 -0
  66. pineuro/tasks/ui_helpers.py +364 -0
  67. pineuro/tasks/utils.py +222 -0
  68. pineuro/utils.py +1971 -0
  69. pineuro-1.0.0.dist-info/METADATA +119 -0
  70. pineuro-1.0.0.dist-info/RECORD +74 -0
  71. pineuro-1.0.0.dist-info/WHEEL +6 -0
  72. pineuro-1.0.0.dist-info/entry_points.txt +2 -0
  73. pineuro-1.0.0.dist-info/licenses/LICENSE +13 -0
  74. pineuro-1.0.0.dist-info/top_level.txt +1 -0
pineuro/__init__.py ADDED
@@ -0,0 +1,284 @@
1
+ """pineuro - Python Real-Time fMRI Neurofeedback
2
+
3
+ A complete pipeline for real-time fMRI neurofeedback research, from volume acquisition
4
+ through preprocessing, network mask extraction, and moment-to-moment BOLD activation estimation.
5
+
6
+ Main Functions
7
+ --------------
8
+ run_feedback_task : Run a complete real-time feedback task
9
+ preprocessing : DICOM conversion, motion correction, brain extraction
10
+ collect_volumes : Watch for and collect incoming scanner volumes (DICOM or NIfTI)
11
+ mask_extraction_rest : Extract network masks using ICA and Yeo atlas
12
+ mask_extraction_mni : Extract network masks directly from MNI atlas (no localizer needed)
13
+ mask_extraction_task : Extract network masks using GLM contrasts from task localizer
14
+ coregister_reference : Coregister masks to real-time scanner position
15
+
16
+ Task Framework
17
+ --------------
18
+ BaseTask : Abstract base for custom neurofeedback tasks
19
+ BaseLocalizerTask : Abstract base for localizer/stimulus tasks
20
+ BallTask : Built-in dual-network ball game with adaptive difficulty
21
+ ThermometerTask : Built-in single-network thermometer feedback
22
+ create_task : Factory function to create tasks from registry
23
+ NetworkRole : Declare network slot semantics for tasks
24
+ ActivationSmoother : Exponential moving average smoothing
25
+ ActivationNormalizer : Percentile-based normalization
26
+
27
+ Session Orchestration
28
+ --------------------
29
+ SessionConfig : High-level session configuration
30
+ LocalizerConfig / FeedbackRunConfig : Per-localizer and per-feedback-run configuration
31
+ run_full_session : Run complete multi-run session
32
+ setup_session : Initialize session (collection, preprocessing, mask extraction)
33
+ run_single_task : Run one feedback/transfer task via task factory
34
+ TaskState : Persistent state across runs
35
+
36
+ Examples
37
+ --------
38
+ >>> from pineuro import preprocessing, mask_extraction_rest, run_feedback_task
39
+ >>>
40
+ >>> # Step 1: Preprocess resting-state data
41
+ >>> preproc_file, mask_file, ref_file = preprocessing(
42
+ ... participant_id='sub-01', session_id='ses-01', task='rest',
43
+ ... root_dir='/data/sub-01/ses-01/preprocessing',
44
+ ... input_dicoms='/scanner/sub-01/rest',
45
+ ... )
46
+ >>>
47
+ >>> # Step 2: Extract participant-specific network masks (ICA + Yeo atlas)
48
+ >>> mask_paths = mask_extraction_rest(
49
+ ... participant_id='sub-01', session_id='ses-01',
50
+ ... root_dir='/data/sub-01/ses-01/mask_extraction',
51
+ ... input_file=preproc_file, mask_file=mask_file, ref_file=ref_file,
52
+ ... networks=['ContA', 'DefaultA'], # returns a list of mask Paths
53
+ ... )
54
+ >>>
55
+ >>> # Step 3: Run a real-time feedback task (here, simulated offline)
56
+ >>> result = run_feedback_task(
57
+ ... mask_paths=mask_paths, subject='sub-01', session='ses-01',
58
+ ... task='ball', run=1,
59
+ ... output_dir='/data/sub-01/ses-01/feedback',
60
+ ... collection_dir='/scanner/incoming',
61
+ ... n_baseline=15, n_volumes=200, tr=2.0,
62
+ ... simulator_source_dir='/data/sub-01/feedback_dicoms', simulator_speed=10.0,
63
+ ... )
64
+ """
65
+
66
+ import importlib as _importlib
67
+ from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
68
+ from importlib.metadata import version as _pkg_version
69
+
70
+ try:
71
+ # Single source of truth: the version declared in pyproject.toml and
72
+ # recorded in the installed package metadata.
73
+ __version__ = _pkg_version("pineuro")
74
+ except _PackageNotFoundError: # pragma: no cover - package not installed
75
+ __version__ = "unknown"
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Lazy import system (PEP 562)
79
+ #
80
+ # Heavy neuroimaging libraries (ANTsPy, nilearn, scipy, nibabel) take 8-17s
81
+ # to import on cold start. By deferring submodule imports until first access,
82
+ # the GUI starts instantly and scripts only pay for what they actually use.
83
+ # ---------------------------------------------------------------------------
84
+
85
+ # Mapping: attribute_name -> (submodule_path, attribute_name_in_submodule)
86
+ _LAZY_IMPORTS: dict[str, tuple[str, str]] = {
87
+ # --- feedback.py (heavy: nibabel, scipy, watchdog, pydicom) ---
88
+ "run_feedback_task": (".feedback", "run_feedback_task"),
89
+ "ConnectivityEstimator": (".feedback", "ConnectivityEstimator"),
90
+ "get_next_run_number": (".feedback", "get_next_run_number"),
91
+ "get_slot_run_info": (".feedback", "get_slot_run_info"),
92
+ # --- preprocessing.py (heavy: nibabel, scipy) ---
93
+ "preprocessing": (".preprocessing", "preprocessing"),
94
+ "collect_volumes": (".preprocessing", "collect_volumes"),
95
+ # --- mask_extraction.py (heavy: nibabel, scipy, pandas) ---
96
+ "mask_extraction_rest": (".mask_extraction", "mask_extraction_rest"),
97
+ "mask_extraction_mni": (".mask_extraction", "mask_extraction_mni"),
98
+ "mask_extraction_task": (".mask_extraction", "mask_extraction_task"),
99
+ "run_mask_extraction_from_config": (".mask_extraction", "run_mask_extraction_from_config"),
100
+ "import_manual_masks": (".mask_extraction", "import_manual_masks"),
101
+ "coregister_reference": (".mask_extraction", "coregister_reference"),
102
+ "create_tissue_masks": (".mask_extraction", "create_tissue_masks"),
103
+ "ATLAS_SOURCES": (".atlas_registry", "ATLAS_SOURCES"),
104
+ "get_atlas_labels": (".mask_extraction", "get_atlas_labels"),
105
+ # --- tasks (lightweight but deferred to avoid loading task modules) ---
106
+ "BaseTask": (".tasks", "BaseTask"),
107
+ "BaseLocalizerTask": (".tasks", "BaseLocalizerTask"),
108
+ "BallTask": (".tasks", "BallTask"),
109
+ "ThermometerTask": (".tasks", "ThermometerTask"),
110
+ "ActivationSmoother": (".tasks", "ActivationSmoother"),
111
+ "ActivationNormalizer": (".tasks", "ActivationNormalizer"),
112
+ "NetworkRole": (".tasks", "NetworkRole"),
113
+ "create_task": (".tasks", "create_task"),
114
+ "create_localizer_task": (".tasks", "create_localizer_task"),
115
+ # --- tasks.state ---
116
+ "TaskState": (".tasks.state", "TaskState"),
117
+ "RunInfo": (".tasks.state", "RunInfo"),
118
+ # --- session.py (lightweight: stdlib only) ---
119
+ "SessionConfig": (".session", "SessionConfig"),
120
+ "PathConfig": (".session", "PathConfig"),
121
+ "LocalizerConfig": (".session", "LocalizerConfig"),
122
+ "LocalizerExtractionConfig": (".session", "LocalizerExtractionConfig"),
123
+ "FeedbackRunConfig": (".session", "FeedbackRunConfig"),
124
+ "QCConfig": (".session", "QCConfig"),
125
+ "GLMConfig": (".session", "GLMConfig"),
126
+ "ConnectivityConfig": (".session", "ConnectivityConfig"),
127
+ "IntermittentConfig": (".session", "IntermittentConfig"),
128
+ "SimulatorConfig": (".session", "SimulatorConfig"),
129
+ "ScannerConfig": (".session", "ScannerConfig"),
130
+ "ReferenceConfig": (".session", "ReferenceConfig"),
131
+ "SessionMetadata": (".session", "SessionMetadata"),
132
+ "ShamConfig": (".session", "ShamConfig"),
133
+ "ShamRunConfig": (".session", "ShamRunConfig"),
134
+ "SimulatorSourceMapping": (".session", "SimulatorSourceMapping"),
135
+ "setup_session": (".session", "setup_session"),
136
+ "run_single_task": (".session", "run_single_task"),
137
+ "run_full_session": (".session", "run_full_session"),
138
+ "run_next_task": (".session", "run_next_task"),
139
+ "reset_task_state": (".session", "reset_task_state"),
140
+ "resolve_localizer_masks": (".session", "resolve_localizer_masks"),
141
+ "detect_existing_sessions": (".session", "detect_existing_sessions"),
142
+ "next_session_id": (".session", "next_session_id"),
143
+ # --- sham.py ---
144
+ "ShamActivationProvider": (".sham", "ShamActivationProvider"),
145
+ "ReplayActivationProvider": (".sham", "ReplayActivationProvider"),
146
+ "load_randomization": (".sham", "load_randomization"),
147
+ "generate_randomization": (".sham", "generate_randomization"),
148
+ "get_condition_for_participant": (".sham", "get_condition_for_participant"),
149
+ "find_feedbacklog_for_replay": (".sham", "find_feedbacklog_for_replay"),
150
+ # --- utils.py (heavy: numpy, nibabel, scipy, templateflow) ---
151
+ "enable_timing": (".utils", "enable_timing"),
152
+ "disable_timing": (".utils", "disable_timing"),
153
+ "get_timing_data": (".utils", "get_timing_data"),
154
+ "save_timing_report": (".utils", "save_timing_report"),
155
+ "Timer": (".utils", "Timer"),
156
+ "check_platform_requirements": (".utils", "check_platform_requirements"),
157
+ "outputs_exist": (".utils", "outputs_exist"),
158
+ "count_volume_files": (".utils", "count_volume_files"),
159
+ "collection_is_complete": (".utils", "collection_is_complete"),
160
+ "ProgressEmitter": (".utils", "ProgressEmitter"),
161
+ "compute_hrf": (".utils", "compute_hrf"),
162
+ "compute_boxcar_task_regressor": (".utils", "compute_boxcar_task_regressor"),
163
+ # --- script_generator.py ---
164
+ "generate_mask_extraction_script": (".script_generator", "generate_mask_extraction_script"),
165
+ "save_script": (".script_generator", "save_script"),
166
+ # --- scanner_setup.py (lightweight: stdlib only) ---
167
+ "test_scanner_connection": (".scanner_setup", "test_scanner_connection"),
168
+ "ConnectionTestResult": (".scanner_setup", "ConnectionTestResult"),
169
+ "ping_scanner": (".scanner_setup", "ping_scanner"),
170
+ "check_watch_directory": (".scanner_setup", "check_watch_directory"),
171
+ "get_setup_instructions": (".scanner_setup", "get_setup_instructions"),
172
+ # --- config module ---
173
+ "config": (".config", ""), # Module import, not attribute
174
+ }
175
+
176
+
177
+ def __getattr__(name: str):
178
+ if name in _LAZY_IMPORTS:
179
+ module_path, attr_name = _LAZY_IMPORTS[name]
180
+ module = _importlib.import_module(module_path, __name__)
181
+ if not attr_name:
182
+ # Module-level import (e.g., config)
183
+ val = module
184
+ else:
185
+ val = getattr(module, attr_name)
186
+ globals()[name] = val # Cache for subsequent access
187
+ return val
188
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
189
+
190
+
191
+ __all__ = [
192
+ # Version
193
+ "__version__",
194
+ # Core functions
195
+ "run_feedback_task",
196
+ "ConnectivityEstimator",
197
+ "get_next_run_number",
198
+ "get_slot_run_info",
199
+ "preprocessing",
200
+ "collect_volumes",
201
+ "mask_extraction_rest",
202
+ "mask_extraction_mni",
203
+ "mask_extraction_task",
204
+ "run_mask_extraction_from_config",
205
+ "import_manual_masks",
206
+ "coregister_reference",
207
+ "ATLAS_SOURCES",
208
+ "get_atlas_labels",
209
+ # Task classes
210
+ "BaseTask",
211
+ "BaseLocalizerTask",
212
+ "BallTask",
213
+ "ThermometerTask",
214
+ "ActivationSmoother",
215
+ "ActivationNormalizer",
216
+ # Task registry & factory
217
+ "NetworkRole",
218
+ "create_task",
219
+ "create_localizer_task",
220
+ # Task regressor computation
221
+ "compute_hrf",
222
+ "compute_boxcar_task_regressor",
223
+ # Task state
224
+ "TaskState",
225
+ "RunInfo",
226
+ # Configuration dataclasses
227
+ "SessionConfig",
228
+ "PathConfig",
229
+ "LocalizerConfig",
230
+ "LocalizerExtractionConfig",
231
+ "FeedbackRunConfig",
232
+ "QCConfig",
233
+ "GLMConfig",
234
+ "ConnectivityConfig",
235
+ "IntermittentConfig",
236
+ "SimulatorConfig",
237
+ "ScannerConfig",
238
+ "ReferenceConfig",
239
+ "SessionMetadata",
240
+ "ShamConfig",
241
+ "ShamRunConfig",
242
+ "SimulatorSourceMapping",
243
+ # Sham functionality
244
+ "ShamActivationProvider",
245
+ "ReplayActivationProvider",
246
+ "load_randomization",
247
+ "generate_randomization",
248
+ "get_condition_for_participant",
249
+ "find_feedbacklog_for_replay",
250
+ # Orchestration functions
251
+ "setup_session",
252
+ "run_single_task",
253
+ "run_full_session",
254
+ "run_next_task",
255
+ "reset_task_state",
256
+ # Multi-session support functions
257
+ "resolve_localizer_masks",
258
+ "detect_existing_sessions",
259
+ "next_session_id",
260
+ # Timing functions
261
+ "enable_timing",
262
+ "disable_timing",
263
+ "get_timing_data",
264
+ "save_timing_report",
265
+ "Timer",
266
+ # Platform checks
267
+ "check_platform_requirements",
268
+ # Utilities
269
+ "outputs_exist",
270
+ "count_volume_files",
271
+ "collection_is_complete",
272
+ "ProgressEmitter",
273
+ # Script generation
274
+ "generate_mask_extraction_script",
275
+ "save_script",
276
+ # Scanner setup
277
+ "test_scanner_connection",
278
+ "ConnectionTestResult",
279
+ "ping_scanner",
280
+ "check_watch_directory",
281
+ "get_setup_instructions",
282
+ # Configuration
283
+ "config",
284
+ ]
@@ -0,0 +1,49 @@
1
+ """Atlas source registry — lightweight metadata for GUI dropdowns.
2
+
3
+ This module is intentionally free of heavy dependencies (no nibabel, scipy,
4
+ nilearn, etc.) so that the GUI can build atlas-related dropdowns without
5
+ triggering multi-second import chains.
6
+
7
+ The actual atlas *fetching* and *label resolution* live in mask_extraction.py,
8
+ which imports ATLAS_SOURCES from here.
9
+ """
10
+
11
+ from typing import Any
12
+
13
+ # ────────────────────────────────────────────────────────────────────
14
+ # Atlas source registry
15
+ # ────────────────────────────────────────────────────────────────────
16
+ ATLAS_SOURCES: dict[str, dict[str, Any]] = {
17
+ # Network atlases (resting-state ICA pathway)
18
+ "yeo_7": {"display_name": "Yeo 7-network", "type": "network", "n_networks": 7},
19
+ "yeo_17": {"display_name": "Yeo 17-network", "type": "network", "n_networks": 17},
20
+ "schaefer_100_7net": {
21
+ "display_name": "Schaefer 100 (7-network)",
22
+ "type": "network",
23
+ "n_networks": 7,
24
+ "n_rois": 100,
25
+ },
26
+ "schaefer_100_17net": {
27
+ "display_name": "Schaefer 100 (17-network)",
28
+ "type": "network",
29
+ "n_networks": 17,
30
+ "n_rois": 100,
31
+ },
32
+ "schaefer_400_7net": {
33
+ "display_name": "Schaefer 400 (7-network)",
34
+ "type": "network",
35
+ "n_networks": 7,
36
+ "n_rois": 400,
37
+ },
38
+ "schaefer_400_17net": {
39
+ "display_name": "Schaefer 400 (17-network)",
40
+ "type": "network",
41
+ "n_networks": 17,
42
+ "n_rois": 400,
43
+ },
44
+ # Anatomical/parcel atlases (cluster thresholding pathway)
45
+ "harvard_oxford": {"display_name": "Harvard-Oxford Cortical", "type": "anatomical"},
46
+ "aal": {"display_name": "AAL (SPM12)", "type": "anatomical"},
47
+ # Whole-brain gray matter (no region selection needed)
48
+ "gray_matter": {"display_name": "Gray Matter (whole-brain)", "type": "tissue"},
49
+ }
pineuro/backup.py ADDED
@@ -0,0 +1,210 @@
1
+ """Session backup: mirror a participant-session folder to a second location.
2
+
3
+ Copies new and changed files from ``<data root>/<participant>/<session>`` to
4
+ ``<destination>/<participant>/<session>``. A file is copied when it is missing
5
+ at the destination or its size or modification time differs, through a
6
+ temporary name, so an interrupted copy never leaves a truncated file under the
7
+ real name. Nothing at the destination is ever deleted: files later discarded or
8
+ cleaned up locally stay in the backup. ``tmp/`` (simulator and per-run scratch)
9
+ is never copied, and the raw DICOM copies in ``collection/`` only when the study
10
+ opts in.
11
+
12
+ The destination folder itself must already exist, so a backup to a drive that
13
+ is not connected fails instead of silently filling the local disk under the
14
+ drive's empty mount point. Standard library only, so the GUI can import it at
15
+ top level; the participant workflow runs it between acquisitions.
16
+ """
17
+
18
+ import json
19
+ import logging
20
+ import os
21
+ import shutil
22
+ from collections.abc import Iterator
23
+ from dataclasses import dataclass, field
24
+ from datetime import datetime
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # The study YAML's top-level ``backup:`` block, with its defaults.
31
+ BACKUP_DEFAULTS: dict[str, Any] = {"enabled": False, "destination": "", "include_dicoms": True}
32
+
33
+ # Never backed up: simulator incoming copies and other per-run scratch.
34
+ _ALWAYS_EXCLUDED = frozenset({"tmp"})
35
+ # Raw DICOM copies; backed up only with ``include_dicoms``.
36
+ _DICOM_DIR = "collection"
37
+ _PARTIAL_SUFFIX = ".pineuro-partial"
38
+ MANIFEST_NAME = ".pineuro_backup.json"
39
+ # Modification times are compared with this tolerance because FAT/exFAT drives
40
+ # (common on external backup disks) store them at 2 s resolution.
41
+ _MTIME_TOLERANCE = 2.0
42
+
43
+
44
+ @dataclass
45
+ class BackupResult:
46
+ """Outcome of one :func:`mirror_session` pass."""
47
+
48
+ destination: Path
49
+ copied: int = 0
50
+ skipped: int = 0 # already up to date at the destination
51
+ bytes_copied: int = 0
52
+ errors: list[str] = field(default_factory=list)
53
+ stopped: bool = False # interrupted via stop_event before finishing
54
+
55
+ @property
56
+ def complete(self) -> bool:
57
+ return not self.errors and not self.stopped
58
+
59
+
60
+ def backup_destination(session_dir: str | Path, destination: str | Path) -> Path:
61
+ """Where *session_dir* (``<root>/<participant>/<session>``) is mirrored to."""
62
+ session_dir = Path(session_dir)
63
+ return Path(destination) / session_dir.parent.name / session_dir.name
64
+
65
+
66
+ def validate_backup_config(backup: dict[str, Any], data_root: str | Path) -> str | None:
67
+ """Return an error message for an unusable ``backup:`` study block, or None."""
68
+ if not backup.get("enabled"):
69
+ return None
70
+ destination = str(backup.get("destination") or "").strip()
71
+ if not destination:
72
+ return "Backup is enabled but no backup folder is set."
73
+ if data_root and _same_path(destination, data_root):
74
+ return "The backup folder cannot be the data directory itself."
75
+ return None
76
+
77
+
78
+ def read_manifest(session_dir: str | Path, destination: str | Path) -> dict[str, Any] | None:
79
+ """The record of the last backup pass of *session_dir*, or None if there is none."""
80
+ path = backup_destination(session_dir, destination) / MANIFEST_NAME
81
+ try:
82
+ with open(path, encoding="utf-8") as f:
83
+ data = json.load(f)
84
+ except (OSError, ValueError):
85
+ return None
86
+ return data if isinstance(data, dict) else None
87
+
88
+
89
+ def mirror_session(
90
+ session_dir: str | Path,
91
+ destination: str | Path,
92
+ *,
93
+ include_dicoms: bool = True,
94
+ stop_event: Any = None,
95
+ ) -> BackupResult:
96
+ """Copy new and changed files from *session_dir* to its backup location.
97
+
98
+ Parameters
99
+ ----------
100
+ session_dir
101
+ The participant-session folder, ``<data root>/<participant>/<session>``.
102
+ destination
103
+ Backup folder; must already exist. The session is mirrored to
104
+ ``<destination>/<participant>/<session>``.
105
+ include_dicoms
106
+ Also copy the raw DICOM copies in ``collection/``.
107
+ stop_event
108
+ Anything with ``is_set()``; checked between files, so a pass can be
109
+ interrupted (e.g. when an acquisition starts) and resumed later.
110
+ """
111
+ src_root = Path(session_dir)
112
+ dst_root = backup_destination(src_root, destination)
113
+ result = BackupResult(destination=dst_root)
114
+
115
+ if not Path(destination).is_dir():
116
+ result.errors.append(f"Backup folder {destination} is not available (is the drive connected?)")
117
+ return result
118
+ if _same_path(dst_root, src_root) or _is_within(dst_root, src_root):
119
+ result.errors.append(f"Backup location {dst_root} is inside the session folder; nothing was copied")
120
+ return result
121
+
122
+ excluded = set(_ALWAYS_EXCLUDED)
123
+ if not include_dicoms:
124
+ excluded.add(_DICOM_DIR)
125
+
126
+ for src in _iter_files(src_root, excluded):
127
+ if stop_event is not None and stop_event.is_set():
128
+ result.stopped = True
129
+ break
130
+ rel = src.relative_to(src_root)
131
+ dst = dst_root / rel
132
+ try:
133
+ src_stat = src.stat()
134
+ if _up_to_date(src_stat, dst):
135
+ result.skipped += 1
136
+ continue
137
+ dst.parent.mkdir(parents=True, exist_ok=True)
138
+ partial = dst.with_name(dst.name + _PARTIAL_SUFFIX)
139
+ shutil.copy2(src, partial)
140
+ os.replace(partial, dst)
141
+ except OSError as exc:
142
+ result.errors.append(f"{rel}: {exc}")
143
+ continue
144
+ result.copied += 1
145
+ result.bytes_copied += src_stat.st_size
146
+
147
+ _write_manifest(src_root, result)
148
+ return result
149
+
150
+
151
+ def _iter_files(root: Path, excluded_top_level: set[str]) -> Iterator[Path]:
152
+ """Regular files under *root* (symlinks skipped), minus excluded top-level folders."""
153
+ stack: list[tuple[Path, bool]] = [(root, True)]
154
+ while stack:
155
+ current, is_top = stack.pop()
156
+ try:
157
+ with os.scandir(current) as entries:
158
+ for entry in entries:
159
+ try:
160
+ if entry.is_dir(follow_symlinks=False):
161
+ if not (is_top and entry.name in excluded_top_level):
162
+ stack.append((Path(entry.path), False))
163
+ elif entry.is_file(follow_symlinks=False) and not entry.name.endswith(_PARTIAL_SUFFIX):
164
+ yield Path(entry.path)
165
+ except OSError:
166
+ continue
167
+ except OSError as exc:
168
+ logger.warning(f"Backup: cannot read {current}: {exc}")
169
+
170
+
171
+ def _up_to_date(src_stat: os.stat_result, dst: Path) -> bool:
172
+ try:
173
+ dst_stat = dst.stat()
174
+ except OSError:
175
+ return False
176
+ return dst_stat.st_size == src_stat.st_size and abs(dst_stat.st_mtime - src_stat.st_mtime) <= _MTIME_TOLERANCE
177
+
178
+
179
+ def _write_manifest(src_root: Path, result: BackupResult) -> None:
180
+ record = {
181
+ "source": str(src_root),
182
+ "finished": datetime.now().isoformat(timespec="seconds"),
183
+ "complete": result.complete,
184
+ "copied": result.copied,
185
+ "skipped": result.skipped,
186
+ "bytes_copied": result.bytes_copied,
187
+ "errors": len(result.errors),
188
+ }
189
+ path = result.destination / MANIFEST_NAME
190
+ try:
191
+ path.parent.mkdir(parents=True, exist_ok=True)
192
+ partial = path.with_name(path.name + _PARTIAL_SUFFIX)
193
+ partial.write_text(json.dumps(record, indent=2), encoding="utf-8")
194
+ os.replace(partial, path)
195
+ except OSError as exc:
196
+ result.errors.append(f"{MANIFEST_NAME}: {exc}")
197
+
198
+
199
+ def _same_path(a: str | Path, b: str | Path) -> bool:
200
+ try:
201
+ return Path(a).resolve() == Path(b).resolve()
202
+ except OSError:
203
+ return False
204
+
205
+
206
+ def _is_within(path: Path, parent: Path) -> bool:
207
+ try:
208
+ return path.resolve().is_relative_to(parent.resolve())
209
+ except (OSError, ValueError):
210
+ return False