Skip to content

Commit a5ee48b

Browse files
committed
force_unique_key feature with docs, example | code improve | logging docs refactor
1 parent 8fe12d7 commit a5ee48b

File tree

8 files changed

+110
-74
lines changed

8 files changed

+110
-74
lines changed
Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
## Logging Configuration
1+
## Configuration
2+
3+
### POST Request Options
4+
5+
One can read [post-request-schema.json](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/post-request-schema.json)
6+
to see and understand the various _optional_ tweaks which can be done when making requests to the API.
7+
8+
There are many [example programs](Examples.md) with client requests given which demonstrate these different behaviours.
9+
10+
11+
### Logging Configuration
212

313
This extension logs messages of different severity `INFO`, `DEBUG`, `ERROR`
414
using the python's inbuilt [logging](https://docs.python.org/3/library/logging.html) module.

docs/source/index.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ more detailed :doc:`Examples` that shows different use-cases for this package.
3636

3737
Quickstart
3838
Examples
39-
Logging
39+
Configuration
4040

4141
API Reference
4242
-------------------------------

examples/basic.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
# make new request for a command with arguments
4242
uri = f"/cmd/{ENDPOINT_AND_CMD}"
4343
# timeout in seconds, default value is 3600
44-
data = {"args": ["hello", "world"], "timeout": 60}
44+
# force_unique_key disables rate-limiting
45+
data = {"args": ["hello", "world"], "timeout": 60, "force_unique_key": True}
4546
resp1 = c.post(uri, json=data).get_json()
4647
print(resp1)
4748
# fetch result

flask_shell2http/api.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@
1818
from flask_executor.futures import Future
1919

2020
# lib imports
21-
from .classes import RequestParser, run_command
21+
from .classes import RunnerParser
2222
from .helpers import get_logger
2323

2424

2525
logger = get_logger()
26-
request_parser = RequestParser()
26+
runner_parser = RunnerParser()
2727

2828

