/
cmd_exec.py
416 lines (344 loc) · 14.5 KB
/
cmd_exec.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
import os
import shutil
import subprocess
import sys
import tempfile
import traceback
from pathlib import Path
from typing import List
from flask import Response, request
from werkzeug.utils import secure_filename
from archivy import click_web
from .input_fields import FieldId
logger = None
def exec(command_path):
"""
Execute the command and stream the output from it as response
:param command_path:
"""
command_path = "cli/" + command_path
global logger
logger = click_web.logger
omitted = ["shell", "run", "routes", "create-admin"]
root_command, *commands = command_path.split("/")
cmd = ["archivy"]
req_to_args = RequestToCommandArgs()
# root command_index should not add a command
cmd.extend(req_to_args.command_args(0))
for i, command in enumerate(commands):
if command in omitted:
return Response(status=400)
cmd.append(command)
cmd.extend(req_to_args.command_args(i + 1))
def _generate_output():
yield _create_cmd_header(commands)
try:
yield from _run_script_and_generate_stream(req_to_args, cmd)
except Exception as e:
# exited prematurely, show the error to user
yield f"\nERROR: Got exception when reading output from script: {type(e)}\n"
yield traceback.format_exc()
raise
return Response(_generate_output(), mimetype="text/plain")
def _run_script_and_generate_stream(
req_to_args: "RequestToCommandArgs", cmd: List[str]
):
"""
Execute the command the via Popen and yield output
"""
logger.info("Executing archivy command")
if not os.environ.get("PYTHONIOENCODING"):
# Fix unicode on windows
os.environ["PYTHONIOENCODING"] = "UTF-8"
process = subprocess.Popen(
cmd, shell=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
)
logger.info("script running Pid: %d", process.pid)
encoding = sys.getdefaultencoding()
with process.stdout:
for line in iter(process.stdout.readline, b""):
yield line.decode(encoding)
process.wait() # wait for the subprocess to exit
logger.info("script finished Pid: %d", process.pid)
for fi in req_to_args.field_infos:
fi.after_script_executed()
def _create_cmd_header(commands: List[str]):
"""
Generate a command header.
Note:
here we always allow to generate HTML as long as we have it between CLICK-WEB comments.
This way the JS frontend can insert it in the correct place in the DOM.
"""
def generate():
yield "<!-- CLICK_WEB START HEADER -->"
yield '<div class="command-line">Executing: {}</div>'.format("/".join(commands))
yield "<!-- CLICK_WEB END HEADER -->"
# important yield this block as one string so it pushed to client in one go.
# so the whole block can be treated as html.
html_str = "\n".join(generate())
return html_str
def _create_result_footer(req_to_args: "RequestToCommandArgs"):
"""
Generate a footer.
Note:
here we always allow to generate HTML as long as we have it between CLICK-WEB comments.
This way the JS frontend can insert it in the correct place in the DOM.
"""
to_download = [
fi
for fi in req_to_args.field_infos
if fi.generate_download_link and fi.link_name
]
# important yield this block as one string so it pushed to client in one go.
# This is so the whole block can be treated as html if JS frontend.
lines = []
lines.append("<!-- CLICK_WEB START FOOTER -->")
if to_download:
lines.append("<b>Result files:</b><br>")
for fi in to_download:
lines.append("<ul> ")
lines.append(f"<li>{_get_download_link(fi)}<br>")
lines.append("</ul>")
else:
lines.append("<b>DONE</b>")
lines.append("<!-- CLICK_WEB END FOOTER -->")
html_str = "\n".join(lines)
yield html_str
def _get_download_link(field_info):
"""Hack as url_for need request context"""
rel_file_path = Path(field_info.file_path).relative_to(click_web.OUTPUT_FOLDER)
uri = f"/static/results/{rel_file_path.as_posix()}"
return f'<a href="{uri}">{field_info.link_name}</a>'
class RequestToCommandArgs:
def __init__(self):
keys = [key for key in list(request.form.keys()) + list(request.files.keys())]
field_infos = [FieldInfo.factory(key) for key in keys if key != "csrf_token"]
# important to sort them so they will be in expected order on command line
self.field_infos = list(sorted(field_infos))
def command_args(self, command_index) -> List[str]:
"""
Convert the post request into a list of command line arguments
:param command_index: (int) the index for the command to get arguments for.
:return: list of command line arguments for command at that cmd_index
"""
args = []
# only include relevant fields for this command index
commands_field_infos = [
fi for fi in self.field_infos if fi.param.command_index == command_index
]
commands_field_infos = sorted(commands_field_infos)
for fi in commands_field_infos:
# must be called mostly for saving and preparing file output.
fi.before_script_execute()
if fi.cmd_opt.startswith("--"):
# it's an option
args.extend(self._process_option(fi))
else:
# argument(s)
if isinstance(fi, FieldFileInfo):
# it's a file, append the written temp file path
# TODO: does file upload support multiple keys? In that case support it.
args.append(fi.file_path)
else:
arg_values = request.form.getlist(fi.key)
has_values = bool("".join(arg_values))
if has_values:
if fi.param.nargs == -1:
# Variadic argument, in html form each argument
# is a separate line in a textarea.
# treat each line we get from text area as a separate argument.
for value in arg_values:
values = value.splitlines()
logger.info(
f'variadic arguments, split into: "{values}"'
)
args.extend(values)
else:
logger.info(f'arg_value: "{arg_values}"')
args.extend(arg_values)
return args
def _process_option(self, field_info):
vals = request.form.getlist(field_info.key)
if field_info.is_file:
if field_info.link_name:
# it's a file, append the file path
yield field_info.cmd_opt
yield field_info.file_path
elif field_info.param.param_type == "flag":
# To work with flag that is default True
# a hidden field with same name is also sent by form.
# This is to detect if checkbox was not checked as then
# we will get the field anyway with the "off flag" as value.
if len(vals) == 1:
off_flag = vals[0]
flag_on_cmd_line = off_flag
else:
# we got both off and on flags, checkbox is checked.
on_flag = vals[1]
flag_on_cmd_line = on_flag
yield flag_on_cmd_line
elif "".join(vals):
# opt with value, if option was given multiple times get the values for each.
# flag options should always be set if we get them
# for normal options they must have a non empty value
yield field_info.cmd_opt
for val in vals:
if val:
yield val
else:
# option with empty values, should not be added to command line.
pass
class FieldInfo:
"""
Extract information from the encoded form input field name
the parts:
[command_index].[opt_or_arg_index].[click_type].[html_input_type].[opt_or_arg_name]
e.g.
"0.0.option.text.text.--an-option"
"0.1.argument.file[rb].text.an-argument"
"""
@staticmethod
def factory(key):
field_id = FieldId.from_string(key)
is_file = field_id.click_type.startswith("file")
is_path = field_id.click_type.startswith("path")
is_uploaded = key in request.files
if is_file:
if is_uploaded:
field_info = FieldFileInfo(field_id)
else:
field_info = FieldOutFileInfo(field_id)
elif is_path:
if is_uploaded:
field_info = FieldPathInfo(field_id)
else:
field_info = FieldPathOutInfo(field_id)
else:
field_info = FieldInfo(field_id)
return field_info
def __init__(self, param: FieldId):
self.param = param
self.key = param.key
"Type of option (file, text)"
self.is_file = self.param.click_type.startswith("file")
"The actual command line option (--debug)"
self.cmd_opt = param.name
self.generate_download_link = False
def before_script_execute(self):
pass
def after_script_executed(self):
pass
def __str__(self):
return str(self.param)
def __lt__(self, other):
"Make class sortable"
return (self.param.command_index, self.param.param_index) < (
other.param.command_index,
other.param.param_index,
)
def __eq__(self, other):
return self.key == other.key
class FieldFileInfo(FieldInfo):
"""
Use for processing input fields of file type.
Saves the posted data to a temp file.
"""
"temp dir is on class in order to be uniqe for each request"
_temp_dir = None
def __init__(self, fimeta):
super().__init__(fimeta)
# Extract the file mode that is in the type e.g file[rw]
self.mode = self.param.click_type.split("[")[1][:-1]
self.generate_download_link = True if "w" in self.mode else False
self.link_name = f"{self.cmd_opt}.out"
logger.info(f"File mode for {self.key} is {self.mode}")
def before_script_execute(self):
self.save()
@classmethod
def temp_dir(cls):
if not cls._temp_dir:
cls._temp_dir = tempfile.mkdtemp(dir=click_web.OUTPUT_FOLDER)
logger.info(f"Temp dir: {cls._temp_dir}")
return cls._temp_dir
def save(self):
logger.info("Saving...")
logger.info("field value is a file! %s", self.key)
file = request.files[self.key]
# if user does not select file, browser also
# submit a empty part without filename
if file.filename == "":
raise ValueError("No selected file")
elif file and file.filename:
filename = secure_filename(file.filename)
name, suffix = os.path.splitext(filename)
fd, filename = tempfile.mkstemp(
dir=self.temp_dir(), prefix=name, suffix=suffix
)
self.file_path = filename
logger.info(f"Saving {self.key} to {filename}")
file.save(filename)
def __str__(self):
res = [super().__str__()]
res.append(f"file_path: {self.file_path}")
return ", ".join(res)
class FieldOutFileInfo(FieldFileInfo):
"""
Used when file option is just for output and form posted it as hidden or text field.
Just create a empty temp file to give it's path to command.
"""
def __init__(self, fimeta):
super().__init__(fimeta)
if self.param.form_type == "text":
self.link_name = request.form[self.key]
# set the postfix to name name provided from form
# this way it will at least have the same extension when downloaded
self.file_suffix = request.form[self.key]
else:
# hidden no preferred file name can be provided by user
self.file_suffix = ".out"
def save(self):
name = secure_filename(self.key)
filename = tempfile.mkstemp(
dir=self.temp_dir(), prefix=name, suffix=self.file_suffix
)
logger.info(f"Creating empty file for {self.key} as {filename}")
self.file_path = filename
class FieldPathInfo(FieldFileInfo):
"""
Use for processing input fields of path type.
Extracts the posted data to a temp folder.
When script finished zip that folder and provide download link to zip file.
"""
def save(self):
super().save()
zip_extract_dir = tempfile.mkdtemp(dir=self.temp_dir())
logger.info(f"Extracting: {self.file_path} to {zip_extract_dir}")
shutil.unpack_archive(self.file_path, zip_extract_dir, "zip")
self.file_path = zip_extract_dir
def after_script_executed(self):
super().after_script_executed()
fd, filename = tempfile.mkstemp(dir=self.temp_dir(), prefix=self.key)
folder_path = self.file_path
self.file_path = filename
logger.info(f"Zipping {self.key} to {filename}")
self.file_path = shutil.make_archive(self.file_path, "zip", folder_path)
logger.info(f"Zip file created {self.file_path}")
self.generate_download_link = True
class FieldPathOutInfo(FieldOutFileInfo):
"""
Use for processing output fields of path type.
Create a folder and use as path to script.
When script finished zip that folder and provide download link to zip file.
"""
def save(self):
super().save()
self.file_path = tempfile.mkdtemp(dir=self.temp_dir())
def after_script_executed(self):
super().after_script_executed()
fd, filename = tempfile.mkstemp(dir=self.temp_dir(), prefix=self.key)
folder_path = self.file_path
self.file_path = filename
logger.info(f"Zipping {self.key} to {filename}")
self.file_path = shutil.make_archive(self.file_path, "zip", folder_path)
logger.info(f"Zip file created {self.file_path}")
self.generate_download_link = True