accessionflow 0.18.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 (105) hide show
  1. accessionflow/api/__init__.py +16 -0
  2. accessionflow/api/attach_api.py +129 -0
  3. accessionflow/api/collate_api.py +381 -0
  4. accessionflow/api/deidentify_api.py +302 -0
  5. accessionflow/api/fields_api.py +75 -0
  6. accessionflow/api/label_api.py +335 -0
  7. accessionflow/api/package_api.py +368 -0
  8. accessionflow/api/tests/__init__.py +0 -0
  9. accessionflow/api/tests/test_churn_probe.py +58 -0
  10. accessionflow/api/tests/test_collate.py +354 -0
  11. accessionflow/api/tests/test_deidentify.py +1314 -0
  12. accessionflow/api/tests/test_deidentify_retirement.py +276 -0
  13. accessionflow/api/tests/test_dicom_churn_probe.py +118 -0
  14. accessionflow/api/tests/test_label.py +686 -0
  15. accessionflow/api/tests/test_label_paths.py +188 -0
  16. accessionflow/api/tests/test_load_ignores_hidden_entries.py +74 -0
  17. accessionflow/api/tests/test_package.py +471 -0
  18. accessionflow/cli/__init__.py +24 -0
  19. accessionflow/cli/attach_cli.py +226 -0
  20. accessionflow/cli/base.py +64 -0
  21. accessionflow/cli/collate_cli.py +590 -0
  22. accessionflow/cli/deidentify_cli.py +279 -0
  23. accessionflow/cli/fields_cli.py +112 -0
  24. accessionflow/cli/label_cli.py +299 -0
  25. accessionflow/cli/package_cli.py +192 -0
  26. accessionflow/cli/tests/test_cli.py +2347 -0
  27. accessionflow/cli/tests/test_collate_orthanc_cli.py +112 -0
  28. accessionflow/cli/tests/test_loop_interval.py +108 -0
  29. accessionflow/cli/tests/test_require_manifest_default.py +88 -0
  30. accessionflow/cli/tests/test_store_commands.py +195 -0
  31. accessionflow/cli/upload_cli.py +14 -0
  32. accessionflow/exceptions.py +36 -0
  33. accessionflow/helpers/__init__.py +3 -0
  34. accessionflow/helpers/_version.py +24 -0
  35. accessionflow/helpers/arg_types.py +938 -0
  36. accessionflow/helpers/fields.py +53 -0
  37. accessionflow/helpers/glob2re.py +156 -0
  38. accessionflow/helpers/logging.py +137 -0
  39. accessionflow/helpers/metadata.py +167 -0
  40. accessionflow/helpers/remotes.py +577 -0
  41. accessionflow/helpers/tests/test_arg_types.py +966 -0
  42. accessionflow/helpers/tests/test_dicom.py +42 -0
  43. accessionflow/helpers/tests/test_fields.py +149 -0
  44. accessionflow/helpers/tests/test_glob2re.py +51 -0
  45. accessionflow/helpers/tests/test_logging.py +64 -0
  46. accessionflow/helpers/tests/test_metadata.py +330 -0
  47. accessionflow/helpers/tests/test_s3_cache_path.py +91 -0
  48. accessionflow/model/__init__.py +0 -0
  49. accessionflow/model/accession.py +2462 -0
  50. accessionflow/model/acquisition.py +152 -0
  51. accessionflow/model/resource.py +434 -0
  52. accessionflow/model/store.py +139 -0
  53. accessionflow/model/store_config.py +162 -0
  54. accessionflow/model/tests/test_accession.py +1768 -0
  55. accessionflow/model/tests/test_from_orthanc.py +1308 -0
  56. accessionflow/model/tests/test_load_duplicate_scan_id.py +54 -0
  57. accessionflow/model/tests/test_manifest_temp_load.py +131 -0
  58. accessionflow/model/tests/test_resource.py +165 -0
  59. accessionflow/model/tests/test_series_fallback.py +165 -0
  60. accessionflow/specs/__default__/medimage/dicom-series.json +51 -0
  61. accessionflow/specs/__default__/medimage/dicom-series.transforms.yaml +32 -0
  62. accessionflow/specs/__init__.py +0 -0
  63. accessionflow/stores/README.md +19 -0
  64. accessionflow/stores/xnat/__init__.py +21 -0
  65. accessionflow/stores/xnat/api/__init__.py +4 -0
  66. accessionflow/stores/xnat/api/check_upload_api.py +332 -0
  67. accessionflow/stores/xnat/api/upload_api.py +843 -0
  68. accessionflow/stores/xnat/arg_types.py +35 -0
  69. accessionflow/stores/xnat/cli/__init__.py +8 -0
  70. accessionflow/stores/xnat/cli/check_upload_cli.py +190 -0
  71. accessionflow/stores/xnat/cli/label_cli.py +4 -0
  72. accessionflow/stores/xnat/cli/upload_cli.py +496 -0
  73. accessionflow/stores/xnat/remotes.py +1232 -0
  74. accessionflow/stores/xnat/scan_types.py +296 -0
  75. accessionflow/stores/xnat/tests/__init__.py +0 -0
  76. accessionflow/stores/xnat/tests/test_all_uploaded_completeness.py +421 -0
  77. accessionflow/stores/xnat/tests/test_checksum_warning_once.py +69 -0
  78. accessionflow/stores/xnat/tests/test_directory_resource_upload.py +324 -0
  79. accessionflow/stores/xnat/tests/test_identity_event.py +37 -0
  80. accessionflow/stores/xnat/tests/test_incomplete_resource.py +154 -0
  81. accessionflow/stores/xnat/tests/test_incomplete_resource_alerting.py +123 -0
  82. accessionflow/stores/xnat/tests/test_nested_resource_checksums.py +186 -0
  83. accessionflow/stores/xnat/tests/test_post_upload_verification.py +207 -0
  84. accessionflow/stores/xnat/tests/test_remotes.py +616 -0
  85. accessionflow/stores/xnat/tests/test_repair_completed_message.py +48 -0
  86. accessionflow/stores/xnat/tests/test_resource_comparison.py +75 -0
  87. accessionflow/stores/xnat/tests/test_s3_session_only_listing.py +211 -0
  88. accessionflow/stores/xnat/tests/test_s3_session_tempdirs.py +102 -0
  89. accessionflow/stores/xnat/tests/test_select_files_to_upload.py +92 -0
  90. accessionflow/stores/xnat/tests/test_session_identity.py +482 -0
  91. accessionflow/stores/xnat/tests/test_session_level_resource.py +100 -0
  92. accessionflow/stores/xnat/tests/test_session_only_listing_completeness.py +137 -0
  93. accessionflow/stores/xnat/tests/test_session_upload_verdict.py +66 -0
  94. accessionflow/stores/xnat/tests/test_upload_batch_staging.py +28 -0
  95. accessionflow/stores/xnat/tests/test_upload_cache_invalidation.py +79 -0
  96. accessionflow/stores/xnat/tests/test_upload_loop.py +386 -0
  97. accessionflow/stores/xnat/tests/test_upload_modality.py +167 -0
  98. accessionflow/stores/xnat/tests/test_upload_repair_completed_event.py +298 -0
  99. accessionflow/stores/xnat/tests/test_xnat_session_date.py +137 -0
  100. accessionflow/tests/test_namespace_packages.py +12 -0
  101. accessionflow-0.18.0.dist-info/METADATA +354 -0
  102. accessionflow-0.18.0.dist-info/RECORD +105 -0
  103. accessionflow-0.18.0.dist-info/WHEEL +4 -0
  104. accessionflow-0.18.0.dist-info/entry_points.txt +3 -0
  105. accessionflow-0.18.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,16 @@
