├── .coveragerc
├── .deepsource.toml
├── .flake8
├── .github
├── CHANGELOG.md
├── FUNDING.yml
├── codeql.yml
└── workflows
│ ├── codeql.yml
│ ├── python-publish.yml
│ └── test.yml
├── .gitignore
├── .lgtm.yml
├── .pre-commit-config.yaml
├── LICENSE
├── MANIFEST.in
├── README.md
├── docs
├── Makefile
├── make.bat
└── source
│ ├── Configuration.md
│ ├── Examples.md
│ ├── Quickstart.md
│ ├── api.rst
│ ├── conf.py
│ ├── index.rst
│ └── requirements.txt
├── examples
├── __init__.py
├── basic.py
├── custom_save_fn.py
├── deletion.py
├── multiple_files.py
├── run_script.py
├── with_callback.py
├── with_decorators.py
└── with_signals.py
├── flask_shell2http
├── __init__.py
├── api.py
├── base_entrypoint.py
├── classes.py
├── exceptions.py
└── helpers.py
├── post-request-schema.json
├── requirements.dev.txt
├── requirements.txt
├── setup.py
├── tests
├── __init__.py
├── _utils.py
├── test_basic.py
├── test_callback_signal.py
├── test_decorators.py
├── test_deletion.py
└── test_multiple_files.py
├── tox.ini
└── version.txt
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | branch = True
3 | parallel = True
4 | source =
5 | flask_shell2http
6 | omit =
7 | examples/*
8 |
9 | [report]
10 | exclude_lines =
11 | if self.debug:
12 | pragma: no cover
13 | raise NotImplementedError
14 | if __name__ == .__main__.:
15 | ignore_errors = True
16 | omit =
17 | examples/*
18 |
--------------------------------------------------------------------------------
/.deepsource.toml:
--------------------------------------------------------------------------------
1 | version = 1
2 |
3 | test_patterns = [
4 | "tests/**",
5 | "test_*.py"
6 | ]
7 |
8 | [[analyzers]]
9 | name = "secrets"
10 | enabled = true
11 |
12 | [[analyzers]]
13 | name = "test-coverage"
14 | enabled = true
15 |
16 | [[analyzers]]
17 | name = "python"
18 | enabled = true
19 |
20 | [analyzers.meta]
21 | runtime_version = "3.x.x"
22 |
23 | [[transformers]]
24 | name = "black"
25 | enabled = true
26 |
27 | [[transformers]]
28 | name = "isort"
29 | enabled = true
30 |
--------------------------------------------------------------------------------
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 88
3 | ignore =
4 | W503, # line break before binary operator
5 | E231, # missing whitespace after ','
6 | exclude =
7 | Dockerfile,
8 | venv,
9 | virtualenv,
10 | .tox*,
11 | isort*,
--------------------------------------------------------------------------------
/.github/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | **[Get it on PyPi](https://pypi.org/project/Flask-Shell2HTTP/)**
4 |
5 | ## [v1.9.1](https://github.com/eshaan7/Flask-Shell2HTTP/releases/tag/v1.9.1)
6 |
7 | Added support for Flask version >=2.1.0 .
8 |
9 | - Fix import errors. [[Issue 42](https://github.com/eshaan7/Flask-Shell2HTTP/issues/42)].
10 | - Fix dependency conflicts in `requirements.dev.txt` file.
11 |
12 | ## [v1.9.0](https://github.com/eshaan7/Flask-Shell2HTTP/releases/tag/v1.9.0)
13 |
14 | - It's now possible to request deletion/cancellation of jobs. [[Issue 38](https://github.com/eshaan7/Flask-Shell2HTTP/issues/38)].
15 |
16 | Say for example, you register a command with the endpoint `/myendpoint` then the different HTTP operations supported are:
17 |
18 | - `POST /myendpoint` to create a job and get unique key (same as earlier)
19 | - `GET /myendpoint?key={key}` to request result of a job (same as earlier)
20 | - `DELETE /myendpoint?key={key}` to request cancellation/deletion of a job. (new)
21 |
22 | ## [v1.8.0](https://github.com/eshaan7/Flask-Shell2HTTP/releases/tag/v1.8.0)
23 |
24 | - Allow `&wait=[false|true]` query parameter in `GET` request. Use `wait=true` when you don't wish to HTTP poll and want the result in a single request only.
25 |
26 | ## [v1.7.0](https://github.com/eshaan7/Flask-Shell2HTTP/releases/tag/v1.7.0)
27 |
28 | **For you:**
29 |
30 | - Deps: Support for both Flask version 1.x and 2.x.
31 | - Feature: The `key` and `result_url` attributes are returned in response even if error is raised (if and when applicable) (See [#25](https://github.com/eshaan7/Flask-Shell2HTTP/issues/25)).
32 | - Docs: Add info about `force_unique_key` option to quickstart guide.
33 |
34 | **Internal:**
35 |
36 | - Much better and improved test cases via tox matrix for both major flask versions, 1.x and 2.x.
37 | - Much better overall type hinting.
38 |
39 | ## [v1.6.0](https://github.com/eshaan7/Flask-Shell2HTTP/releases/tag/v1.6.0)
40 |
41 | Added support for a new parameter `force_unique_key` in the POST request.
42 |
43 | ```json
44 | "force_unique_key": {
45 | "type": "boolean",
46 | "title": "Flag to enable/disable internal rate limiting mechanism",
47 | "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",
48 | "default": false
49 | }
50 | ```
51 |
52 | See [post-request-options configuration](https://flask-shell2http.readthedocs.io/en/latest/Configuration.html#post-request-options) in docs for more info.
53 |
54 | _For prior versions, see directly [here](https://github.com/eshaan7/Flask-Shell2HTTP/releases)._
55 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | custom: ["https://xscode.com/eshaan7/flask-shell2http"]
2 |
--------------------------------------------------------------------------------
/.github/codeql.yml:
--------------------------------------------------------------------------------
1 | query-filters:
2 | - exclude:
3 | id: py/similar-function
4 | - exclude:
5 | id: py/empty-except
6 | - exclude:
7 | id: py/call-to-non-callable
8 | - include:
9 | id: py/undefined-placeholder-variable
10 | - include:
11 | id: py/uninitialized-local-variable
12 | - include:
13 | id: py/request-without-cert-validation
14 | - include:
15 | id: py/return-or-yield-outside-function
16 | - include:
17 | id: py/file-not-closed
18 | - include:
19 | id: py/exit-from-finally
20 | - include:
21 | id: py/ineffectual-statement
22 | - include:
23 | id: py/unused-global-variable
24 | - include:
25 | id: py/hardcoded-credentials
26 | - include:
27 | id: py/import-of-mutable-attribute
28 | - include:
29 | id: py/cyclic-import
30 | - include:
31 | id: py/unnecessary-lambda
32 | - include:
33 | id: py/print-during-import
34 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | name: "CodeQL"
2 |
3 | on:
4 | push:
5 | branches: [ "master" ]
6 | pull_request:
7 | branches: [ "master" ]
8 | schedule:
9 | - cron: "26 5 * * 0"
10 |
11 | jobs:
12 | analyze:
13 | name: Analyze
14 | runs-on: ubuntu-latest
15 | permissions:
16 | actions: read
17 | contents: read
18 | security-events: write
19 |
20 | strategy:
21 | fail-fast: false
22 | matrix:
23 | language: [ python ]
24 |
25 | steps:
26 | - name: Checkout
27 | uses: actions/checkout@v3
28 |
29 | - name: Initialize CodeQL
30 | uses: github/codeql-action/init@v2
31 | with:
32 | languages: ${{ matrix.language }}
33 | config-file: ./.github/codeql.yml
34 | queries: +security-and-quality
35 |
36 | - name: Autobuild
37 | uses: github/codeql-action/autobuild@v2
38 |
39 | - name: Perform CodeQL Analysis
40 | uses: github/codeql-action/analyze@v2
41 | with:
42 | category: "/language:${{ matrix.language }}"
43 |
--------------------------------------------------------------------------------
/.github/workflows/python-publish.yml:
--------------------------------------------------------------------------------
1 | # This workflows will upload a Python Package using Twine when a release is created
2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
3 |
4 | name: Upload Python Package
5 |
6 | on:
7 | release:
8 | types: [created]
9 |
10 | jobs:
11 | deploy:
12 |
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - uses: actions/checkout@v2
17 | - name: Set up Python
18 | uses: actions/setup-python@v2
19 | with:
20 | python-version: '3.x'
21 | - name: Install dependencies
22 | run: |
23 | python -m pip install --upgrade pip
24 | pip install setuptools wheel twine
25 | - name: Build and publish
26 | env:
27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
29 | run: |
30 | python setup.py sdist bdist_wheel
31 | twine upload dist/*
32 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 |
3 | on:
4 | push:
5 | branches: [master]
6 | pull_request:
7 | branches: [master]
8 |
9 | jobs:
10 | test:
11 | env:
12 | DEEPSOURCE_DSN: ${{ secrets.DEEPSOURCE_DSN }}
13 | runs-on: ${{ matrix.os || 'ubuntu-latest' }}
14 | strategy:
15 | fail-fast: false
16 | max-parallel: 6
17 | matrix:
18 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
19 | include:
20 | - python-version: 3.6
21 | os: ubuntu-20.04
22 | steps:
23 | - uses: actions/checkout@v2
24 | with:
25 | fetch-depth: 1
26 | ref: ${{ github.event.pull_request.head.sha }}
27 |
28 | - name: Set up Python ${{ matrix.python-version }}
29 | uses: actions/setup-python@v2
30 | with:
31 | python-version: ${{ matrix.python-version }}
32 |
33 | - name: Install dependencies
34 | run: |
35 | python -m pip install --upgrade pip
36 | pip install -r requirements.dev.txt
37 |
38 | - name: Run tox tests
39 | run: |
40 | # run tests with coverage
41 | tox
42 |
43 | - name: Report test-coverage to DeepSource
44 | if: ${{ matrix.python-version == '3.11' }}
45 | run: |
46 | # Install the CLI
47 | curl https://deepsource.io/cli | sh
48 | # Send the report to DeepSource
49 | ./bin/deepsource report --analyzer test-coverage --key python --value-file coverage.xml
50 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode
2 | venv/
3 | virtualenv/
4 | __pycache__/
5 | app.py
6 | test.py
7 | build/
8 | dist/
9 | *.egg-info
--------------------------------------------------------------------------------
/.lgtm.yml:
--------------------------------------------------------------------------------
1 | queries:
2 | - exclude: py/similar-function
3 | - exclude: py/empty-except
4 | - exclude: py/call-to-non-callable
5 | - include: py/undefined-placeholder-variable
6 | - include: py/uninitialized-local-variable
7 | - include: py/request-without-cert-validation
8 | - include: py/return-or-yield-outside-function
9 | - include: py/file-not-closed
10 | - include: py/exit-from-finally
11 | - include: py/ineffectual-statement
12 | - include: py/unused-global-variable
13 | - include: py/hardcoded-credentials
14 | - include: py/import-of-mutable-attribute
15 | - include: py/cyclic-import
16 | - include: py/unnecessary-lambda
17 | - include: py/print-during-import
18 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/psf/black
3 | rev: 20.8b1
4 | hooks:
5 | - id: black
6 | - repo: https://gitlab.com/pycqa/flake8
7 | rev: 3.8.4
8 | hooks:
9 | - id: flake8
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2020, Eshaan Bansal
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | 2. Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | 3. Neither the name of the copyright holder nor the names of its
17 | contributors may be used to endorse or promote products derived from
18 | this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.md
2 | include version.txt
3 | include requirements.txt
4 | include requirements.dev.txt
5 | include docs/source/requirements.txt
6 | include LICENSE
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Flask-Shell2HTTP
2 |
3 | [](https://pypi.org/project/Flask-Shell2HTTP/)
4 | [](https://github.com/Eshaan7/flask-shell2http/actions?query=workflow%3A%22Linter+%26+Tests%22)
5 | [](https://codecov.io/gh/Eshaan7/flask-shell2http/)
6 | [](https://www.codefactor.io/repository/github/eshaan7/flask-shell2http)
7 |
8 |
9 |
10 |
11 | A minimalist [Flask](https://github.com/pallets/flask) extension that serves as a RESTful/HTTP wrapper for python's subprocess API.
12 |
13 | - **Convert any command-line tool into a REST API service.**
14 | - Execute pre-defined shell commands asynchronously and securely via flask's endpoints with dynamic arguments, file upload, callback function capabilities.
15 | - Designed for binary to binary/HTTP communication, development, prototyping, remote control and [more](https://flask-shell2http.readthedocs.io/en/stable/Examples.html).
16 |
17 | ## Use Cases
18 |
19 | - Set a script that runs on a succesful POST request to an endpoint of your choice. See [Example code](examples/run_script.py).
20 | - Map a base command to an endpoint and pass dynamic arguments to it. See [Example code](examples/basic.py).
21 | - Can also process multiple uploaded files in one command. See [Example code](examples/multiple_files.py).
22 | - This is useful for internal docker-to-docker communications if you have different binaries distributed in micro-containers. See [real-life example](https://github.com/intelowlproject/IntelOwl/blob/master/integrations/malware_tools_analyzers/app.py).
23 | - You can define a callback function/ use signals to listen for process completion. See [Example code](examples/with_callback.py).
24 | - Maybe want to pass some additional context to the callback function ?
25 | - Maybe intercept on completion and update the result ? See [Example code](examples/custom_save_fn.py)
26 | - You can also apply [View Decorators](https://flask.palletsprojects.com/en/1.1.x/patterns/viewdecorators/) to the exposed endpoint. See [Example code](examples/with_decorators.py).
27 |
28 | > Note: This extension is primarily meant for executing long-running
29 | > shell commands/scripts (like nmap, code-analysis' tools) in background from an HTTP request and getting the result at a later time.
30 |
31 | ## Documentation
32 |
33 | [](https://flask-shell2http.readthedocs.io/en/latest/?badge=latest)
34 |
35 | - Read the [Quickstart](https://flask-shell2http.readthedocs.io/en/stable/Quickstart.html) from the [documentation](https://flask-shell2http.readthedocs.io/) to get started!
36 | - I also highly recommend the [Examples](https://flask-shell2http.readthedocs.io/en/stable/Examples.html) section.
37 | - [CHANGELOG](https://github.com/eshaan7/Flask-Shell2HTTP/blob/master/.github/CHANGELOG.md).
38 |
39 | ## Quick Start
40 |
41 | ##### Dependencies
42 |
43 | - Python: `>=v3.6`
44 | - [Flask](https://pypi.org/project/Flask/)
45 | - [Flask-Executor](https://pypi.org/project/Flask-Executor)
46 |
47 | ##### Installation
48 |
49 | ```bash
50 | $ pip install flask flask_shell2http
51 | ```
52 |
53 | ##### Example Program
54 |
55 | Create a file called `app.py`.
56 |
57 | ```python
58 | from flask import Flask
59 | from flask_executor import Executor
60 | from flask_shell2http import Shell2HTTP
61 |
62 | # Flask application instance
63 | app = Flask(__name__)
64 |
65 | executor = Executor(app)
66 | shell2http = Shell2HTTP(app=app, executor=executor, base_url_prefix="/commands/")
67 |
68 | def my_callback_fn(context, future):
69 | # optional user-defined callback function
70 | print(context, future.result())
71 |
72 | shell2http.register_command(endpoint="saythis", command_name="echo", callback_fn=my_callback_fn, decorators=[])
73 | ```
74 |
75 | Run the application server with, `$ flask run -p 4000`.
76 |
77 | With <10 lines of code, we succesfully mapped the shell command `echo` to the endpoint `/commands/saythis`.
78 |
79 | ##### Making HTTP calls
80 |
81 | This section demonstrates how we can now call/ execute commands over HTTP that we just mapped in the [example](#example-program) above.
82 |
83 | ```bash
84 | $ curl -X POST -H 'Content-Type: application/json' -d '{"args": ["Hello", "World!"]}' http://localhost:4000/commands/saythis
85 | ```
86 |
87 | or using python's requests module,
88 |
89 | ```python
90 | # You can also add a timeout if you want, default value is 3600 seconds
91 | data = {"args": ["Hello", "World!"], "timeout": 60}
92 | resp = requests.post("http://localhost:4000/commands/saythis", json=data)
93 | print("Result:", resp.json())
94 | ```
95 |
96 |
97 |
98 | > Note: You can see the JSON schema for the POST request [here](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/post-request-schema.json).
99 |
100 | returns JSON,
101 |
102 | ```json
103 | {
104 | "key": "ddbe0a94",
105 | "result_url": "http://localhost:4000/commands/saythis?key=ddbe0a94&wait=false",
106 | "status": "running"
107 | }
108 | ```
109 |
110 | Then using this `key` you can query for the result or just by going to the `result_url`,
111 |
112 | ```bash
113 | $ curl http://localhost:4000/commands/saythis?key=ddbe0a94&wait=true # wait=true so we do not have to poll
114 | ```
115 |
116 | Returns result in JSON,
117 |
118 | ```json
119 | {
120 | "report": "Hello World!\n",
121 | "key": "ddbe0a94",
122 | "start_time": 1593019807.7754705,
123 | "end_time": 1593019807.782958,
124 | "process_time": 0.00748753547668457,
125 | "returncode": 0,
126 | "error": null
127 | }
128 | ```
129 |
130 | ## Inspiration
131 |
132 | This was initially made to integrate various command-line tools easily with [Intel Owl](https://github.com/intelowlproject/IntelOwl), which I am working on as part of Google Summer of Code.
133 |
134 | The name was inspired by the awesome folks over at [msoap/shell2http](https://github.com/msoap/shell2http).
135 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line, and also
5 | # from the environment for the first two.
6 | SPHINXOPTS ?=
7 | SPHINXBUILD ?= sphinx-build
8 | SOURCEDIR = source
9 | BUILDDIR = build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
21 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=source
11 | set BUILDDIR=build
12 |
13 | if "%1" == "" goto help
14 |
15 | %SPHINXBUILD% >NUL 2>NUL
16 | if errorlevel 9009 (
17 | echo.
18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
19 | echo.installed, then set the SPHINXBUILD environment variable to point
20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
21 | echo.may add the Sphinx directory to PATH.
22 | echo.
23 | echo.If you don't have Sphinx installed, grab it from
24 | echo.http://sphinx-doc.org/
25 | exit /b 1
26 | )
27 |
28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
29 | goto end
30 |
31 | :help
32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
33 |
34 | :end
35 | popd
36 |
--------------------------------------------------------------------------------
/docs/source/Configuration.md:
--------------------------------------------------------------------------------
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
12 |
13 | This extension logs messages of different severity `INFO`, `DEBUG`, `ERROR`
14 | using the python's inbuilt [logging](https://docs.python.org/3/library/logging.html) module.
15 |
16 | There are no default handlers or stream defined for the logger so it's upto the user to define them.
17 |
18 | Here's a snippet of code that shows how you can access this extension's logger object and add a custom handler to it.
19 |
20 | ```python
21 | # python's inbuilt logging module
22 | import logging
23 | # get the flask_shell2http logger
24 | logger = logging.getLogger("flask_shell2http")
25 | # create new handler
26 | handler = logging.StreamHandler(sys.stdout)
27 | logger.addHandler(handler)
28 | # log messages of severity DEBUG or lower to the console
29 | logger.setLevel(logging.DEBUG) # this is really important!
30 | ```
31 |
32 | Please consult the Flask's official docs on
33 | [extension logs](https://flask.palletsprojects.com/en/1.1.x/logging/#other-libraries) for more details.
--------------------------------------------------------------------------------
/docs/source/Examples.md:
--------------------------------------------------------------------------------
1 | ## Examples
2 |
3 | I have created some example python scripts to demonstrate various use-cases. These include extension setup as well as making test HTTP calls with python's [requests](https://requests.readthedocs.io/en/master/) module.
4 |
5 | - [run_script.py](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/examples/run_script.py): Execute a script on a succesful POST request to an endpoint.
6 | - [basic.py](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/examples/basic.py): Map a base command to an endpoint and pass dynamic arguments to it. Can also pass in a timeout.
7 | - [multiple_files.py](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/examples/multiple_files.py): Upload multiple files for a single command.
8 | - [with_callback.py](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/examples/with_callback.py): Define a callback function that executes on command/process completion.
9 | - [with_signals.py](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/examples/with_signals.py): Using [Flask Signals](https://flask.palletsprojects.com/en/1.1.x/signals/) as callback function.
10 | - [with_decorators.py](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/examples/with_decorators.py): Shows how to apply [View Decorators](https://flask.palletsprojects.com/en/1.1.x/patterns/viewdecorators/) to the exposed endpoint. Useful in case you wish to apply authentication, caching, etc. to the endpoint.
11 | - [custom_save_fn.py](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/examples/custom_save_fn.py): There may be cases where the process doesn't print result to standard output but to a file/database. This example shows how to pass additional context to the callback function, intercept the future object after completion and update it's result attribute before it's ready to be consumed.
12 | - [deletion.py](https://github.com/Eshaan7/Flask-Shell2HTTP/blob/master/examples/deletion.py): Example demonstrating how to request cancellation/deletion of an already running job.
13 |
--------------------------------------------------------------------------------
/docs/source/Quickstart.md:
--------------------------------------------------------------------------------
1 | ## Quick Start
2 |
3 | ##### Dependencies
4 |
5 | * Python: `>=v3.6`
6 | * [Flask](https://pypi.org/project/Flask/)
7 | * [Flask-Executor](https://pypi.org/project/Flask-Executor)
8 |
9 | ##### Installation
10 |
11 | ```bash
12 | $ pip install flask flask_shell2http
13 | ```
14 |
15 | ##### Example Program
16 |
17 | Create a file called `app.py`.
18 |
19 | ```python
20 | from flask import Flask
21 | from flask_executor import Executor
22 | from flask_shell2http import Shell2HTTP
23 |
24 | # Flask application instance
25 | app = Flask(__name__)
26 |
27 | executor = Executor(app)
28 | shell2http = Shell2HTTP(app=app, executor=executor, base_url_prefix="/commands/")
29 |
30 | def my_callback_fn(context, future):
31 | # optional user-defined callback function
32 | print(context, future.result())
33 |
34 | shell2http.register_command(endpoint="saythis", command_name="echo", callback_fn=my_callback_fn, decorators=[])
35 | ```
36 |
37 | Run the application server with, `$ flask run -p 4000`.
38 |
39 | With <10 lines of code, we succesfully mapped the shell command `echo` to the endpoint `/commands/saythis`.
40 |
41 | ##### Making HTTP calls
42 |
43 | This section demonstrates how we can now call/ execute commands over HTTP that we just mapped in the [example](#example-program) above.
44 |
45 | ```bash
46 | $ curl -X POST -H 'Content-Type: application/json' -d '{"args": ["Hello", "World!"]}' http://localhost:4000/commands/saythis
47 | ```
48 |
49 | or using python's requests module,
50 |
51 | ```python
52 | # You can also add a timeout if you want, default value is 3600 seconds
53 | data = {"args": ["Hello", "World!"], "timeout": 60, "force_unique_key": False}
54 | resp = requests.post("http://localhost:4000/commands/saythis", json=data)
55 | print("Result:", resp.json())
56 | ```
57 |
58 |
59 |
60 | returns JSON,
61 |
62 | ```json
63 | {
64 | "key": "ddbe0a94",
65 | "result_url": "http://localhost:4000/commands/saythis?key=ddbe0a94&wait=false",
66 | "status": "running"
67 | }
68 | ```
69 |
70 | Then using this `key` you can query for the result or just by going to the `result_url`,
71 |
72 | ```bash
73 | $ curl http://localhost:4000/commands/saythis?key=ddbe0a94&wait=true # wait=true so we don't need to poll
74 | ```
75 |
76 | Returns result in JSON,
77 |
78 | ```json
79 | {
80 | "report": "Hello World!\n",
81 | "key": "ddbe0a94",
82 | "start_time": 1593019807.7754705,
83 | "end_time": 1593019807.782958,
84 | "process_time": 0.00748753547668457,
85 | "returncode": 0,
86 | "error": null,
87 | }
88 | ```
89 |
90 |
91 |
Hint
92 | Use
wait=true
when you don't wish to HTTP poll and want the result in a single request only.
93 | This is especially ideal in case you specified a low
timeout
value in the
POST
request.
94 |
95 |
96 |
97 |
98 |
Hint
99 | 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.
100 |
101 |
102 |
103 |
Note
104 | You can see the full JSON schema for the POST request,
here.
105 |
106 |
107 |
108 | ##### Bonus
109 |
110 | You can also define callback functions or use signals for reactive programming. There may be cases where the process doesn't print result to standard output but to a file/database. In such cases, you may want to intercept the future object and update it's result attribute.
111 | I request you to take a look at [Examples.md](Examples.md) for such use-cases.
--------------------------------------------------------------------------------
/docs/source/api.rst:
--------------------------------------------------------------------------------
1 | API Reference
2 | -------------
3 |
4 | If you are looking for information on a specific function, class or
5 | method, this part of the documentation is for you.
6 |
7 | .. automodule:: flask_shell2http.base_entrypoint
8 | :members:
9 |
10 | .. toctree::
11 | :maxdepth: 2
--------------------------------------------------------------------------------
/docs/source/conf.py:
--------------------------------------------------------------------------------
1 | # Configuration file for the Sphinx documentation builder.
2 | #
3 | # This file only contains a selection of the most common options. For a full
4 | # list see the documentation:
5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html
6 |
7 | # -- Path setup --------------------------------------------------------------
8 |
9 | # If extensions (or modules to document with autodoc) are in another directory,
10 | # add these directories to sys.path here. If the directory is relative to the
11 | # documentation root, use os.path.abspath to make it absolute, like shown here.
12 | #
13 | import os
14 | import pathlib
15 | import sys
16 |
17 | from pallets_sphinx_themes import ProjectLink
18 |
19 | sys.path.insert(0, os.path.abspath("../.."))
20 |
21 |
22 | # -- Project information -----------------------------------------------------
23 |
24 | project = "Flask-Shell2HTTP"
25 | copyright = "2020, Eshaan Bansal"
26 | author = "Eshaan Bansal"
27 |
28 | # The full version, including alpha/beta/rc tags
29 | release = (pathlib.Path(__file__).parent.parent.parent / "version.txt").read_text()
30 |
31 |
32 | # -- General configuration ---------------------------------------------------
33 |
34 | # Add any Sphinx extension module names here, as strings. They can be
35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
36 | # ones.
37 | extensions = [
38 | "recommonmark",
39 | "pallets_sphinx_themes",
40 | "sphinx.ext.autodoc",
41 | "sphinx.ext.napoleon",
42 | "sphinx.ext.autosectionlabel",
43 | ]
44 |
45 | source_suffix = [".rst", ".md"]
46 |
47 | # Add any paths that contain templates here, relative to this directory.
48 | templates_path = ["_templates"]
49 |
50 | # List of patterns, relative to source directory, that match files and
51 | # directories to ignore when looking for source files.
52 | # This pattern also affects html_static_path and html_extra_path.
53 | exclude_patterns = []
54 |
55 |
56 | # -- Options for HTML output -------------------------------------------------
57 |
58 | # The theme to use for HTML and HTML Help pages. See the documentation for
59 | # a list of builtin themes.
60 | #
61 | html_theme = "flask"
62 |
63 | # Custom options
64 |
65 | html_context = {
66 | "project_links": [
67 | ProjectLink("Donate To The Author", "https://paypal.me/eshaanbansal"),
68 | ProjectLink(
69 | "Flask-Shell2HTTP Website", "https://flask-shell2http.readthedocs.io/"
70 | ),
71 | ProjectLink("PyPI releases", "https://pypi.org/project/Flask-Shell2HTTP/"),
72 | ProjectLink("Source Code", "https://github.com/eshaan7/flask-shell2http"),
73 | ProjectLink(
74 | "Issue Tracker", "https://github.com/eshaan7/flask-shell2http/issues"
75 | ),
76 | ]
77 | }
78 | html_sidebars = {
79 | "index": ["project.html", "localtoc.html", "searchbox.html"],
80 | "**": ["localtoc.html", "relations.html", "searchbox.html"],
81 | }
82 | singlehtml_sidebars = {"index": ["project.html", "localtoc.html"]}
83 | html_static_path = ["_static"]
84 | # html_favicon = "_static/flask-icon.png"
85 | # html_logo = "_static/flask-icon.png"
86 | html_title = f"Flask-Shell2HTTP Documentation ({release})"
87 |
88 |
89 | # Add any paths that contain custom static files (such as style sheets) here,
90 | # relative to this directory. They are copied after the builtin static files,
91 | # so a file named "default.css" will overwrite the builtin "default.css".
92 | html_static_path = ["_static"]
93 |
--------------------------------------------------------------------------------
/docs/source/index.rst:
--------------------------------------------------------------------------------
1 | Welcome to Flask-Shell2HTTP!
2 | ================================
3 |
4 | .. image:: https://img.shields.io/lgtm/grade/python/g/Eshaan7/Flask-Shell2HTTP.svg?logo=lgtm&logoWidth=18
5 | .. image:: https://img.shields.io/pypi/v/flask-shell2http
6 | .. image:: https://github.com/Eshaan7/Flask-Shell2HTTP/workflows/Linter%20&%20Tests/badge.svg?branch=master
7 | .. image:: https://codecov.io/gh/Eshaan7/Flask-Shell2HTTP/branch/master/graph/badge.svg?token=UQ43PYQPMR
8 | .. image:: https://www.codefactor.io/repository/github/eshaan7/flask-shell2http/badge
9 |
10 | A minimalist Flask_ extension that serves as a RESTful/HTTP wrapper for python's subprocess API.
11 |
12 | - **Convert any command-line tool into a REST API service.**
13 | - Execute shell commands asynchronously and safely via flask's endpoints.
14 | - Designed for binary to binary/HTTP communication, development, prototyping, remote control and more.
15 |
16 | .. _Flask: https://github.com/pallets/flask
17 |
18 | **Use Cases:**
19 |
20 | - Set a script that runs on a succesful POST request to an endpoint of your choice.
21 | - Map a base command to an endpoint and pass dynamic arguments to it.
22 | - Can also process multiple uploaded files in one command.
23 | - This is useful for internal docker-to-docker communications if you have different binaries distributed in micro-containers.
24 | - You can define a callback function/ use signals to listen for process completion.
25 | - You can also apply View Decorators to the exposed endpoint.
26 |
27 | `Note: This extension is primarily meant for executing long-running
28 | shell commands/scripts (like nmap, code-analysis' tools) in background from an HTTP request and getting the result at a later time.`
29 |
30 | Quickstart
31 | -------------------------------
32 | Get started at :doc:`Quickstart`. There are also
33 | more detailed :doc:`Examples` that shows different use-cases for this package.
34 |
35 | .. toctree::
36 | :maxdepth: 2
37 |
38 | Quickstart
39 | Examples
40 | Configuration
41 |
42 | API Reference
43 | -------------------------------
44 | If you are looking for information on a specific function, class or
45 | method, this part of the documentation is for you.
46 |
47 | .. toctree::
48 | :maxdepth: 2
49 |
50 | api
51 |
52 | Indices and tables
53 | ================================
54 |
55 | * :ref:`genindex`
56 | * :ref:`modindex`
57 | * :ref:`search`
58 |
--------------------------------------------------------------------------------
/docs/source/requirements.txt:
--------------------------------------------------------------------------------
1 | sphinx==4.4.0
2 | Pallets-Sphinx-Themes>=2.0.2
3 | sphinxcontrib-applehelp==1.0.2
4 | sphinxcontrib-devhelp==1.0.2
5 | sphinxcontrib-htmlhelp>=2.0.0
6 | sphinxcontrib-jsmath==1.0.1
7 | sphinxcontrib-napoleon==0.7
8 | sphinxcontrib-qthelp==1.0.3
9 | sphinxcontrib-serializinghtml>=1.1.5
10 | commonmark
11 | recommonmark
--------------------------------------------------------------------------------
/examples/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eshaan7/Flask-Shell2HTTP/9e0fe36b45a8baee486538eb7484b7c63f80f78b/examples/__init__.py
--------------------------------------------------------------------------------
/examples/basic.py:
--------------------------------------------------------------------------------
1 | # system imports
2 | import logging
3 | import sys
4 |
5 | # web imports
6 | from flask import Flask
7 | from flask_executor import Executor
8 |
9 | from flask_shell2http import Shell2HTTP
10 |
11 | # Flask application instance
12 | app = Flask(__name__)
13 |
14 | # Logging
15 | formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
16 | logger = logging.getLogger("flask_shell2http")
17 | handler = logging.StreamHandler(sys.stdout)
18 | handler.setFormatter(formatter)
19 | logger.addHandler(handler)
20 | logger.setLevel(logging.INFO)
21 |
22 |
23 | # application factory
24 | executor = Executor(app)
25 | shell2http = Shell2HTTP(app, executor, base_url_prefix="/cmd/")
26 |
27 | ENDPOINT = "echo"
28 | shell2http.register_command(endpoint=ENDPOINT, command_name=ENDPOINT)
29 |
30 |
31 | # Test Runner
32 | if __name__ == "__main__":
33 | app.testing = True
34 | c = app.test_client()
35 | """
36 | The final executed command becomes:
37 | ```bash
38 | $ echo hello world
39 | ```
40 | """
41 | # make new request for a command with arguments
42 | uri = f"/cmd/{ENDPOINT}"
43 | # timeout in seconds, default value is 3600
44 | # force_unique_key disables rate-limiting
45 | data = {"args": ["hello", "world"], "timeout": 60, "force_unique_key": True}
46 | resp1 = c.post(uri, json=data).get_json()
47 | print(resp1)
48 | # fetch result
49 | result_url = resp1["result_url"].replace("wait=false", "wait=true")
50 | resp2 = c.get(result_url).get_json()
51 | print(resp2)
52 |
--------------------------------------------------------------------------------
/examples/custom_save_fn.py:
--------------------------------------------------------------------------------
1 | # web imports
2 | from flask import Flask
3 | from flask_executor import Executor
4 | from flask_executor.futures import Future
5 |
6 | from flask_shell2http import Shell2HTTP
7 |
8 | # Flask application instance
9 | app = Flask(__name__)
10 |
11 | # application factory
12 | executor = Executor(app)
13 | shell2http = Shell2HTTP(app, executor)
14 |
15 | ENDPOINT = "echo"
16 |
17 |
18 | def intercept_result(context, future: Future):
19 | """
20 | Will be invoked on every process completion
21 | """
22 | data = None
23 | if future.done():
24 | fname = context.get("read_result_from", None)
25 | with open(fname) as f:
26 | data = f.read()
27 | # 1. get current result object
28 | res = future.result() # this returns a dictionary
29 | # 2. update the report variable,
30 | # you may update only these: report,error,status
31 | res["report"] = data
32 | if context.get("force_success", False):
33 | res["status"] = "success"
34 | # 3. set new result
35 | future._result = res
36 |
37 |
38 | shell2http.register_command(
39 | endpoint=ENDPOINT, command_name=ENDPOINT, callback_fn=intercept_result
40 | )
41 |
42 |
43 | # Test Runner
44 | if __name__ == "__main__":
45 | app.testing = True
46 | c = app.test_client()
47 | # request new process
48 | data = {
49 | "args": ["hello", "world"],
50 | "callback_context": {
51 | "read_result_from": "/path/to/saved/file",
52 | "force_success": True,
53 | },
54 | }
55 | r = c.post(f"/{ENDPOINT}", json=data)
56 | # get result
57 | result_url = r.get_json()["result_url"]
58 | resp = c.get(result_url)
59 | print(resp.get_json())
60 |
--------------------------------------------------------------------------------
/examples/deletion.py:
--------------------------------------------------------------------------------
1 | # web imports
2 | from flask import Flask
3 | from flask_executor import Executor
4 |
5 | from flask_shell2http import Shell2HTTP
6 |
7 | # Flask application instance
8 | app = Flask(__name__)
9 |
10 | # application factory
11 | executor = Executor(app)
12 | shell2http = Shell2HTTP(app, executor)
13 |
14 |
15 | shell2http.register_command(
16 | endpoint="sleep",
17 | command_name="sleep",
18 | )
19 |
20 |
21 | # Test Runner
22 | if __name__ == "__main__":
23 | app.testing = True
24 | c = app.test_client()
25 | # request new process
26 | r1 = c.post("/sleep", json={"args": ["10"], "force_unique_key": True})
27 | print(r1)
28 | # request cancellation
29 | r2 = c.delete(f"/sleep?key={r1.get_json()['key']}")
30 | print(r2)
31 |
--------------------------------------------------------------------------------
/examples/multiple_files.py:
--------------------------------------------------------------------------------
1 | # system imports
2 | import json
3 | import tempfile
4 |
5 | import requests
6 |
7 | # web imports
8 | from flask import Flask
9 | from flask_executor import Executor
10 |
11 | from flask_shell2http import Shell2HTTP
12 |
13 | # Flask application instance
14 | app = Flask(__name__)
15 |
16 | # application factory
17 | executor = Executor()
18 | executor.init_app(app)
19 | shell2http = Shell2HTTP(base_url_prefix="/cmd/")
20 | shell2http.init_app(app, executor)
21 |
22 | shell2http.register_command(endpoint="strings", command_name="strings")
23 |
24 |
25 | # go to http://localhost:4000/ to execute
26 | @app.route("/")
27 | def test():
28 | """
29 | Prefix each filename with @ in arguments.\n
30 | Files are stored in temporary directories which are flushed on command completion.\n
31 | The final executed command becomes:
32 | ```bash
33 | $ strings /tmp/inputfile /tmp/someotherfile
34 | ```
35 | """
36 | url = "http://localhost:4000/cmd/strings"
37 | # create and read dummy data from temporary files
38 | with tempfile.TemporaryFile() as fp:
39 | fp.write(b"Hello world!")
40 | fp.seek(0)
41 | f = fp.read()
42 | # they key should be `request_json` only.
43 | form_data = {"args": ["@inputfile", "@someotherfile"]}
44 | req_data = {"request_json": json.dumps(form_data)}
45 | req_files = {"inputfile": f, "someotherfile": f}
46 | resp = requests.post(url=url, files=req_files, data=req_data)
47 | resp_data = resp.json()
48 | print(resp_data)
49 | key = resp_data["key"]
50 | if key:
51 | report = requests.get(f"{url}?key={key}")
52 | return report.json()
53 | return resp_data
54 |
55 |
56 | # Application Runner
57 | if __name__ == "__main__":
58 | app.run(port=4000)
59 |
--------------------------------------------------------------------------------
/examples/run_script.py:
--------------------------------------------------------------------------------
1 | # web imports
2 | from flask import Flask
3 | from flask_executor import Executor
4 |
5 | from flask_shell2http import Shell2HTTP
6 |
7 | # Flask application instance
8 | app = Flask(__name__)
9 |
10 | # application factory
11 | executor = Executor(app)
12 | shell2http = Shell2HTTP(app, executor, base_url_prefix="/scripts/")
13 |
14 | shell2http.register_command(endpoint="hacktheplanet", command_name="./fuxsocy.py")
15 |
16 |
17 | # Application Runner
18 | if __name__ == "__main__":
19 | app.testing = True
20 | c = app.test_client()
21 | """
22 | The final executed command becomes:
23 | ```bash
24 | $ ./fuxsocy.py
25 | ```
26 | """
27 | uri = "/scripts/hacktheplanet"
28 | resp1 = c.post(uri, json={"args": []}).get_json()
29 | print(resp1)
30 | # fetch result
31 | result_url = resp1["result_url"]
32 | resp2 = c.get(result_url).get_json()
33 | print(resp2)
34 |
--------------------------------------------------------------------------------
/examples/with_callback.py:
--------------------------------------------------------------------------------
1 | # web imports
2 | from flask import Flask
3 | from flask_executor import Executor
4 | from flask_executor.futures import Future
5 |
6 | from flask_shell2http import Shell2HTTP
7 |
8 | # Flask application instance
9 | app = Flask(__name__)
10 |
11 | # application factory
12 | executor = Executor(app)
13 | shell2http = Shell2HTTP(app, executor)
14 |
15 |
16 | def my_callback_fn(extra_callback_context, future: Future):
17 | """
18 | Will be invoked on every process completion
19 | """
20 | print("[i] Process running ?:", future.running())
21 | print("[i] Process completed ?:", future.done())
22 | print("[+] Result: ", future.result())
23 | # future.result() returns a dictionary
24 | print("[+] Context: ", extra_callback_context)
25 |
26 |
27 | shell2http.register_command(
28 | endpoint="echo/callback", command_name="echo", callback_fn=my_callback_fn
29 | )
30 |
31 |
32 | # Test Runner
33 | if __name__ == "__main__":
34 | app.testing = True
35 | c = app.test_client()
36 | # request new process
37 | data = {"args": ["hello", "world"]}
38 | c.post("/echo/callback", json=data)
39 | # request another new process
40 | data = {"args": ["Hello", "Friend!"], "callback_context": {"testkey": "testvalue"}}
41 | c.post("/echo/callback", json=data)
42 |
--------------------------------------------------------------------------------
/examples/with_decorators.py:
--------------------------------------------------------------------------------
1 | # generic imports
2 | import functools
3 |
4 | # web imports
5 | from flask import Flask, Response, abort, g, request
6 | from flask_executor import Executor
7 |
8 | from flask_shell2http import Shell2HTTP
9 |
10 | # Flask application instance
11 | app = Flask(__name__)
12 |
13 | # application factory
14 | executor = Executor(app)
15 | shell2http = Shell2HTTP(app, executor, base_url_prefix="/cmd/")
16 |
17 |
18 | # few decorators [1]
19 | def logging_decorator(f):
20 | @functools.wraps(f)
21 | def decorator(*args, **kwargs):
22 | print("*" * 64)
23 | print(
24 | "from logging_decorator: " + request.url + " : " + str(request.remote_addr)
25 | )
26 | print("*" * 64)
27 | return f(*args, **kwargs)
28 |
29 | return decorator
30 |
31 |
32 | def login_required(f):
33 | @functools.wraps(f)
34 | def decorator(*args, **kwargs):
35 | if not hasattr(g, "user") or g.user is None:
36 | abort(Response("You are not logged in.", 401))
37 | return f(*args, **kwargs)
38 |
39 | return decorator
40 |
41 |
42 | shell2http.register_command(
43 | endpoint="public/echo", command_name="echo", decorators=[logging_decorator]
44 | )
45 |
46 | shell2http.register_command(
47 | endpoint="protected/echo",
48 | command_name="echo",
49 | decorators=[login_required, logging_decorator], # [2]
50 | )
51 |
52 | # [1] View Decorators:
53 | # https://flask.palletsprojects.com/en/1.1.x/patterns/viewdecorators/
54 | # [2] remember that decorators are applied from left to right in a stack manner.
55 | # But are executed in right to left manner.
56 | # Put logging_decorator first and you will see what happens.
57 |
58 |
59 | # Test Runner
60 | if __name__ == "__main__":
61 | app.testing = True
62 | c = app.test_client()
63 | # request 1
64 | data = {"args": ["hello", "world"]}
65 | r1 = c.post("cmd/public/echo", json=data)
66 | print(r1.json, r1.status_code)
67 | # request 2
68 | data = {"args": ["Hello", "Friend!"]}
69 | r2 = c.post("cmd/protected/echo", json=data)
70 | print(r2.data, r2.status_code)
71 |
--------------------------------------------------------------------------------
/examples/with_signals.py:
--------------------------------------------------------------------------------
1 | # web imports
2 | from blinker import Namespace # or from flask.signals import Namespace
3 | from flask import Flask
4 | from flask_executor import Executor
5 | from flask_executor.futures import Future
6 |
7 | from flask_shell2http import Shell2HTTP
8 |
9 | # Flask application instance
10 | app = Flask(__name__)
11 |
12 | # application factory
13 | executor = Executor(app)
14 | shell2http = Shell2HTTP(app, executor)
15 |
16 | # Signal Handling
17 | signal_handler = Namespace()
18 | my_signal = signal_handler.signal("on_echo_complete")
19 | # ..or any other name of your choice
20 |
21 |
22 | @my_signal.connect
23 | def my_callback_fn(sender, extra_callback_context, future: Future):
24 | """
25 | Will be invoked on every process completion
26 | """
27 | print("Process completed ?:", future.done())
28 | print("Result: ", future.result())
29 |
30 |
31 | def send_proxy(extra_callback_context, future: Future):
32 | my_signal.send(
33 | "send_proxy", extra_callback_context=extra_callback_context, future=future
34 | )
35 |
36 |
37 | shell2http.register_command(
38 | endpoint="echo/signal", command_name="echo", callback_fn=send_proxy
39 | )
40 |
41 |
42 | # Test Runner
43 | if __name__ == "__main__":
44 | app.testing = True
45 | c = app.test_client()
46 | # request new process
47 | data = {"args": ["Hello", "Friend!"]}
48 | c.post("/echo/signal", json=data)
49 | # request new process
50 | data = {"args": ["Bye", "Friend!"]}
51 | c.post("/echo/signal", json=data)
52 |
--------------------------------------------------------------------------------
/flask_shell2http/__init__.py:
--------------------------------------------------------------------------------
1 | # flake8: noqa
2 | """
3 | flask_shell2http
4 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~
5 | Flask-Shell2HTTP
6 | :copyright: (c) 2020 by Eshaan Bansal.
7 | :license: BSD, see LICENSE for more details.
8 | """
9 |
10 | from .base_entrypoint import Shell2HTTP
11 |
--------------------------------------------------------------------------------
/flask_shell2http/api.py:
--------------------------------------------------------------------------------
1 | """
2 | flask_shell2http.api
3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~
4 | Flask-Shell2HTTP API class
5 | :copyright: (c) 2020 by Eshaan Bansal.
6 | :license: BSD, see LICENSE for more details.
7 | """
8 |
9 | # system imports
10 | import functools
11 | from http import HTTPStatus
12 | from typing import Any, Callable, Dict
13 |
14 | # web imports
15 | from flask import jsonify, make_response, request
16 | from flask.views import MethodView
17 | from flask_executor import Executor
18 | from flask_executor.futures import Future
19 |
20 | # lib imports
21 | from .classes import RunnerParser
22 | from .exceptions import JobNotFoundException, JobStillRunningException
23 | from .helpers import get_logger
24 |
25 | logger = get_logger()
26 | runner_parser = RunnerParser()
27 |
28 |
29 | class Shell2HttpAPI(MethodView):
30 | """
31 | ``Flask.MethodView`` that creates ``GET``, ``POST`` and ``DELETE``
32 | methods for a given endpoint.
33 | This is invoked on ``Shell2HTTP.register_command``.
34 |
35 | *Internal use only.*
36 | """
37 |
38 | def get(self):
39 | """
40 | Get report by job key.
41 | Args:
42 | key (str):
43 | - Future key
44 | wait (str):
45 | - If ``true``, then wait for future to finish and return result.
46 | """
47 |
48 | key: str = ""
49 | report: Dict = {}
50 | try:
51 | key = request.args.get("key")
52 | wait = request.args.get("wait", "").lower() == "true"
53 | logger.info(
54 | f"Job: '{key}' --> Report requested. "
55 | f"Requester: '{request.remote_addr}'."
56 | )
57 | if not key:
58 | raise Exception("No key provided in arguments.")
59 |
60 | # get the future object
61 | future: Future = self.executor.futures._futures.get(key)
62 | if not future:
63 | raise JobNotFoundException(f"No report exists for key: '{key}'.")
64 |
65 | # check if job has been finished
66 | if not wait and not future.done():
67 | raise JobStillRunningException()
68 |
69 | # pop future object since it has been finished
70 | self.executor.futures.pop(key)
71 |
72 | # if yes, get result from store
73 | report = future.result()
74 | if not report:
75 | raise JobNotFoundException(f"Job: '{key}' --> No report exists.")
76 |
77 | logger.debug(f"Job: '{key}' --> Requested report: {report}")
78 | return jsonify(report)
79 |
80 | except JobNotFoundException as e:
81 | logger.error(e)
82 | return make_response(jsonify(error=str(e)), HTTPStatus.NOT_FOUND)
83 |
84 | except JobStillRunningException:
85 | logger.debug(f"Job: '{key}' --> still running.")
86 | return make_response(
87 | jsonify(
88 | status="running",
89 | key=key,
90 | result_url=self.__build_result_url(key),
91 | ),
92 | HTTPStatus.OK,
93 | )
94 |
95 | except Exception as e:
96 | logger.error(e)
97 | return make_response(jsonify(error=str(e)), HTTPStatus.BAD_REQUEST)
98 |
99 | def post(self):
100 | key: str = ""
101 | try:
102 | logger.info(
103 | f"Received request for endpoint: '{request.url_rule}'. "
104 | f"Requester: '{request.remote_addr}'."
105 | )
106 | # Check if request data is correct and parse it
107 | cmd, timeout, callback_context, key = runner_parser.parse_req(
108 | request, self.command_name
109 | )
110 |
111 | # run executor job in background
112 | future = self.executor.submit_stored(
113 | future_key=key,
114 | fn=runner_parser.run_command,
115 | cmd=cmd,
116 | timeout=timeout,
117 | key=key,
118 | )
119 | # callback that removes the temporary directory
120 | future.add_done_callback(runner_parser.cleanup_temp_dir)
121 | if self.user_callback_fn:
122 | # user defined callback fn with callback_context if any
123 | future.add_done_callback(
124 | functools.partial(self.user_callback_fn, callback_context)
125 | )
126 |
127 | logger.info(f"Job: '{key}' --> added to queue for command: {cmd}")
128 | result_url = self.__build_result_url(key)
129 | return make_response(
130 | jsonify(status="running", key=key, result_url=result_url),
131 | HTTPStatus.ACCEPTED,
132 | )
133 |
134 | except Exception as e:
135 | logger.error(e)
136 | response_dict = {"error": str(e)}
137 | if key:
138 | response_dict["key"] = key
139 | response_dict["result_url"] = self.__build_result_url(key)
140 | return make_response(jsonify(response_dict), HTTPStatus.BAD_REQUEST)
141 |
142 | def delete(self):
143 | """
144 | Cancel (if running) and delete job by job key.
145 | Args:
146 | key (str):
147 | - Future key
148 | """
149 | try:
150 | key = request.args.get("key")
151 | logger.info(
152 | f"Job: '{key}' --> deletion requested. "
153 | f"Requester: '{request.remote_addr}'."
154 | )
155 | if not key:
156 | raise Exception("No key provided in arguments.")
157 |
158 | # get the future object
159 | future: Future = self.executor.futures._futures.get(key)
160 | if not future:
161 | raise JobNotFoundException(f"No job exists for key: '{key}'.")
162 |
163 | # cancel and delete from memory
164 | future.cancel()
165 | self.executor.futures.pop(key)
166 |
167 | return make_response({}, HTTPStatus.NO_CONTENT)
168 |
169 | except JobNotFoundException as e:
170 | logger.error(e)
171 | return make_response(jsonify(error=str(e)), HTTPStatus.NOT_FOUND)
172 |
173 | except Exception as e:
174 | logger.error(e)
175 | return make_response(jsonify(error=str(e)), HTTPStatus.BAD_REQUEST)
176 |
177 | @classmethod
178 | def __build_result_url(cls, key: str) -> str:
179 | return f"{request.base_url}?key={key}&wait=false"
180 |
181 | def __init__(
182 | self,
183 | command_name: str,
184 | user_callback_fn: Callable[[Dict, Future], Any],
185 | executor: Executor,
186 | ):
187 | self.command_name: str = command_name
188 | self.user_callback_fn = user_callback_fn
189 | self.executor: Executor = executor
190 |
--------------------------------------------------------------------------------
/flask_shell2http/base_entrypoint.py:
--------------------------------------------------------------------------------
1 | # system imports
2 | from collections import OrderedDict
3 | from typing import Any, Callable, Dict, List
4 |
5 | # web imports
6 | from flask_executor import Executor
7 | from flask_executor.futures import Future
8 |
9 | # lib imports
10 | from .api import Shell2HttpAPI
11 | from .helpers import get_logger
12 |
13 | logger = get_logger()
14 |
15 |
16 | class Shell2HTTP:
17 | """
18 | Flask-Shell2HTTP base entrypoint class.
19 | The only public API available to users.
20 |
21 | Attributes:
22 | app: Flask application instance.
23 | executor: Flask-Executor instance
24 | base_url_prefix (str): base prefix to apply to endpoints. Defaults to "/".
25 |
26 | Example::
27 |
28 | app = Flask(__name__)
29 | executor = Executor(app)
30 | shell2http = Shell2HTTP(app=app, executor=executor, base_url_prefix="/tasks/")
31 | """
32 |
33 | __commands: "OrderedDict[str, str]" = OrderedDict()
34 | __url_prefix: str = "/"
35 |
36 | def __init__(
37 | self, app=None, executor: Executor = None, base_url_prefix: str = "/"
38 | ) -> None:
39 | self.__url_prefix = base_url_prefix
40 | if app and executor:
41 | self.init_app(app, executor)
42 |
43 | def init_app(self, app, executor: Executor) -> None:
44 | """
45 | For use with Flask's `Application Factory`_ method.
46 |
47 | Example::
48 |
49 | executor = Executor()
50 | shell2http = Shell2HTTP(base_url_prefix="/commands/")
51 | app = Flask(__name__)
52 | executor.init_app(app)
53 | shell2http.init_app(app=app, executor=executor)
54 |
55 | .. _Application Factory:
56 | https://flask.palletsprojects.com/en/1.1.x/patterns/appfactories/
57 | """
58 | self.app = app
59 | self.__executor: Executor = executor
60 | self.__init_extension()
61 |
62 | def __init_extension(self) -> None:
63 | """
64 | Adds the Shell2HTTP() instance to `app.extensions` list.
65 | For internal use only.
66 | """
67 | if not hasattr(self.app, "extensions"):
68 | self.app.extensions = {}
69 |
70 | self.app.extensions["shell2http"] = self
71 |
72 | def register_command(
73 | self,
74 | endpoint: str,
75 | command_name: str,
76 | callback_fn: Callable[[Dict, Future], Any] = None,
77 | decorators: List = None,
78 | ) -> None:
79 | """
80 | Function to map a shell command to an endpoint.
81 |
82 | Args:
83 | endpoint (str):
84 | - your command would live here: ``/{base_url_prefix}/{endpoint}``
85 | command_name (str):
86 | - The base command which can be executed from the given endpoint.
87 | - If ``command_name='echo'``, then all arguments passed
88 | to this endpoint will be appended to ``echo``.\n
89 | For example,
90 | if you pass ``{ "args": ["Hello", "World"] }``
91 | in POST request, it gets converted to ``echo Hello World``.\n
92 | callback_fn (Callable[[Dict, Future], Any]):
93 | - An optional function that is invoked when a requested process
94 | to this endpoint completes execution.
95 | - This is added as a
96 | ``concurrent.Future.add_done_callback(fn=callback_fn)``
97 | - The same callback function may be used for multiple commands.
98 | - if request JSON contains a `callback_context` attr, it will be passed
99 | as the first argument to this function.
100 | decorators (List[Callable]):
101 | - A List of view decorators to apply to the endpoint.
102 | - *New in version v1.5.0*
103 |
104 | Examples::
105 |
106 | def my_callback_fn(context: dict, future: Future) -> None:
107 | print(future.result(), context)
108 |
109 | shell2http.register_command(endpoint="echo", command_name="echo")
110 | shell2http.register_command(
111 | endpoint="myawesomescript",
112 | command_name="./fuxsocy.py",
113 | callback_fn=my_callback_fn,
114 | decorators=[],
115 | )
116 | """
117 | if decorators is None:
118 | decorators = []
119 | uri: str = self.__construct_route(endpoint)
120 | # make sure the given endpoint is not already registered
121 | cmd_already_exists = self.__commands.get(uri)
122 | if cmd_already_exists:
123 | logger.error(
124 | "Failed to register since given endpoint: "
125 | f"'{endpoint}' already maps to command: '{cmd_already_exists}'."
126 | )
127 | return None
128 |
129 | # else, add new URL rule
130 | view_func = Shell2HttpAPI.as_view(
131 | endpoint,
132 | command_name=command_name,
133 | user_callback_fn=callback_fn,
134 | executor=self.__executor,
135 | )
136 | # apply decorators, if any
137 | for dec in decorators:
138 | view_func = dec(view_func)
139 | # register URL rule
140 | self.app.add_url_rule(
141 | uri,
142 | view_func=view_func,
143 | )
144 | self.__commands.update({uri: command_name})
145 | logger.info(f"New endpoint: '{uri}' registered for command: '{command_name}'.")
146 |
147 | def get_registered_commands(self) -> "OrderedDict[str, str]":
148 | """
149 | Most of the time you won't need this since
150 | Flask provides a ``Flask.url_map`` attribute.
151 |
152 | Returns:
153 | OrderedDict[uri, command]
154 | i.e. mapping of registered commands and their URLs.
155 | """
156 | return self.__commands
157 |
158 | def __construct_route(self, endpoint: str) -> str:
159 | """
160 | For internal use only.
161 | """
162 | return self.__url_prefix + endpoint
163 |
--------------------------------------------------------------------------------
/flask_shell2http/classes.py:
--------------------------------------------------------------------------------
1 | # system imports
2 | import json
3 | import shutil
4 | import subprocess
5 | import tempfile
6 | import time
7 | from typing import Any, Dict, List, Optional, Tuple
8 |
9 | # web imports
10 | from flask_executor.futures import Future
11 | from werkzeug.utils import secure_filename
12 |
13 | try:
14 | from flask.helpers import safe_join
15 | except ImportError:
16 | from werkzeug.utils import safe_join
17 |
18 | # lib imports
19 | from .helpers import DEFAULT_TIMEOUT, gen_key, get_logger, list_replace
20 |
21 | logger = get_logger()
22 |
23 |
24 | class RunnerParser:
25 | """
26 | Utility class to parse incoming POST request
27 | data into meaningful arguments, generate key and run command.
28 | Internal use Only.
29 | """
30 |
31 | __tmpdirs: Dict[str, str] = {}
32 |
33 | @staticmethod
34 | def __parse_multipart_req(args: List[str], files) -> Tuple[List[str], str]:
35 | # Check if file part exists
36 | fnames = []
37 | for arg in args:
38 | if arg.startswith("@"):
39 | fnames.append(arg.strip("@"))
40 |
41 | if not fnames:
42 | raise Exception(
43 | "No filename(s) specified."
44 | "Please prefix file argument(s) with @ character."
45 | )
46 |
47 | # create a new temporary directory
48 | tmpdir: str = tempfile.mkdtemp()
49 | for fname in fnames:
50 | if fname not in files:
51 | raise Exception(f"No File part with filename: {fname} in request.")
52 | req_file = files.get(fname)
53 | filename = secure_filename(req_file.filename)
54 | # calc file location
55 | f_loc = safe_join(tmpdir, filename)
56 | # save file into temp directory
57 | req_file.save(f_loc)
58 | # replace @filename with it's file location in system
59 | list_replace(args, "@" + fname, f_loc)
60 |
61 | logger.debug(f"Request files saved under temp directory: '{tmpdir}'")
62 | return args, tmpdir
63 |
64 | def parse_req(
65 | self, request, base_command: str
66 | ) -> Tuple[List[str], int, Dict[str, Any], str]:
67 | # default values if request is w/o any data
68 | # i.e. just run-script
69 | tmpdir = None
70 | # default values
71 | args: List[str] = []
72 | timeout: int = DEFAULT_TIMEOUT
73 | callback_context: Dict[str, Any] = {}
74 | randomize_key = False
75 | if request.is_json:
76 | # request does not contain a file
77 | args = request.json.get("args", [])
78 | timeout = request.json.get("timeout", DEFAULT_TIMEOUT)
79 | callback_context = request.json.get("callback_context", {})
80 | randomize_key = request.json.get("force_unique_key", False)
81 | elif request.files:
82 | # request contains file and form_data
83 | data = json.loads(request.form.get("request_json", "{}"))
84 | received_args = data.get("args", [])
85 | timeout = data.get("timeout", DEFAULT_TIMEOUT)
86 | callback_context = data.get("callback_context", {})
87 | randomize_key = data.get("force_unique_key", False)
88 | args, tmpdir = self.__parse_multipart_req(received_args, request.files)
89 |
90 | cmd: List[str] = base_command.split(" ")
91 | cmd.extend(args)
92 | key: str = gen_key(cmd, randomize=randomize_key)
93 | if tmpdir:
94 | self.__tmpdirs.update({key: tmpdir})
95 |
96 | return cmd, timeout, callback_context, key
97 |
98 | def cleanup_temp_dir(self, future: Future) -> None:
99 | key: Optional[str] = future.result().get("key", None)
100 | if not key:
101 | return None
102 | tmpdir: Optional[str] = self.__tmpdirs.get(key, None)
103 | if not tmpdir:
104 | return None
105 |
106 | try:
107 | shutil.rmtree(tmpdir)
108 | logger.debug(
109 | f"Job: '{key}' --> Temporary directory: '{tmpdir}' "
110 | "successfully deleted."
111 | )
112 | self.__tmpdirs.pop(key)
113 | except Exception:
114 | logger.debug(
115 | f"Job: '{key}' --> Failed to clear Temporary directory: '{tmpdir}'."
116 | )
117 |
118 | @staticmethod
119 | def run_command(cmd: List[str], timeout: int, key: str) -> Dict[str, Any]:
120 | """
121 | This function is called by the executor to run given command
122 | using a subprocess asynchronously.
123 |
124 | :param cmd: List[str]
125 | command to run split as a list
126 | :param key: str
127 | future_key of particular Future instance
128 | :param timeout: int
129 | maximum timeout in seconds (default = 3600)
130 |
131 | :rtype: Dict[str, Any]
132 |
133 | :returns:
134 | A Concurrent.Future object where future.result() is the report
135 | """
136 | start_time: float = time.time()
137 | proc = subprocess.Popen(
138 | cmd,
139 | stdout=subprocess.PIPE,
140 | stderr=subprocess.PIPE,
141 | )
142 | stdout: Optional[str] = None
143 | stderr: Optional[str] = None
144 | returncode: int = 0
145 | try:
146 | outs, errs = proc.communicate(timeout=int(timeout))
147 | stdout = outs.decode("utf-8")
148 | stderr = errs.decode("utf-8")
149 | returncode = proc.returncode
150 | logger.info(f"Job: '{key}' --> finished with returncode: '{returncode}'.")
151 |
152 | except subprocess.TimeoutExpired:
153 | proc.kill()
154 | stdout, _ = [s.decode("utf-8") for s in proc.communicate()]
155 | stderr = f"command timedout after {timeout} seconds."
156 | returncode = proc.returncode
157 | logger.error(f"Job: '{key}' --> failed. Reason: \"{stderr}\".")
158 |
159 | except Exception as e:
160 | proc.kill()
161 | returncode = -1
162 | stdout = None
163 | stderr = str(e)
164 | logger.error(f"Job: '{key}' --> failed. Reason: \"{stderr}\".")
165 |
166 | end_time: float = time.time()
167 | process_time = end_time - start_time
168 | return dict(
169 | key=key,
170 | report=stdout,
171 | error=stderr,
172 | returncode=returncode,
173 | start_time=start_time,
174 | end_time=end_time,
175 | process_time=process_time,
176 | )
177 |
--------------------------------------------------------------------------------
/flask_shell2http/exceptions.py:
--------------------------------------------------------------------------------
1 | class JobNotFoundException(Exception):
2 | """
3 | Raised when no job exists for requested key.
4 | """
5 |
6 |
7 | class JobStillRunningException(Exception):
8 | """
9 | Raised when job is still running.
10 | """
11 |
--------------------------------------------------------------------------------
/flask_shell2http/helpers.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | import logging
3 | import uuid
4 | from typing import List
5 |
6 | from flask import current_app
7 |
8 | DEFAULT_TIMEOUT = 3600
9 |
10 |
11 | def list_replace(lst: List, old, new) -> None:
12 | """
13 | replace list elements (inplace)
14 | """
15 | idx = -1
16 | try:
17 | while True:
18 | i = lst.index(old, idx + 1)
19 | lst[i] = new
20 | except ValueError:
21 | pass
22 |
23 |
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 |
30 | def calc_hash(lst: List) -> str:
31 | """
32 | Internal use only.
33 | Calculates sha1sum of given command with it's byte-string.
34 | This is for non-cryptographic purpose,
35 | that's why a faster and insecure hashing algorithm is chosen.
36 | """
37 | current_app.config.get("Shell2HTTP_")
38 | to_hash = " ".join(lst).encode("utf-8")
39 | return hashlib.sha1(to_hash).hexdigest()
40 |
41 |
42 | def get_logger() -> logging.Logger:
43 | return logging.getLogger("flask_shell2http")
44 |
--------------------------------------------------------------------------------
/post-request-schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json-schema.org/draft-07/schema",
3 | "type": "object",
4 | "title": "The POST request schema",
5 | "default": {},
6 | "examples": [
7 | {
8 | "args": ["hello", "world"],
9 | "timeout": 60,
10 | "force_unique_key": true,
11 | "callback_context": {
12 | "read_result_from": "/path/to/saved/file",
13 | "force_success": true
14 | }
15 | }
16 | ],
17 | "required": [],
18 | "properties": {
19 | "args": {
20 | "type": "array",
21 | "title": "Arguments that will be appended to the base command",
22 | "default": [],
23 | "examples": [
24 | ["hello", "world"],
25 | ["@inputfile", "@someotherfile"]
26 | ]
27 | },
28 | "timeout": {
29 | "type": "integer",
30 | "title": "subprocess timeout in seconds",
31 | "description": "Maximum timeout after which subprocess fails if not already complete.",
32 | "default": 3600
33 | },
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 | },
40 | "callback_context": {
41 | "type": "object",
42 | "title": "Additional context for user defined callback function",
43 | "description": "This object will be passed as the first argument of user-defined callback function",
44 | "default": {},
45 | "examples": [
46 | {
47 | "read_result_from": "/path/to/saved/file",
48 | "force_success": true
49 | }
50 | ]
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/requirements.dev.txt:
--------------------------------------------------------------------------------
1 | flask_testing
2 | coverage
3 | tox
4 | tox-gh-actions
5 |
6 | black==22.3.0
7 | flake8==3.8.4
8 | pre-commit==2.9.2
9 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | flask>=1.0.0
2 | blinker
3 | flask-executor
4 | contextvars;python_version<"3.7"
5 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | """
2 | # Flask-Shell2HTTP
3 |
4 | A minimalist REST API wrapper for python's subprocess API.
5 | Execute shell commands asynchronously and safely from flask's endpoints.
6 |
7 | ##### Docs & Example usage on GitHub: https://github.com/eshaan7/flask-shell2http
8 | """
9 | import pathlib
10 |
11 | from setuptools import setup
12 |
13 | # The directory containing this file
14 | HERE = pathlib.Path(__file__).parent
15 |
16 | # The text of the README file
17 | README = (HERE / "README.md").read_text()
18 |
19 | # version
20 | VERSION = (HERE / "version.txt").read_text()
21 |
22 | GITHUB_URL = "https://github.com/eshaan7/flask-shell2http"
23 |
24 | requirements = (HERE / "requirements.txt").read_text().split("\n")
25 |
26 | requirements_test = (HERE / "requirements.dev.txt").read_text().split("\n")
27 |
28 | # This call to setup() does all the work
29 | setup(
30 | name="Flask-Shell2HTTP",
31 | version=VERSION,
32 | url=GITHUB_URL,
33 | license="BSD",
34 | author="Eshaan Bansal",
35 | author_email="eshaan7bansal@gmail.com",
36 | description="A minimalist REST API wrapper for python's subprocess API.",
37 | long_description=README,
38 | long_description_content_type="text/markdown",
39 | py_modules=["flask_shell2http"],
40 | zip_safe=False,
41 | packages=["flask_shell2http"],
42 | include_package_data=True,
43 | platforms="any",
44 | python_requires=">= 3.6",
45 | install_requires=requirements,
46 | classifiers=[
47 | "Environment :: Web Environment",
48 | "Intended Audience :: Developers",
49 | "Operating System :: OS Independent",
50 | "License :: OSI Approved :: BSD License",
51 | "Programming Language :: Python :: 3",
52 | "Programming Language :: Python :: 3.6",
53 | "Programming Language :: Python :: 3.7",
54 | "Programming Language :: Python :: 3.8",
55 | "Programming Language :: Python :: 3.9",
56 | "Programming Language :: Python :: 3.10",
57 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
58 | "Topic :: Software Development :: Libraries :: Python Modules",
59 | ],
60 | keywords="flask shell2http subprocess python",
61 | project_urls={
62 | "Documentation": GITHUB_URL,
63 | "Funding": "https://www.paypal.me/eshaanbansal",
64 | "Source": GITHUB_URL,
65 | "Tracker": "{}/issues".format(GITHUB_URL),
66 | },
67 | # List additional groups of dependencies here (e.g. development
68 | # dependencies). You can install these using the following syntax,
69 | # for example:
70 | # $ pip install -e .[dev,test]
71 | extras_require={
72 | "test": requirements + requirements_test,
73 | },
74 | )
75 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eshaan7/Flask-Shell2HTTP/9e0fe36b45a8baee486538eb7484b7c63f80f78b/tests/__init__.py
--------------------------------------------------------------------------------
/tests/_utils.py:
--------------------------------------------------------------------------------
1 | import time
2 |
3 | from flask_testing import TestCase
4 |
5 | post_req_keys = ("key", "status", "result_url")
6 |
7 | get_req_keys = (
8 | "end_time",
9 | "process_time",
10 | "error",
11 | "key",
12 | "report",
13 | "returncode",
14 | "start_time",
15 | )
16 |
17 |
18 | class CustomTestCase(TestCase):
19 | def fetch_result(self, key: str):
20 | uri = self.uri + f"?key={key}"
21 | running = True
22 | r = None
23 | while running:
24 | r = self.client.get(uri)
25 | status = r.json.get("status", None)
26 | if not status or status != "running":
27 | running = False
28 | # sleep a bit before next request
29 | time.sleep(0.5)
30 | return r
31 |
--------------------------------------------------------------------------------
/tests/test_basic.py:
--------------------------------------------------------------------------------
1 | import time
2 |
3 | from examples.basic import app, shell2http
4 | from tests._utils import CustomTestCase, get_req_keys, post_req_keys
5 |
6 |
7 | class TestBasic(CustomTestCase):
8 | uri = "/cmd/echo"
9 |
10 | @staticmethod
11 | def create_app():
12 | app.config["TESTING"] = True
13 | shell2http.register_command(endpoint="sleep", command_name="sleep")
14 | return app
15 |
16 | def test_keys_and_basic_sanity(self):
17 | # make request
18 | r1 = self.client.post(self.uri, json={"args": ["hello", "world"]})
19 | self.assertStatus(r1, 202)
20 | r1_json = r1.get_json()
21 | for k in post_req_keys:
22 | self.assertIn(k, r1_json)
23 | # fetch result
24 | r2 = self.fetch_result(r1_json["key"])
25 | self.assertStatus(r2, 200)
26 | r2_json = r2.get_json()
27 | for k in get_req_keys:
28 | self.assertIn(k, r2_json)
29 | self.assertEqual(r2_json["key"], r1_json["key"])
30 | self.assertEqual(r2_json["report"], "hello world\n")
31 | self.assertEqual(r2_json["returncode"], 0)
32 | self.assertEqual(
33 | r2_json["process_time"], r2_json["end_time"] - r2_json["start_time"]
34 | )
35 |
36 | def test_timeout_raises_error(self):
37 | # make request
38 | # timeout in seconds, default value is 3600
39 | r1 = self.client.post("/cmd/sleep", json={"args": ["5"], "timeout": 1})
40 | self.assertStatus(r1, 202)
41 | r1_json = r1.get_json()
42 | # sleep for sometime
43 | time.sleep(2)
44 | # fetch result
45 | r2 = self.fetch_result(r1_json["key"])
46 | self.assertStatus(r2, 200)
47 | r2_json = r2.get_json()
48 | print(r2_json)
49 | self.assertEqual(r2_json["key"], r1_json["key"])
50 | self.assertEqual(r2_json["report"], "")
51 | self.assertEqual(r2_json["error"], "command timedout after 1 seconds.")
52 | self.assertEqual(r2_json["returncode"], -9)
53 |
54 | def test_duplicate_request_raises_error(self):
55 | data = {"args": ["test_duplicate_request_raises_error"]}
56 | _ = self.client.post(self.uri, json=data)
57 | r2 = self.client.post(self.uri, json=data)
58 | self.assertStatus(
59 | r2, 400, message="future key would already exist thus bad request"
60 | )
61 | r2_json = r2.get_json()
62 | self.assertIn("error", r2_json)
63 | self.assertIn("key", r2_json)
64 | self.assertIn("result_url", r2_json)
65 |
66 | def test_duplicate_request_after_report_fetch(self):
67 | data = {"args": ["test_duplicate_request_after_report_fetch"]}
68 | # make 1st request
69 | r1 = self.client.post(self.uri, json=data)
70 | r1_json = r1.get_json()
71 | # wait for initial request to complete
72 | _ = self.fetch_result(r1_json["key"])
73 | # now make 2nd request
74 | r2 = self.client.post(self.uri, json=data)
75 | r2_json = r2.get_json()
76 | # should have same response
77 | self.assertDictEqual(r2_json, r1_json)
78 |
79 | def test_force_unique_key(self):
80 | data = {"args": ["test_force_unique_key"]}
81 | r1 = self.client.post(self.uri, json=data)
82 | r1_json = r1.get_json()
83 | r2 = self.client.post(self.uri, json={**data, "force_unique_key": True})
84 | r2_json = r2.get_json()
85 | self.assertNotEqual(r2_json["key"], r1_json["key"])
86 |
87 | def test_get_with_wait(self):
88 | # 1. POST request
89 | r1 = self.client.post("/cmd/sleep", json={"args": ["2"]})
90 | r1_json = r1.get_json()
91 | # 2. GET request with wait=True
92 | r2 = self.client.get(r1_json["result_url"].replace("false", "true"))
93 | r2_json = r2.get_json()
94 | # 3. asserts
95 | self.assertEqual(r2_json["key"], r1_json["key"])
96 | self.assertEqual(r2_json["report"], "")
97 | self.assertEqual(r2_json["error"], "")
98 | self.assertEqual(r2_json["returncode"], 0)
99 |
--------------------------------------------------------------------------------
/tests/test_callback_signal.py:
--------------------------------------------------------------------------------
1 | from examples.with_signals import app, my_signal
2 | from tests._utils import CustomTestCase
3 |
4 |
5 | class TestCallbackAndSignal(CustomTestCase):
6 | uri = "/echo/signal"
7 |
8 | @staticmethod
9 | def create_app():
10 | app.config["TESTING"] = True
11 | return app
12 |
13 | def test_callback_fn_gets_called(self):
14 | self.signal_was_called = False
15 |
16 | @my_signal.connect
17 | def handler(sender, **kwargs):
18 | self.signal_was_called = True
19 |
20 | data = {"args": ["test_callback_fn_gets_called"]}
21 | # make request
22 | r1 = self.client.post(self.uri, json=data)
23 | # fetch report
24 | _ = self.fetch_result(r1.json["key"])
25 | self.assertTrue(self.signal_was_called)
26 |
--------------------------------------------------------------------------------
/tests/test_decorators.py:
--------------------------------------------------------------------------------
1 | from examples.with_decorators import app
2 | from tests._utils import CustomTestCase
3 |
4 |
5 | class TestDecorators(CustomTestCase):
6 | public_uri = "/cmd/public/echo"
7 | private_uri = "/cmd/protected/echo"
8 | uri = public_uri # default
9 |
10 | @staticmethod
11 | def create_app():
12 | app.config["TESTING"] = True
13 | return app
14 |
15 | def test_decorators(self):
16 | data = {"args": ["hello", "world"]}
17 | # make request
18 | r1 = self.client.post(self.public_uri, json=data)
19 | self.assertStatus(
20 | r1,
21 | 202,
22 | message="202 status code because `login_required` decorator not applied",
23 | )
24 | r2 = self.client.post(self.private_uri, json=data)
25 | print(r1.json, r2.json)
26 | self.assertStatus(
27 | r2,
28 | 401,
29 | message="401 status code because `login_required` decorator was applied",
30 | )
31 |
--------------------------------------------------------------------------------
/tests/test_deletion.py:
--------------------------------------------------------------------------------
1 | from examples.deletion import app
2 | from tests._utils import CustomTestCase
3 |
4 |
5 | class TestDeletion(CustomTestCase):
6 | uri = "/sleep"
7 |
8 | @staticmethod
9 | def create_app():
10 | app.config["TESTING"] = True
11 | return app
12 |
13 | def test_delete__204(self):
14 | # create command process
15 | r1 = self.client.post(self.uri, json={"args": ["10"], "force_unique_key": True})
16 | r1_json = r1.get_json()
17 | self.assertStatus(r1, 202)
18 | # request cancellation: correct key
19 | r2 = self.client.delete(f"{self.uri}?key={r1_json['key']}")
20 | self.assertStatus(r2, 204)
21 |
22 | def test_delete__400(self):
23 | # create command process
24 | r1 = self.client.post(self.uri, json={"args": ["10"], "force_unique_key": True})
25 | self.assertStatus(r1, 202)
26 | # request cancellation: no key
27 | r2 = self.client.delete(f"{self.uri}?key=")
28 | self.assertStatus(r2, 400)
29 |
30 | def test_delete__404(self):
31 | # create command process
32 | r1 = self.client.post(self.uri, json={"args": ["10"], "force_unique_key": True})
33 | self.assertStatus(r1, 202)
34 | # request cancellation: invalid key
35 | r2 = self.client.delete(f"{self.uri}?key=abcdefg")
36 | self.assertStatus(r2, 404)
37 |
--------------------------------------------------------------------------------
/tests/test_multiple_files.py:
--------------------------------------------------------------------------------
1 | import io
2 | import json
3 |
4 | from examples.multiple_files import app
5 | from tests._utils import CustomTestCase
6 |
7 |
8 | class TestMultipleFiles(CustomTestCase):
9 | uri = "/cmd/strings"
10 |
11 | @staticmethod
12 | def create_app():
13 | app.config["TESTING"] = True
14 | return app
15 |
16 | def test_multiple_files(self):
17 | req_files = {
18 | "inputfile": (io.BytesIO(b"Test File #1"), "inputfile"),
19 | "someotherfile": (io.BytesIO(b"Test File #2"), "someotherfile"),
20 | }
21 | # the key should be `request_json` only.
22 | req_data = {
23 | "request_json": json.dumps({"args": ["@inputfile", "@someotherfile"]})
24 | }
25 | data = {**req_data, **req_files}
26 | # make request
27 | r1 = self.client.post(self.uri, data=data, content_type="multipart/form-data")
28 | key = r1.json["key"]
29 | r2 = self.fetch_result(key)
30 | r2_json = r2.get_json()
31 | self.assertEqual(r2_json["key"], key)
32 | self.assertEqual(r2_json["report"], "Test File #1\nTest File #2\n")
33 | self.assertEqual(r2_json["returncode"], 0)
34 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist =
3 | docs-html,
4 | py{36,37,38,39,310,311}-flask1
5 | py{36,37,38,39,310,311}-flask2
6 |
7 | [testenv]
8 | commands =
9 | coverage run -m unittest discover tests
10 | coverage combine
11 | coverage report -m
12 | coverage xml
13 | setenv =
14 | PIP_INDEX_URL = https://pypi.python.org/simple/
15 | deps =
16 | -r requirements.txt
17 | coverage
18 | flask_testing
19 | requests
20 | flask1: flask>=1.1.3,<2.0.0
21 | flask1: markupsafe==2.0.1
22 | flask2: flask>=2.0.0,<3.0.0
23 |
24 | [gh-actions]
25 | python =
26 | 3.6: py36
27 | 3.7: py37
28 | 3.8: py38, docs-html
29 | 3.9: py39
30 | 3.10: py310
31 | 3.11: py311
32 |
33 | [testenv:docs-html]
34 | deps =
35 | -r docs/source/requirements.txt
36 | commands = sphinx-build -b html -d docs/build/doctrees docs/source docs/build/html
37 |
--------------------------------------------------------------------------------
/version.txt:
--------------------------------------------------------------------------------
1 | 1.9.1
--------------------------------------------------------------------------------