qmenta-client 2.1__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.
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.3
2
+ Name: qmenta-client
3
+ Version: 2.1
4
+ Summary: Python client lib to interact with the QMENTA platform.
5
+ Author: QMENTA
6
+ Author-email: dev@qmenta.com
7
+ Requires-Python: >=3.10,<4.0
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Dist: future (>=0.18.2,<0.19.0)
16
+ Requires-Dist: pytest-cov (>=6.1.1,<7.0.0)
17
+ Requires-Dist: qmenta-core (==4.1)
18
+ Project-URL: Homepage, https://www.qmenta.com/
@@ -0,0 +1,39 @@
1
+ [tool.poetry]
2
+ name = "qmenta-client"
3
+ version = "2.1"
4
+ description = "Python client lib to interact with the QMENTA platform."
5
+ authors = ["QMENTA <dev@qmenta.com>"]
6
+ homepage = "https://www.qmenta.com/"
7
+ classifiers=[
8
+ "Development Status :: 4 - Beta",
9
+ "Intended Audience :: Developers",
10
+ ]
11
+ packages = [
12
+ { include = "qmenta", from = "src" }
13
+ ]
14
+
15
+ [tool.poetry.dependencies]
16
+ python = "^3.10"
17
+ future = "^0.18.2"
18
+ qmenta-core = "4.1"
19
+ pytest-cov = "^6.1.1"
20
+
21
+ [tool.poetry.group.dev.dependencies]
22
+ pytest = "^7.1.2"
23
+ sphinx = "^5.0"
24
+ sphinx-rtd-theme = "^1.0.0"
25
+ dom-toml = "^0.6.0"
26
+ scriv = {version = "^0.15.2", extras = ["toml"]}
27
+ coverage = "^7.2.5"
28
+ flake8 = "^7.1.2"
29
+ responses = "^0.25.7"
30
+
31
+
32
+ [build-system]
33
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
34
+ build-backend = "poetry.core.masonry.api"
35
+
36
+ [tool.scriv]
37
+ format = "md"
38
+ md_header_level = "2"
39
+ insert_marker = "scriv-insert-here"
@@ -0,0 +1 @@
1
+ __path__ = __import__("pkgutil").extend_path(__path__, __name__)
@@ -0,0 +1,326 @@
1
+ from __future__ import print_function
2
+
3
+ import logging
4
+ from qmenta.core import errors
5
+ from qmenta.core import platform
6
+ from urllib3.exceptions import HTTPError
7
+
8
+ from .Project import Project
9
+ from .utils import load_json
10
+
11
+ logger_name = "qmenta.client"
12
+
13
+
14
+ class Account:
15
+ """
16
+ It represents your QMENTA account and implements the HTTP connection
17
+ with the server. Once it is instantiated it will act as an identifier
18
+ used by the rest of objects.
19
+
20
+ Parameters
21
+ ----------
22
+ username : str
23
+ Username on the platform. To get one go to https://platform.qmenta.com
24
+ password : str
25
+ The password assigned to the username.
26
+ base_url : str
27
+ The base url of the platform.
28
+ verify_certificates : bool
29
+ verify SSL certificates?
30
+
31
+ Attributes
32
+ ----------
33
+ auth : qmenta.core.platform.Auth
34
+ The Auth object used for communication with the platform
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ username,
40
+ password,
41
+ base_url="https://platform.qmenta.com",
42
+ verify_certificates=True,
43
+ ):
44
+
45
+ self._cookie = None
46
+ self.username = username
47
+ self.password = password
48
+ self.baseurl = base_url
49
+ self.verify_certificates = verify_certificates
50
+ self.auth = None
51
+ self.login()
52
+
53
+ def __repr__(self):
54
+ rep = "<Account session for {}>".format(self.username)
55
+ return rep
56
+
57
+ def login(self):
58
+ """
59
+ Login to the platform.
60
+
61
+ Raises
62
+ ------
63
+ qmenta.core.platform.ConnectionError
64
+ When the connection to the platform fails.
65
+ qmenta.core.platform.InvalidLoginError
66
+ When invalid credentials are provided.
67
+ """
68
+ logger = logging.getLogger(logger_name)
69
+ try:
70
+ auth = platform.Auth.login(
71
+ self.username,
72
+ self.password,
73
+ base_url=self.baseurl,
74
+ ask_for_2fa_input=True,
75
+ )
76
+ except errors.PlatformError as e:
77
+ logger.error("Failed to log in: {}".format(e))
78
+ self.auth = None
79
+ raise
80
+
81
+ self.auth = auth
82
+ logger.info("Logged in as {}".format(self.username))
83
+
84
+ def logout(self):
85
+ """
86
+ Logout from the platform.
87
+
88
+ Raises
89
+ ------
90
+ qmenta.core.errors.PlatformError
91
+ When the logout was not successful
92
+ """
93
+
94
+ logger = logging.getLogger(logger_name)
95
+ try:
96
+ platform.parse_response(platform.post(self.auth, "logout"))
97
+ except errors.PlatformError as e:
98
+ logger.error("Logout was unsuccessful: {}".format(e))
99
+ raise
100
+
101
+ logger.info("Logged out successfully")
102
+
103
+ def get_project(self, project_id):
104
+ """
105
+ Retrieve a project instance, given its id, which can be obtained
106
+ checking account.projects.
107
+
108
+ Parameters
109
+ ----------
110
+ project_id : int or str
111
+ ID of the project to retrieve, either the numeric ID or the name
112
+
113
+ Returns
114
+ -------
115
+ Project
116
+ A project object representing the desired project
117
+ """
118
+ if type(project_id) is int or type(project_id) is float:
119
+ return Project(self, int(project_id))
120
+ elif type(project_id) is str:
121
+ projects = self.projects
122
+ projects_match = [
123
+ proj for proj in projects if proj["name"] == project_id
124
+ ]
125
+ if not projects_match:
126
+ raise Exception(
127
+ f"Project {project_id} does not exist or is not available "
128
+ f"for this user."
129
+ )
130
+ return Project(self, int(projects_match[0]["id"]))
131
+ return None
132
+
133
+ @property
134
+ def projects(self):
135
+ """
136
+ List all the projects available to the current user.
137
+
138
+ Returns
139
+ -------
140
+ list[dict]
141
+ List of project information (name, and id)
142
+ """
143
+ logger = logging.getLogger(logger_name)
144
+
145
+ try:
146
+ data = platform.parse_response(
147
+ platform.post(
148
+ self.auth, "projectset_manager/get_projectset_list"
149
+ )
150
+ )
151
+ except errors.PlatformError as e:
152
+ logger.error("Failed to get project list: {}".format(e))
153
+ raise
154
+
155
+ titles = []
156
+ for project in data:
157
+ titles.append({"name": project["name"], "id": project["_id"]})
158
+ return titles
159
+
160
+ def add_project(
161
+ self,
162
+ project_abbreviation,
163
+ project_name,
164
+ description="",
165
+ users=None,
166
+ from_date="",
167
+ to_date="",
168
+ ):
169
+ """
170
+ Add a new project to the user account.
171
+
172
+ Parameters
173
+ ----------
174
+ project_abbreviation : str
175
+ Abbreviation of the project name.
176
+ project_name : str
177
+ Project name.
178
+ description : str
179
+ Description of the project.
180
+ users : list[str]
181
+ List of users to which this project is available.
182
+ from_date : str
183
+ Date of beginning of the project.
184
+ to_date : str
185
+ Date of ending of the project.
186
+
187
+ Returns
188
+ -------
189
+ bool
190
+ True if project was correctly added, False otherwise
191
+ """
192
+ if users is None:
193
+ users = []
194
+ logger = logging.getLogger(logger_name)
195
+ for project in self.projects:
196
+ if project["name"] == project_name:
197
+ logger.error("Project name or abbreviation already exists.")
198
+ return False
199
+
200
+ try:
201
+ platform.parse_response(
202
+ platform.post(
203
+ self.auth,
204
+ "projectset_manager/upsert_project",
205
+ data={
206
+ "name": project_name,
207
+ "description": description,
208
+ "from_date": from_date,
209
+ "to_date": to_date,
210
+ "abbr": project_abbreviation,
211
+ "users": "|".join(users),
212
+ },
213
+ )
214
+ )
215
+ except errors.PlatformError as e:
216
+ logger.error(e)
217
+ return False
218
+
219
+ for project in self.projects:
220
+ if project["name"] == project_name:
221
+ logger.info("Project was successfully created.")
222
+ return Project(self, int(project["id"]))
223
+ logger.error("Project could note be created.")
224
+ return False
225
+
226
+ def _send_request(
227
+ self,
228
+ path,
229
+ req_parameters=None,
230
+ req_headers=None,
231
+ stream=False,
232
+ return_raw_response=False,
233
+ response_timeout=900.0,
234
+ ):
235
+ """
236
+ TODO: not used and contains warnings, maybe we should delete it.
237
+
238
+ Send a request to the QMENTA Platform.
239
+
240
+ Interaction with the server is performed as POST requests.
241
+
242
+ Parameters
243
+ ----------
244
+ req_parameters : dict
245
+ Data to send in the POST request.
246
+ req_headers : dict
247
+ Extra headers to include in the request:
248
+ stream : bool
249
+ Defer downloading the response body until accessing the
250
+ response.content attribute.
251
+ return_raw_response : bool
252
+ When True, return the response from the
253
+ server as-is. When False (by default),
254
+ parse the answer as json to return a
255
+ dictionary.
256
+ response_timeout : float
257
+ The timeout time in seconds to wait for the response.
258
+ """
259
+
260
+ req_headers = req_headers or {}
261
+ req_url = "/".join((self.baseurl, path))
262
+ if self._cookie is not None:
263
+ req_headers["Cookie"] = self._cookie
264
+ req_headers["Mint-Api-Call"] = "1"
265
+
266
+ logger = logging.getLogger(logger_name)
267
+ try:
268
+ if path == "upload":
269
+ response = self.pool.request(
270
+ "POST",
271
+ req_url,
272
+ body=req_parameters,
273
+ headers=req_headers,
274
+ timeout=response_timeout,
275
+ preload_content=not stream,
276
+ )
277
+ else:
278
+ response = self.pool.request(
279
+ "POST",
280
+ req_url,
281
+ req_parameters or {},
282
+ headers=req_headers,
283
+ timeout=response_timeout,
284
+ preload_content=not stream,
285
+ )
286
+ if response.status >= 400:
287
+ raise HTTPError(
288
+ "STATUS {}: {}".format(response.status, response.reason)
289
+ )
290
+ except Exception as e:
291
+ error = "Could not send request. ERROR: {0}".format(e)
292
+ logger.error(error)
293
+ raise
294
+
295
+ # Set the login cookie in our object
296
+ if "set-cookie" in response.headers:
297
+ self._cookie = response.headers["set-cookie"]
298
+
299
+ if return_raw_response:
300
+ return response
301
+
302
+ # raise exception if there is no response from server
303
+ if not response:
304
+ error = "No response from server."
305
+ logger.error(error)
306
+ raise Exception(error)
307
+
308
+ try:
309
+ parsed_content = load_json(response.data)
310
+ except Exception:
311
+ error = "Could not parse the response as JSON data: {}".format(
312
+ response.data
313
+ )
314
+ logger.error(error)
315
+ raise
316
+
317
+ # throw exceptions if anything strange happened
318
+ if "error" in parsed_content:
319
+ error = parsed_content["error"] or "Unknown error"
320
+ logger.error(error)
321
+ raise Exception(error)
322
+ elif "success" in parsed_content and parsed_content["success"] == 3:
323
+ error = parsed_content["message"]
324
+ logger.error(error)
325
+ raise Exception(error)
326
+ return parsed_content
@@ -0,0 +1,151 @@
1
+ import glob
2
+ import json
3
+ import os
4
+ from collections import defaultdict
5
+ from tempfile import TemporaryDirectory, mkstemp
6
+
7
+ import pandas as pd
8
+ from pydicom import dcmread
9
+
10
+ import qmenta.client
11
+ from qmenta.client.utils import unzip_dicoms
12
+
13
+
14
+ class File:
15
+ """
16
+ It represents files stored in the QMENTA Platform.
17
+ Once it is instantiated it will act as an identifier used by the rest of
18
+ objects.
19
+
20
+ :param project: A QMENTA Project instance
21
+ :type project: qmenta.client.Project
22
+
23
+ :param subject_name: The name of the subject you want to work with
24
+ :type subject_name: str
25
+
26
+ :param ssid: The ssid of the subject you want to work with
27
+ :type ssid: str
28
+
29
+ :param file_name: The file_name of the file you want to work with
30
+ :type file_name: str
31
+
32
+ `ff_container = File(project, "MRB", "1", "gre_field_mapping_3.zip")`
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ project: qmenta.client.Project,
38
+ subject_name: str,
39
+ ssid: str,
40
+ file_name: str,
41
+ ):
42
+ self.file_name = file_name
43
+ self.project = project
44
+ self._container_id = None
45
+ for cont in self.project.list_input_containers():
46
+ if (
47
+ cont["patient_secret_name"] == subject_name
48
+ and cont["ssid"] == ssid
49
+ ):
50
+ self._container_id = cont["container_id"]
51
+ break
52
+ if self.file_name.endswith(".zip"):
53
+ self._file_type = "DICOM"
54
+ elif self.file_name.endswith(".nii"):
55
+ self._file_type = "NIfTI"
56
+ elif self.file_name.endswith(".nii.gz"):
57
+ self._file_type = "NIfTI-GZ"
58
+
59
+ def __str__(self):
60
+ return self.file_name
61
+
62
+ def pull_scan_sequence_info(
63
+ self, specify_dicom_tags=None, output_format: str = "csv"
64
+ ):
65
+ """
66
+ Pulling scan sequence information (e.g. series number, series type,
67
+ series description, number of files, file formats available)
68
+ via the API in CSV/TSV or JSON format.
69
+
70
+ Additionally, pulling specified DICOM headers themselves via tha API.
71
+
72
+ It downloads the file defined in the File object and extracts the
73
+ DICOM information. Nifti is not supported at the moment
74
+
75
+ :param specify_dicom_tags: tags to extract from DICOM header
76
+ :type output_format: list
77
+
78
+ :param output_format: A file output type. Supported formats: "csv",
79
+ "tsv", "json", dict
80
+ :type output_format: str
81
+
82
+ :return:
83
+ :rtype dict:
84
+ """
85
+ if specify_dicom_tags is None:
86
+ specify_dicom_tags = list()
87
+ valid_output_formats = ["csv", "tsv", "json", "dict"]
88
+ assert (
89
+ output_format in valid_output_formats
90
+ ), f"Output format not valid. Choose one of {valid_output_formats}"
91
+
92
+ with TemporaryDirectory() as temp_dir:
93
+ local_file_name = mkstemp(dir=temp_dir) + ".zip"
94
+ self.project.download_file(
95
+ self._container_id,
96
+ self.file_name,
97
+ local_file_name,
98
+ overwrite=False,
99
+ )
100
+ if self._file_type == "DICOM":
101
+ unzip_dicoms(local_file_name, temp_dir, exclude_members=None)
102
+ dicoms = glob.glob(os.path.join(temp_dir, "*"))
103
+ output_data = defaultdict(list)
104
+ number_files = 0
105
+ for dcm_file in dicoms:
106
+ try:
107
+ open_file = dcmread(dcm_file)
108
+ number_files += 1
109
+ if number_files == 1:
110
+ # only needed for one file, all have the same data.
111
+ for attribute in specify_dicom_tags:
112
+ # error handling of attributes send by the user
113
+ assert (
114
+ len(attribute) == 2
115
+ ), "Invalid length of DICOM Attribute"
116
+ assert isinstance(
117
+ attribute[0], int
118
+ ), "Invalid type of DICOM Attribute"
119
+ output_data["dicom tag"].append(
120
+ open_file[attribute].tag
121
+ )
122
+ output_data["attribute"].append(
123
+ open_file[attribute].name
124
+ )
125
+ output_data["value"].append(
126
+ open_file[attribute].value
127
+ )
128
+ except Exception as e:
129
+ print(str(e))
130
+ pass
131
+
132
+ output_data["dicom tag"].append("N/A")
133
+ output_data["attribute"].append("Number of files")
134
+ output_data["value"].append(number_files)
135
+
136
+ if output_format == "csv":
137
+ pd.DataFrame(output_data).to_csv(
138
+ f"scan_sequence_info.{output_format}", index=False
139
+ )
140
+
141
+ elif output_format == "tsv":
142
+ pd.DataFrame(output_data).to_csv(
143
+ f"scan_sequence_info.{output_format}", sep="\t", index=False
144
+ )
145
+
146
+ elif output_format == "json":
147
+ with open(f"scan_sequence_info.{output_format}", "w") as fp:
148
+ json.dump(output_data, fp)
149
+
150
+ elif output_format == "dict":
151
+ return output_data