Skip to content

Commit 006b532

Browse files
committed
Better Logging, memory management | added result_url to response
1 parent 3892d74 commit 006b532

File tree

8 files changed

+110
-76
lines changed

8 files changed

+110
-76
lines changed

docs/source/Logging.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ Here's a snippet of code that shows how you can access this extension's logger o
1212
import logging
1313
# get the flask_shell2http logger
1414
logger = logging.getLogger("flask_shell2http")
15-
# log messages of severity DEBUG or lower to the console
15+
# create new handler
1616
handler = logging.StreamHandler(sys.stdout)
1717
logger.addHandler(handler)
18-
logger.setLevel(logging.DEBUG)
18+
# log messages of severity DEBUG or lower to the console
19+
logger.setLevel(logging.DEBUG) # this is really important!
1920
```
2021

2122
Please consult the Flask's official docs on

docs/source/Quickstart.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,16 @@ returns JSON,
5656

5757
```json
5858
{
59-
"key": "ddbe0a94847c65f9b8198424ffd07c50",
59+
"key": "ddbe0a94847c",
60+
"result_url": "http://localhost:4000/commands/saythis?key=ddbe0a94847c",
6061
"status": "running"
6162
}
6263
```
6364

64-
Then using this `key` you can query for the result,
65+
Then using this `key` you can query for the result or just by going to the `result_url`,
6566

6667
```bash
67-
$ curl http://localhost:4000/commands/saythis?key=ddbe0a94847c65f9b8198424ffd07c50
68+
$ curl http://localhost:4000/commands/saythis?key=ddbe0a94847c
6869
```
6970

7071
Returns result in JSON,
@@ -73,7 +74,7 @@ Returns result in JSON,
7374
{
7475
"end_time": 1593019807.782958,
7576
"error": "",
76-
"md5": "ddbe0a94847c65f9b8198424ffd07c50",
77+
"md5": "ddbe0a94847c",
7778
"process_time": 0.00748753547668457,
7879
"report": {
7980
"result": "Hello World!\n"

docs/source/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
author = "Eshaan Bansal"
2727

2828
# The full version, including alpha/beta/rc tags
29-
release = "1.1.3"
29+
release = "1.2.0"
3030

3131

3232
# -- General configuration ---------------------------------------------------

flask_shell2http/api.py

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,76 +19,81 @@
1919

2020

2121
logger = get_logger()
22+
store = ReportStore()
23+
request_parser = RequestParser()
2224

2325

2426
class shell2httpAPI(MethodView):
2527
"""
26-
Flask.MethodView that defines GET and POST methods for an URL rule.
27-
This is invoked on `Shell2HTTP.register_command`, in which case
28-
the URL rule is the given endpoint.
28+
Flask.MethodView that registers GET and POST methods for a given endpoint.
29+
This is invoked on `Shell2HTTP.register_command`.
2930
Internal use only.
3031
"""
3132

32-
command_name: str
33-
executor: JobExecutor
34-
store: ReportStore
35-
request_parser: RequestParser
36-
3733
def get(self):
3834
try:
3935
key: str = request.args.get("key")
40-
logger.info(f"Report requested for key:'{key}'.")
36+
logger.info(
37+
f"Job: '{key}' --> Report requested. "
38+
f"Requester: '{request.remote_addr}'."
39+
)
4140
if not key:
4241
raise Exception("No key provided in arguments.")
4342
# check if job has been finished
4443
future = self.executor.get_job(key)
4544
if future:
4645
if not future.done:
46+
logger.debug(f"Job: '{key}' --> still running.")
4747
return make_response(jsonify(status="running", key=key), 200)
4848

4949
# pop future object since it has been finished
5050
self.executor.pop_job(key)
5151

5252
# if yes, get result from store
53-
report = self.store.get_one(key)
53+
report = store.pop_and_get_one(key)
5454
if not report:
55-
raise Exception(f"Report does not exist for key:{key}.")
55+
raise Exception(f"No report exists for key: '{key}'.")
5656

5757
resp = report.to_dict()
58-
logger.debug(f"Requested report: {resp}")
58+
logger.debug(f"Job: '{key}' --> Requested report: {resp}")
5959
return make_response(jsonify(resp), HTTPStatus.OK)
6060

6161
except Exception as e:
62-
logger.exception(e)
62+
logger.error(e)
6363
return make_response(jsonify(error=str(e)), HTTPStatus.NOT_FOUND)
6464

6565
def post(self):
6666
try:
67-
logger.info(f"Received request for endpoint: '{request.url_rule}'.")
67+
logger.info(
68+
f"Received request for endpoint: '{request.url_rule}'. "
69+
f"Requester: '{request.remote_addr}'."
70+
)
6871
# Check if command is correct and parse it
69-
cmd, key = self.request_parser.parse_req(request)
72+
cmd, key = request_parser.parse_req(request, self.command_name)
7073

7174
# run executor job in background
72-
job_key = JobExecutor.make_key(key)
7375
future = self.executor.new_job(
74-
future_key=job_key, fn=self.executor.run_command, cmd=cmd, key=key
76+
future_key=JobExecutor.make_key(key),
77+
fn=self.executor.run_command,
78+
cmd=cmd,
79+
key=key,
7580
)
7681
# callback that adds result to store
77-
future.add_done_callback(self.store.save_result)
82+
future.add_done_callback(store.save_result)
7883
# callback that removes the temporary directory
79-
future.add_done_callback(self.request_parser.cleanup_temp_dir)
84+
future.add_done_callback(request_parser.cleanup_temp_dir)
8085

81-
logger.info(f"Job: '{job_key}' added to queue for command: {cmd}")
86+
logger.info(f"Job: '{key}' --> added to queue for command: {cmd}")
87+
result_url = f"{request.base_url}?key={key}"
8288
return make_response(
83-
jsonify(status="running", key=key), HTTPStatus.ACCEPTED,
89+
jsonify(status="running", key=key, result_url=result_url),
90+
HTTPStatus.ACCEPTED,
8491
)
8592

8693
except Exception as e:
87-
logger.exception(e)
94+
logger.error(e)
8895
return make_response(jsonify(error=str(e)), HTTPStatus.BAD_REQUEST)
8996

90-
def __init__(self, command_name, executor):
91-
self.command_name = command_name
92-
self.executor = JobExecutor(executor)
93-
self.store = ReportStore()
94-
self.request_parser = RequestParser(command_name)
97+
def __init__(self, command_name, job_executor):
98+
self.command_name: str = command_name
99+
self.executor: JobExecutor = job_executor

flask_shell2http/base_entrypoint.py

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from collections import OrderedDict
33

44
# lib imports
5+
from .classes import JobExecutor
56
from .api import shell2httpAPI
67
from .helpers import get_logger
78

@@ -49,7 +50,7 @@ def init_app(self, app, executor) -> None:
4950
https://flask.palletsprojects.com/en/1.1.x/patterns/appfactories/
5051
"""
5152
self.app = app
52-
self.__executor = executor
53+
self.__executor = JobExecutor(executor)
5354
self.__init_extension()
5455

5556
def __init_extension(self) -> None:
@@ -84,26 +85,33 @@ def register_command(self, endpoint: str, command_name: str) -> None:
8485
endpoint="myawesomescript", command_name="./fuxsocy.py"
8586
)
8687
"""
87-
if not self.__commands.get(endpoint):
88-
url = self.__construct_route(endpoint)
89-
self.app.add_url_rule(
90-
url,
91-
view_func=shell2httpAPI.as_view(
92-
command_name, command_name=command_name, executor=self.__executor
93-
),
88+
uri = self.__construct_route(endpoint)
89+
# make sure the given endpoint is not already registered
90+
cmd_already_exists = self.__commands.get(uri)
91+
if cmd_already_exists:
92+
logger.error(
93+
"Failed to register since given endpoint: "
94+
f"'{endpoint}' already maps to command: '{cmd_already_exists}'."
9495
)
95-
self.__commands.update({command_name: url})
96-
logger.info(
97-
f"New endpoint: '{endpoint}' registered for command: '{command_name}'."
98-
)
99-
100-
def get_registered_commands(self):
96+
return None
97+
98+
# else, add new URL rule
99+
self.app.add_url_rule(
100+
uri,
101+
view_func=shell2httpAPI.as_view(
102+
command_name, command_name=command_name, job_executor=self.__executor,
103+
),
104+
)
105+
self.__commands.update({uri: command_name})
106+
logger.info(f"New URI: '{uri}' registered for command: '{command_name}'.")
107+
108+
def get_registered_commands(self) -> "OrderedDict[str, str]":
101109
"""
102110
Most of the time you won't need this since
103111
Flask provides a ``Flask.url_map`` attribute.
104112
105113
Returns:
106-
OrderedDict i.e. mapping of registered commands and their URLs.
114+
OrderedDict[uri, command] i.e. mapping of registered commands and their URLs.
107115
"""
108116
return self.__commands
109117

flask_shell2http/classes.py

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import time
33
import subprocess
44
import tempfile
5+
import shutil
56
from collections import OrderedDict
7+
from typing import List, Dict
68

79
# web imports
810
from flask import safe_join
@@ -74,7 +76,6 @@ def run_command(self, cmd, key) -> Report:
7476
:returns:
7577
A ConcurrentFuture object where future.result = Report()
7678
"""
77-
job_key: str = self.make_key(key)
7879
start_time = time.time()
7980
try:
8081
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
@@ -88,7 +89,7 @@ def run_command(self, cmd, key) -> Report:
8889
else:
8990
status = "failed"
9091

91-
logger.info(f"Job: '{job_key}' finished with status: '{status}'.")
92+
logger.info(f"Job: '{key}' --> finished with status: '{status}'.")
9293
return Report(
9394
key=key,
9495
report=stdout,
@@ -100,7 +101,7 @@ def run_command(self, cmd, key) -> Report:
100101
except Exception as e:
101102
str_err = str(e)
102103
self.cancel_job(key)
103-
logger.error(f"Job: '{job_key}' failed. Reason: {str_err}.")
104+
logger.error(f"Job: '{key}' --> failed. Reason: {str_err}.")
104105
return Report(
105106
key=key,
106107
report=None,
@@ -128,8 +129,11 @@ def save_result(self, future) -> None:
128129
def get_all(self):
129130
return self.__results
130131

131-
def get_one(self, key) -> Report:
132-
return self.__results.get(key)
132+
def pop_and_get_one(self, key) -> Report:
133+
try:
134+
return self.__results.pop(key)
135+
except KeyError:
136+
return None
133137

134138

135139
class RequestParser:
@@ -138,8 +142,10 @@ class RequestParser:
138142
Internal use Only.
139143
"""
140144

145+
__tmpdirs: Dict[str, str] = {}
146+
141147
@staticmethod
142-
def __parse_multipart_req(args: list, files):
148+
def __parse_multipart_req(args: List[str], files) -> (List[str], str):
143149
# Check if file part exists
144150
fnames = []
145151
for arg in args:
@@ -154,7 +160,7 @@ def __parse_multipart_req(args: list, files):
154160
)
155161

156162
# create a new temporary directory
157-
tmpdir = tempfile.mkdtemp()
163+
tmpdir: str = tempfile.mkdtemp()
158164
for fname in fnames:
159165
if fname not in files:
160166
raise Exception(
@@ -173,33 +179,45 @@ def __parse_multipart_req(args: list, files):
173179
logger.debug(f"Request files saved under temp directory: '{tmpdir}'")
174180
return args, tmpdir
175181

176-
def parse_req(self, request) -> (str, str):
182+
def parse_req(self, request, base_command: str) -> (str, str):
183+
args: List[str] = []
184+
tmpdir = None
177185
if request.is_json:
178186
# request does not contain a file
179187
args = request.json.get("args", [])
180188
elif request.files:
181189
# request contains file
182190
received_args = request.form.getlist("args")
183-
args, self.tmpdir = RequestParser.__parse_multipart_req(
191+
args, tmpdir = RequestParser.__parse_multipart_req(
184192
received_args, request.files
185193
)
186194
else:
187195
# request is w/o any data
188196
# i.e. just run-script
189197
args = []
190198

191-
cmd: list = self.command_name.split(" ")
199+
cmd: List[str] = base_command.split(" ")
192200
cmd.extend(args)
193-
return cmd, calc_hash(cmd)
194-
195-
def cleanup_temp_dir(self, _) -> None:
196-
if hasattr(self, "tmpdir"):
197-
try:
198-
__import__("shutil").rmtree(self.tmpdir)
199-
logger.debug(f"temp directory: '{self.tmpdir}' was deleted.")
200-
del self.tmpdir
201-
except Exception:
202-
logger.debug(f"Failed to clear temp directory: '{self.tmpdir}'.")
203-
204-
def __init__(self, command_name) -> None:
205-
self.command_name = command_name
201+
key: str = calc_hash(cmd)
202+
if tmpdir:
203+
self.__tmpdirs.update({key: tmpdir})
204+
205+
return cmd, key
206+
207+
def cleanup_temp_dir(self, future) -> None:
208+
key: str = future.result().key
209+
tmpdir: str = self.__tmpdirs.get(key, None)
210+
if not tmpdir:
211+
return None
212+
213+
try:
214+
shutil.rmtree(tmpdir)
215+
logger.debug(
216+
f"Job: '{key}' --> Temporary directory: '{tmpdir}' "
217+
"successfully deleted."
218+
)
219+
self.__tmpdirs.pop(key)
220+
except Exception:
221+
logger.debug(
222+
f"Job: '{key}' --> Failed to clear Temporary directory: '{tmpdir}'."
223+
)

flask_shell2http/helpers.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import logging
2+
from typing import List
23

34

4-
def list_replace(lst: list, old, new) -> None:
5+
def list_replace(lst: List, old, new) -> None:
56
"""
67
replace list elements (inplace)
78
"""
@@ -14,15 +15,15 @@ def list_replace(lst: list, old, new) -> None:
1415
pass
1516

1617

17-
def calc_hash(lst: list) -> str:
18+
def calc_hash(lst: List) -> str:
1819
"""
1920
Internal use only.
2021
Calculates sha1sum of given command with it's byte-string.
2122
This is for non-cryptographic purpose,
2223
that's why a faster and insecure hashing algorithm is chosen.
2324
"""
2425
to_hash = " ".join(lst).encode("ascii")
25-
return __import__("hashlib").sha1(to_hash).hexdigest()
26+
return __import__("hashlib").sha1(to_hash).hexdigest()[:12]
2627

2728

2829
def get_logger() -> logging.Logger:

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
setup(
1919
name="Flask-Shell2HTTP",
20-
version="1.1.3",
20+
version="1.2.0",
2121
url=GITHUB_URL,
2222
license="BSD",
2323
author="Eshaan Bansal",

0 commit comments

Comments
 (0)