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.
- qmenta_core-4.1.1.dev716/PKG-INFO +32 -0
- qmenta_core-4.1.1.dev716/README.md +3 -0
- qmenta_core-4.1.1.dev716/pyproject.toml +61 -0
- qmenta_core-4.1.1.dev716/src/qmenta/__init__.py +1 -0
- qmenta_core-4.1.1.dev716/src/qmenta/core/.gitignore +1 -0
- qmenta_core-4.1.1.dev716/src/qmenta/core/__init__.py +10 -0
- qmenta_core-4.1.1.dev716/src/qmenta/core/auth.py +342 -0
- qmenta_core-4.1.1.dev716/src/qmenta/core/errors.py +50 -0
- qmenta_core-4.1.1.dev716/src/qmenta/core/platform.py +185 -0
- qmenta_core-4.1.1.dev716/src/qmenta/core/upload/__init__.py +4 -0
- qmenta_core-4.1.1.dev716/src/qmenta/core/upload/multi.py +132 -0
- qmenta_core-4.1.1.dev716/src/qmenta/core/upload/prepare.py +262 -0
- qmenta_core-4.1.1.dev716/src/qmenta/core/upload/single.py +593 -0
- qmenta_core-4.1.1.dev716/src/qmenta/py.typed +0 -0
|
@@ -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,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
|