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