├── .coveragerc ├── .github └── workflows │ ├── build_and_release.yml │ └── tests_and_coverage.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── examples ├── __globals__.py ├── bad.ext ├── bar │ ├── __init__.py │ └── baz.py ├── environ.py ├── exc.py ├── exit.py ├── foo.py ├── home │ └── index.py ├── hybrid.py ├── import.py ├── main.py ├── path.py ├── static │ ├── hello.gif │ ├── hello.mp4 │ └── index.html └── syntax.py ├── httpout ├── __init__.py ├── __main__.py ├── exceptions.py ├── httpout.py ├── request.py ├── response.py └── utils │ ├── __init__.py │ ├── modules.py │ └── modules.pyx ├── pyproject.toml ├── requirements.txt ├── setup.py ├── sonar-project.properties └── tests ├── __init__.py ├── __main__.py ├── test_cli.py ├── test_http.py └── utils.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | concurrency = multiprocessing,thread 3 | parallel = True 4 | relative_files = True 5 | source = httpout/ 6 | -------------------------------------------------------------------------------- /.github/workflows/build_and_release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | branches: ['build', 'release'] 6 | pull_request: 7 | branches: ['main', 'master'] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build_sdist: 12 | if: | 13 | startsWith(github.ref_name, 'build') || 14 | startsWith(github.ref_name, 'release') 15 | name: Build sdist 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Install dependencies 21 | run: | 22 | python -m venv --system-site-packages .local 23 | echo "$HOME/.local/bin" >> $GITHUB_PATH 24 | python -m pip install --upgrade pip build setuptools Cython 25 | 26 | - name: Build sdist and verify its integrity 27 | run: | 28 | python -m build --sdist --no-isolation 29 | for f in dist/httpout-*.tar.gz; do gzip -t "$f"; done 30 | 31 | - uses: actions/upload-artifact@v4 32 | with: 33 | name: dist-sdist 34 | path: dist/httpout-*.tar.gz 35 | 36 | build_wheels: 37 | if: | 38 | startsWith(github.ref_name, 'build') || 39 | startsWith(github.ref_name, 'release') 40 | name: Build ${{ matrix.cibw_python }} on ${{ matrix.os }} 41 | runs-on: ${{ matrix.os }} 42 | strategy: 43 | matrix: 44 | os: [ubuntu-latest] 45 | cibw_python: 46 | - 'cp37-*' 47 | - 'cp38-*' 48 | - 'cp39-*' 49 | - 'cp310-*' 50 | - 'cp311-*' 51 | - 'cp312-*' 52 | - 'cp313-*' 53 | - 'pp310-*' 54 | cibw_arch: ['x86_64'] 55 | timeout-minutes: 10 56 | steps: 57 | - uses: actions/checkout@v4 58 | 59 | - name: Install dependencies 60 | run: | 61 | python -m venv --system-site-packages .local 62 | echo "$HOME/.local/bin" >> $GITHUB_PATH 63 | python -m pip install --upgrade pip 64 | python -m pip install cibuildwheel==2.21.1 65 | PYTHON_VERSION=${{ matrix.cibw_python }} 66 | echo "PYTHON_VERSION=${PYTHON_VERSION%-*}" >> $GITHUB_ENV 67 | 68 | - name: Build wheels 69 | run: | 70 | python -m cibuildwheel --output-dir dist 71 | env: 72 | CIBW_BUILD_VERBOSITY: 1 73 | CIBW_BUILD: ${{ matrix.cibw_python }} 74 | CIBW_ARCHS: ${{ matrix.cibw_arch }} 75 | 76 | - name: Verify the existence of the '.so' files 77 | run: | 78 | for f in dist/httpout-*.whl; do 79 | unzip -l "$f" | grep '\shttpout/utils/modules\..*\.so$'; 80 | done 81 | 82 | - uses: actions/upload-artifact@v4 83 | with: 84 | name: dist-${{ matrix.os }}-${{ env.PYTHON_VERSION }} 85 | path: dist/httpout-*.whl 86 | 87 | release: 88 | if: | 89 | startsWith(github.ref_name, 'build') || 90 | startsWith(github.ref_name, 'release') 91 | name: Upload release to PyPI 92 | needs: ['build_sdist', 'build_wheels'] 93 | runs-on: ubuntu-latest 94 | environment: 95 | name: pypi 96 | url: https://pypi.org/p/httpout 97 | permissions: 98 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 99 | steps: 100 | - uses: actions/download-artifact@v4 101 | with: 102 | path: dist 103 | pattern: dist-* 104 | merge-multiple: true 105 | 106 | - run: | 107 | tree -L 2 108 | 109 | - name: Publish to PyPI 110 | if: ${{ startsWith(github.ref_name, 'release') }} 111 | uses: pypa/gh-action-pypi-publish@release/v1 112 | -------------------------------------------------------------------------------- /.github/workflows/tests_and_coverage.yml: -------------------------------------------------------------------------------- 1 | name: Tests and Coverage 2 | 3 | on: 4 | push: 5 | branches: ['testing'] 6 | pull_request: 7 | branches: ['main', 'master'] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | tests: 12 | name: Python ${{ matrix.python_version }} on ${{ matrix.os }} 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | python_version: 17 | - '3.13' 18 | - '3.12' 19 | - '3.11' 20 | - '3.9' 21 | - '3.8' 22 | - '3.7' 23 | - 'pypy3.10' 24 | os: ['ubuntu-20.04', 'windows-latest'] 25 | timeout-minutes: 10 26 | steps: 27 | - uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 2 30 | 31 | - name: List the modified files 32 | run: echo "FILES_MODIFIED=$(git diff --name-only HEAD HEAD^ | xargs)" >> $GITHUB_ENV 33 | if: ${{ !startsWith(matrix.os, 'windows-') }} 34 | 35 | - name: List the modified files (Windows) 36 | run: | 37 | $FILES_MODIFIED = $(git diff --name-only HEAD HEAD^) -join ' ' 38 | echo "FILES_MODIFIED=$FILES_MODIFIED" | Out-File -FilePath $env:GITHUB_ENV 39 | if: ${{ startsWith(matrix.os, 'windows-') }} 40 | 41 | - name: Setup Python 42 | uses: actions/setup-python@v4 43 | with: 44 | python-version: ${{ matrix.python_version }} 45 | if: ${{ contains(env.FILES_MODIFIED, '.py') }} 46 | 47 | - name: Install dependencies 48 | run: | 49 | python -m pip install --upgrade pip 50 | python -m pip install --upgrade coverage 51 | python -m pip install --upgrade awaiter 52 | python -m pip install --upgrade tremolo 53 | if: ${{ contains(env.FILES_MODIFIED, '.py') }} 54 | 55 | - name: Lint 56 | run: | 57 | python -m pip install --upgrade bandit 58 | python -m bandit --recursive httpout/ 59 | python -m pip install --upgrade flake8 60 | python -m flake8 httpout/ 61 | if: | 62 | contains(env.FILES_MODIFIED, '.py') && 63 | matrix.os == 'ubuntu-20.04' && matrix.python_version == '3.13' 64 | 65 | - name: Run tests 66 | run: python -m tests 67 | if: ${{ contains(env.FILES_MODIFIED, '.py') && matrix.python_version != '3.13' }} 68 | 69 | - name: Run tests with coverage 70 | run: | 71 | python -m coverage run -m tests 72 | python -m coverage combine 73 | mkdir artifact && mv .coverage artifact/.coverage.${{ matrix.os }} 74 | if: | 75 | contains(env.FILES_MODIFIED, '.py') && 76 | matrix.python_version == '3.13' && !startsWith(matrix.os, 'windows-') 77 | 78 | - name: Run tests with coverage (Windows) 79 | run: | 80 | python -m coverage run -m tests 81 | python -m coverage combine 82 | mkdir artifact && move .coverage artifact\.coverage.windows 83 | shell: cmd 84 | if: | 85 | contains(env.FILES_MODIFIED, '.py') && 86 | matrix.python_version == '3.13' && startsWith(matrix.os, 'windows-') 87 | 88 | - uses: actions/upload-artifact@v4 89 | with: 90 | name: artifact-${{ matrix.os }} 91 | path: artifact 92 | include-hidden-files: true 93 | if: ${{ contains(env.FILES_MODIFIED, '.py') && matrix.python_version == '3.13' }} 94 | 95 | report: 96 | name: Upload coverage to SonarCloud Scan 97 | needs: ['tests'] 98 | runs-on: ubuntu-latest 99 | steps: 100 | - uses: actions/checkout@v4 101 | with: 102 | fetch-depth: 2 103 | 104 | - name: List the modified files 105 | run: echo "FILES_MODIFIED=$(git diff --name-only HEAD HEAD^ | xargs)" >> $GITHUB_ENV 106 | 107 | - name: Install dependencies 108 | run: | 109 | python -m venv --system-site-packages .local 110 | echo "$HOME/.local/bin" >> $GITHUB_PATH 111 | python -m pip install --upgrade pip 112 | python -m pip install --upgrade coverage 113 | python -m pip install --upgrade awaiter 114 | python -m pip install --upgrade tremolo 115 | if: ${{ contains(env.FILES_MODIFIED, '.py') }} 116 | 117 | - uses: actions/download-artifact@v4 118 | with: 119 | path: artifact 120 | pattern: artifact-* 121 | merge-multiple: true 122 | if: ${{ contains(env.FILES_MODIFIED, '.py') }} 123 | 124 | - name: Combine and view report 125 | run: | 126 | python -m coverage combine artifact 127 | python -m coverage report --show-missing --skip-covered 128 | python -m coverage xml 129 | if: ${{ contains(env.FILES_MODIFIED, '.py') }} 130 | 131 | - uses: sonarsource/sonarcloud-github-action@v3 132 | env: 133 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # needed to get PR information, if any 134 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 135 | if: ${{ contains(env.FILES_MODIFIED, '.py') }} 136 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | share/python-wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .nox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | *.py,cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | cover/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | .pybuilder/ 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | # For a library or package, you might want to ignore these files since the code is 86 | # intended to run in multiple environments; otherwise, check them in: 87 | # .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # poetry 97 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 98 | # This is especially recommended for binary packages to ensure reproducibility, and is more 99 | # commonly ignored for libraries. 100 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 101 | #poetry.lock 102 | 103 | # pdm 104 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 105 | #pdm.lock 106 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 107 | # in version control. 108 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 109 | .pdm.toml 110 | .pdm-python 111 | .pdm-build/ 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 nggit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft httpout 2 | prune tests 3 | global-exclude *.py[cod] __pycache__ 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # httpout 2 | [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=nggit_httpout&metric=coverage)](https://sonarcloud.io/summary/new_code?id=nggit_httpout) 3 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=nggit_httpout&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=nggit_httpout) 4 | 5 | httpout is a runtime environment for Python files. It allows you to execute your Python scripts from a web URL, the `print()` output goes to your browser. 6 | 7 | This is the classic way to deploy your scripts to the web. 8 | You just need to put your regular `.py` files as well as other static files in the document root and each will be routable from the web. No server reload is required! 9 | 10 | It provides a native experience for running your script from the web. 11 | 12 | ## How does it work? 13 | httpout will assign every route either like `/hello.py` or `/index.py` with the name `__main__` and executes its corresponding file as a module in a thread pool. 14 | Monkey patching is done at the module-level by hijacking the [`__import__`](https://docs.python.org/3/library/functions.html#import__). 15 | 16 | In the submodules perspective, the `__main__` object points to the main module such as `/hello.py`, rather than pointing to `sys.modules['__main__']` or the web server itself. 17 | 18 | httpout does not perform a cache mechanism like standard imports or with [sys.modules](https://docs.python.org/3/library/sys.html#sys.modules) to avoid conflicts with other modules / requests. Because each request must have its own namespace. 19 | 20 | To keep it simple, only the main module is cached (as code object). 21 | The cache will be valid during HTTP Keep-Alive. 22 | So if you just change the script there is no need to reload the server process, just wait until the connection is lost. 23 | 24 | Keep in mind this may not work for running complex python scripts, 25 | e.g. running other server processes or multithreaded applications as each route is not a real main thread. 26 | 27 | ![httpout](https://raw.githubusercontent.com/nggit/httpout/main/examples/static/hello.gif) 28 | 29 | ## Installation 30 | ``` 31 | python3 -m pip install --upgrade httpout 32 | ``` 33 | 34 | ## Example 35 | ```python 36 | # hello.py 37 | import time 38 | 39 | 40 | print('
Hello...')
 41 | 
 42 | time.sleep(1)
 43 | print('and')
 44 | 
 45 | time.sleep(2)
 46 | print('Bye!
') 47 | ``` 48 | 49 | Put `hello.py` in the `examples/` folder, then run the httpout server with: 50 | ``` 51 | python3 -m httpout --port 8000 examples/ 52 | ``` 53 | 54 | and your `hello.py` can be accessed at [http://localhost:8000/hello.py](http://localhost:8000/hello.py). 55 | If you don't want the `.py` suffix in the URL, you can instead create a `hello/` folder with `index.py` inside. 56 | 57 | ## Handling forms 58 | This is an overview of how to view request methods and read form data. 59 | 60 | ```python 61 | # form.py 62 | from httpout import wait, request, response 63 | 64 | 65 | method_str = request.environ['REQUEST_METHOD'] 66 | method_bytes = request.method 67 | 68 | 69 | if method_str != 'POST': 70 | response.set_status(405, 'Method Not Allowed') 71 | print('Method Not Allowed') 72 | exit() 73 | 74 | 75 | # we can't use await outside the async context 76 | # so wait() is used here because request.form() is a coroutine object 77 | form_data = wait(request.form()) 78 | 79 | print(method_str, method_bytes, form_data) 80 | ``` 81 | 82 | It can also be written this way: 83 | ```python 84 | # form.py 85 | from httpout import run, request, response 86 | 87 | 88 | method_str = request.environ['REQUEST_METHOD'] 89 | method_bytes = request.method 90 | 91 | 92 | if method_str != 'POST': 93 | response.set_status(405, 'Method Not Allowed') 94 | print('Method Not Allowed') 95 | exit() 96 | 97 | 98 | async def main(): 99 | # using await instead of wait() 100 | form_data = await request.form() 101 | 102 | print(method_str, method_bytes, form_data) 103 | 104 | 105 | run(main()) 106 | ``` 107 | 108 | Then you can do: 109 | ``` 110 | curl -d foo=bar http://localhost:8000/form.py 111 | ``` 112 | 113 | ## Features 114 | httpout is designed to be fun. It's not built for perfectionists. httpout has: 115 | - A [hybrid async and sync](https://httpout.github.io/hybrid.html), the two worlds can coexist in your script seamlessly; It's not yet time to drop your favorite synchronous library 116 | - More lightweight than running CGI scripts 117 | - Your `print()`s are sent immediately line by line without waiting for the script to finish like a typical CGI 118 | - No need for a templating engine, just do `if-else` and `print()` making your script portable for both CLI and web 119 | - And more 120 | 121 | ## Security 122 | It's important to note that httpout only focuses on request security; 123 | to ensure that [path traversal](https://en.wikipedia.org/wiki/Directory_traversal_attack) through the URL never happens. 124 | 125 | httpout will never validate the script you write, 126 | you can still access objects like `os`, `eval()`, `open()`, even traversal out of the document root. 127 | So this stage is your responsibility. 128 | 129 | FYI, PHP used to have something called [Safe Mode](https://web.archive.org/web/20201014032613/https://www.php.net/manual/en/features.safe-mode.php), but it was deemed *architecturally incorrect*, so they removed it. 130 | 131 | > The PHP safe mode is an attempt to solve the shared-server security problem. 132 | > It is architecturally incorrect to try to solve this problem at the PHP level, 133 | > but since the alternatives at the web server and OS levels aren't very realistic, 134 | > many people, especially ISP's, use safe mode for now. 135 | 136 | ## License 137 | MIT License 138 | -------------------------------------------------------------------------------- /examples/__globals__.py: -------------------------------------------------------------------------------- 1 | 2 | import __main__ 3 | 4 | from httpout import app, modules 5 | 6 | # just for testing. the only thing that matters here is the `app` :) 7 | assert __main__ is modules['__globals__'] 8 | 9 | # in routes it should be available as `__globals__.counter` 10 | # you can't access this from inside the middleware, btw 11 | counter = 0 12 | 13 | 14 | # this middleware is usually not placed here but in a separate package 15 | class _MyMiddleware: 16 | def __init__(self, app): 17 | app.add_middleware(self._on_request, 'request') 18 | app.add_middleware(self._on_response, 'response') 19 | 20 | async def _on_request(self, **server): 21 | response = server['response'] 22 | 23 | response.set_header('X-Powered-By', 'foo') 24 | response.set_header('X-Debug', 'bar') 25 | 26 | async def _on_response(self, **server): 27 | response = server['response'] 28 | 29 | if not response.headers_sent(): 30 | del response.headers[b'x-debug'] 31 | 32 | 33 | app.logger.info('entering %s', __file__) 34 | 35 | # apply middleware 36 | _MyMiddleware(app) 37 | 38 | 39 | @app.on_worker_stop 40 | async def _on_worker_stop(**worker): 41 | app.logger.info('exiting %s', __file__) 42 | 43 | # incremented in `main.py` 44 | assert counter > 0 45 | -------------------------------------------------------------------------------- /examples/bad.ext: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nggit/httpout/29a7f1f63b7d63c0292a4cfb96dbc6b984ecc5d9/examples/bad.ext -------------------------------------------------------------------------------- /examples/bar/__init__.py: -------------------------------------------------------------------------------- 1 | # package: bar 2 | 3 | # test import itself 4 | import bar 5 | import httpout.run 6 | 7 | # test relative imports 8 | from . import baz # noqa: F401 9 | from .baz import world # noqa: F401 10 | 11 | assert httpout is bar 12 | assert httpout.run is run # noqa: F821 13 | -------------------------------------------------------------------------------- /examples/bar/baz.py: -------------------------------------------------------------------------------- 1 | 2 | import __main__ 3 | 4 | 5 | def world(): 6 | print('World!') 7 | __main__.MESSAGES[0] = 'OK' 8 | -------------------------------------------------------------------------------- /examples/environ.py: -------------------------------------------------------------------------------- 1 | 2 | from httpout import request 3 | 4 | print(request.method, request.environ['REQUEST_URI']) 5 | -------------------------------------------------------------------------------- /examples/exc.py: -------------------------------------------------------------------------------- 1 | 2 | from httpout import run 3 | from httpout.exceptions import WebSocketException 4 | 5 | 6 | print('Hi') 7 | 8 | 9 | async def main(): 10 | raise WebSocketException('') 11 | 12 | 13 | run(main()) 14 | -------------------------------------------------------------------------------- /examples/exit.py: -------------------------------------------------------------------------------- 1 | 2 | from httpout import __server__ 3 | 4 | print('Hello, ', end='') 5 | 6 | if __server__['QUERY_STRING']: 7 | exit(__server__['QUERY_STRING'] + '!\n') 8 | 9 | exit(0) 10 | -------------------------------------------------------------------------------- /examples/foo.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | import __main__ 4 | 5 | from httpout import response 6 | from bar import world 7 | 8 | response.append_header('Foo', 'bar') 9 | response.set_header('Foo', 'baz') 10 | 11 | 12 | class Foo: 13 | def __del__(self): 14 | if sys is None or sys.implementation.name == 'cpython': 15 | print(hello) 16 | 17 | async def hello(self): 18 | response.set_cookie('foo', 'bar') 19 | response.set_status(201, 'Created') 20 | response.set_content_type('text/plain') 21 | await response.write(b'Hello\n') 22 | world() 23 | 24 | if sys.implementation.name != 'cpython': 25 | __main__.MESSAGES.append(None) 26 | 27 | 28 | foo = Foo() 29 | hello = foo.hello 30 | 31 | # test recursive 32 | foo.bar = foo 33 | -------------------------------------------------------------------------------- /examples/home/index.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nggit/httpout/29a7f1f63b7d63c0292a4cfb96dbc6b984ecc5d9/examples/home/index.py -------------------------------------------------------------------------------- /examples/hybrid.py: -------------------------------------------------------------------------------- 1 | 2 | import asyncio 3 | 4 | 5 | async def main(): 6 | await asyncio.sleep(0.5) 7 | print('Done!') 8 | 9 | 10 | run(main()) # noqa: F821 11 | print('OK') 12 | # leaves the thread while main() is still running 13 | # should print the 'OK' first 14 | -------------------------------------------------------------------------------- /examples/import.py: -------------------------------------------------------------------------------- 1 | 2 | from httpout import globals, context, app # noqa: F401 3 | from httpout import foo # noqa: F401 4 | -------------------------------------------------------------------------------- /examples/main.py: -------------------------------------------------------------------------------- 1 | 2 | import asyncio 3 | import time 4 | 5 | # `__globals__` and `wait` are built-in. 6 | # but can be optionally imported to satisfy linters 7 | from httpout import __globals__, wait 8 | from foo import hello 9 | 10 | MESSAGES = ['Done!'] 11 | 12 | time.sleep(0.1) 13 | 14 | 15 | async def main(): 16 | __globals__.counter += 1 17 | 18 | await hello() 19 | await asyncio.sleep(0.1) 20 | 21 | 22 | if __name__ == '__main__': 23 | wait(main()) 24 | 25 | for message in MESSAGES: 26 | print(message) 27 | -------------------------------------------------------------------------------- /examples/path.py: -------------------------------------------------------------------------------- 1 | 2 | print(__server__['SCRIPT_NAME'], __server__['PATH_INFO']) # noqa: F821 3 | -------------------------------------------------------------------------------- /examples/static/hello.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nggit/httpout/29a7f1f63b7d63c0292a4cfb96dbc6b984ecc5d9/examples/static/hello.gif -------------------------------------------------------------------------------- /examples/static/hello.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nggit/httpout/29a7f1f63b7d63c0292a4cfb96dbc6b984ecc5d9/examples/static/hello.mp4 -------------------------------------------------------------------------------- /examples/static/index.html: -------------------------------------------------------------------------------- 1 |

Hello, World!

-------------------------------------------------------------------------------- /examples/syntax.py: -------------------------------------------------------------------------------- 1 | 2 | return # noqa: 706 3 | -------------------------------------------------------------------------------- /httpout/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 nggit 2 | 3 | __version__ = '0.1.0' 4 | __all__ = ('HTTPOut',) 5 | 6 | from .httpout import HTTPOut # noqa: E402 7 | -------------------------------------------------------------------------------- /httpout/__main__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 nggit 2 | 3 | import sys 4 | 5 | import tremolo 6 | 7 | from httpout import __version__, HTTPOut 8 | 9 | app = tremolo.Application() 10 | 11 | HTTPOut(app) 12 | 13 | 14 | def usage(**context): 15 | print('Usage: python3 -m httpout [OPTIONS] DOCUMENT_ROOT') 16 | print() 17 | print('Example:') 18 | print(' python3 -m httpout --port 8080 /home/user/public_html') 19 | print(' python3 -m httpout --debug --port 8080 /home/user/public_html') 20 | print() 21 | print('Options:') 22 | print(' --host Listen host. Defaults to "127.0.0.1"') 23 | print(' --port Listen port. Defaults to 8000') 24 | print(' --bind Address to bind.') 25 | print(' Instead of using --host or --port') 26 | print(' E.g. "127.0.0.1:8000" or "/tmp/file.sock"') # noqa: E501 27 | print(' Multiple binds can be separated by commas') # noqa: E501 28 | print(' E.g. "127.0.0.1:8000,:8001"') 29 | print(' --worker-num Number of worker processes. Defaults to 1') # noqa: E501 30 | print(' --thread-pool-size Number of executor threads per process') # noqa: E501 31 | print(' Defaults to 5') 32 | print(' --limit-memory Restart the worker if this limit (in KiB) is reached') # noqa: E501 33 | print(' (Linux-only). Defaults to 0 or unlimited') # noqa: E501 34 | print(' --ssl-cert SSL certificate location') 35 | print(' E.g. "/path/to/fullchain.pem"') 36 | print(' --ssl-key SSL private key location') 37 | print(' E.g. "/path/to/privkey.pem"') 38 | print(' --directory-index Index files to be served on directory-based URLs') # noqa: E501 39 | print(' Must be separated by commas. E.g. "index.py,index.html"') # noqa: E501 40 | print(' --debug Enable debug mode') 41 | print(' Intended for development') 42 | print(' --log-level Defaults to "DEBUG". See') 43 | print(' https://docs.python.org/3/library/logging.html#levels') # noqa: E501 44 | print(' --log-fmt Python\'s log format. If empty defaults to "%(message)s"') # noqa: E501 45 | print(' --loop A fully qualified event loop name') 46 | print(' E.g. "asyncio" or "asyncio.SelectorEventLoop"') # noqa: E501 47 | print(' It expects the respective module to already be present') # noqa: E501 48 | print(' --shutdown-timeout Maximum number of seconds to wait after SIGTERM is') # noqa: E501 49 | print(' sent to a worker process. Defaults to 30 (seconds)') # noqa: E501 50 | print(' --version Print the httpout version and exit') 51 | print(' --help Show this help and exit') 52 | print() 53 | print('Please run "python3 -m tremolo --help" to see more available options') # noqa: E501 54 | return 0 55 | 56 | 57 | def bind(value='', **context): 58 | context['options']['host'] = None 59 | 60 | try: 61 | for bind in value.split(','): 62 | if ':\\' not in bind and ':' in bind: 63 | host, port = bind.rsplit(':', 1) 64 | app.listen(int(port), host=host.strip('[]') or None) 65 | else: 66 | app.listen(bind) 67 | except ValueError: 68 | print(f'Invalid --bind value "{value}"') 69 | return 1 70 | 71 | 72 | def version(**context): 73 | print( 74 | 'httpout %s (tremolo %s, %s %d.%d.%d, %s)' % 75 | (__version__, 76 | tremolo.__version__, 77 | sys.implementation.name, 78 | *sys.version_info[:3], 79 | sys.platform) 80 | ) 81 | return 0 82 | 83 | 84 | def threads(value, **context): 85 | try: 86 | context['options']['thread_pool_size'] = int(value) 87 | except ValueError: 88 | print( 89 | f'Invalid --thread-pool-size value "{value}". It must be a number' 90 | ) 91 | return 1 92 | 93 | 94 | def indexes(value, **context): 95 | context['options']['directory_index'] = value.split(',') 96 | 97 | 98 | if __name__ == '__main__': 99 | options = tremolo.utils.parse_args( 100 | help=usage, bind=bind, version=version, thread_pool_size=threads, 101 | directory_index=indexes 102 | ) 103 | 104 | if sys.argv[-1] != sys.argv[0] and not sys.argv[-1].startswith('-'): 105 | options['document_root'] = sys.argv[-1] 106 | 107 | if 'document_root' not in options: 108 | print('You must specify DOCUMENT_ROOT. Use "--help" for help') 109 | sys.exit(1) 110 | 111 | if 'server_name' not in options: 112 | options['server_name'] = 'HTTPOut' 113 | 114 | app.run(**options) 115 | -------------------------------------------------------------------------------- /httpout/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 nggit 2 | 3 | from tremolo.exceptions import ( # noqa: F401 4 | WebSocketException, 5 | WebSocketClientClosed, 6 | WebSocketServerClosed 7 | ) 8 | -------------------------------------------------------------------------------- /httpout/httpout.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 nggit 2 | 3 | import asyncio 4 | import builtins 5 | import os 6 | import sys 7 | 8 | from types import ModuleType 9 | 10 | from awaiter import MultiThreadExecutor 11 | from tremolo.exceptions import BadRequest, NotFound, Forbidden 12 | from tremolo.lib.websocket import WebSocket 13 | from tremolo.utils import html_escape 14 | 15 | from .request import HTTPRequest 16 | from .response import HTTPResponse 17 | from .utils import ( 18 | is_safe_path, new_module, exec_module, cleanup_modules, mime_types 19 | ) 20 | 21 | 22 | class HTTPOut: 23 | def __init__(self, app): 24 | app.add_hook(self._on_worker_start, 'worker_start') 25 | app.add_hook(self._on_worker_stop, 'worker_stop') 26 | app.add_middleware(self._on_request, 'request', priority=9999) # low 27 | app.add_middleware(self._on_close, 'close') 28 | 29 | async def _on_worker_start(self, **worker): 30 | loop = worker['loop'] 31 | logger = worker['logger'] 32 | g = worker['globals'] 33 | thread_pool_size = g.options.get('thread_pool_size', 5) 34 | document_root = os.path.abspath( 35 | g.options.get('document_root', os.getcwd()) 36 | ) 37 | g.options['document_root'] = document_root 38 | g.options['directory_index'] = g.options.get( 39 | 'directory_index', ['index.py', 'index.html'] 40 | ) 41 | 42 | logger.info('entering directory: %s', document_root) 43 | os.chdir(document_root) 44 | sys.path.insert(0, document_root) 45 | 46 | # provides __globals__, a worker-level context 47 | module = new_module('__globals__') 48 | worker['__globals__'] = module or ModuleType('__globals__') 49 | worker['modules'] = {'__globals__': worker['__globals__']} 50 | py_import = builtins.__import__ 51 | 52 | def wait(coro, timeout=None): 53 | return asyncio.run_coroutine_threadsafe(coro, loop).result(timeout) 54 | 55 | def load_module(name, globals, level=0): 56 | if '__server__' in globals: 57 | modules = globals['__main__'].__server__['modules'] 58 | else: 59 | modules = worker['modules'] 60 | 61 | if name in modules: 62 | # already imported 63 | return modules[name] 64 | 65 | module = new_module(name, level, document_root) 66 | 67 | if module: 68 | logger.info('%s: importing %s', globals['__name__'], name) 69 | 70 | if '__server__' in globals: 71 | module.__main__ = globals['__main__'] 72 | module.__server__ = globals['__main__'].__server__ 73 | module.print = globals['__main__'].print 74 | module.run = globals['__main__'].run 75 | module.wait = wait 76 | 77 | modules[name] = module 78 | exec_module(module) 79 | 80 | return module 81 | 82 | def ho_import(name, globals=None, locals=None, fromlist=(), level=0): 83 | if (name not in sys.builtin_module_names and 84 | globals is not None and '__file__' in globals and 85 | globals['__file__'].startswith(document_root)): 86 | # satisfy import __main__ 87 | if name == '__main__': 88 | logger.info('%s: importing __main__', globals['__name__']) 89 | 90 | if globals['__name__'] == '__globals__': 91 | return worker['__globals__'] 92 | 93 | return globals['__main__'] 94 | 95 | oldname = name 96 | 97 | if level > 0: 98 | name = globals['__name__'].rsplit('.', level)[0] 99 | 100 | if oldname != '': 101 | name = f'{name}.{oldname}' 102 | 103 | module = load_module(name, globals, level) 104 | 105 | if module: 106 | if oldname == '': 107 | # relative import 108 | for child in fromlist: 109 | module.__dict__[child] = load_module( 110 | f'{name}.{child}', globals 111 | ) 112 | 113 | return module 114 | 115 | parent, _, child = name.partition('.') 116 | 117 | if parent == 'httpout' and (child == '' or child in globals): 118 | if '__server__' in globals: 119 | module = globals['__main__'].__server__['modules'][ 120 | globals['__name__'] 121 | ] 122 | else: 123 | module = worker['modules'][globals['__name__']] 124 | 125 | # handles virtual imports, 126 | # e.g. from httpout import request, response 127 | if fromlist: 128 | for child in fromlist: 129 | if child in module.__dict__: 130 | continue 131 | 132 | if ('__server__' in globals and 133 | child in module.__server__): 134 | module.__dict__[child] = module.__server__[ 135 | child 136 | ] 137 | elif child in worker: 138 | module.__dict__[child] = worker[child] 139 | else: 140 | raise ImportError( 141 | f'cannot import name \'{child}\' ' 142 | f'from \'{name}\'' 143 | ) 144 | 145 | return module 146 | 147 | return py_import(name, globals, locals, fromlist, level) 148 | 149 | builtins.__import__ = ho_import 150 | builtins.__globals__ = worker['__globals__'] 151 | builtins.exit = sys.exit 152 | 153 | g.wait = wait 154 | g.caches = {} 155 | g.executor = MultiThreadExecutor(thread_pool_size) 156 | g.executor.start() 157 | 158 | if module: 159 | exec_module(module) 160 | 161 | async def _on_worker_stop(self, **worker): 162 | g = worker['globals'] 163 | 164 | await g.executor.shutdown() 165 | 166 | async def _on_request(self, **server): 167 | request = server['request'] 168 | response = server['response'] 169 | logger = server['logger'] 170 | ctx = server['context'] 171 | g = server['globals'] 172 | document_root = g.options['document_root'] 173 | 174 | if not request.is_valid: 175 | raise BadRequest 176 | 177 | # no need to unquote path 178 | # in fact, the '%' character in the path will be rejected. 179 | # httpout strictly uses A-Z a-z 0-9 - _ . for directory names 180 | # which does not need the use of percent-encoding 181 | path = request.path.decode('latin-1') 182 | path_info = path[(path + '.py/').find('.py/') + 3:] 183 | 184 | if path_info: 185 | path = path[:path.rfind(path_info)] 186 | path_info = os.path.normpath(path_info).replace(os.sep, '/') 187 | 188 | module_path = os.path.abspath( 189 | os.path.join(document_root, os.path.normpath(path.lstrip('/'))) 190 | ) 191 | 192 | if not module_path.startswith(document_root): 193 | raise Forbidden('Path traversal is not allowed') 194 | 195 | if '/.' in path and not path.startswith('/.well-known/'): 196 | raise Forbidden('Access to dotfiles is prohibited') 197 | 198 | if not is_safe_path(path): 199 | raise Forbidden('Unsafe URL detected') 200 | 201 | dirname, basename = os.path.split(module_path) 202 | ext = os.path.splitext(basename)[-1] 203 | request_uri = request.url.decode('latin-1') 204 | 205 | if ext == '': 206 | dirname = module_path 207 | 208 | # no file extension in the URL, try index.py, index.html, etc. 209 | for basename in g.options['directory_index']: 210 | module_path = os.path.join(dirname, basename) 211 | ext = os.path.splitext(basename)[-1] 212 | 213 | if os.path.exists(module_path): 214 | break 215 | 216 | if basename.startswith('_') or not os.path.isfile(module_path): 217 | raise NotFound('URL not found:', html_escape(request_uri)) 218 | 219 | if ext == '.py': 220 | # begin loading the module 221 | logger.info('%s -> __main__: %s', path, module_path) 222 | 223 | server['request'] = HTTPRequest(request, server) 224 | server['response'] = HTTPResponse(response) 225 | 226 | if (g.options['ws'] and 227 | b'sec-websocket-key' in request.headers and 228 | b'upgrade' in request.headers and 229 | request.headers[b'upgrade'].lower() == b'websocket'): 230 | server['websocket'] = WebSocket(request, response) 231 | else: 232 | server['websocket'] = None 233 | 234 | server['REQUEST_METHOD'] = request.method.decode('latin-1') 235 | server['SCRIPT_NAME'] = module_path[len(document_root):].replace( 236 | os.sep, '/' 237 | ) 238 | server['PATH_INFO'] = path_info 239 | server['QUERY_STRING'] = request.query_string.decode('latin-1') 240 | server['REMOTE_ADDR'] = request.ip.decode('latin-1') 241 | server['HTTP_HOST'] = request.host.decode('latin-1') 242 | server['REQUEST_URI'] = request_uri 243 | server['REQUEST_SCHEME'] = request.scheme.decode('latin-1') 244 | server['DOCUMENT_ROOT'] = document_root 245 | 246 | module = ModuleType('__main__') 247 | module.__file__ = module_path 248 | module.__main__ = module 249 | server['modules'] = {'__main__': module} 250 | module.__server__ = server 251 | module.print = server['response'].print 252 | module.run = server['response'].run_coroutine 253 | module.wait = g.wait 254 | code = g.caches.get(module_path, None) 255 | 256 | if code: 257 | logger.info('%s: using cache', path) 258 | 259 | try: 260 | # execute module in another thread 261 | result = await g.executor.submit(exec_module, module, code) 262 | await server['response'].join() 263 | 264 | if result: 265 | g.caches[module_path] = result 266 | logger.info('%s: cached', path) 267 | else: 268 | # cache is going to be deleted on @app.on_close 269 | # but it can be delayed on a Keep-Alive request 270 | ctx.module_path = module_path 271 | except BaseException as exc: 272 | await server['response'].join() 273 | await server['response'].handle_exception(exc) 274 | finally: 275 | await g.executor.submit( 276 | cleanup_modules, server['modules'], g.options['debug'] 277 | ) 278 | await server['response'].join() 279 | server['modules'].clear() 280 | # EOF 281 | return b'' 282 | 283 | # not a module 284 | if ext not in mime_types: 285 | raise Forbidden(f'Disallowed file extension: {ext}') 286 | 287 | logger.info('%s -> %s: %s', path, mime_types[ext], module_path) 288 | await response.sendfile( 289 | module_path, content_type=mime_types[ext], executor=g.executor 290 | ) 291 | # exit middleware without closing the connection 292 | return True 293 | 294 | async def _on_close(self, **server): 295 | logger = server['logger'] 296 | ctx = server['context'] 297 | g = server['globals'] 298 | 299 | if 'module_path' in ctx: 300 | g.caches[ctx.module_path] = None 301 | logger.info('cache deleted: %s', ctx.module_path) 302 | -------------------------------------------------------------------------------- /httpout/request.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 nggit 2 | 3 | 4 | class HTTPRequest: 5 | def __init__(self, request, environ): 6 | self.request = request 7 | self.environ = environ 8 | 9 | def __getattr__(self, name): 10 | return getattr(self.request, name) 11 | -------------------------------------------------------------------------------- /httpout/response.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 nggit 2 | 3 | import asyncio 4 | import concurrent.futures 5 | 6 | from traceback import TracebackException 7 | from tremolo.utils import html_escape 8 | 9 | 10 | class HTTPResponse: 11 | def __init__(self, response): 12 | self.response = response 13 | self.loop = response.request.protocol.loop 14 | self.logger = response.request.protocol.logger 15 | self.tasks = set() 16 | 17 | def __getattr__(self, name): 18 | return getattr(self.response, name) 19 | 20 | @property 21 | def protocol(self): # don't cache request.protocol 22 | return self.response.request.protocol 23 | 24 | def create_task(self, coro): 25 | task = self.loop.create_task(coro) 26 | 27 | self.tasks.add(task) 28 | task.add_done_callback(self.tasks.discard) 29 | 30 | async def join(self): 31 | while self.tasks: 32 | await self.tasks.pop() 33 | 34 | async def handle_exception(self, exc): 35 | if self.protocol is None or self.protocol.transport is None: 36 | return 37 | 38 | if self.protocol.transport.is_closing(): # maybe stuck? 39 | self.protocol.transport.abort() 40 | return 41 | 42 | if self.response.request.upgraded: 43 | await self.response.handle_exception(exc) 44 | else: 45 | self.response.request.http_keepalive = False 46 | 47 | if isinstance(exc, Exception): 48 | if not self.response.headers_sent(): 49 | self.response.set_status(500, b'Internal Server Error') 50 | self.response.set_content_type(b'text/html; charset=utf-8') 51 | 52 | if self.protocol.options['debug']: 53 | te = TracebackException.from_exception(exc) 54 | await self.response.write( 55 | b'\n' % b'
  • '.join( 56 | html_escape(line) 57 | .encode() for line in te.format() 58 | ) 59 | ) 60 | else: 61 | await self.response.write( 62 | f'\n' 64 | .encode() 65 | ) 66 | elif isinstance(exc, SystemExit): 67 | if exc.code: 68 | await self.response.write(str(exc.code).encode()) 69 | else: 70 | self.protocol.print_exception(exc) 71 | 72 | def run_coroutine(self, coro): 73 | fut = concurrent.futures.Future() 74 | 75 | async def callback(): 76 | try: 77 | result = await coro 78 | 79 | if not fut.done(): 80 | fut.set_result(result) 81 | except BaseException as exc: 82 | if not fut.done(): 83 | fut.set_result(None) 84 | 85 | await self.handle_exception(exc) 86 | 87 | self.loop.call_soon_threadsafe(self.create_task, callback()) 88 | return fut 89 | 90 | def call_soon(self, func, *args, **kwargs): 91 | try: 92 | loop = asyncio.get_running_loop() 93 | 94 | if loop is self.loop: 95 | return func(*args, **kwargs) 96 | except RuntimeError: 97 | pass 98 | 99 | fut = concurrent.futures.Future() 100 | 101 | def callback(): 102 | try: 103 | result = func(*args, **kwargs) 104 | 105 | if not fut.done(): 106 | fut.set_result(result) 107 | except BaseException as exc: 108 | if not fut.done(): 109 | fut.set_exception(exc) 110 | 111 | self.loop.call_soon_threadsafe(callback) 112 | return fut.result() 113 | 114 | def headers_sent(self, sent=False): 115 | return self.call_soon(self.response.headers_sent, sent) 116 | 117 | def append_header(self, name, value): 118 | self.call_soon(self.response.append_header, name, value) 119 | 120 | def set_header(self, name, value=''): 121 | self.call_soon(self.response.set_header, name, value) 122 | 123 | def set_cookie(self, name, value='', *, expires=0, path='/', domain=None, 124 | secure=False, httponly=False, samesite=None): 125 | self.call_soon( 126 | self.response.set_cookie, name, value, expires=expires, path=path, 127 | domain=domain, secure=secure, httponly=httponly, samesite=samesite 128 | ) 129 | 130 | def set_status(self, status=200, message='OK'): 131 | self.call_soon(self.response.set_status, status, message) 132 | 133 | def set_content_type(self, content_type='text/html; charset=utf-8'): 134 | self.call_soon(self.response.set_content_type, content_type) 135 | 136 | async def write(self, data, **kwargs): 137 | if not self.response.headers_sent(): 138 | await self.protocol.run_middlewares('response', reverse=True) 139 | 140 | await self.response.write(data, **kwargs) 141 | 142 | def print(self, *args, sep=' ', end='\n', **kwargs): 143 | coro = self.write((sep.join(map(str, args)) + end).encode()) 144 | 145 | try: 146 | loop = asyncio.get_running_loop() 147 | 148 | if loop is self.loop: 149 | self.create_task(coro) 150 | return 151 | except RuntimeError: 152 | pass 153 | 154 | self.loop.call_soon_threadsafe(self.create_task, coro) 155 | -------------------------------------------------------------------------------- /httpout/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 nggit 2 | 3 | __all__ = ( 4 | 'WORD_CHARS', 'PATH_CHARS', 'is_safe_path', 5 | 'new_module', 'exec_module', 'cleanup_modules', 'mime_types' 6 | ) 7 | 8 | import os # noqa: E402 9 | import sys # noqa: E402 10 | 11 | from types import ModuleType # noqa: E402 12 | 13 | from .modules import exec_module, cleanup_modules # noqa: E402 14 | 15 | # \w 16 | WORD_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_' 17 | PATH_CHARS = WORD_CHARS + '-/.' 18 | 19 | 20 | def is_safe_path(path): 21 | if len(path) > 255 or path.strip(PATH_CHARS) != '' or '..' in path: 22 | return False 23 | 24 | return True 25 | 26 | 27 | def new_module(name, level=0, document_root=None): 28 | if document_root is None: 29 | document_root = os.getcwd() 30 | 31 | module_path = os.path.join( 32 | document_root, 33 | name.replace('.', os.sep), '__init__.py' 34 | ) 35 | 36 | if not os.path.isfile(module_path): 37 | module_path = os.path.join( 38 | document_root, name.replace('.', os.sep) + '.py' 39 | ) 40 | 41 | if os.path.isfile(module_path): 42 | if name in sys.modules: 43 | if ('__file__' in sys.modules[name].__dict__ and 44 | sys.modules[name].__file__.startswith(document_root)): 45 | del sys.modules[name] 46 | 47 | raise ImportError(f'module name conflict: {name}') 48 | 49 | module = ModuleType(name) 50 | module.__file__ = module_path 51 | module.__package__ = ( 52 | os.path.dirname(module_path)[len(document_root):] 53 | .lstrip(os.sep) 54 | .rsplit(os.sep, level)[0] 55 | .replace(os.sep, '.') 56 | ) 57 | 58 | if name == module.__package__: 59 | module.__path__ = [os.path.dirname(module_path)] 60 | 61 | return module 62 | 63 | 64 | # https://developer.mozilla.org 65 | # /en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types 66 | mime_types = { 67 | '.aac': 'audio/aac', 68 | '.abw': 'application/x-abiword', 69 | '.apng': 'image/apng', 70 | '.arc': 'application/x-freearc', 71 | '.avif': 'image/avif', 72 | '.avi': 'video/x-msvideo', 73 | '.azw': 'application/vnd.amazon.ebook', 74 | '.bin': 'application/octet-stream', 75 | '.bmp': 'image/bmp', 76 | '.bz': 'application/x-bzip', 77 | '.bz2': 'application/x-bzip2', 78 | '.cda': 'application/x-cdf', 79 | '.csh': 'application/x-csh', 80 | '.css': 'text/css', 81 | '.csv': 'text/csv', 82 | '.doc': 'application/msword', 83 | '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', # noqa: 501 84 | '.eot': 'application/vnd.ms-fontobject', 85 | '.epub': 'application/epub+zip', 86 | 87 | # Note: Windows and macOS might use 'application/x-gzip' 88 | '.gz': 'application/gzip', 89 | 90 | '.gif': 'image/gif', 91 | '.htm': 'text/html', 92 | '.html': 'text/html', 93 | '.ico': 'image/vnd.microsoft.icon', 94 | '.ics': 'text/calendar', 95 | '.jar': 'application/java-archive', 96 | '.jpeg': 'image/jpeg', 97 | '.jpg': 'image/jpeg', 98 | '.js': 'text/javascript', # Or 'application/javascript' 99 | '.json': 'application/json', 100 | '.jsonld': 'application/ld+json', 101 | '.mid': 'audio/midi', 102 | '.midi': 'audio/midi', 103 | '.mjs': 'text/javascript', 104 | '.mp3': 'audio/mpeg', 105 | '.mp4': 'video/mp4', 106 | '.mpeg': 'video/mpeg', 107 | '.mpkg': 'application/vnd.apple.installer+xml', 108 | '.odp': 'application/vnd.oasis.opendocument.presentation', 109 | '.ods': 'application/vnd.oasis.opendocument.spreadsheet', 110 | '.odt': 'application/vnd.oasis.opendocument.text', 111 | '.oga': 'audio/ogg', 112 | '.ogv': 'video/ogg', 113 | '.ogx': 'application/ogg', 114 | '.opus': 'audio/ogg', 115 | '.otf': 'font/otf', 116 | '.png': 'image/png', 117 | '.pdf': 'application/pdf', 118 | '.php': 'application/x-httpd-php', 119 | '.ppt': 'application/vnd.ms-powerpoint', 120 | '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', # noqa: 501 121 | '.rar': 'application/vnd.rar', 122 | '.rtf': 'application/rtf', 123 | '.sh': 'application/x-sh', 124 | '.svg': 'image/svg+xml', 125 | '.tar': 'application/x-tar', 126 | '.tif': 'image/tiff', 127 | '.tiff': 'image/tiff', 128 | '.ts': 'video/mp2t', 129 | '.ttf': 'font/ttf', 130 | '.txt': 'text/plain', 131 | '.vsd': 'application/vnd.visio', 132 | '.wav': 'audio/wav', 133 | '.weba': 'audio/webm', 134 | '.webm': 'video/webm', 135 | '.webp': 'image/webp', 136 | '.woff': 'font/woff', 137 | '.woff2': 'font/woff2', 138 | '.xhtml': 'application/xhtml+xml', 139 | '.xls': 'application/vnd.ms-excel', 140 | '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # noqa: 501 141 | '.xml': 'application/xml', # 'text/xml' is still used sometimes 142 | '.xul': 'application/vnd.mozilla.xul+xml', 143 | 144 | # Note: Windows might use 'application/x-zip-compressed' 145 | '.zip': 'application/zip', 146 | 147 | '.3gp': 'video/3gpp', # 'audio/3gpp' if it doesn't contain video 148 | '.3g2': 'video/3gpp2', # 'audio/3gpp2' if it doesn't contain video 149 | '.7z': 'application/x-7z-compressed' 150 | } 151 | -------------------------------------------------------------------------------- /httpout/utils/modules.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 nggit 2 | 3 | import os 4 | import sys 5 | 6 | from types import ModuleType 7 | 8 | 9 | def exec_module(module, code=None, max_size=8 * 1048576): 10 | if code is None: 11 | if os.stat(module.__file__).st_size > max_size: 12 | raise ValueError(f'File {module.__file__} exceeds the max_size') 13 | 14 | with open(module.__file__, 'r') as f: 15 | code = compile(f.read(), module.__file__, 'exec') 16 | exec(code, module.__dict__) # nosec B102 17 | 18 | return code 19 | 20 | exec(code, module.__dict__) # nosec B102 21 | 22 | 23 | def cleanup_modules(modules, debug=0): 24 | if debug: 25 | if debug == 1: 26 | print(' cleanup_modules:') 27 | 28 | debug += 4 29 | 30 | for module_name, module in modules.items(): 31 | module_dict = getattr(module, '__dict__', None) 32 | 33 | if module_dict: 34 | for name, value in module_dict.items(): 35 | if name.startswith('__'): 36 | continue 37 | 38 | value_module = getattr(value, '__module__', '__main__') 39 | 40 | if value_module != '__main__' and value_module in sys.modules: 41 | continue 42 | 43 | if not (value is module or 44 | isinstance(value, (type, ModuleType))): 45 | value_dict = getattr(value, '__dict__', None) 46 | 47 | if value_dict: 48 | cleanup_modules(value_dict, debug) 49 | 50 | module_dict[name] = None 51 | 52 | if debug: 53 | print(' ' * debug, ',-- deleted:', name, value) 54 | 55 | if not module_name.startswith('__'): 56 | modules[module_name] = None 57 | 58 | if debug: 59 | print(' ' * debug, '|') 60 | print(' ' * debug, 'deleted:', module_name, module) 61 | -------------------------------------------------------------------------------- /httpout/utils/modules.pyx: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 nggit 2 | 3 | import sys 4 | 5 | from types import ModuleType 6 | 7 | from libc.stdio cimport (FILE, fopen, fclose, fread, feof, ferror, 8 | SEEK_SET, SEEK_END, fseek, ftell) 9 | 10 | 11 | def exec_module(module, code=None, size_t max_size=8 * 1048576): 12 | cdef FILE* fp 13 | cdef char[4096] buf 14 | cdef size_t file_size, n 15 | cdef bytearray data 16 | 17 | if code is None: 18 | fp = fopen(module.__file__.encode('utf-8'), 'rb') 19 | 20 | if fp is NULL: 21 | raise OSError(f'Failed to open file: {module.__file__}') 22 | 23 | fseek(fp, 0, SEEK_END) 24 | file_size = ftell(fp) 25 | 26 | if file_size > max_size: 27 | fclose(fp) 28 | raise ValueError(f'File {module.__file__} exceeds the max_size') 29 | 30 | fseek(fp, 0, SEEK_SET) 31 | data = bytearray() 32 | 33 | while True: 34 | n = fread(buf, 1, sizeof(buf), fp) 35 | 36 | if n <= 0: 37 | break 38 | 39 | data.extend(buf[:n]) 40 | 41 | if ferror(fp): 42 | fclose(fp) 43 | raise OSError(f'Error reading file: {module.__file__}') 44 | 45 | fclose(fp) 46 | 47 | code = compile(data, module.__file__, 'exec') 48 | exec(code, module.__dict__) 49 | 50 | return code 51 | 52 | exec(code, module.__dict__) 53 | 54 | 55 | def cleanup_modules(modules, int debug=0): 56 | cdef str module_name, name 57 | cdef dict module_dict, value_dict 58 | 59 | if debug: 60 | if debug == 1: 61 | print(' cleanup_modules:') 62 | 63 | debug += 4 64 | 65 | for module_name, module in modules.items(): 66 | module_dict = getattr(module, '__dict__', None) 67 | 68 | if module_dict: 69 | for name, value in module_dict.items(): 70 | if name.startswith('__'): 71 | continue 72 | 73 | value_module = getattr(value, '__module__', '__main__') 74 | 75 | if value_module != '__main__' and value_module in sys.modules: 76 | continue 77 | 78 | if not (value is module or 79 | isinstance(value, (type, ModuleType))): 80 | value_dict = getattr(value, '__dict__', None) 81 | 82 | if value_dict: 83 | cleanup_modules(value_dict, debug) 84 | 85 | module_dict[name] = None 86 | 87 | if debug: 88 | print(' ' * debug, ',-- deleted:', name, value) 89 | 90 | if not module_name.startswith('__'): 91 | modules[module_name] = None 92 | 93 | if debug: 94 | print(' ' * debug, '|') 95 | print(' ' * debug, 'deleted:', module_name, module) 96 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 'setuptools>=61.0', 'wheel', 'Cython' ] 3 | build-backend = 'setuptools.build_meta' 4 | 5 | [project] 6 | name = 'httpout' 7 | authors = [ 8 | { name = 'nggit', email = 'contact@anggit.com' }, 9 | ] 10 | description = """\ 11 | httpout is a runtime environment for Python files. \ 12 | It allows you to execute your Python scripts from a web URL, \ 13 | the print() output goes to your browser.\ 14 | """ 15 | requires-python = '>=3.7' 16 | dependencies = [ 17 | 'awaiter', 18 | 'tremolo>=0.2.0', 19 | ] 20 | license = { text = 'MIT License' } 21 | classifiers = [ 22 | 'Development Status :: 4 - Beta', 23 | 'Intended Audience :: Developers', 24 | 'License :: OSI Approved :: MIT License', 25 | 'Operating System :: OS Independent', 26 | 'Topic :: Internet :: WWW/HTTP :: HTTP Servers', 27 | 'Topic :: Software Development :: Libraries :: Application Frameworks', 28 | ] 29 | dynamic = [ 'version', 'readme' ] 30 | 31 | [project.urls] 32 | Homepage = 'https://github.com/nggit/httpout' 33 | Source = 'https://github.com/nggit/httpout' 34 | Funding = 'https://github.com/sponsors/nggit' 35 | 36 | [tool.setuptools.dynamic] 37 | version = { attr = 'httpout.__version__' } 38 | readme = { file = 'README.md', content-type = 'text/markdown' } 39 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | awaiter 2 | tremolo>=0.2.0 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 nggit 2 | 3 | import os 4 | 5 | from setuptools import setup, Extension 6 | from setuptools.command.build_ext import build_ext 7 | from Cython.Build import cythonize 8 | 9 | 10 | class OptionalBuildExt(build_ext): 11 | """Allow C extension building to fail gracefully.""" 12 | def build_extension(self, ext): 13 | try: 14 | super().build_extension(ext) 15 | except Exception: 16 | print( 17 | f'Failed to build {ext.name}. ' 18 | 'Falling back to pure Python version.' 19 | ) 20 | finally: 21 | for source in ext.sources: 22 | path = os.path.join(self.build_lib, source) 23 | 24 | if os.path.exists(path): 25 | os.unlink(path) 26 | print(f'Deleted: {path}') 27 | 28 | 29 | extensions = [ 30 | Extension( 31 | 'httpout.utils.modules', 32 | sources=[os.path.join('httpout', 'utils', 'modules.pyx')], 33 | optional=True 34 | ) 35 | ] 36 | 37 | setup( 38 | ext_modules=cythonize(extensions), 39 | cmdclass={'build_ext': OptionalBuildExt}, 40 | setup_requires=['Cython'] 41 | ) 42 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=nggit_httpout 2 | sonar.organization=nggit 3 | sonar.sources=httpout/ 4 | sonar.python.coverage.reportPaths=coverage.xml 5 | sonar.c.file.suffixes=- 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nggit/httpout/29a7f1f63b7d63c0292a4cfb96dbc6b984ecc5d9/tests/__init__.py -------------------------------------------------------------------------------- /tests/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import multiprocessing as mp 4 | import os 5 | import sys 6 | import signal 7 | import unittest 8 | 9 | PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 10 | 11 | # makes imports relative from the repo directory 12 | sys.path.insert(0, PROJECT_DIR) 13 | 14 | from tremolo import Application # noqa: E402 15 | from httpout import HTTPOut # noqa: E402 16 | 17 | app = Application() 18 | 19 | HTTPOut(app) 20 | 21 | HTTP_HOST = '127.0.0.1' 22 | HTTP_PORT = 28008 23 | DOCUMENT_ROOT = os.path.join(PROJECT_DIR, 'examples') 24 | 25 | 26 | def main(): 27 | mp.set_start_method('spawn', force=True) 28 | 29 | p = mp.Process( 30 | target=app.run, 31 | kwargs=dict( 32 | host=HTTP_HOST, port=HTTP_PORT, 33 | document_root=DOCUMENT_ROOT, app=None, debug=False, 34 | server_name='HTTPOut' 35 | ) 36 | ) 37 | p.start() 38 | 39 | try: 40 | suite = unittest.TestLoader().discover('tests') 41 | unittest.TextTestRunner().run(suite) 42 | finally: 43 | if p.is_alive(): 44 | os.kill(p.pid, signal.SIGTERM) 45 | p.join() 46 | 47 | 48 | if __name__ == '__main__': 49 | main() 50 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | import unittest 6 | 7 | from io import StringIO 8 | 9 | # makes imports relative from the repo directory 10 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 11 | 12 | from httpout.__main__ import usage, bind, version, threads # noqa: E402 13 | from tremolo.utils import parse_args # noqa: E402 14 | 15 | STDOUT = sys.stdout 16 | 17 | 18 | def run(): 19 | return parse_args( 20 | help=usage, bind=bind, version=version, thread_pool_size=threads 21 | ) 22 | 23 | 24 | class TestCLI(unittest.TestCase): 25 | def setUp(self): 26 | print('\r\n[', self.id(), ']') 27 | 28 | self.output = StringIO() 29 | 30 | def tearDown(self): 31 | self.output.close() 32 | sys.argv.clear() 33 | 34 | def test_cli_version(self): 35 | sys.argv.append('--version') 36 | 37 | code = 0 38 | sys.stdout = self.output 39 | 40 | try: 41 | run() 42 | except SystemExit as exc: 43 | if exc.code: 44 | code = exc.code 45 | 46 | sys.stdout = STDOUT 47 | 48 | self.assertEqual(self.output.getvalue()[:8], 'httpout ') 49 | self.assertEqual(code, 0) 50 | 51 | def test_cli_help(self): 52 | sys.argv.append('--help') 53 | 54 | code = 0 55 | sys.stdout = self.output 56 | 57 | try: 58 | run() 59 | except SystemExit as exc: 60 | if exc.code: 61 | code = exc.code 62 | 63 | sys.stdout = STDOUT 64 | 65 | self.assertEqual(self.output.getvalue()[:6], 'Usage:') 66 | self.assertEqual(code, 0) 67 | 68 | def test_cli_bind(self): 69 | sys.argv.extend(['--bind', 'localhost:8000']) 70 | 71 | code = 0 72 | sys.stdout = self.output 73 | 74 | try: 75 | self.assertEqual(run()['host'], None) 76 | except SystemExit as exc: 77 | if exc.code: 78 | code = exc.code 79 | 80 | sys.stdout = STDOUT 81 | 82 | self.assertEqual(self.output.getvalue(), '') 83 | self.assertEqual(code, 0) 84 | 85 | def test_cli_bindsocket(self): 86 | sys.argv.extend(['--bind', '/tmp/file.sock']) 87 | 88 | code = 0 89 | sys.stdout = self.output 90 | 91 | try: 92 | run() 93 | except SystemExit as exc: 94 | if exc.code: 95 | code = exc.code 96 | 97 | sys.stdout = STDOUT 98 | 99 | self.assertEqual(self.output.getvalue(), '') 100 | self.assertEqual(code, 0) 101 | 102 | def test_cli_bindsocket_windows(self): 103 | sys.argv.extend(['--bind', r'C:\Somewhere\Temp\file.sock']) 104 | 105 | code = 0 106 | sys.stdout = self.output 107 | 108 | try: 109 | run() 110 | except SystemExit as exc: 111 | if exc.code: 112 | code = exc.code 113 | 114 | sys.stdout = STDOUT 115 | 116 | self.assertEqual(self.output.getvalue(), '') 117 | self.assertEqual(code, 0) 118 | 119 | def test_cli_invalidbind(self): 120 | sys.argv.extend(['--bind', 'localhost:xx']) 121 | 122 | code = 0 123 | sys.stdout = self.output 124 | 125 | try: 126 | run() 127 | except SystemExit as exc: 128 | if exc.code: 129 | code = exc.code 130 | 131 | sys.stdout = STDOUT 132 | 133 | self.assertEqual(self.output.getvalue()[:15], 'Invalid --bind ') 134 | self.assertEqual(code, 1) 135 | 136 | def test_cli_invalidarg(self): 137 | sys.argv.append('--invalid') 138 | 139 | code = 0 140 | sys.stdout = self.output 141 | 142 | try: 143 | run() 144 | except SystemExit as exc: 145 | if exc.code: 146 | code = exc.code 147 | 148 | sys.stdout = STDOUT 149 | 150 | self.assertEqual(self.output.getvalue()[:31], 151 | 'Unrecognized option "--invalid"') 152 | self.assertEqual(code, 1) 153 | 154 | def test_cli_document_root(self): 155 | sys.argv.extend(['', '/home/user/public_html']) 156 | 157 | code = 0 158 | sys.stdout = self.output 159 | 160 | try: 161 | run() 162 | except SystemExit as exc: 163 | if exc.code: 164 | code = exc.code 165 | 166 | sys.stdout = STDOUT 167 | 168 | self.assertEqual(self.output.getvalue(), '') 169 | self.assertEqual(code, 0) 170 | 171 | 172 | if __name__ == '__main__': 173 | unittest.main() 174 | -------------------------------------------------------------------------------- /tests/test_http.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | import unittest 6 | 7 | # makes imports relative from the repo directory 8 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 9 | 10 | from tests.__main__ import main, HTTP_HOST, HTTP_PORT # noqa: E402 11 | from tests.utils import getcontents # noqa: E402 12 | 13 | 14 | class TestHTTP(unittest.TestCase): 15 | def setUp(self): 16 | print('\r\n[', self.id(), ']') 17 | 18 | def test_index_notfound(self): 19 | header, body = getcontents(host=HTTP_HOST, 20 | port=HTTP_PORT, 21 | method='GET', 22 | url='/', 23 | version='1.1') 24 | 25 | self.assertEqual( 26 | header[:header.find(b'\r\n')], 27 | b'HTTP/1.1 404 Not Found' 28 | ) 29 | self.assertEqual(body, b'URL not found: /') 30 | 31 | def test_index_empty(self): 32 | header, body = getcontents(host=HTTP_HOST, 33 | port=HTTP_PORT, 34 | method='GET', 35 | url='/home', 36 | version='1.1') 37 | 38 | self.assertEqual(header[:header.find(b'\r\n')], b'HTTP/1.1 200 OK') 39 | self.assertTrue(b'\r\nContent-Length: 0' in header) 40 | 41 | # these are set by the middleware 42 | self.assertTrue(b'\r\nX-Powered-By: foo' in header) 43 | self.assertTrue(b'\r\nX-Debug: bar' in header) 44 | 45 | self.assertEqual(body, b'') 46 | 47 | def test_imports(self): 48 | header, body = getcontents(host=HTTP_HOST, 49 | port=HTTP_PORT, 50 | method='GET', 51 | url='/main.py', 52 | version='1.1') 53 | 54 | self.assertEqual( 55 | header[:header.find(b'\r\n')], 56 | b'HTTP/1.1 201 Created' 57 | ) 58 | self.assertTrue(b'\r\nFoo: baz' in header) 59 | self.assertTrue(b'\r\nSet-Cookie: foo=bar; ' in header) 60 | self.assertTrue(b'\r\nContent-Type: text/plain' in header) 61 | 62 | # these are set by the middleware 63 | self.assertTrue(b'\r\nX-Powered-By: foo' in header) 64 | self.assertFalse(b'\r\nX-Debug: bar' in header) 65 | 66 | self.assertEqual( 67 | body, 68 | b'6\r\nHello\n\r\n7\r\nWorld!\n\r\n3\r\nOK\n\r\n' 69 | b'5\r\nNone\n\r\n0\r\n\r\n' 70 | ) 71 | 72 | def test_import_error(self): 73 | header, body = getcontents(host=HTTP_HOST, 74 | port=HTTP_PORT, 75 | method='GET', 76 | url='/import.py', 77 | version='1.1') 78 | 79 | self.assertEqual( 80 | header[:header.find(b'\r\n')], 81 | b'HTTP/1.1 500 Internal Server Error' 82 | ) 83 | self.assertEqual( 84 | body, 85 | b'5B\r\n\n\r\n0\r\n\r\n' 87 | ) 88 | 89 | def test_hybrid(self): 90 | header, body = getcontents(host=HTTP_HOST, 91 | port=HTTP_PORT, 92 | method='GET', 93 | url='/hybrid.py', 94 | version='1.1') 95 | 96 | self.assertEqual(header[:header.find(b'\r\n')], b'HTTP/1.1 200 OK') 97 | self.assertEqual(body, b'3\r\nOK\n\r\n6\r\nDone!\n\r\n0\r\n\r\n') 98 | 99 | def test_request_environ(self): 100 | header, body = getcontents(host=HTTP_HOST, 101 | port=HTTP_PORT, 102 | method='GET', 103 | url='/environ.py', 104 | version='1.1') 105 | 106 | self.assertEqual(header[:header.find(b'\r\n')], b'HTTP/1.1 200 OK') 107 | self.assertEqual(body, b'13\r\nb\'GET\' /environ.py\n\r\n0\r\n\r\n') 108 | 109 | def test_path_info(self): 110 | header, body = getcontents(host=HTTP_HOST, 111 | port=HTTP_PORT, 112 | method='GET', 113 | url='//path.py/path//info///', 114 | version='1.1') 115 | 116 | self.assertEqual(header[:header.find(b'\r\n')], b'HTTP/1.1 200 OK') 117 | self.assertEqual(body, b'14\r\n/path.py /path/info\n\r\n0\r\n\r\n') 118 | 119 | def test_syntax_error(self): 120 | header, body = getcontents(host=HTTP_HOST, 121 | port=HTTP_PORT, 122 | method='GET', 123 | url='/syntax.py', 124 | version='1.1') 125 | 126 | self.assertEqual( 127 | header[:header.find(b'\r\n')], 128 | b'HTTP/1.1 500 Internal Server Error' 129 | ) 130 | self.assertTrue(b'\n\r\n0\r\n\r\n') 132 | 133 | def test_exc_after_print(self): 134 | header, body = getcontents(host=HTTP_HOST, 135 | port=HTTP_PORT, 136 | method='GET', 137 | url='/exc.py', 138 | version='1.1') 139 | 140 | self.assertEqual(header[:header.find(b'\r\n')], b'HTTP/1.1 200 OK') 141 | self.assertEqual( 142 | body, 143 | b'3\r\nHi\n\r\n27\r\n\n\r\n' 144 | b'0\r\n\r\n' 145 | ) 146 | 147 | def test_exit_after_print(self): 148 | header, body = getcontents(host=HTTP_HOST, 149 | port=HTTP_PORT, 150 | method='GET', 151 | url='/exit.py', 152 | version='1.1') 153 | 154 | self.assertEqual(header[:header.find(b'\r\n')], b'HTTP/1.1 200 OK') 155 | self.assertEqual(body, b'7\r\nHello, \r\n0\r\n\r\n') 156 | 157 | def test_exit_str(self): 158 | header, body = getcontents(host=HTTP_HOST, 159 | port=HTTP_PORT, 160 | method='GET', 161 | url='/exit.py?World', 162 | version='1.1') 163 | 164 | self.assertEqual(header[:header.find(b'\r\n')], b'HTTP/1.1 200 OK') 165 | self.assertEqual(body, b'7\r\nHello, \r\n7\r\nWorld!\n\r\n0\r\n\r\n') 166 | 167 | def test_static_index(self): 168 | header, body = getcontents(host=HTTP_HOST, 169 | port=HTTP_PORT, 170 | method='GET', 171 | url='/static/', 172 | version='1.1') 173 | 174 | self.assertEqual(header[:header.find(b'\r\n')], b'HTTP/1.1 200 OK') 175 | self.assertTrue(b'\r\nContent-Type: text/html' in header) 176 | self.assertEqual(body, b'

    Hello, World!

    ') 177 | 178 | def test_static_file(self): 179 | header, body = getcontents(host=HTTP_HOST, 180 | port=HTTP_PORT, 181 | method='GET', 182 | url='/static/hello.gif', 183 | version='1.1') 184 | 185 | self.assertEqual(header[:header.find(b'\r\n')], b'HTTP/1.1 200 OK') 186 | self.assertTrue(b'\r\nContent-Type: image/gif' in header) 187 | self.assertEqual(body[:6], b'GIF89a') 188 | 189 | def test_badrequest(self): 190 | header, body = getcontents( 191 | host=HTTP_HOST, 192 | port=HTTP_PORT, 193 | raw=b'GET HTTP/\r\nHost: localhost:%d\r\n\r\n' % HTTP_PORT 194 | ) 195 | 196 | self.assertEqual( 197 | header[:header.find(b'\r\n')], 198 | b'HTTP/1.1 400 Bad Request' 199 | ) 200 | 201 | def test_private_file(self): 202 | header, body = getcontents(host=HTTP_HOST, 203 | port=HTTP_PORT, 204 | method='GET', 205 | url='/__globals__.py', 206 | version='1.1') 207 | 208 | self.assertEqual( 209 | header[:header.find(b'\r\n')], 210 | b'HTTP/1.1 404 Not Found' 211 | ) 212 | 213 | def test_sec_path_traversal(self): 214 | header, body = getcontents(host=HTTP_HOST, 215 | port=HTTP_PORT, 216 | method='GET', 217 | url='../.ssh/id_rsa', 218 | version='1.1') 219 | 220 | self.assertEqual( 221 | header[:header.find(b'\r\n')], 222 | b'HTTP/1.1 403 Forbidden' 223 | ) 224 | self.assertEqual(body, b'Path traversal is not allowed') 225 | 226 | def test_sec_dotfiles(self): 227 | header, body = getcontents(host=HTTP_HOST, 228 | port=HTTP_PORT, 229 | method='GET', 230 | url='/.env', 231 | version='1.1') 232 | 233 | self.assertEqual( 234 | header[:header.find(b'\r\n')], 235 | b'HTTP/1.1 403 Forbidden' 236 | ) 237 | self.assertEqual(body, b'Access to dotfiles is prohibited') 238 | 239 | def test_sec_long_path(self): 240 | header, body = getcontents(host=HTTP_HOST, 241 | port=HTTP_PORT, 242 | method='GET', 243 | url='/q' * 128, 244 | version='1.1') 245 | 246 | self.assertEqual( 247 | header[:header.find(b'\r\n')], 248 | b'HTTP/1.1 403 Forbidden' 249 | ) 250 | self.assertEqual(body, b'Unsafe URL detected') 251 | 252 | def test_sec_unsafe_chars_percent(self): 253 | header, body = getcontents(host=HTTP_HOST, 254 | port=HTTP_PORT, 255 | method='GET', 256 | url='/example.php%00.png', 257 | version='1.1') 258 | 259 | self.assertEqual( 260 | header[:header.find(b'\r\n')], 261 | b'HTTP/1.1 403 Forbidden' 262 | ) 263 | self.assertEqual(body, b'Unsafe URL detected') 264 | 265 | def test_sec_unsafe_chars_nul(self): 266 | header, body = getcontents(host=HTTP_HOST, 267 | port=HTTP_PORT, 268 | method='GET', 269 | url='/example.php\x00.png', 270 | version='1.1') 271 | 272 | # NUL is already handled by upstream 273 | self.assertEqual( 274 | header[:header.find(b'\r\n')], 275 | b'HTTP/1.0 400 Bad Request' 276 | ) 277 | 278 | def test_disallowed_ext(self): 279 | header, body = getcontents(host=HTTP_HOST, 280 | port=HTTP_PORT, 281 | method='GET', 282 | url='/bad.ext', 283 | version='1.1') 284 | 285 | self.assertEqual( 286 | header[:header.find(b'\r\n')], 287 | b'HTTP/1.1 403 Forbidden' 288 | ) 289 | self.assertEqual(body, b'Disallowed file extension: .ext') 290 | 291 | 292 | if __name__ == '__main__': 293 | main() 294 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | __all__ = ('read_header', 'getcontents') 2 | 3 | import socket # noqa: E402 4 | import time # noqa: E402 5 | 6 | 7 | def read_header(header, key): 8 | name = b'\r\n%s: ' % key 9 | values = [] 10 | start = 0 11 | 12 | while True: 13 | start = header.find(name, start) 14 | 15 | if start == -1: 16 | break 17 | 18 | start += len(name) 19 | values.append(header[start:header.find(b'\r\n', start)]) 20 | 21 | return values 22 | 23 | 24 | # a simple HTTP client for tests 25 | def getcontents(host, port, method='GET', url='/', version='1.1', headers=(), 26 | data='', raw=b'', timeout=10, max_retries=10): 27 | if max_retries <= 0: 28 | raise ValueError('max_retries is exceeded, or it cannot be negative') 29 | 30 | method = method.upper().encode('latin-1') 31 | url = url.encode('latin-1') 32 | version = version.encode('latin-1') 33 | 34 | if raw == b'': 35 | headers = list(headers) 36 | 37 | if data: 38 | if not headers: 39 | headers.append( 40 | 'Content-Type: application/x-www-form-urlencoded' 41 | ) 42 | 43 | headers.append('Content-Length: %d' % len(data)) 44 | 45 | raw = b'%s %s HTTP/%s\r\nHost: %s:%d\r\n%s\r\n\r\n%s' % ( 46 | method, url, version, host.encode('latin-1'), port, 47 | '\r\n'.join(headers).encode('latin-1'), data.encode('latin-1') 48 | ) 49 | 50 | family = socket.AF_INET 51 | 52 | if ':' in host: 53 | family = socket.AF_INET6 54 | 55 | if host in ('0.0.0.0', '::'): 56 | host = 'localhost' 57 | 58 | with socket.socket(family, socket.SOCK_STREAM) as sock: 59 | sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) 60 | sock.settimeout(timeout) 61 | 62 | while sock.connect_ex((host, port)) != 0: # server is not ready yet? 63 | print('getcontents: reconnecting: %s:%d' % (host, port)) 64 | time.sleep(1) 65 | 66 | request_header = raw[:raw.find(b'\r\n\r\n') + 4] 67 | request_body = raw[raw.find(b'\r\n\r\n') + 4:] 68 | 69 | try: 70 | sock.sendall(request_header) 71 | 72 | if not (b'\r\nExpect: 100-continue' in request_header or 73 | b'\r\nUpgrade:' in request_header): 74 | sock.sendall(request_body) 75 | 76 | response_data = bytearray() 77 | response_header = b'' 78 | content_length = -1 79 | 80 | while True: 81 | if ((content_length != -1 and 82 | len(response_data) >= content_length) or 83 | response_data.endswith(b'\r\n0\r\n\r\n')): 84 | break 85 | 86 | buf = sock.recv(4096) 87 | 88 | if not buf: 89 | break 90 | 91 | response_data.extend(buf) 92 | 93 | if response_header: 94 | continue 95 | 96 | header_size = response_data.find(b'\r\n\r\n') 97 | 98 | if header_size == -1: 99 | continue 100 | 101 | response_header = response_data[:header_size] 102 | del response_data[:header_size + 4] 103 | 104 | if method == b'HEAD': 105 | break 106 | 107 | values = read_header(response_header, b'Content-Length') 108 | 109 | if values: 110 | content_length = int(values[0]) 111 | 112 | if response_header.startswith(b'HTTP/%s 100 ' % version): 113 | sock.sendall(request_body) 114 | response_header = b'' 115 | elif response_header.startswith(b'HTTP/%s 101 ' % version): 116 | sock.sendall(request_body) 117 | 118 | return response_header, bytes(response_data) 119 | except OSError: # retry if either sendall() or recv() fails 120 | print( 121 | 'getcontents: retry (%d): %s' % (max_retries, request_header) 122 | ) 123 | time.sleep(1) 124 | return getcontents( 125 | host, port, raw=raw, max_retries=max_retries - 1 126 | ) 127 | --------------------------------------------------------------------------------