29-
class shell2httpAPI(MethodView):
29+
class Shell2HttpAPI(MethodView):
3030
"""
3131
Flask.MethodView that registers GET and POST methods for a given endpoint.
3232
This is invoked on `Shell2HTTP.register_command`.
@@ -75,16 +75,20 @@ def post(self):
7575
f"Requester: '{request.remote_addr}'."
7676
)
7777
# Check if request data is correct and parse it
78-
cmd, timeout, callback_context, key = request_parser.parse_req(
78+
cmd, timeout, callback_context, key = runner_parser.parse_req(
7979
request, self.command_name
8080
)
8181

8282
# run executor job in background
8383
future = self.executor.submit_stored(
84-
future_key=key, fn=run_command, cmd=cmd, timeout=timeout, key=key,
84+
future_key=key,
85+
fn=runner_parser.run_command,
86+
cmd=cmd,
87+
timeout=timeout,
88+
key=key,
8589
)
8690
# callback that removes the temporary directory
87-
future.add_done_callback(request_parser.cleanup_temp_dir)
91+
future.add_done_callback(runner_parser.cleanup_temp_dir)
8892
if self.user_callback_fn:
8993
# user defined callback fn with callback_context if any
9094
future.add_done_callback(

flask_shell2http/base_entrypoint.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from flask_executor.futures import Future
88

99
# lib imports
10-
from .api import shell2httpAPI
10+
from .api import Shell2HttpAPI
1111
from .helpers import get_logger
1212

1313

@@ -126,7 +126,7 @@ def my_callback_fn(context: dict, future: Future) -> None:
126126
return None
127127

128128
# else, add new URL rule
129-
view_func = shell2httpAPI.as_view(
129+
view_func = Shell2HttpAPI.as_view(
130130
endpoint,
131131
command_name=command_name,
132132
user_callback_fn=callback_fn,

flask_shell2http/classes.py

Lines changed: 64 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -12,67 +12,15 @@
1212
from flask_executor.futures import Future
1313

1414
# lib imports
15-
from .helpers import list_replace, calc_hash, get_logger, DEFAULT_TIMEOUT
15+
from .helpers import list_replace, gen_key, get_logger, DEFAULT_TIMEOUT
1616

1717
logger = get_logger()
1818

1919

20-
def run_command(cmd: List[str], timeout: int, key: str) -> Dict:
20+
class RunnerParser:
2121
"""
22-
This function is called by the executor to run given command
23-
using a subprocess asynchronously.
24-
25-
:param cmd: List[str]
26-
command to run split as a list
27-
:param key: str
28-
future_key of particular Future instance
29-
:param timeout: int
30-
maximum timeout in seconds (default = 3600)
31-
32-
:rtype: Dict
33-
34-
:returns:
35-
A Concurrent.Future object where future.result() is the report
36-
"""
37-
start_time: float = time.time()
38-
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,)
39-
try:
40-
outs, errs = proc.communicate(timeout=int(timeout))
41-
stdout = outs.decode("utf-8")
42-
stderr = errs.decode("utf-8")
43-
returncode = proc.returncode
44-
logger.info(f"Job: '{key}' --> finished with returncode: '{returncode}'.")
45-
46-
except subprocess.TimeoutExpired:
47-
proc.kill()
48-
stdout, _ = [s.decode("utf-8") for s in proc.communicate()]
49-
stderr = f"command timedout after {timeout} seconds."
50-
returncode = proc.returncode
51-
logger.error(f"Job: '{key}' --> failed. Reason: \"{stderr}\".")
52-
53-
except Exception as e:
54-
proc.kill()
55-
returncode = -1
56-
stdout = None
57-
stderr = str(e)
58-
logger.error(f"Job: '{key}' --> failed. Reason: \"{stderr}\".")
59-
60-
end_time: float = time.time()
61-
process_time = end_time - start_time
62-
return dict(
63-
key=key,
64-
report=stdout,
65-
error=stderr,
66-
returncode=returncode,
67-
start_time=start_time,
68-
end_time=end_time,
69-
process_time=process_time,
70-
)
71-
72-
73-
class RequestParser:
74-
"""
75-
Utility class to parse incoming POST request data into meaningful arguments.
22+
Utility class to parse incoming POST request
23+
data into meaningful arguments, generate key and run command.
7624
Internal use Only.
7725
"""
7826

@@ -112,28 +60,30 @@ def __parse_multipart_req(args: List[str], files) -> (List[str], str):
11260
def parse_req(self, request, base_command: str) -> (str, int, Dict, str):
11361
# default values if request is w/o any data
11462
# i.e. just run-script
63+
tmpdir = None
64+
# default values
11565
args: List[str] = []
11666
timeout: int = DEFAULT_TIMEOUT
117-
tmpdir = None
11867
callback_context = {}
68+
randomize_key = False
11969
if request.is_json:
12070
# request does not contain a file
12171
args = request.json.get("args", [])
12272
timeout: int = request.json.get("timeout", DEFAULT_TIMEOUT)
12373
callback_context = request.json.get("callback_context", {})
74+
randomize_key = request.json.get("force_unique_key", False)
12475
elif request.files:
12576
# request contains file and form_data
12677
data = json.loads(request.form.get("request_json", "{}"))
12778
received_args = data.get("args", [])
12879
timeout: int = data.get("timeout", DEFAULT_TIMEOUT)
12980
callback_context = data.get("callback_context", {})
130-
args, tmpdir = RequestParser.__parse_multipart_req(
131-
received_args, request.files
132-
)
81+
randomize_key = data.get("force_unique_key", False)
82+
args, tmpdir = self.__parse_multipart_req(received_args, request.files)
13383

13484
cmd: List[str] = base_command.split(" ")
13585
cmd.extend(args)
136-
key: str = calc_hash(cmd)
86+
key: str = gen_key(cmd, randomize=randomize_key)
13787
if tmpdir:
13888
self.__tmpdirs.update({key: tmpdir})
13989

@@ -158,3 +108,56 @@ def cleanup_temp_dir(self, future: Future) -> None:
158108
logger.debug(
159109
f"Job: '{key}' --> Failed to clear Temporary directory: '{tmpdir}'."
160110
)
111+
112+
@staticmethod
113+
def run_command(cmd: List[str], timeout: int, key: str) -> Dict:
114+
"""
115+
This function is called by the executor to run given command
116+
using a subprocess asynchronously.
117+
118+
:param cmd: List[str]
119+
command to run split as a list
120+
:param key: str
121+
future_key of particular Future instance
122+
:param timeout: int
123+
maximum timeout in seconds (default = 3600)
124+
125+
:rtype: Dict
126+
127+
:returns:
128+
A Concurrent.Future object where future.result() is the report
129+
"""
130+
start_time: float = time.time()
131+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,)
132+
try:
133+
outs, errs = proc.communicate(timeout=int(timeout))
134+
stdout = outs.decode("utf-8")
135+
stderr = errs.decode("utf-8")
136+
returncode = proc.returncode
137+
logger.info(f"Job: '{key}' --> finished with returncode: '{returncode}'.")
138+
139+
except subprocess.TimeoutExpired:
140+
proc.kill()
141+
stdout, _ = [s.decode("utf-8") for s in proc.communicate()]
142+
stderr = f"command timedout after {timeout} seconds."
143+
returncode = proc.returncode
144+
logger.error(f"Job: '{key}' --> failed. Reason: \"{stderr}\".")
145+
146+
except Exception as e:
147+
proc.kill()
148+
returncode = -1
149+
stdout = None
150+
stderr = str(e)
151+
logger.error(f"Job: '{key}' --> failed. Reason: \"{stderr}\".")
152+
153+
end_time: float = time.time()
154+
process_time = end_time - start_time
155+
return dict(
156+
key=key,
157+
report=stdout,
158+
error=stderr,
159+
returncode=returncode,
160+
start_time=start_time,
161+
end_time=end_time,
162+
process_time=process_time,
163+
)

flask_shell2http/helpers.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import logging
2+
import hashlib
3+
import uuid
24
from typing import List
35

6+
from flask import current_app
7+
48
DEFAULT_TIMEOUT = 3600
59

610

@@ -17,15 +21,22 @@ def list_replace(lst: List, old, new) -> None:
1721
pass
1822

1923

24+
def gen_key(lst: List, randomize=False) -> str:
25+
if randomize:
26+
return str(uuid.uuid4())[:8]
27+
return calc_hash(lst)[:8]
28+
29+
2030
def calc_hash(lst: List) -> str:
2131
"""
2232
Internal use only.
2333
Calculates sha1sum of given command with it's byte-string.
2434
This is for non-cryptographic purpose,
2535
that's why a faster and insecure hashing algorithm is chosen.
2636
"""
27-
to_hash = " ".join(lst).encode("ascii")
28-
return __import__("hashlib").sha1(to_hash).hexdigest()[:8]
37+
current_app.config.get("Shell2HTTP_")
38+
to_hash = " ".join(lst).encode("utf-8")
39+
return hashlib.sha1(to_hash).hexdigest()
2940

3041

3142
def get_logger() -> logging.Logger:

post-request-schema.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
{
88
"args": ["hello", "world"],
99
"timeout": 60,
10+
"force_unique_key": true,
1011
"callback_context": {
1112
"read_result_from": "/path/to/saved/file",
1213
"force_success": true
@@ -30,6 +31,12 @@
3031
"description": "Maximum timeout after which subprocess fails if not already complete.",
3132
"default": 3600
3233
},
34+
"force_unique_key": {
35+
"type": "boolean",
36+
"title": "Flag to enable/disable internal rate limiting mechanism",
37+
"description": "By default, the key is the SHA1 sum of the command + args POSTed to the API. This is done as a rate limiting measure so as to prevent multiple jobs with same parameters, if one such job is already running. If force_unique_key is set to true, the API will bypass this default behaviour and a psuedorandom key will be returned instead",
38+
"default": false
39+
},
3340
"callback_context": {
3441
"type": "object",
3542
"title": "Additional context for user defined callback function",

0 commit comments

Comments
 (0)