1
+ from .attach_api import attach
2
+ from .collate_api import BUILD_NAME_DEFAULT, collate, collate_orthanc
3
+ from .deidentify_api import deidentify
4
+ from .label_api import INVALID_DIRNAME, label
5
+ from .package_api import package
6
+
7
+ __all__ = [
8
+ "collate",
9
+ "collate_orthanc",
10
+ "label",
11
+ "deidentify",
12
+ "package",
13
+ "attach",
14
+ "BUILD_NAME_DEFAULT",
15
+ "INVALID_DIRNAME",
16
+ ]
@@ -0,0 +1,129 @@
1
+ import traceback
2
+ import typing as ty
3
+ from pathlib import Path
4
+
5
+ from fileformats.core import FileSet
6
+ from tqdm import tqdm
7
+
8
+ from accessionflow.helpers.arg_types import AttachedFiles, OnResourceClash
9
+ from accessionflow.helpers.logging import logger
10
+ from accessionflow.helpers.remotes import LocalSessionListing, list_session_dirs
11
+ from accessionflow.model.accession import Accession
12
+
13
+
14
+ def attach(
15
+ input_dir: Path,
16
+ output_dir: Path,
17
+ datatype: ty.Type[FileSet],
18
+ glob: str,
19
+ identity_pattern: str,
20
+ spaces_to_underscores: bool = False,
21
+ on_resource_clash: OnResourceClash = "error",
22
+ raise_errors: bool = False,
23
+ require_manifest: bool = True,
24
+ copy_mode: FileSet.CopyMode = FileSet.CopyMode.copy,
25
+ unlink_source: str | None = None,
26
+ ) -> list[str]:
27
+
28
+ session_dirs = list_session_dirs(input_dir)
29
+ sessions: list[LocalSessionListing] = [LocalSessionListing(p) for p in session_dirs]
30
+
31
+ errors: list[str] = []
32
+ # Check __metadata__/ for sessions whose directories have been
33
+ # removed. Create metadata-only sessions from the YAML files so attached files can still be discovered.
34
+ metadata_dir = input_dir / Accession.METADATA_DIR
35
+ metadata_sessions: list[Accession] = []
36
+ if metadata_dir.is_dir():
37
+ existing_names = {p.name for p in session_dirs}
38
+ for yaml_path in sorted(metadata_dir.glob("*.yaml")):
39
+ session_name = yaml_path.stem
40
+ if session_name not in existing_names:
41
+ try:
42
+ metadata_sessions.append(Accession.from_metadata_yaml(yaml_path))
43
+ logger.info(
44
+ "Created metadata-only session '%s' from '%s'",
45
+ session_name,
46
+ yaml_path,
47
+ )
48
+ except Exception as e:
49
+ logger.error(
50
+ "Failed to load metadata session from '%s': %s",
51
+ yaml_path,
52
+ e,
53
+ )
54
+ errors.append(str(e))
55
+
56
+ num_sessions = len(sessions) + len(metadata_sessions)
57
+ logger.info(
58
+ "Found %d sessions in staging directory to stage'%s'",
59
+ num_sessions,
60
+ input_dir,
61
+ )
62
+
63
+ for session_listing in tqdm(
64
+ sessions,
65
+ total=num_sessions,
66
+ desc=f"Processing staged sessions found in '{input_dir}'",
67
+ ):
68
+ try:
69
+ session = Accession.load(
70
+ session_listing.cache_path,
71
+ require_manifest=require_manifest,
72
+ check_checksums=False,
73
+ )
74
+ attached = session.attach_files(
75
+ [AttachedFiles(datatype, glob, identity_pattern)],
76
+ spaces_to_underscores=spaces_to_underscores,
77
+ on_resource_clash=on_resource_clash,
78
+ )
79
+ session.save(output_dir, copy_mode=copy_mode)
80
+ except Exception as e:
81
+ if raise_errors:
82
+ raise
83
+ logger.error(
84
+ "Error attaching files for session '%s': %s",
85
+ session_listing.name,
86
+ str(e),
87
+ )
88
+ logger.debug(traceback.format_exc())
89
+ errors.append(str(e))
90
+ else:
91
+ if unlink_source is not None:
92
+ # 'all' and 'keep-metadata' are equivalent here: the attached files
93
+ # live at an arbitrary external location, not a directory tree
94
+ # accessionflow owns, so only the individual matched files are ever
95
+ # removed
96
+ for fileset in attached:
97
+ fileset.unlink()
98
+
99
+ for session in tqdm(
100
+ metadata_sessions,
101
+ total=len(metadata_sessions),
102
+ desc="Processing metadata-only sessions",
103
+ ):
104
+ try:
105
+ attached = session.attach_files(
106
+ [AttachedFiles(datatype, glob, identity_pattern)],
107
+ spaces_to_underscores=spaces_to_underscores,
108
+ on_resource_clash=on_resource_clash,
109
+ )
110
+ session.save(output_dir, copy_mode=copy_mode)
111
+ except Exception as e:
112
+ if raise_errors:
113
+ raise
114
+ logger.error(
115
+ "Error attaching files for metadata-only session '%s': %s",
116
+ session.name,
117
+ str(e),
118
+ )
119
+ logger.debug(traceback.format_exc())
120
+ errors.append(str(e))
121
+ else:
122
+ if unlink_source is not None:
123
+ for fileset in attached:
124
+ fileset.unlink()
125
+ if errors:
126
+ logger.error("Attachment completed with %s errors", len(errors))
127
+ else:
128
+ logger.info("Attachment completed successfully without errors")
129
+ return errors
@@ -0,0 +1,381 @@
1
+ import time
2
+ import traceback
3
+ import typing as ty
4
+ from pathlib import Path
5
+
6
+ from fileformats.core import FileSet
7
+ from fileformats.medimage import DicomSeries
8
+ from tqdm import tqdm
9
+
10
+ from accessionflow.helpers.arg_types import (
11
+ ClashSpec,
12
+ IDSpec,
13
+ MetadataTable,
14
+ OnResourceClash,
15
+ PathMetadataRegex,
16
+ )
17
+ from accessionflow.helpers.logging import logger
18
+ from accessionflow.model.accession import Accession
19
+
20
+ BUILD_NAME_DEFAULT = "__build__"
21
+
22
+ # Default ID specs, shared verbatim with the ``accessionflow collate`` CLI options in
23
+ # ``collate_cli.py`` so the API and CLI can never drift apart
24
+ DEFAULT_SESSION_FIELD: tuple[IDSpec, ...] = (
25
+ IDSpec("StudyInstanceUID", "medimage/dicom-collection"),
26
+ )
27
+ DEFAULT_SCAN_FIELD: tuple[IDSpec, ...] = (
28
+ IDSpec("SeriesNumber", "medimage/dicom-collection"),
29
+ )
30
+ DEFAULT_RESOURCE_FIELD: tuple[IDSpec, ...] = ()
31
+ _DEFAULT_DATATYPES: tuple[type[FileSet], ...] = (DicomSeries,)
32
+
33
+
34
+ def collate(
35
+ input_paths: list[str],
36
+ output_dir: Path,
37
+ datatypes: ty.Sequence[type[FileSet]] = _DEFAULT_DATATYPES,
38
+ session: ty.Sequence[IDSpec] = DEFAULT_SESSION_FIELD,
39
+ scan: ty.Sequence[IDSpec] = DEFAULT_SCAN_FIELD,
40
+ resource: ty.Sequence[IDSpec] = DEFAULT_RESOURCE_FIELD,
41
+ path_metadata_regex: ty.Sequence[PathMetadataRegex] = (),
42
+ unlink_source: str | None = None,
43
+ raise_errors: bool = False,
44
+ copy_mode: FileSet.CopyMode = FileSet.CopyMode.hardlink_or_copy,
45
+ wait_period: int = 0,
46
+ collation_map: dict[type[FileSet], FileSet.CopyCollation] | None = None,
47
+ conversion_map: (
48
+ dict[type[FileSet], tuple[type[FileSet], dict[str, str]]] | None
49
+ ) = None,
50
+ allow_unrecognised: ty.Sequence[str] = (),
51
+ exclude_paths: ty.Sequence[str] = (),
52
+ ignore_datatypes: ty.Sequence[type[FileSet]] = (),
53
+ on_resource_clash: OnResourceClash | ty.Sequence[ClashSpec] = "error",
54
+ metadata_tables: list[MetadataTable] | None = None,
55
+ recursive: bool = False,
56
+ ) -> list[str]:
57
+ """Collates the input files into sessions/scans/resources and stages them into the
58
+ staging directory. Project/subject/visit IDs and scan descriptions are not
59
+ labelled at this point, see the 'label' function for that.
60
+
61
+ Parameters
62
+ ----------
63
+ input_paths: list[str]
64
+ List of paths to search for input files. Can be local paths or S3 paths.
65
+ output_dir: Path
66
+ Path to the staging directory where the collated sessions will be saved. This should be a local path.
67
+ datatypes: ty.Sequence[type[FileSet]]
68
+ FileSet types to look for in the input files. Only files with these datatypes will be
69
+ considered for staging. Defaults to ``(DicomSeries,)`` to mirror the CLI.
70
+ session: ty.Sequence[IDSpec]
71
+ List of field specifications to use for extracting the session UIDs from the input files to collate them into
72
+ separate sessions. Defaults to ``DEFAULT_SESSION_FIELD`` (``StudyInstanceUID``
73
+ scoped to DICOM collections; the same object backs the CLI's ``--session``);
74
+ other fileset types need an explicit spec.
75
+ scan: ty.Sequence[IDSpec]
76
+ List of field specifications to use for extracting the scan IDs from the input files to collate them into
77
+ scans. Defaults to ``DEFAULT_SCAN_FIELD`` (``SeriesNumber`` scoped to DICOM
78
+ collections; shared with the CLI's ``--scan``); for a fileset whose type is not
79
+ matched by any spec here the scan is named after that fileset's resource.
80
+ resource: ty.Sequence[IDSpec]
81
+ List of field specifications to use for extracting the resource IDs from the input files to collate them into
82
+ resources. If empty, each resource is labelled with the mime-like rendering
83
+ of its fileset type name, e.g. 'vectra-export', 'sqlite3-db'
84
+ path_metadata_regex: ty.Sequence[PathMetadataRegex]
85
+ Regular expressions to extract "metadata" values from resource file paths as named groups. The named
86
+ groups are used as metadata fields for the resource files, and the extracted values will be used to populate
87
+ the corresponding metadata fields to complement the metadata read from the file headers.
88
+ unlink_source: str | None
89
+ If "all" or "keep-metadata", the input files will be unlinked one by one after staging (both behave the same
90
+ here, since the source isn't a directory tree that accessionflow owns). If None, the input files will be left
91
+ in place.
92
+ raise_errors: bool
93
+ If True, any errors encountered during staging will raise an exception. If False, errors will be logged and the
94
+ staging process will continue for the remaining sessions.
95
+ copy_mode: FileSet.CopyMode
96
+ The copy mode to use when saving the sessions. This determines whether files are copied, moved or symlinked when
97
+ saving the sessions to the staging directory.
98
+ collation_map: dict[ty.Type[FileSet], FileSet.CopyCollation] | None
99
+ A mapping of FileSet types to CopyCollation objects that specify how to collate files of that type when saving the
100
+ sessions. If None, the default collation behavior for each FileSet type will be used.
101
+ conversion_map: dict[ty.Type[FileSet], tuple[ty.Type[FileSet], dict[str, str]]] | None
102
+ A mapping of source FileSet types to (target FileSet types, conversion options).
103
+ When a resource matches a source type, it will be converted to the target type during save,
104
+ with the options passed through to ``convert()``.
105
+ allow_unrecognised: ty.Sequence[str]
106
+ Regexes matched against the *basename* of any input path that no datatype recognised;
107
+ matches are skipped instead of raising ``FormatRecognitionError``. ``[".*"]`` tolerates
108
+ all unrecognised files. Does not affect recognised filesets.
109
+ exclude_paths: ty.Sequence[str]
110
+ Globs matched against each input path *relative to its input directory*, applied before
111
+ classification so a match is dropped even if a datatype would claim it (e.g. a vendor
112
+ thumbnail that is a valid ``image/png``). ``*`` does not cross ``/``, ``**`` does.
113
+ ignore_datatypes: ty.Sequence[type[FileSet]]
114
+ Datatypes expected in the input but not wanted: recognised filesets of these types are
115
+ dropped rather than raising, and (with ``recursive``) matching directories are skipped
116
+ without descending. A path matching neither ``datatypes`` nor ``ignore_datatypes`` (nor
117
+ ``allow_unrecognised`` / ``exclude_paths``) still raises.
118
+ wait_period: int
119
+ If provided, this is the number of seconds that must have passed since the last modification time of the session before
120
+ it will be staged. This can be used to avoid staging sessions that are still being modified or created.
121
+ on_resource_clash: OnResourceClash or Sequence[ClashSpec]
122
+ Behaviour when two filesets resolve to the same scan/resource name. A bare policy
123
+ string ("error"/"avoid"/"merge"/"overwrite") applies to any clash. A sequence of
124
+ ``ClashSpec`` (policy + datatype scope) resolves each clash with the first spec whose
125
+ scope covers *both* filesets - a clash no spec covers raises. "avoid" suffixes,
126
+ "merge" folds into one ``SetOf``, "overwrite" replaces. Default "error".
127
+ recursive: bool
128
+ If True, the input paths will be searched recursively for files to stage. If False, only the files directly within the
129
+ input paths will be considered for staging.
130
+ metadata_tables: list[MetadataTable] | None
131
+ Specify metadata tables to extract and join metadata from input files (ACFLOW_METADATA_TABLES env. var).
132
+ The 'path' arg specifies the location of the metadata table file. Its format is auto-detected as CSV or
133
+ TSV from the file extension; a different format can be forced by appending its mime-type in square
134
+ brackets, e.g. 'path/to/table.dat[text/csv]'.
135
+ The "row frequency" arg specifies what each row in the
136
+ metadata table corresponds to in the data hierarchy, and can be one of 'session', 'scan', 'resource',
137
+ 'fileset', 'fileset[<mime-type>]'. When one or more mime-types are given in square brackets after
138
+ 'fileset' they restrict the join to input files of those types (multiple mime-types can be '|'-separated,
139
+ e.g. 'fileset[image/png|image/jpeg]'); a bare 'fileset' matches any input file.
140
+ The 'join-exprs' arg is a comma-separated list of '<column-name>=<cell-value>' expressions; a row is a
141
+ match when every expression holds. The '<cell-value>' is either the name of an existing metadata field
142
+ or a Python format string over one or more metadata fields, e.g. '{PatientID}_{SessionID}'. All columns
143
+ of the matched row are then merged into the target's metadata.
144
+ The example below extracts the relative path of an image file with `path_metadata_regex` and uses it to
145
+ join a table whose 'ImagePath' column holds spreadsheet HYPERLINK() formulas::
146
+
147
+ collate(
148
+ ...,
149
+ path_metadata_regex=[
150
+ PathMetadataRegex(
151
+ regex=r".*/(?P<relpath>[\\w-]+/[\\w-]+\\.(?:png|jpg))",
152
+ datatype="image/png|image/jpeg",
153
+ )
154
+ ],
155
+ metadata_tables=[
156
+ MetadataTable(
157
+ table_file="path/to/table.csv[text/csv]",
158
+ row_frequency="fileset[image/png|image/jpeg]",
159
+ join_exprs='ImagePath=HYPERLINK("{relpath}")',
160
+ )
161
+ ],
162
+ )
163
+ """
164
+
165
+ errors = []
166
+
167
+ # Create sub-directory of the output directory to build sessions in before
168
+ # moving them into their final location
169
+ build_dir = output_dir / BUILD_NAME_DEFAULT
170
+
171
+ build_dir.mkdir(parents=True, exist_ok=True)
172
+
173
+ sessions = Accession.from_paths(
174
+ files_path=input_paths,
175
+ datatypes=datatypes,
176
+ session_field=session,
177
+ scan_field=scan,
178
+ resource_field=resource,
179
+ recursive=recursive,
180
+ on_resource_clash=on_resource_clash,
181
+ allow_unrecognised=allow_unrecognised,
182
+ exclude_paths=exclude_paths,
183
+ ignore_datatypes=ignore_datatypes,
184
+ path_metadata_regex=path_metadata_regex,
185
+ metadata_tables=metadata_tables,
186
+ )
187
+
188
+ errors = save_sessions_to_dir(
189
+ sessions,
190
+ f"Collating files found in '{input_paths}' to {output_dir!s}",
191
+ wait_period=wait_period,
192
+ build_dir=build_dir,
193
+ copy_mode=copy_mode,
194
+ output_dir=output_dir,
195
+ unlink_source=unlink_source,
196
+ raise_errors=raise_errors,
197
+ collation_map=collation_map,
198
+ conversion_map=conversion_map,
199
+ )
200
+ if errors:
201
+ logger.error("Collating completed with %s errors", len(errors))
202
+ else:
203
+ logger.info("Collating completed successfully")
204
+ return errors
205
+
206
+
207
+ def collate_orthanc(
208
+ url: str,
209
+ store_dir: Path,
210
+ output_dir: Path,
211
+ user: str,
212
+ password: str,
213
+ to_process_label: str | None = None,
214
+ processed_label: str | None = None,
215
+ unlink_source: str | None = None,
216
+ raise_errors: bool = False,
217
+ copy_mode: FileSet.CopyMode = FileSet.CopyMode.hardlink_or_copy,
218
+ wait_period: int = 0,
219
+ conversion_map: (
220
+ dict[type[FileSet], tuple[type[FileSet], dict[str, str]]] | None
221
+ ) = None,
222
+ ) -> list[str]:
223
+ """Collates the input files into sessions and stages them into the staging directory.
224
+
225
+ Parameters
226
+ ----------
227
+ url: str
228
+ Orthanc server to retrieve the DICOM resources from.
229
+ output_dir: Path
230
+ Path to the staging directory where the collated sessions will be saved. This should be the final location for the collated sessions.
231
+ user: str
232
+ Orthanc user to login with
233
+ password: str
234
+ Orthanc password to login with
235
+ processed_label: str | None
236
+ The label applied to the sessions in Orthanc by this script to signify that they have already been processed.
237
+ to_process_label: str | None
238
+ The label externally applied to sessions in Orthanc to signify that should be processed. If None,
239
+ all sessions will be processed.
240
+ session_id: list[IDSpec] | None
241
+ List of ID specifications to use for extracting the session ID from the input files. If None, the
242
+ session ID will be generated from the subject and visit IDs.
243
+ scan_id: list[IDSpec]
244
+ List of ID specifications to use for extracting the scan ID from the input files.
245
+ unlink_source: str | None
246
+ If "all" or "keep-metadata", the source studies in Orthanc will be unlinked after staging. Not yet
247
+ implemented. If None, the source studies will be left in place.
248
+ raise_errors: bool
249
+ If True, any errors encountered during staging will raise an exception. If False, errors will be logged and the
250
+ staging process will continue for the remaining sessions.
251
+ copy_mode: FileSet.CopyMode
252
+ The copy mode to use when saving the sessions. This determines whether files are copied, moved or symlinked when
253
+ saving the sessions to the staging directory.
254
+ wait_period: int
255
+ If provided, this is the number of seconds that must have passed since the last modification time of the session before
256
+ it will be staged. This can be used to avoid staging sessions that are still being modified or created.
257
+ conversion_map: dict[ty.Type[FileSet], tuple[ty.Type[FileSet], dict[str, str]]] | None
258
+ A mapping of source FileSet types to (target FileSet types, conversion options).
259
+ When a staged series matches a source type, it will be converted to the target type
260
+ before the study is labelled as processed, with the options passed through to ``convert()``.
261
+ """
262
+
263
+ if (
264
+ unlink_source is not None
265
+ or copy_mode is not FileSet.CopyMode.hardlink_or_copy
266
+ or raise_errors is True
267
+ ):
268
+ raise NotImplementedError(
269
+ "'unlink_source', copy_mode' and 'raise_errors' are not yet implemented for Orthanc collation."
270
+ )
271
+
272
+ errors = []
273
+
274
+ # Create sub-directory of the output directory to build sessions in before
275
+ # moving them into their final location
276
+ build_dir = output_dir / BUILD_NAME_DEFAULT
277
+
278
+ build_dir.mkdir(parents=True, exist_ok=True)
279
+
280
+ sessions = Accession.from_orthanc( # noqa
281
+ url=url,
282
+ output_dir=output_dir,
283
+ store_dir=store_dir,
284
+ user=user,
285
+ password=password,
286
+ to_process_label=to_process_label,
287
+ processed_label=processed_label,
288
+ wait_period=wait_period,
289
+ conversion_map=conversion_map,
290
+ errors=errors,
291
+ )
292
+
293
+ # Should from_orthanc() not actually move the data, just reference it in place like from_paths()
294
+ # does? If so, we can just call save_sessions_to_dir() here.
295
+
296
+ # errors = save_sessions_to_dir(
297
+ # sessions,
298
+ # f"Collating resources found in Orthanc instance at '{url}' to {output_dir}",
299
+ # build_dir=build_dir,
300
+ # copy_mode=copy_mode,
301
+ # output_dir=output_dir,
302
+ # unlink_source=unlink_source,
303
+ # raise_errors=raise_errors,
304
+ # )
305
+
306
+ if errors:
307
+ logger.error("Collating from Orthanc completed with %s errors", len(errors))
308
+ else:
309
+ logger.info("Collating from Orthanc completed successfully")
310
+ return errors
311
+
312
+
313
+ def save_sessions_to_dir(
314
+ sessions: list[Accession],
315
+ msg: str,
316
+ build_dir,
317
+ copy_mode: FileSet.CopyMode,
318
+ output_dir: Path,
319
+ wait_period: int = 0,
320
+ collation_map=None,
321
+ conversion_map: (
322
+ dict[type[FileSet], tuple[ty.Type[FileSet], dict[str, str]]] | None
323
+ ) = None,
324
+ unlink_source: str | None = None,
325
+ raise_errors: bool = False,
326
+ ) -> list[str]:
327
+ errors = []
328
+ for session in tqdm(sessions, msg):
329
+ if wait_period:
330
+ last_mod = session.last_modified()
331
+ if (time.time_ns() - last_mod) < wait_period * 1e9:
332
+ logger.info(
333
+ "Skipping collation of session '%s' as it was last modified "
334
+ "at %s which is less than %s seconds ago to ensure transfer is complete. ",
335
+ session.uid,
336
+ last_mod,
337
+ wait_period,
338
+ )
339
+ continue
340
+
341
+ try:
342
+ # We save the session into a temporary "pre-stage" directory first before
343
+ # moving them into the final "staged" directory. This is to prevent the
344
+ # files being transferred/deleted until the saved session is in a final state.
345
+ _, saved_dir = session.save(
346
+ build_dir,
347
+ copy_mode=copy_mode,
348
+ collation_map=collation_map,
349
+ conversion_map=conversion_map,
350
+ )
351
+ logger.info(
352
+ "Successfully collated session '%s' to '%s'",
353
+ session.uid,
354
+ str(saved_dir),
355
+ )
356
+ session_output_dir = output_dir.joinpath(*session.staging_relpath)
357
+ Accession.move_dir(saved_dir, session_output_dir)
358
+ if unlink_source is not None:
359
+ # 'all' and 'keep-metadata' are equivalent here: this session's source
360
+ # files may live in a directory shared with other, not-yet-processed
361
+ # sessions, so only the individual files are ever removed — never the
362
+ # whole parent directory (unlike 'label'/'deidentify', which clean up
363
+ # a staged directory that accessionflow created and owns exclusively)
364
+ session.unlink()
365
+ except Exception as e:
366
+ if not raise_errors:
367
+ msg = (
368
+ f"Skipping '{session.uid}' session due to error in collation: \"{e}\""
369
+ f"\n{traceback.format_exc()}\n\n"
370
+ )
371
+ logger.error(msg)
372
+ errors.append(msg)
373
+ continue
374
+ else:
375
+ raise
376
+
377
+ if errors:
378
+ logger.error("Collating from Orthanc completed with %s errors", len(errors))
379
+ else:
380
+ logger.info("Collating from Orthanc completed successfully")
381
+ return errors