qmenta-sdk-lib 2.0.1.dev3472__tar.gz

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 (37) hide show
  1. qmenta_sdk_lib-2.0.1.dev3472/PKG-INFO +18 -0
  2. qmenta_sdk_lib-2.0.1.dev3472/pyproject.toml +28 -0
  3. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/__init__.py +1 -0
  4. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/__init__.py +1 -0
  5. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/bids/__init__.py +0 -0
  6. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/bids/make_entrypoint.py +24 -0
  7. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/bids/wrapper.py +255 -0
  8. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/client.py +116 -0
  9. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/communication.py +296 -0
  10. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/context.py +1696 -0
  11. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/directory_utils.py +18 -0
  12. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/executor.py +137 -0
  13. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/init.py +147 -0
  14. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/local/__init__.py +0 -0
  15. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/local/client.py +27 -0
  16. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/local/context.py +654 -0
  17. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/local/executor.py +120 -0
  18. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/local/parse_settings.py +88 -0
  19. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/log_capture.py +76 -0
  20. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/make_entrypoint.py +39 -0
  21. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/tool_maker/__init__.py +0 -0
  22. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/tool_maker/context.py +42 -0
  23. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/tool_maker/file_filter.py +101 -0
  24. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/tool_maker/inputs.py +286 -0
  25. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/tool_maker/integration_ui.py +385 -0
  26. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/tool_maker/integration_workflow_ui.py +485 -0
  27. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/tool_maker/launch_gui.py +162 -0
  28. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/tool_maker/make_files.py +117 -0
  29. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/tool_maker/modalities.py +39 -0
  30. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/tool_maker/outputs.py +395 -0
  31. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/tool_maker/run_test_docker.py +216 -0
  32. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/tool_maker/templates_tool_maker/Dockerfile_schema +26 -0
  33. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/tool_maker/templates_tool_maker/description_schema +1 -0
  34. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/tool_maker/templates_tool_maker/qmenta.png +0 -0
  35. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/tool_maker/templates_tool_maker/test_tool_schema +75 -0
  36. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/tool_maker/templates_tool_maker/tool_schema +203 -0
  37. qmenta_sdk_lib-2.0.1.dev3472/python/qmenta/sdk/tool_maker/tool_maker.py +872 -0
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.3
2
+ Name: qmenta-sdk-lib
3
+ Version: 2.0.1.dev3472
4
+ Summary: QMENTA SDK for tool development.
5
+ Author: QMENTA
6
+ Maintainer: Marc Ramos
7
+ Requires-Python: >=3.10,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Requires-Dist: PySimpleGui (>=1.0)
14
+ Requires-Dist: enum-compat (>=0.0.1)
15
+ Requires-Dist: future (>=0.18.2)
16
+ Requires-Dist: humanfriendly (>=1.0)
17
+ Requires-Dist: qmenta-client (>=1.0)
18
+ Requires-Dist: requests (>=1.0)
@@ -0,0 +1,28 @@
1
+ [tool.poetry]
2
+ name = "qmenta-sdk-lib"
3
+ version = "2.0.1.dev3472"
4
+ description = "QMENTA SDK for tool development."
5
+ authors = ["QMENTA"]
6
+ packages = [
7
+ {include = "qmenta", from = "python"}
8
+ ]
9
+ maintainers = ["Marc Ramos", "Vicente Ferrer", "Tim Peeters"]
10
+ [tool.poetry.dependencies]
11
+ python = "^3.10"
12
+ qmenta-client = ">=1.0"
13
+ enum-compat = ">=0.0.1"
14
+ future = ">=0.18.2"
15
+ requests = ">=1.0"
16
+ humanfriendly = ">=1.0"
17
+ PySimpleGui = ">=1.0"
18
+
19
+ [tool.poetry.group.test]
20
+ optional = true
21
+ [tool.poetry.group.test.dependencies]
22
+ pytest = ">=8.2.0"
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["templates_tool_maker"]
26
+
27
+ [tool.setuptools.package-data]
28
+ mypkg = ["*_schema*", "*.png"]
@@ -0,0 +1 @@
1
+ __path__ = __import__("pkgutil").extend_path(__path__, __name__)
@@ -0,0 +1 @@
1
+ __version__ = "{version}" # This is filled by CI at build time (also used for documentation)
@@ -0,0 +1,24 @@
1
+ import sys
2
+ from argparse import ArgumentParser
3
+
4
+ entry_point_contents = """#!/bin/bash -eu
5
+ # Add your configuration here:
6
+ # ...
7
+
8
+ # Tool start:
9
+ exec {executable} -m qmenta.sdk.executor "$@"
10
+ """
11
+
12
+
13
+ def make_entrypoint():
14
+
15
+ parser = ArgumentParser()
16
+ parser.add_argument("target")
17
+ options = parser.parse_args()
18
+
19
+ with open(options.target, "w") as fp:
20
+ fp.write(entry_point_contents.format(executable=sys.executable))
21
+
22
+
23
+ if __name__ == "__main__":
24
+ make_entrypoint()
@@ -0,0 +1,255 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import shutil
5
+ from subprocess import check_output, CalledProcessError, STDOUT
6
+
7
+ from qmenta.sdk.directory_utils import mkdirs, TemporaryDirectory
8
+
9
+
10
+ def tag_files(files):
11
+ # TODO: improve tagging
12
+ tfiles = []
13
+
14
+ for f in files:
15
+ tags = []
16
+
17
+ # Modality
18
+ if "T1w" in f:
19
+ tags.append("m:T1")
20
+ elif "T2w" in f:
21
+ tags.append("m:T2")
22
+
23
+ # Tags
24
+ if "brainmask" in f:
25
+ tags.append("mask")
26
+ elif "labels" in f:
27
+ tags.append("labels")
28
+ if "tissue_labels" in f:
29
+ tags.append("tissue_segmentation")
30
+ elif "biasfield" in f:
31
+ tags.append("bf")
32
+ elif "restore" in f:
33
+ tags.append("bfc")
34
+ elif "anat2" in f or "2anat" in f:
35
+ tags.append("warp")
36
+
37
+ tfiles.append((f, tags))
38
+
39
+ return tfiles
40
+
41
+
42
+ def generate_file_path(original_name, sub_name, modality_name, cat_dir):
43
+ """
44
+ Returns a valid BIDS name (full path) for the given file name.
45
+
46
+ Parameters
47
+ ----------
48
+ original_name : str
49
+ Original name of the file.
50
+ sub_name : str
51
+ QMENTA platform subject name (SUB-XXX_...).
52
+ modality_name : str
53
+ Modality string in the BIDS format (may be part of the name).
54
+ cat_dir : str
55
+ Path to the directory where the file will be stored.
56
+
57
+ Returns
58
+ -------
59
+ str
60
+ The full BIDS-compatible path where the file should be downloaded.
61
+ """
62
+
63
+ # Try to detect if the file name is already in BIDS format
64
+ if original_name.startswith("sub-") and modality_name in original_name:
65
+
66
+ # Use the original name (replacing the subject ID)
67
+ target_name = "_".join([sub_name] + original_name.split("_")[1:])
68
+
69
+ else:
70
+
71
+ # Automatically generate a BIDS compatible name for the file
72
+ extension = "." + ".".join(original_name.split(".")[1:])
73
+ if extension in [
74
+ ".bval",
75
+ ".bvec",
76
+ ]: # In BIDS, bval and bvec use dwi as modality
77
+ target_name = "{}_dwi{}".format(sub_name, extension)
78
+ else:
79
+ target_name = "{}_{}{}".format(sub_name, modality_name, extension)
80
+
81
+ return os.path.join(cat_dir, target_name)
82
+
83
+
84
+ def format_input_data(context, subject, session, path):
85
+ """
86
+ Format input data followings the BIDS specification: http://bids.neuroimaging.io/bids_spec.pdf
87
+
88
+ :param context: AnalysisContext of the running analysis
89
+ :param subject: Subject name (from analysis_data)
90
+ :param session: Session ID (from analysis_data)
91
+ :param path: Destination directory where the files will be placed
92
+
93
+ TODO: support group analyses (multiple input containers).
94
+ """
95
+
96
+ logger = logging.getLogger(__name__)
97
+
98
+ dataset_description = {"Name": "QMENTA_ANALYSIS", "BIDSVersion": "1.1.0"}
99
+
100
+ # The input container specification must include file filters following the bids_ff_names nomenclature
101
+ bids_ff_names = {
102
+ "anat": [
103
+ "T1w",
104
+ "T2w",
105
+ "T1rho",
106
+ "T1map",
107
+ "T2map",
108
+ "T2star",
109
+ "FLAIR",
110
+ "FLASH",
111
+ "PD",
112
+ "PDmap",
113
+ "PDT2",
114
+ "inplaneT1",
115
+ "inplaneT2",
116
+ "angio",
117
+ ],
118
+ "func": ["bold", "events", "physio", "sbref"],
119
+ "dwi": ["dwi", "bval", "bvec"],
120
+ "fmap": ["phasediff, magnitude1"],
121
+ }
122
+
123
+ supported_extensions = [".nii", ".nii.gz", ".bval", ".bvec", ".tsv"]
124
+
125
+ # BIDS spec does not allow underscores or dashes
126
+ sub_name = "sub-{}".format(subject.replace("-", "").replace("_", ""))
127
+ sub_path = os.path.join(path, sub_name)
128
+ mkdirs(sub_path)
129
+
130
+ with open(os.path.join(path, "dataset_description.json"), "w") as fp:
131
+ json.dump(dataset_description, fp)
132
+
133
+ for category in list(bids_ff_names.keys()):
134
+ for modality_name in bids_ff_names[category]:
135
+ try:
136
+ file_handlers = context.get_files(
137
+ "input",
138
+ file_filter_condition_name="c_{}".format(modality_name),
139
+ )
140
+ except: # noqa: E731,E123,E722
141
+ continue
142
+
143
+ for fh in file_handlers:
144
+
145
+ # NIFTI IS A MUST FOR BIDS
146
+ if any(
147
+ [fh.name.endswith(ext) for ext in supported_extensions]
148
+ ):
149
+ cat_dir = os.path.join(sub_path, category)
150
+ mkdirs(cat_dir)
151
+
152
+ target_path = generate_file_path(
153
+ fh.name, subject, modality_name, cat_dir
154
+ )
155
+
156
+ if os.path.isfile(target_path):
157
+ logger.warning(
158
+ "Multiple files with same name ({}). Only the first will be used".format(
159
+ target_path
160
+ )
161
+ )
162
+ break
163
+
164
+ with TemporaryDirectory() as tmp_dir:
165
+ download_path = fh.download(tmp_dir)
166
+ shutil.move(download_path, target_path)
167
+ logger.info("File: {}".format(target_path))
168
+
169
+ metadata = fh.get_file_info()
170
+ if metadata:
171
+ metadata_target_name = "{}_{}{}".format(
172
+ sub_name, modality_name, ".json"
173
+ )
174
+ metadata_target_path = os.path.join(
175
+ cat_dir, metadata_target_name
176
+ )
177
+ with open(metadata_target_path, "w") as fp:
178
+ json.dump(metadata, fp)
179
+ else:
180
+ logger.warning(
181
+ "File {} extension not supported ({})".format(
182
+ fh.name, supported_extensions
183
+ )
184
+ )
185
+
186
+
187
+ def format_settings(settings):
188
+ # Bypass from JSON schema
189
+ # See docs.qmenta.com for more information
190
+
191
+ # FIXME: Better define inputs, for now, we'll skip common settings
192
+ ignore_param_list = ["input", "age_months"]
193
+
194
+ options = []
195
+ for param in settings:
196
+ if param not in ignore_param_list:
197
+ options += ["--" + param, str(settings[param])]
198
+ return options
199
+
200
+
201
+ def run(context):
202
+ logger = logging.getLogger(__name__)
203
+
204
+ # Get information from the platform such as patient name, user_id, ssid...
205
+ analysis_data = context.fetch_analysis_data()
206
+ context.set_progress(message="Preparing data...")
207
+
208
+ # Create in/out dirs
209
+ input_path = "/analysis/input/"
210
+ output_path = "/analysis/output/"
211
+ mkdirs(input_path)
212
+ mkdirs(output_path)
213
+
214
+ # Download data from the platform and store following the BIDS specification
215
+ format_input_data(
216
+ context,
217
+ subject=analysis_data["patient_secret_name"],
218
+ session=analysis_data["ssid"],
219
+ path=input_path,
220
+ )
221
+
222
+ # Select type of analysis (TODO: add support for group analysis)
223
+ analysis_type = "participant"
224
+
225
+ # Prepare a list of arguments from the settings
226
+ options = format_settings(context.get_settings())
227
+
228
+ # Run the original BIDS-APP entrypoint
229
+ app_path = os.environ["BIDS_APP_ENTRYPOINT"]
230
+ context.set_progress(
231
+ message="Running {}".format(os.path.basename(app_path))
232
+ )
233
+ try:
234
+ args = [app_path, input_path, output_path, analysis_type] + options
235
+ logger.info("Calling: {}".format(" ".join(args)))
236
+ ret = check_output(args, stderr=STDOUT)
237
+ logger.info(ret)
238
+ except CalledProcessError as e:
239
+ raise RuntimeError(
240
+ "Program exited with error: {}\nOutput:\n{}".format(e, e.output)
241
+ )
242
+ except Exception:
243
+ raise
244
+
245
+ # Upload output files
246
+ context.set_progress(message="Saving output...")
247
+ output_files = []
248
+ for dirpath, _, filenames in os.walk(output_path):
249
+ for f in filenames:
250
+ file_path = os.path.abspath(os.path.join(dirpath, f))
251
+ output_files.append(file_path)
252
+
253
+ for f, tags in tag_files(output_files):
254
+ output_container_path = os.path.relpath(f, output_path)
255
+ context.upload_file(f, output_container_path, tags=tags)
@@ -0,0 +1,116 @@
1
+ import importlib
2
+ import inspect
3
+ import logging
4
+
5
+ from enum import Enum
6
+
7
+ from qmenta.sdk.context import NoFilesError
8
+
9
+
10
+ class AnalysisState(Enum):
11
+ RUNNING = "running"
12
+ COMPLETED = "completed"
13
+ EXCEPTION = "exception"
14
+ NO_FILES = "no_files"
15
+
16
+
17
+ class ExecClient(object):
18
+ def __init__(self, analysis_id, comm, context, log_path):
19
+ self.__analysis_id = analysis_id
20
+ self.__comm = comm
21
+ self.__context = context
22
+ self.__log_path = log_path
23
+
24
+ def __call__(self, user_script_path):
25
+ """
26
+ Run an analysis provided by the user
27
+
28
+ Parameters
29
+ ----------
30
+ user_script_path : str
31
+ Should look like 'path.to.module:object', where 'object' should be an importable analysis tool class
32
+ or a function taking one 'context' argument.
33
+ """
34
+
35
+ logger = logging.getLogger(__name__)
36
+ logger.info("Launching: {}".format(user_script_path))
37
+
38
+ analysis_state = None
39
+ try:
40
+ # noinspection PyTypeChecker
41
+ self.set_state(AnalysisState.RUNNING, log_path=self.__log_path)
42
+ class_or_func = load_user_analysis(user_script_path)
43
+ self.__context.fetch_analysis_data()
44
+
45
+ # run the analysis
46
+ # may allow classes to be used later
47
+ assert inspect.isfunction(class_or_func)
48
+ self.__context.set_progress(value=0, message="Running")
49
+ class_or_func(self.__context)
50
+ except NoFilesError:
51
+ logger.warning("No files found")
52
+ analysis_state = AnalysisState.NO_FILES
53
+ except Exception as e:
54
+ logger.exception(e)
55
+ self.__context.set_progress(message="Error")
56
+ analysis_state = AnalysisState.EXCEPTION
57
+ raise e
58
+ else:
59
+ self.__context.set_progress(message="Completed", value=100)
60
+ analysis_state = AnalysisState.COMPLETED
61
+ finally:
62
+ self.upload_log()
63
+ # noinspection PyTypeChecker
64
+ self.set_state(analysis_state)
65
+
66
+ def set_state(self, state, **kwargs):
67
+ """
68
+ Set analysis state
69
+
70
+ Parameters
71
+ ----------
72
+ state : AnalysisState
73
+ One of AnalysisState.RUNNING, AnalysisState.COMPLETED, AnalysisState.EXCEPTION or AnalysisState.NO_FILES
74
+ kwargs : dict
75
+ """
76
+ assert isinstance(
77
+ state, AnalysisState
78
+ ), "'state' is {}, not an 'AnalysisState'".format(state)
79
+ data_to_send = {
80
+ "analysis_id": self.__analysis_id,
81
+ "state": state.value,
82
+ }
83
+ data_to_send.update(kwargs)
84
+ logging.getLogger(__name__).info(
85
+ "State = {state!r} for analysis {analysis_id}".format(
86
+ **data_to_send
87
+ )
88
+ )
89
+ self.__comm.send_request(
90
+ "analysis_manager/set_analysis_state", data_to_send
91
+ )
92
+
93
+ def upload_log(self):
94
+
95
+ data_to_send = {"analysis_id": self.__analysis_id}
96
+ log_contents = ""
97
+ try:
98
+ with open(self.__log_path, "r") as log_file:
99
+ log_contents = log_file.read()
100
+ except IOError:
101
+ log_contents = "Log file error ({})".format(self.__log_path)
102
+ finally:
103
+ # Send file in one single request
104
+ # FIXME Implement chuncked upload
105
+ self.__comm.send_files(
106
+ "analysis_manager/upload_log",
107
+ files={"file": ("exec.log", log_contents)},
108
+ req_data=data_to_send,
109
+ )
110
+
111
+
112
+ def load_user_analysis(user_script_path):
113
+ module_name, class_or_func_name = user_script_path.split(":")
114
+ user_module = importlib.import_module(module_name)
115
+ class_or_func = getattr(user_module, class_or_func_name)
116
+ return class_or_func