qmenta-core 4.1.1.dev716__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,32 @@
1
+ Metadata-Version: 2.3
2
+ Name: qmenta-core
3
+ Version: 4.1.1.dev716
4
+ Summary: QMENTA core library to communicate with the QMENTA platform.
5
+ License: Proprietary
6
+ Author: QMENTA
7
+ Author-email: dev@qmenta.com
8
+ Requires-Python: >=3.10,<4.0
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: Other/Proprietary License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Requires-Dist: blinker (>=1.4,<2.0)
18
+ Requires-Dist: importlib-metadata (>=6.8.0,<7.0.0)
19
+ Requires-Dist: packaging (>=25.0,<26.0)
20
+ Requires-Dist: python-dotenv (>=1.0.0,<2.0.0)
21
+ Requires-Dist: pyyaml (>=6.0.1,<7.0.0)
22
+ Requires-Dist: qmenta-anon (>=2.1.dev377,<3.0)
23
+ Requires-Dist: requests (>=2.31.0,<3.0.0)
24
+ Requires-Dist: xdg (>=6.0.0,<7.0.0)
25
+ Project-URL: Documentation, https://docs.qmenta.com/core/
26
+ Project-URL: Homepage, https://www.qmenta.com/
27
+ Description-Content-Type: text/markdown
28
+
29
+ # QMENTA Core
30
+ This Python library contains core functionality for communicating with
31
+ the QMENTA platform.
32
+
@@ -0,0 +1,3 @@
1
+ # QMENTA Core
2
+ This Python library contains core functionality for communicating with
3
+ the QMENTA platform.
@@ -0,0 +1,61 @@
1
+ [tool.poetry]
2
+ name = "qmenta-core"
3
+ version = "4.1.1.dev716"
4
+ description = "QMENTA core library to communicate with the QMENTA platform."
5
+ license = "Proprietary"
6
+ authors = ["QMENTA <dev@qmenta.com>"]
7
+ readme = "README.md"
8
+ homepage = "https://www.qmenta.com/"
9
+ documentation = "https://docs.qmenta.com/core/"
10
+ classifiers=[
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ ]
14
+ packages = [
15
+ { include = "qmenta", from = "src" }
16
+ ]
17
+
18
+ [tool.poetry.scripts]
19
+ qmenta-auth = 'qmenta.core.auth:main'
20
+
21
+ [tool.poetry.dependencies]
22
+ python = "^3.10"
23
+ requests = "^2.31.0"
24
+ pyyaml = "^6.0.1"
25
+ qmenta-anon = "^2.1.dev377"
26
+ importlib-metadata = "^6.8.0"
27
+ xdg = "^6.0.0"
28
+ python-dotenv = "^1.0.0"
29
+ # We are not requiring the latest blinker 1.6.2 because that is not
30
+ # available in Google Colab, see EN-1810.
31
+ blinker = "^1.4"
32
+ packaging = "^25.0"
33
+
34
+ [tool.poetry.group.dev.dependencies]
35
+ flake8 = "^6.1.0"
36
+ mypy = "^1.5.1"
37
+ pytest = "^7.4.0"
38
+ coverage = {version = "^7.3.0", extras = ["toml"]}
39
+ pytest-cov = "^6.1.1"
40
+ sphinx-rtd-theme = "^1.3.0"
41
+ dom-toml = "^0.6.1"
42
+ scriv = {version = "^1.3.1", extras = ["toml"]}
43
+ types-requests = "^2.31.0.2"
44
+
45
+ [build-system]
46
+ requires = ["poetry-core>=1.0.0"]
47
+ build-backend = "poetry.core.masonry.api"
48
+
49
+ [tool.coverage.report]
50
+ fail_under = 90
51
+
52
+ [tool.scriv]
53
+ format = "md"
54
+ md_header_level = "2"
55
+ insert_marker = "scriv-insert-here"
56
+
57
+ [tool.mypy]
58
+ mypy_path = "src"
59
+ strict = true
60
+ ignore_missing_imports = true
61
+ disallow_untyped_calls = false
@@ -0,0 +1 @@
1
+ __path__ = __import__('pkgutil').extend_path(__path__, __name__)
@@ -0,0 +1 @@
1
+ VERSION
@@ -0,0 +1,10 @@
1
+ from importlib_metadata import version, PackageNotFoundError
2
+
3
+ __version__: str
4
+ try:
5
+ __version__ = version('qmenta-core')
6
+ except PackageNotFoundError:
7
+ # Package not installed. Using a local dev version.
8
+ __version__ = "0.0dev0"
9
+
10
+ __path__ = __import__('pkgutil').extend_path(__path__, __name__)
@@ -0,0 +1,342 @@
1
+ import argparse
2
+ from enum import Enum, unique
3
+ import os
4
+ import requests
5
+ from getpass import getpass
6
+ from pathlib import Path
7
+ from typing import Optional, Dict
8
+ from urllib.parse import urljoin, urlparse
9
+
10
+ from xdg import xdg_data_home
11
+ from dotenv import load_dotenv
12
+
13
+ from qmenta.core.errors import (
14
+ ActionFailedError,
15
+ ConnectionError,
16
+ InvalidResponseError,
17
+ PlatformError
18
+ )
19
+
20
+
21
+ class InvalidLoginError(ActionFailedError):
22
+ """
23
+ When the provided credentials are incorrect, or when the used token
24
+ is not valid.
25
+ """
26
+ pass
27
+
28
+
29
+ class Needs2FAError(ActionFailedError):
30
+ """
31
+ When a 2FA code must to be provided to log in.
32
+ """
33
+ pass
34
+
35
+
36
+ @unique
37
+ class PlatformURL(Enum):
38
+ platform = 'https://platform.qmenta.com'
39
+ staging = 'https://staging.qmenta.com'
40
+ test = 'https://test.qmenta.com'
41
+ test_insecure = 'http://test.qmenta.com'
42
+ local_ip = "http://127.0.0.1:8080"
43
+ localhost = "http://localhost:8080"
44
+
45
+
46
+ class Auth:
47
+ """
48
+ Class for authenticating to the platform.
49
+ Do not use the constructor directly, but use the login() function to
50
+ create a new authentication.
51
+
52
+ Attributes
53
+ ----------
54
+ base_url : str
55
+ The URL of the platform to connect to.
56
+ Default value: 'https://platform.qmenta.com'
57
+ token : str
58
+ The authentication token, returned by the platform when logging in.
59
+ """
60
+ def __init__(self, base_url: str, token: str) -> None:
61
+ validate_url(base_url)
62
+ self.base_url = base_url
63
+ self.token = token
64
+ self._session: Optional[requests.Session] = None
65
+
66
+ @staticmethod
67
+ def qmenta_auth_env_file() -> Path:
68
+ """
69
+ Return the path of the qmenta auth env file
70
+ """
71
+ return xdg_data_home() / 'QMENTA' / 'auth' / '.env'
72
+
73
+ @classmethod
74
+ def from_env(cls, dot_env: Optional[Path] = None) -> 'Auth':
75
+ """
76
+ Create an Auth object using the QMENTA_URL and QMENTA_AUTH_TOKEN
77
+ environment variables.
78
+ If the variables are not set in the environment, but they exist in
79
+ the file dot_env, then those values are used.
80
+
81
+ This function can be used to create an Auth object in scripts to
82
+ communicate with QMENTA Platform, after the `qmenta-auth` command
83
+ has been run to authenticate.
84
+
85
+ Parameters
86
+ ----------
87
+ dot_env: Path
88
+ The location of the .env file to read the environment variables.
89
+ If no value is supplied, qmenta_auth_env_file() is used as
90
+ the default value. (Optional)
91
+
92
+ Raises
93
+ ------
94
+ InvalidLoginError
95
+ When one of the needed environment variables was not found
96
+ """
97
+
98
+ # Loads variables from the .env file, but does NOT override existing
99
+ # values already set in the environment.
100
+ # No exception is raised if the file is not found.
101
+ dotenv_file = dot_env or cls.qmenta_auth_env_file()
102
+ load_dotenv(dotenv_file)
103
+
104
+ try:
105
+ token: str = os.environ["QMENTA_AUTH_TOKEN"]
106
+ url: str = os.environ["QMENTA_URL"]
107
+ except KeyError as e:
108
+ raise InvalidLoginError(f'Missing environment variable: {e}')
109
+
110
+ print(f'Using authentication token for {url}')
111
+ return cls(url, token)
112
+
113
+ @classmethod
114
+ def login(cls, username: str, password: str,
115
+ code_2fa: Optional[str] = None,
116
+ ask_for_2fa_input: bool = False,
117
+ base_url: str = PlatformURL.platform.value) -> 'Auth':
118
+ """
119
+ Authenticate to the platform using username and password.
120
+
121
+ Parameters
122
+ ----------
123
+ username : str
124
+ The username to log in on the platform. For all new platform
125
+ accounts, this is the e-mail address of the user.
126
+ Example: 'example@qmenta.com'
127
+ password : str
128
+ The QMENTA platform password of the user.
129
+ code_2fa : str
130
+ The 2FA code that was sent to your phone (optional).
131
+ ask_for_2fa_input: bool
132
+ When set to True, the user is asked input the 2FA code
133
+ in the command-line interface when it is needed. If the user does
134
+ not have 2FA enabled, no input is requested.
135
+ This is useful for scripts.
136
+ When set to False, a Needs2FAError exception is raised when
137
+ a 2FA code is needed. This is useful for GUIs.
138
+ Default value: False
139
+ base_url : str
140
+ The URL of the platform to connect to.
141
+ Default value: 'https://platform.qmenta.com'
142
+
143
+ Returns
144
+ -------
145
+ Auth
146
+ The Auth object that was logged in with.
147
+
148
+ Raises
149
+ ------
150
+ ConnectionError
151
+ If there was a problem setting up the network connection with the
152
+ platform and for 404 and 5xx response status code.
153
+ InvalidResponseError
154
+ If the platform returned an invalid response.
155
+ InvalidLoginError
156
+ If the login was invalid. This can happen when the
157
+ username/password combination is incorrect, or when the account is
158
+ not active or 2FA is required to be set up.
159
+ Needs2FAError
160
+ When a login attempt was done without a valid 2FA code.
161
+ The 2FA code has been sent to your phone, and must be provided
162
+ in the next call to the login function.
163
+ """
164
+ url: str = urljoin(base_url, '/login')
165
+
166
+ try:
167
+ response: requests.Response = requests.post(
168
+ url, data={
169
+ 'username': username, 'password': password,
170
+ 'code_2fa': code_2fa
171
+ }
172
+ )
173
+ # Raises an exception for 4xx and 5xx status codes
174
+ response.raise_for_status()
175
+ # Assuming the response contains JSON data
176
+ data: dict = response.json()
177
+ # Process the data here
178
+ except requests.exceptions.HTTPError as e:
179
+ if e.response.status_code == 404:
180
+ raise ConnectionError("API returned a 404 error: Not Found")
181
+ else:
182
+ raise ConnectionError("API returned an error:", e)
183
+ except InvalidResponseError as e:
184
+ raise InvalidResponseError("Invalid Response Error:", e)
185
+ except requests.RequestException as e:
186
+ raise ConnectionError(str(e))
187
+ except Exception as e:
188
+ raise Exception("An error occurred:", e)
189
+
190
+ try:
191
+ if data["success"] != 1:
192
+ # Login was not successful
193
+ if data.get("account_state", "") == '2fa_need':
194
+ if ask_for_2fa_input:
195
+ input_2fa = input("Please enter your 2FA code: ")
196
+ return Auth.login(
197
+ username, password, code_2fa=input_2fa,
198
+ ask_for_2fa_input=True, base_url=base_url
199
+ )
200
+ else:
201
+ raise Needs2FAError(
202
+ 'Provide the 2FA code sent to your phone, '
203
+ 'or set the ask_for_2fa_input parameter'
204
+ )
205
+ else:
206
+ raise InvalidLoginError(data['error'])
207
+
208
+ token: str = data['token']
209
+ except KeyError as e:
210
+ raise InvalidResponseError(f'Missing key: {e}')
211
+
212
+ return cls(base_url, token)
213
+
214
+ def get_session(self) -> requests.Session:
215
+ if not self._session:
216
+ self._session = requests.Session()
217
+
218
+ # Session may store other cookies such as 'route'
219
+ auth_cookie = requests.cookies.create_cookie(
220
+ name='AUTH_COOKIE', value=self.token
221
+ )
222
+ # Add or update it
223
+ self._session.cookies.set_cookie(auth_cookie)
224
+ self._session.headers.update(self._headers())
225
+
226
+ return self._session
227
+
228
+ def _headers(self) -> Dict[str, str]:
229
+ h = {
230
+ 'Mint-Api-Call': '1'
231
+ }
232
+ return h
233
+
234
+
235
+ def write_dot_env_file(token: str, url: str,
236
+ filename: Optional[Path] = None) -> None:
237
+ """
238
+ Write the token and URL to a .env file.
239
+
240
+ Parameters
241
+ ----------
242
+ token: str
243
+ The token to write to the .env file
244
+ url: str
245
+ The URL to write to the .env file
246
+ filename: Path
247
+ The filename of the .env file to write to (optional).
248
+ If no value is supplied, it will be written to the default
249
+ location qmenta_auth_env_file().
250
+
251
+ Raises
252
+ ------
253
+ OSError
254
+ When the output file could not be written
255
+ """
256
+ validate_url(url)
257
+
258
+ filepath = filename or Auth.qmenta_auth_env_file()
259
+ os.makedirs(os.path.dirname(os.path.abspath(filepath)), exist_ok=True)
260
+
261
+ with open(filepath, 'w') as envFile:
262
+ print(f'QMENTA_URL={url}', file=envFile)
263
+ print(f'QMENTA_AUTH_TOKEN={token}', file=envFile)
264
+
265
+ print(f'Auth token was written to {filepath}')
266
+
267
+
268
+ def validate_url(url: str) -> None:
269
+ """
270
+ Validate the URL as a valid http or https URL.
271
+
272
+ Raises
273
+ ------
274
+ ValueError
275
+ When the URL is not valid
276
+ """
277
+
278
+ parsed_url = urlparse(url)
279
+
280
+ if parsed_url.scheme == 'http':
281
+ print('WARNING: Only use http for local testing.')
282
+ elif not parsed_url.scheme == 'https':
283
+ raise ValueError(
284
+ 'URL should start with https://. '
285
+ 'Example: https://platform.menta.com'
286
+ )
287
+ if parsed_url.path not in ['', '/']:
288
+ raise ValueError(
289
+ 'Provide only the root URL of the backend server. '
290
+ 'Example: https://platform.qmenta.com'
291
+ )
292
+ if parsed_url.username or parsed_url.password or parsed_url.query:
293
+ raise ValueError(
294
+ 'Provide only the root URL of the backend server. '
295
+ 'Example: https://platform.qmenta.com'
296
+ )
297
+
298
+ url_values = [p_url.value for p_url in PlatformURL]
299
+ if parsed_url.scheme + '://' + parsed_url.netloc not in url_values:
300
+ raise ValueError(
301
+ "base_url must be one of '{}', ".format("', '".join(url_values))
302
+ + f"not '{parsed_url.netloc}'."
303
+ )
304
+
305
+
306
+ def main() -> None:
307
+ parser = argparse.ArgumentParser(
308
+ description=(
309
+ 'Log in on QMENTA platform and store the authentication '
310
+ 'token in a .env file. Username and password may be '
311
+ 'provided as parameters, or will be asked as user input. '
312
+ )
313
+ )
314
+ parser.add_argument('--username', help='Username to login',)
315
+ parser.add_argument('--password', help='Password')
316
+ parser.add_argument(
317
+ 'url', help='Platform URL, for example: https://platform.qmenta.com')
318
+ args = parser.parse_args()
319
+
320
+ try:
321
+ validate_url(args.url)
322
+ except ValueError as err:
323
+ print(err)
324
+ return
325
+
326
+ username = args.username or input("Username: ")
327
+ password = args.password or getpass()
328
+
329
+ try:
330
+ auth = Auth.login(
331
+ username=username, password=password,
332
+ ask_for_2fa_input=True, base_url=args.url
333
+ )
334
+ except PlatformError as err:
335
+ print(err)
336
+ return
337
+
338
+ write_dot_env_file(token=auth.token, url=auth.base_url)
339
+
340
+
341
+ if __name__ == '__main__': # pragma: no cover
342
+ main()
@@ -0,0 +1,50 @@
1
+ """
2
+ Define the root error class for all QMENTA exceptions. All exceptions raised
3
+ by the QMENTA Core library are subclasses of ``qmenta.core.errors.Error`` and
4
+ thus are expected exceptions. If other exceptions are raised, this indicates
5
+ unexpected behavior of the library.
6
+ """
7
+
8
+
9
+ class Error(Exception):
10
+ """
11
+ Base class for all QMENTA Core errors.
12
+ """
13
+ def __init__(self, *args: str) -> None:
14
+ Exception.__init__(self, *args)
15
+
16
+
17
+ class CannotReadFileError(Error):
18
+ """
19
+ When a file cannot be read.
20
+ """
21
+ pass
22
+
23
+
24
+ class PlatformError(Error):
25
+ """
26
+ When there is a problem in the communication with the platform.
27
+ """
28
+ pass
29
+
30
+
31
+ class ConnectionError(PlatformError):
32
+ """
33
+ When there was a problem setting up the connection with QMENTA platform.
34
+ """
35
+ def __init__(self, message: str) -> None:
36
+ Error.__init__(self, f'Connection error: {message}')
37
+
38
+
39
+ class InvalidResponseError(PlatformError):
40
+ """
41
+ The QMENTA platform returned an unexpected response.
42
+ """
43
+ pass
44
+
45
+
46
+ class ActionFailedError(PlatformError):
47
+ """
48
+ When the requested action was not successful.
49
+ """
50
+ pass
@@ -0,0 +1,185 @@
1
+ import requests
2
+ from qmenta.core.auth import Auth
3
+ from qmenta.core.errors import (
4
+ ActionFailedError,
5
+ ConnectionError,
6
+ InvalidResponseError,
7
+ )
8
+ from typing import Dict, Any, List
9
+ from urllib.parse import urljoin
10
+
11
+ """
12
+ Handles all the communication with the QMENTA platform.
13
+ """
14
+
15
+
16
+ class ChooseDataError(ActionFailedError):
17
+ """
18
+ When a trying to start an analysis, but data has to be chosen
19
+
20
+ Parameters
21
+ ----------
22
+ warning : str
23
+ Warning message returned by the platform
24
+ data_to_choose : dict
25
+ Specification of the data to choose returned by the platform
26
+ analysis_id : int
27
+ The ID of the analysis for which data needs to be chosen,
28
+ returned by the platform.
29
+ """
30
+
31
+ def __init__(
32
+ self, warning: str, data_to_choose: Dict[str, List[str]],
33
+ analysis_id: int
34
+ ) -> None:
35
+ self.warning: str = warning
36
+ self.data_to_choose: dict = data_to_choose
37
+ self.analysis_id: int = analysis_id
38
+
39
+
40
+ def _raise_for_success_value(r: Dict[str, Any]) -> None:
41
+ """
42
+ Raise the appropriate exception depending on the value of success in
43
+ the response dict
44
+
45
+ Parameters
46
+ ----------
47
+ r : dict
48
+ The dict that was returned by the platform
49
+
50
+ Raises
51
+ ------
52
+ InvalidResponseError
53
+ When the response of the platform cannot be converted to JSON,
54
+ or when it has unexpected values or missing keys.
55
+ ActionFailedError
56
+ When the requested action could not be performed by the platform
57
+ ChooseDataError
58
+ When a POST was done to start an analysis, but data needs to be
59
+ chosen before the analysis can be started.
60
+ """
61
+ try:
62
+ success: int = r['success']
63
+ if success == 0:
64
+ raise ActionFailedError(r['error'])
65
+ if success == 1:
66
+ # Good!
67
+ pass
68
+ elif success == 2:
69
+ # You have to choose data
70
+ raise ChooseDataError(
71
+ warning=r['warning'],
72
+ data_to_choose=r['data_to_choose'],
73
+ analysis_id=r['analysis_id']
74
+ )
75
+ elif success == 3:
76
+ raise ActionFailedError(r['message'])
77
+ else:
78
+ raise InvalidResponseError(
79
+ 'Unexpected value for success: {}'.format(success)
80
+ )
81
+ except KeyError as e:
82
+ raise InvalidResponseError('Missing key: {}'.format(e))
83
+
84
+
85
+ def parse_response(response: requests.Response) -> Any:
86
+ """
87
+ Convert a platform response to JSON and check that it is valid.
88
+ This function should be applied to the output of post().
89
+
90
+ Parameters
91
+ ----------
92
+ response : requests.Response
93
+ The response from the platform
94
+
95
+ Raises
96
+ ------
97
+ InvalidResponseError
98
+ When the response of the platform cannot be converted to JSON,
99
+ or when it has unexpected values or missing keys.
100
+ ActionFailedError
101
+ When the requested action could not be performed by the platform
102
+ ChooseDataError
103
+ When a POST was done to start an analysis, but data needs to be
104
+ chosen before the analysis can be started.
105
+
106
+ Returns
107
+ -------
108
+ dict or list
109
+ When the platform returns a response with a list in the JSON, it
110
+ is returned. Otherwise, it is assumed that the returned value is a
111
+ dict. In case the dict has a 'data' key, the value of data in the
112
+ dict is returned, otherwise the full dict is returned.
113
+ """
114
+ try:
115
+ d: Any = response.json()
116
+ except ValueError:
117
+ raise InvalidResponseError(
118
+ 'Could not decode JSON for response {}'.format(response))
119
+
120
+ if isinstance(d, dict):
121
+ _raise_for_success_value(d)
122
+ assert d['success'] == 1
123
+ if 'data' in d:
124
+ return d['data']
125
+ else:
126
+ return d
127
+ elif isinstance(d, list):
128
+ # In some cases, the platform does not return a dict with additional
129
+ # information, but only a list with the results.
130
+ result = d
131
+ else:
132
+ raise InvalidResponseError(
133
+ 'Response is not a dict or list: {}'.format(response.text))
134
+
135
+ return result
136
+
137
+
138
+ def post(
139
+ auth: Auth, endpoint: str, data: Dict[str, Any] = {},
140
+ headers: Dict[str, Any] = {}, stream: bool = False,
141
+ timeout: float = 60.0
142
+ ) -> requests.Response:
143
+ """
144
+ Post the given data and headers to the specified platform's endpoint.
145
+
146
+ Parameters
147
+ ----------
148
+ auth : qmenta.core.platform.Auth
149
+ Auth object that was used to authenticate to the QMENTA platform
150
+ endpoint : str
151
+ The end-point in the platform to post to
152
+ data : dict
153
+ The data to post
154
+ headers : dict
155
+ The headers to post
156
+ stream : bool
157
+ Stream the response. This is used when downloading files.
158
+ Default value: False.
159
+ timeout : float
160
+ Timeout in seconds. If no bytes have been received within this time,
161
+ an exception is raised. Default value: 30.
162
+
163
+ Raises
164
+ ------
165
+ qmenta.core.errors.ConnectionError
166
+ When there is a problem connecting to the QMENTA platform
167
+
168
+ Returns
169
+ -------
170
+ requests.Response
171
+ The response object returned by the request.
172
+ """
173
+ url: str = urljoin(auth.base_url, endpoint)
174
+ try:
175
+ r = auth.get_session().post(
176
+ url=url,
177
+ data=data,
178
+ headers=headers,
179
+ stream=stream,
180
+ timeout=timeout
181
+ )
182
+ except requests.RequestException as e:
183
+ raise ConnectionError(str(e))
184
+
185
+ return r
@@ -0,0 +1,4 @@
1
+ from .single import FileInfo, UploadStatus, SingleUpload # noqa
2
+ from .multi import MultipleThreadedUploads # noqa
3
+
4
+ __path__ = __import__('pkgutil').extend_path(__path__, __name__)