qmenta-sdk-lib 2.0.1.dev3450__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_sdk_lib-2.0.1.dev3450/PKG-INFO +16 -0
- qmenta_sdk_lib-2.0.1.dev3450/pyproject.toml +39 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/__init__.py +1 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/__init__.py +1 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/bids/__init__.py +0 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/bids/make_entrypoint.py +24 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/bids/wrapper.py +228 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/client.py +103 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/communication.py +273 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/context.py +1323 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/directory_utils.py +18 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/executor.py +119 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/init.py +118 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/local/__init__.py +0 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/local/client.py +23 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/local/context.py +555 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/local/executor.py +95 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/local/parse_settings.py +68 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/log_capture.py +76 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/make_entrypoint.py +34 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/tool_maker/__init__.py +0 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/tool_maker/context.py +34 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/tool_maker/file_filter.py +88 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/tool_maker/inputs.py +257 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/tool_maker/integration_ui.py +300 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/tool_maker/integration_workflow_ui.py +423 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/tool_maker/launch_gui.py +135 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/tool_maker/make_files.py +109 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/tool_maker/modalities.py +39 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/tool_maker/outputs.py +379 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/tool_maker/run_test_docker.py +191 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/tool_maker/templates_tool_maker/Dockerfile_schema +26 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/tool_maker/templates_tool_maker/description_schema +1 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/tool_maker/templates_tool_maker/qmenta.png +0 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/tool_maker/templates_tool_maker/test_tool_schema +75 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/tool_maker/templates_tool_maker/tool_schema +203 -0
- qmenta_sdk_lib-2.0.1.dev3450/python/qmenta/sdk/tool_maker/tool_maker.py +756 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: qmenta_sdk_lib
|
|
3
|
+
Version: 2.0.1.dev3450
|
|
4
|
+
Summary: QMENTA SDK for tool development.
|
|
5
|
+
Author: QMENTA
|
|
6
|
+
Requires-Python: >=3.10,<4.0
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Requires-Dist: PySimpleGui (>=1.0)
|
|
12
|
+
Requires-Dist: enum-compat (>=0.0.1)
|
|
13
|
+
Requires-Dist: future (>=0.18.2)
|
|
14
|
+
Requires-Dist: humanfriendly (>=1.0)
|
|
15
|
+
Requires-Dist: qmenta-client (>=1.0)
|
|
16
|
+
Requires-Dist: requests (>=1.0)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "qmenta_sdk_lib"
|
|
3
|
+
version = "2.0.1.dev3450"
|
|
4
|
+
description = "QMENTA SDK for tool development."
|
|
5
|
+
authors = ["QMENTA"]
|
|
6
|
+
packages = [
|
|
7
|
+
{include = "qmenta", from = "python"}
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
[tool.poetry.dependencies]
|
|
11
|
+
python = "^3.10"
|
|
12
|
+
qmenta-client = ">=1.0"
|
|
13
|
+
enum-compat = ">=0.0.1"
|
|
14
|
+
future = ">=0.18.2"
|
|
15
|
+
requests = ">=1.0"
|
|
16
|
+
humanfriendly = ">=1.0"
|
|
17
|
+
PySimpleGui = ">=1.0"
|
|
18
|
+
|
|
19
|
+
[tool.poetry.dev-dependencies]
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["poetry-core>=1.0.0"]
|
|
23
|
+
build-backend = "poetry.core.masonry.api"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["templates_tool_maker"]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.package-data]
|
|
29
|
+
mypkg = ["*_schema*", "*.png"]
|
|
30
|
+
|
|
31
|
+
[project]
|
|
32
|
+
authors = [
|
|
33
|
+
{name = "Tim Peeters", email = "tim@qmenta.com"}
|
|
34
|
+
]
|
|
35
|
+
maintainers = [
|
|
36
|
+
{name = "Marc Ramos", email = "marc@qmenta.com"},
|
|
37
|
+
{name = "Vicente Ferrer", email = "vicente.ferrer@qmenta.com"}
|
|
38
|
+
]
|
|
39
|
+
readme = {file = "README.md", content-type = "text/markdown"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "{version}" # This is filled by CI at build time (also used for documentation)
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from argparse import ArgumentParser
|
|
3
|
+
|
|
4
|
+
entry_point_contents = """#!/bin/bash -eu
|
|
5
|
+
# Add your configuration here:
|
|
6
|
+
# ...
|
|
7
|
+
|
|
8
|
+
# Tool start:
|
|
9
|
+
exec {executable} -m qmenta.sdk.executor "$@"
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def make_entrypoint():
|
|
14
|
+
|
|
15
|
+
parser = ArgumentParser()
|
|
16
|
+
parser.add_argument("target")
|
|
17
|
+
options = parser.parse_args()
|
|
18
|
+
|
|
19
|
+
with open(options.target, "w") as fp:
|
|
20
|
+
fp.write(entry_point_contents.format(executable=sys.executable))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
make_entrypoint()
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from subprocess import check_output, CalledProcessError, STDOUT
|
|
6
|
+
|
|
7
|
+
from qmenta.sdk.directory_utils import mkdirs, TemporaryDirectory
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def tag_files(files):
|
|
11
|
+
# TODO: improve tagging
|
|
12
|
+
tfiles = []
|
|
13
|
+
|
|
14
|
+
for f in files:
|
|
15
|
+
tags = []
|
|
16
|
+
|
|
17
|
+
# Modality
|
|
18
|
+
if "T1w" in f:
|
|
19
|
+
tags.append("m:T1")
|
|
20
|
+
elif "T2w" in f:
|
|
21
|
+
tags.append("m:T2")
|
|
22
|
+
|
|
23
|
+
# Tags
|
|
24
|
+
if "brainmask" in f:
|
|
25
|
+
tags.append("mask")
|
|
26
|
+
elif "labels" in f:
|
|
27
|
+
tags.append("labels")
|
|
28
|
+
if "tissue_labels" in f:
|
|
29
|
+
tags.append("tissue_segmentation")
|
|
30
|
+
elif "biasfield" in f:
|
|
31
|
+
tags.append("bf")
|
|
32
|
+
elif "restore" in f:
|
|
33
|
+
tags.append("bfc")
|
|
34
|
+
elif "anat2" in f or "2anat" in f:
|
|
35
|
+
tags.append("warp")
|
|
36
|
+
|
|
37
|
+
tfiles.append((f, tags))
|
|
38
|
+
|
|
39
|
+
return tfiles
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def generate_file_path(original_name, sub_name, modality_name, cat_dir):
|
|
43
|
+
"""
|
|
44
|
+
Returns a valid BIDS name (full path) for the given file name.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
original_name : str
|
|
49
|
+
Original name of the file.
|
|
50
|
+
sub_name : str
|
|
51
|
+
QMENTA platform subject name (SUB-XXX_...).
|
|
52
|
+
modality_name : str
|
|
53
|
+
Modality string in the BIDS format (may be part of the name).
|
|
54
|
+
cat_dir : str
|
|
55
|
+
Path to the directory where the file will be stored.
|
|
56
|
+
|
|
57
|
+
Returns
|
|
58
|
+
-------
|
|
59
|
+
str
|
|
60
|
+
The full BIDS-compatible path where the file should be downloaded.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
# Try to detect if the file name is already in BIDS format
|
|
64
|
+
if original_name.startswith("sub-") and modality_name in original_name:
|
|
65
|
+
|
|
66
|
+
# Use the original name (replacing the subject ID)
|
|
67
|
+
target_name = "_".join([sub_name] + original_name.split("_")[1:])
|
|
68
|
+
|
|
69
|
+
else:
|
|
70
|
+
|
|
71
|
+
# Automatically generate a BIDS compatible name for the file
|
|
72
|
+
extension = "." + ".".join(original_name.split(".")[1:])
|
|
73
|
+
if extension in [".bval", ".bvec"]: # In BIDS, bval and bvec use dwi as modality
|
|
74
|
+
target_name = "{}_dwi{}".format(sub_name, extension)
|
|
75
|
+
else:
|
|
76
|
+
target_name = "{}_{}{}".format(sub_name, modality_name, extension)
|
|
77
|
+
|
|
78
|
+
return os.path.join(cat_dir, target_name)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def format_input_data(context, subject, session, path):
|
|
82
|
+
"""
|
|
83
|
+
Format input data followings the BIDS specification: http://bids.neuroimaging.io/bids_spec.pdf
|
|
84
|
+
|
|
85
|
+
:param context: AnalysisContext of the running analysis
|
|
86
|
+
:param subject: Subject name (from analysis_data)
|
|
87
|
+
:param session: Session ID (from analysis_data)
|
|
88
|
+
:param path: Destination directory where the files will be placed
|
|
89
|
+
|
|
90
|
+
TODO: support group analyses (multiple input containers).
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
logger = logging.getLogger(__name__)
|
|
94
|
+
|
|
95
|
+
dataset_description = {"Name": "QMENTA_ANALYSIS", "BIDSVersion": "1.1.0"}
|
|
96
|
+
|
|
97
|
+
# The input container specification must include file filters following the bids_ff_names nomenclature
|
|
98
|
+
bids_ff_names = {
|
|
99
|
+
"anat": [
|
|
100
|
+
"T1w",
|
|
101
|
+
"T2w",
|
|
102
|
+
"T1rho",
|
|
103
|
+
"T1map",
|
|
104
|
+
"T2map",
|
|
105
|
+
"T2star",
|
|
106
|
+
"FLAIR",
|
|
107
|
+
"FLASH",
|
|
108
|
+
"PD",
|
|
109
|
+
"PDmap",
|
|
110
|
+
"PDT2",
|
|
111
|
+
"inplaneT1",
|
|
112
|
+
"inplaneT2",
|
|
113
|
+
"angio",
|
|
114
|
+
],
|
|
115
|
+
"func": ["bold", "events", "physio", "sbref"],
|
|
116
|
+
"dwi": ["dwi", "bval", "bvec"],
|
|
117
|
+
"fmap": ["phasediff, magnitude1"],
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
supported_extensions = [".nii", ".nii.gz", ".bval", ".bvec", ".tsv"]
|
|
121
|
+
|
|
122
|
+
# BIDS spec does not allow underscores or dashes
|
|
123
|
+
sub_name = "sub-{}".format(subject.replace("-", "").replace("_", ""))
|
|
124
|
+
sub_path = os.path.join(path, sub_name)
|
|
125
|
+
mkdirs(sub_path)
|
|
126
|
+
|
|
127
|
+
with open(os.path.join(path, "dataset_description.json"), "w") as fp:
|
|
128
|
+
json.dump(dataset_description, fp)
|
|
129
|
+
|
|
130
|
+
for category in list(bids_ff_names.keys()):
|
|
131
|
+
for modality_name in bids_ff_names[category]:
|
|
132
|
+
try:
|
|
133
|
+
file_handlers = context.get_files("input", file_filter_condition_name="c_{}".format(modality_name))
|
|
134
|
+
except: # noqa: E731,E123,E722
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
for fh in file_handlers:
|
|
138
|
+
|
|
139
|
+
# NIFTI IS A MUST FOR BIDS
|
|
140
|
+
if any([fh.name.endswith(ext) for ext in supported_extensions]):
|
|
141
|
+
cat_dir = os.path.join(sub_path, category)
|
|
142
|
+
mkdirs(cat_dir)
|
|
143
|
+
|
|
144
|
+
target_path = generate_file_path(fh.name, subject, modality_name, cat_dir)
|
|
145
|
+
|
|
146
|
+
if os.path.isfile(target_path):
|
|
147
|
+
logger.warning(
|
|
148
|
+
"Multiple files with same name ({}). Only the first will be used".format(target_path)
|
|
149
|
+
)
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
with TemporaryDirectory() as tmp_dir:
|
|
153
|
+
download_path = fh.download(tmp_dir)
|
|
154
|
+
shutil.move(download_path, target_path)
|
|
155
|
+
logger.info("File: {}".format(target_path))
|
|
156
|
+
|
|
157
|
+
metadata = fh.get_file_info()
|
|
158
|
+
if metadata:
|
|
159
|
+
metadata_target_name = "{}_{}{}".format(sub_name, modality_name, ".json")
|
|
160
|
+
metadata_target_path = os.path.join(cat_dir, metadata_target_name)
|
|
161
|
+
with open(metadata_target_path, "w") as fp:
|
|
162
|
+
json.dump(metadata, fp)
|
|
163
|
+
else:
|
|
164
|
+
logger.warning("File {} extension not supported ({})".format(fh.name, supported_extensions))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def format_settings(settings):
|
|
168
|
+
# Bypass from JSON schema
|
|
169
|
+
# See docs.qmenta.com for more information
|
|
170
|
+
|
|
171
|
+
# FIXME: Better define inputs, for now, we'll skip common settings
|
|
172
|
+
ignore_param_list = ["input", "age_months"]
|
|
173
|
+
|
|
174
|
+
options = []
|
|
175
|
+
for param in settings:
|
|
176
|
+
if param not in ignore_param_list:
|
|
177
|
+
options += ["--" + param, str(settings[param])]
|
|
178
|
+
return options
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def run(context):
|
|
182
|
+
logger = logging.getLogger(__name__)
|
|
183
|
+
|
|
184
|
+
# Get information from the platform such as patient name, user_id, ssid...
|
|
185
|
+
analysis_data = context.fetch_analysis_data()
|
|
186
|
+
context.set_progress(message="Preparing data...")
|
|
187
|
+
|
|
188
|
+
# Create in/out dirs
|
|
189
|
+
input_path = "/analysis/input/"
|
|
190
|
+
output_path = "/analysis/output/"
|
|
191
|
+
mkdirs(input_path)
|
|
192
|
+
mkdirs(output_path)
|
|
193
|
+
|
|
194
|
+
# Download data from the platform and store following the BIDS specification
|
|
195
|
+
format_input_data(
|
|
196
|
+
context, subject=analysis_data["patient_secret_name"], session=analysis_data["ssid"], path=input_path
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Select type of analysis (TODO: add support for group analysis)
|
|
200
|
+
analysis_type = "participant"
|
|
201
|
+
|
|
202
|
+
# Prepare a list of arguments from the settings
|
|
203
|
+
options = format_settings(context.get_settings())
|
|
204
|
+
|
|
205
|
+
# Run the original BIDS-APP entrypoint
|
|
206
|
+
app_path = os.environ["BIDS_APP_ENTRYPOINT"]
|
|
207
|
+
context.set_progress(message="Running {}".format(os.path.basename(app_path)))
|
|
208
|
+
try:
|
|
209
|
+
args = [app_path, input_path, output_path, analysis_type] + options
|
|
210
|
+
logger.info("Calling: {}".format(" ".join(args)))
|
|
211
|
+
ret = check_output(args, stderr=STDOUT)
|
|
212
|
+
logger.info(ret)
|
|
213
|
+
except CalledProcessError as e:
|
|
214
|
+
raise RuntimeError("Program exited with error: {}\nOutput:\n{}".format(e, e.output))
|
|
215
|
+
except Exception:
|
|
216
|
+
raise
|
|
217
|
+
|
|
218
|
+
# Upload output files
|
|
219
|
+
context.set_progress(message="Saving output...")
|
|
220
|
+
output_files = []
|
|
221
|
+
for dirpath, _, filenames in os.walk(output_path):
|
|
222
|
+
for f in filenames:
|
|
223
|
+
file_path = os.path.abspath(os.path.join(dirpath, f))
|
|
224
|
+
output_files.append(file_path)
|
|
225
|
+
|
|
226
|
+
for f, tags in tag_files(output_files):
|
|
227
|
+
output_container_path = os.path.relpath(f, output_path)
|
|
228
|
+
context.upload_file(f, output_container_path, tags=tags)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import inspect
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
from qmenta.sdk.context import NoFilesError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AnalysisState(Enum):
|
|
11
|
+
RUNNING = "running"
|
|
12
|
+
COMPLETED = "completed"
|
|
13
|
+
EXCEPTION = "exception"
|
|
14
|
+
NO_FILES = "no_files"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ExecClient(object):
|
|
18
|
+
def __init__(self, analysis_id, comm, context, log_path):
|
|
19
|
+
self.__analysis_id = analysis_id
|
|
20
|
+
self.__comm = comm
|
|
21
|
+
self.__context = context
|
|
22
|
+
self.__log_path = log_path
|
|
23
|
+
|
|
24
|
+
def __call__(self, user_script_path):
|
|
25
|
+
"""
|
|
26
|
+
Run an analysis provided by the user
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
user_script_path : str
|
|
31
|
+
Should look like 'path.to.module:object', where 'object' should be an importable analysis tool class
|
|
32
|
+
or a function taking one 'context' argument.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
logger.info("Launching: {}".format(user_script_path))
|
|
37
|
+
|
|
38
|
+
analysis_state = None
|
|
39
|
+
try:
|
|
40
|
+
# noinspection PyTypeChecker
|
|
41
|
+
self.set_state(AnalysisState.RUNNING, log_path=self.__log_path)
|
|
42
|
+
class_or_func = load_user_analysis(user_script_path)
|
|
43
|
+
self.__context.fetch_analysis_data()
|
|
44
|
+
|
|
45
|
+
# run the analysis
|
|
46
|
+
# may allow classes to be used later
|
|
47
|
+
assert inspect.isfunction(class_or_func)
|
|
48
|
+
self.__context.set_progress(value=0, message="Running")
|
|
49
|
+
class_or_func(self.__context)
|
|
50
|
+
except NoFilesError:
|
|
51
|
+
logger.warning("No files found")
|
|
52
|
+
analysis_state = AnalysisState.NO_FILES
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.exception(e)
|
|
55
|
+
self.__context.set_progress(message="Error")
|
|
56
|
+
analysis_state = AnalysisState.EXCEPTION
|
|
57
|
+
raise e
|
|
58
|
+
else:
|
|
59
|
+
self.__context.set_progress(message="Completed", value=100)
|
|
60
|
+
analysis_state = AnalysisState.COMPLETED
|
|
61
|
+
finally:
|
|
62
|
+
self.upload_log()
|
|
63
|
+
# noinspection PyTypeChecker
|
|
64
|
+
self.set_state(analysis_state)
|
|
65
|
+
|
|
66
|
+
def set_state(self, state, **kwargs):
|
|
67
|
+
"""
|
|
68
|
+
Set analysis state
|
|
69
|
+
|
|
70
|
+
Parameters
|
|
71
|
+
----------
|
|
72
|
+
state : AnalysisState
|
|
73
|
+
One of AnalysisState.RUNNING, AnalysisState.COMPLETED, AnalysisState.EXCEPTION or AnalysisState.NO_FILES
|
|
74
|
+
kwargs : dict
|
|
75
|
+
"""
|
|
76
|
+
assert isinstance(state, AnalysisState), "'state' is {}, not an 'AnalysisState'".format(state)
|
|
77
|
+
data_to_send = {"analysis_id": self.__analysis_id, "state": state.value}
|
|
78
|
+
data_to_send.update(kwargs)
|
|
79
|
+
logging.getLogger(__name__).info("State = {state!r} for analysis {analysis_id}".format(**data_to_send))
|
|
80
|
+
self.__comm.send_request("analysis_manager/set_analysis_state", data_to_send)
|
|
81
|
+
|
|
82
|
+
def upload_log(self):
|
|
83
|
+
|
|
84
|
+
data_to_send = {"analysis_id": self.__analysis_id}
|
|
85
|
+
log_contents = ""
|
|
86
|
+
try:
|
|
87
|
+
with open(self.__log_path, "r") as log_file:
|
|
88
|
+
log_contents = log_file.read()
|
|
89
|
+
except IOError:
|
|
90
|
+
log_contents = "Log file error ({})".format(self.__log_path)
|
|
91
|
+
finally:
|
|
92
|
+
# Send file in one single request
|
|
93
|
+
# FIXME Implement chuncked upload
|
|
94
|
+
self.__comm.send_files(
|
|
95
|
+
"analysis_manager/upload_log", files={"file": ("exec.log", log_contents)}, req_data=data_to_send
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_user_analysis(user_script_path):
|
|
100
|
+
module_name, class_or_func_name = user_script_path.split(":")
|
|
101
|
+
user_module = importlib.import_module(module_name)
|
|
102
|
+
class_or_func = getattr(user_module, class_or_func_name)
|
|
103
|
+
return class_or_func
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import urllib.parse as urlparse
|
|
9
|
+
except ImportError:
|
|
10
|
+
import urlparse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CommunicationObject(object):
|
|
14
|
+
def __init__(self, url, project_id, token, timeout=900.0, chunk_size=256, verify=True, critical=False):
|
|
15
|
+
"""
|
|
16
|
+
Communications with the platform
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
url : str
|
|
21
|
+
Platform URL
|
|
22
|
+
project_id : int
|
|
23
|
+
Unique project identifier
|
|
24
|
+
token : str
|
|
25
|
+
Unique token that authenticates the communication
|
|
26
|
+
timeout : float or tuple, optional
|
|
27
|
+
How many seconds to wait for the server to send data before giving up, as a float, or a
|
|
28
|
+
(connect timeout, read timeout) tuple. Defaults to
|
|
29
|
+
chunk_size : int
|
|
30
|
+
Size of chunks in bytes (1MB = 1024KB, 1KB = 1024B)
|
|
31
|
+
verify : bool, optional
|
|
32
|
+
Controls whether the server's TLS certificate is verified.
|
|
33
|
+
Defaults to True.
|
|
34
|
+
critical: bool, optional
|
|
35
|
+
Overrides total_retry seting in requests by making themm fail after the first error if set.
|
|
36
|
+
"""
|
|
37
|
+
self.timeout = timeout
|
|
38
|
+
self.chunk_size = chunk_size
|
|
39
|
+
|
|
40
|
+
self.__req_url = url
|
|
41
|
+
self.__project_id = project_id
|
|
42
|
+
self.__token = token
|
|
43
|
+
self.__verify = verify
|
|
44
|
+
self.__critical = critical
|
|
45
|
+
|
|
46
|
+
self.__session = requests.Session()
|
|
47
|
+
|
|
48
|
+
def __enter__(self):
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
52
|
+
ret = self.send_request("logout")
|
|
53
|
+
if not ret["success"]:
|
|
54
|
+
# Not being able to logout is a security issue and must be a fatal error
|
|
55
|
+
raise RuntimeError("Unable to logout")
|
|
56
|
+
|
|
57
|
+
def send_request(
|
|
58
|
+
self,
|
|
59
|
+
access_point=None,
|
|
60
|
+
req_data=None,
|
|
61
|
+
req_headers=None,
|
|
62
|
+
return_json=True,
|
|
63
|
+
timeout=None,
|
|
64
|
+
total_retry=5,
|
|
65
|
+
stream=False,
|
|
66
|
+
url=None,
|
|
67
|
+
method="POST",
|
|
68
|
+
**kwargs
|
|
69
|
+
):
|
|
70
|
+
"""
|
|
71
|
+
Send a request to the platform to the given API endpoint.
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
access_point : str, optional
|
|
76
|
+
API endpoint. Will be joined with the request URL provided at construction time to create the
|
|
77
|
+
full ("absolute") url
|
|
78
|
+
req_data : dict or str, optional
|
|
79
|
+
Request data
|
|
80
|
+
req_headers : dict, optional
|
|
81
|
+
Request headers
|
|
82
|
+
return_json : bool, optional
|
|
83
|
+
Whether the response should be a JSON object or a requests.Response instance.
|
|
84
|
+
Defaults to True.
|
|
85
|
+
timeout : float, optional
|
|
86
|
+
Timeout in seconds for this request.
|
|
87
|
+
Defaults to the timeout set at construction time.
|
|
88
|
+
total_retry : int, optional
|
|
89
|
+
Number of connection retries.
|
|
90
|
+
Defaults to 5.
|
|
91
|
+
stream : bool, optional
|
|
92
|
+
If False, the response content will be immediately downloaded.
|
|
93
|
+
url : str, optional
|
|
94
|
+
When no access_point is provided, a full url must be provided through this argument
|
|
95
|
+
kwargs : dict
|
|
96
|
+
Additional arguments to be passed to `requests.Session.post`
|
|
97
|
+
method: str
|
|
98
|
+
HTTP verb (for instance POST or GET)
|
|
99
|
+
|
|
100
|
+
Returns
|
|
101
|
+
-------
|
|
102
|
+
requests.Response or dict
|
|
103
|
+
JSON response encoded in a dictionary if return_json is True (default),
|
|
104
|
+
otherwise requests.Response object.
|
|
105
|
+
"""
|
|
106
|
+
logger = logging.getLogger(__name__)
|
|
107
|
+
|
|
108
|
+
# Default parameters
|
|
109
|
+
req_data = req_data or dict()
|
|
110
|
+
req_headers = req_headers or dict()
|
|
111
|
+
timeout = timeout or self.timeout
|
|
112
|
+
|
|
113
|
+
if access_point:
|
|
114
|
+
# Add authentication for the platform
|
|
115
|
+
req_headers["Cookie"] = "AUTH_COOKIE={}; MINT_AP={}".format(self.__token, self.__project_id)
|
|
116
|
+
req_headers["Mint-Api-Call"] = "1"
|
|
117
|
+
url = urlparse.urljoin(self.__req_url, access_point)
|
|
118
|
+
|
|
119
|
+
retry_counter = 0
|
|
120
|
+
while True:
|
|
121
|
+
try:
|
|
122
|
+
response = self.__session.request(
|
|
123
|
+
method=method,
|
|
124
|
+
url=url,
|
|
125
|
+
data=req_data,
|
|
126
|
+
headers=req_headers,
|
|
127
|
+
timeout=timeout,
|
|
128
|
+
verify=self.__verify,
|
|
129
|
+
stream=stream,
|
|
130
|
+
**kwargs
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# log & raise error if we get a HTTP error code
|
|
134
|
+
if not response.ok:
|
|
135
|
+
logger.debug("{!r} HTTP response: {!r}".format(url, response.text))
|
|
136
|
+
response.raise_for_status()
|
|
137
|
+
|
|
138
|
+
except (requests.HTTPError, requests.Timeout, requests.ConnectionError):
|
|
139
|
+
retry_counter += 1
|
|
140
|
+
if self.__critical or total_retry == retry_counter:
|
|
141
|
+
raise
|
|
142
|
+
time.sleep(retry_counter * 5)
|
|
143
|
+
|
|
144
|
+
else:
|
|
145
|
+
return json.loads(response.text) if return_json else response
|
|
146
|
+
|
|
147
|
+
def send_files(
|
|
148
|
+
self,
|
|
149
|
+
access_point,
|
|
150
|
+
files,
|
|
151
|
+
req_data=None,
|
|
152
|
+
req_headers=None,
|
|
153
|
+
return_json=True,
|
|
154
|
+
timeout=None,
|
|
155
|
+
total_retry=5,
|
|
156
|
+
stream=False,
|
|
157
|
+
**kwargs
|
|
158
|
+
):
|
|
159
|
+
"""
|
|
160
|
+
Send a POST request to the platform to the given API endpoint.
|
|
161
|
+
|
|
162
|
+
Parameters
|
|
163
|
+
----------
|
|
164
|
+
access_point : str
|
|
165
|
+
API endpoint. Will be joined with the request URL provided at construction time to create the
|
|
166
|
+
full ("absolute") url
|
|
167
|
+
files : dict
|
|
168
|
+
Request files
|
|
169
|
+
req_data : dict or str, optional
|
|
170
|
+
Request data
|
|
171
|
+
req_headers : dict, optional
|
|
172
|
+
Request headers
|
|
173
|
+
return_json : bool, optional
|
|
174
|
+
Whether the response should be a JSON object or a requests.Response instance.
|
|
175
|
+
Defaults to True.
|
|
176
|
+
timeout : float, optional
|
|
177
|
+
Timeout in seconds for this request.
|
|
178
|
+
Defaults to the timeout set at construction time.
|
|
179
|
+
total_retry : int, optional
|
|
180
|
+
Number of connection retries.
|
|
181
|
+
Defaults to 5.
|
|
182
|
+
stream : bool, optional
|
|
183
|
+
If False, the response content will be immediately downloaded.
|
|
184
|
+
kwargs : dict
|
|
185
|
+
Additional arguments to be passed to `requests.Session.post`
|
|
186
|
+
|
|
187
|
+
Returns
|
|
188
|
+
-------
|
|
189
|
+
requests.Response or dict
|
|
190
|
+
JSON response encoded in a dictionary if return_json is True (default),
|
|
191
|
+
otherwise requests.Response object.
|
|
192
|
+
"""
|
|
193
|
+
logger = logging.getLogger(__name__)
|
|
194
|
+
|
|
195
|
+
# Default parameters
|
|
196
|
+
req_data = req_data or dict()
|
|
197
|
+
req_headers = req_headers or dict()
|
|
198
|
+
timeout = timeout or self.timeout
|
|
199
|
+
|
|
200
|
+
# Add authentication for the platform
|
|
201
|
+
req_headers["Cookie"] = "AUTH_COOKIE={}; MINT_AP={}".format(self.__token, self.__project_id)
|
|
202
|
+
req_headers["Mint-Api-Call"] = "1"
|
|
203
|
+
|
|
204
|
+
retry_counter = 0
|
|
205
|
+
while True:
|
|
206
|
+
try:
|
|
207
|
+
url = urlparse.urljoin(self.__req_url, access_point)
|
|
208
|
+
response = self.__session.post(
|
|
209
|
+
url,
|
|
210
|
+
files=files,
|
|
211
|
+
data=req_data,
|
|
212
|
+
headers=req_headers,
|
|
213
|
+
timeout=timeout,
|
|
214
|
+
verify=self.__verify,
|
|
215
|
+
stream=stream,
|
|
216
|
+
**kwargs
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Raise error if we get a HTTP error code
|
|
220
|
+
response.raise_for_status()
|
|
221
|
+
result = json.loads(response.text) if return_json else response
|
|
222
|
+
logger.info("{!r} HTTP response: {!r}".format(url, result))
|
|
223
|
+
return result
|
|
224
|
+
|
|
225
|
+
except (requests.HTTPError, requests.Timeout, requests.ConnectionError):
|
|
226
|
+
retry_counter += 1
|
|
227
|
+
if self.__critical or total_retry == retry_counter:
|
|
228
|
+
raise
|
|
229
|
+
time.sleep(retry_counter * 5)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class DummyCommunicationObject(object):
|
|
233
|
+
def __init__(self, url, chunk_size=1, mock_response=None):
|
|
234
|
+
self.__req_url = url
|
|
235
|
+
self.chunk_size = chunk_size
|
|
236
|
+
self.mock_response = mock_response
|
|
237
|
+
self.sent_contents = None
|
|
238
|
+
|
|
239
|
+
def send_request(
|
|
240
|
+
self,
|
|
241
|
+
access_point=None,
|
|
242
|
+
req_data=None,
|
|
243
|
+
req_headers=None,
|
|
244
|
+
return_json=True,
|
|
245
|
+
timeout=None,
|
|
246
|
+
total_retry=5,
|
|
247
|
+
stream=False,
|
|
248
|
+
url=None,
|
|
249
|
+
method="POST",
|
|
250
|
+
**kwargs
|
|
251
|
+
):
|
|
252
|
+
self.sent_contents = req_data
|
|
253
|
+
if stream and method == "GET":
|
|
254
|
+
return DummmyResponse()
|
|
255
|
+
elif self.mock_response is None or stream:
|
|
256
|
+
return DummmyResponse()
|
|
257
|
+
else:
|
|
258
|
+
return self.mock_response
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class DummmySession(object):
|
|
262
|
+
|
|
263
|
+
def get(self, url, stream=False):
|
|
264
|
+
if stream:
|
|
265
|
+
return DummmyResponse()
|
|
266
|
+
else:
|
|
267
|
+
return "{}"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class DummmyResponse(object):
|
|
271
|
+
|
|
272
|
+
def iter_content(self, *args, **kwargs):
|
|
273
|
+
return []
|