qmenta-core 4.1.dev703__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.

Potentially problematic release.


This version of qmenta-core might be problematic. Click here for more details.

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