diff --git a/.coveragerc b/.coveragerc index f4c5b83f..724b2872 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,7 +1,7 @@ [run] branch = True -source = sanic, tests -omit = site-packages +source = sanic +omit = site-packages, sanic/utils.py, sanic/__main__.py [html] -directory = coverage \ No newline at end of file +directory = coverage diff --git a/.gitignore b/.gitignore index d7872c5c..4a834a7a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,17 @@ *~ *.egg-info *.egg +*.eggs +*.pyc .coverage .coverage.* coverage .tox settings.py -*.pyc .idea/* .cache/* +.python-version +docs/_build/ +docs/_api/ +build/* +.DS_Store diff --git a/.travis.yml b/.travis.yml index 942a5df2..c18e895b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,18 +1,37 @@ +sudo: false language: python -python: - - '3.5' -install: - - pip install -r requirements.txt - - pip install -r requirements-dev.txt - - python setup.py install - - pip install flake8 - - pip install pytest -before_script: flake8 sanic -script: py.test -v tests +cache: + directories: + - $HOME/.cache/pip +matrix: + include: + - env: TOX_ENV=py35 + python: 3.5 + - env: TOX_ENV=py35-no-ext + python: 3.5 + - env: TOX_ENV=py36 + python: 3.6 + - env: TOX_ENV=py36-no-ext + python: 3.6 + - env: TOX_ENV=py37 + python: 3.7 + dist: xenial + sudo: true + - env: TOX_ENV=py37-no-ext + python: 3.7 + dist: xenial + sudo: true + - env: TOX_ENV=flake8 + python: 3.6 + - env: TOX_ENV=check + python: 3.6 +install: pip install -U tox +script: travis_retry tox -e $TOX_ENV deploy: provider: pypi user: channelcat password: - secure: jH4+Di2/qcBwWVhI5/3NYd/JuDDgf5/cF85h+oQnAjgwP6me3th9RS0PHL2gjKJrmyRgwrW7a3eSAityo5sQSlBloQCNrtCE30rkDiwtgoIxDW72NR/nE8nUkS9Utgy87eS+3B4NrO7ag4GTqO5ET8SQ4/MCiQwyUQATLXj2s2eTpQvqJeZG6YgoeFAOYvlR580yznXoOwldWlkiymJiWSdR/01lthtWCi40sYC/QoU7psODJ/tPcsqgQtQKyUVsci7mKvp3Y8ImkoO/POM01jYNsS9qLh5pKTNCEYxtyzC77whenCNHn7WReVidd56g1ADosbNo4yY/1D3VAvwjUnkQ0SzdBQfT7IIzccEuC0j1NXKPN97OX0a6XzyUMYJ1XiU3juTJOPxdYBPbsDM3imQiwrOh1faIf0HCgNTN+Lxe5l8obCH7kffNcVUhs2zI0+2t4MS5tjb/OVuYD/TFn+bM33DqzLctTOK/pGn6xefzZcdzb191LPo99Lof+4fo6jNUpb0UmcBu5ZJzxh0lGe8FPIK3UAG/hrYDDgjx8s8RtUJjcEUQz0659XffYx7DLlgHO7cWyfjrHD3yrLzDbYr5mAS4FR+4D917V7UL+on4SsKHN00UuMGPguqSYo/xYyPLnJU5XK0du4MIpsNMB8TtrJOIewOOfD32+AisPQ8= + secure: OgADRQH3+dTL5swGzXkeRJDNbLpFzwqYnXB4iLD0Npvzj9QnKyQVvkbaeq6VmV9dpEFb5ULaAKYQq19CrXYDm28yanUSn6jdJ4SukaHusi7xt07U6H7pmoX/uZ2WZYqCSLM8cSp8TXY/3oV3rY5Jfj/AibE5XTbim5/lrhsvW6NR+ALzxc0URRPAHDZEPpojTCjSTjpY0aDsaKWg4mXVRMFfY3O68j6KaIoukIZLuoHfePLKrbZxaPG5VxNhMHEaICdxVxE/dO+7pQmQxXuIsEOHK1QiVJ9YrSGcNqgEqhN36kYP8dqMeVB07sv8Xa6o/Uax2/wXS2HEJvuwP1YD6WkoZuo9ZB85bcMdg7BV9jJDbVFVPJwc75BnTLHrMa3Q1KrRlKRDBUXBUsQivPuWhFNwUgvEayq2qSI3aRQR4Z0O+DfboEhXYojSoD64/EWBTZ7vhgbvOTGEdukUQSYrKj9P8jc1s8exomTsAiqdFxTUpzfiammUSL+M93lP4urtahl1jjXFX7gd3DzdEEb0NsGkx5lm/qdsty8/TeAvKUmC+RVU6T856W6MqN0P+yGbpWUARcSE7fwztC3SPxwAuxvIN3BHmRhOUHoORPNG2VpfbnscIzBKJR4v0JKzbpi0IDa66K+tCGsCEvQuL4cxVOtoUySPWNSUAyUWWUrGM2k= on: tags: true + distributions: "sdist bdist_wheel" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..84e7be78 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +Version 0.1 +----------- + - 0.1.7 + - Reversed static url and directory arguments to meet spec + - 0.1.6 + - Static files + - Lazy Cookie Loading + - 0.1.5 + - Cookies + - Blueprint listeners and ordering + - Faster Router + - Fix: Incomplete file reads on medium+ sized post requests + - Breaking: after_start and before_stop now pass sanic as their first argument + - 0.1.4 + - Multiprocessing + - 0.1.3 + - Blueprint support + - Faster Response processing + - 0.1.1 - 0.1.2 + - Struggling to update pypi via CI + - 0.1.0 + - Released to public \ No newline at end of file diff --git a/CONDUCT.md b/CONDUCT.md new file mode 100644 index 00000000..5f83b52d --- /dev/null +++ b/CONDUCT.md @@ -0,0 +1,74 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at sanic-maintainers@googlegroups.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..60c4da2a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing + +Thank you for your interest! Sanic is always looking for contributors. If you +don't feel comfortable contributing code, adding docstrings to the source files +is very appreciated. + +We are committed to providing a friendly, safe and welcoming environment for all, +regardless of gender, sexual orientation, disability, ethnicity, religion, +or similar personal characteristic. +Our [code of conduct](./CONDUCT.md) sets the standards for behavior. + +## Installation + +To develop on sanic (and mainly to just run the tests) it is highly recommend to +install from sources. + +So assume you have already cloned the repo and are in the working directory with +a virtual environment already set up, then run: + +```bash +python setup.py develop && pip install -r requirements-dev.txt +``` + +## Running tests + +To run the tests for sanic it is recommended to use tox like so: + +```bash +tox +``` + +See it's that simple! + +## Pull requests! + +So the pull request approval rules are pretty simple: +1. All pull requests must pass unit tests. +2. All pull requests must be reviewed and approved by at least +one current collaborator on the project. +3. All pull requests must pass flake8 checks. +4. All pull requests must be consistent with the existing code. +5. If you decide to remove/change anything from any common interface +a deprecation message should accompany it. +6. If you implement a new feature you should have at least one unit +test to accompany it. +7. An example must be one of the following: + * Example of how to use Sanic + * Example of how to use Sanic extensions + * Example of how to use Sanic and asynchronous library + +## Documentation + +Sanic's documentation is built +using [sphinx](http://www.sphinx-doc.org/en/1.5.1/). Guides are written in +Markdown and can be found in the `docs` folder, while the module reference is +automatically generated using `sphinx-apidoc`. + +To generate the documentation from scratch: + +```bash +sphinx-apidoc -fo docs/_api/ sanic +sphinx-build -b html docs docs/_build +``` + +The HTML documentation will be created in the `docs/_build` folder. + +## Warning + +One of the main goals of Sanic is speed. Code that lowers the performance of +Sanic without significant gains in usability, security, or features may not be +merged. Please don't let this intimidate you! If you have any concerns about an +idea, open an issue for discussion and help. diff --git a/LICENSE b/LICENSE index 63b4b681..74ee7987 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) [year] [fullname] +Copyright (c) 2016-present Channel Cat Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..e52d6670 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,7 @@ +include README.rst +include MANIFEST.in +include LICENSE +include setup.py + +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..6fc1c8fa --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +test: + find . -name "*.pyc" -delete + docker build -t sanic/test-image -f docker/Dockerfile . + docker run -t sanic/test-image tox diff --git a/README.md b/README.md deleted file mode 100644 index e302a66c..00000000 --- a/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# Sanic - -[![Build Status](https://travis-ci.org/channelcat/sanic.svg?branch=master)](https://travis-ci.org/channelcat/sanic) -[![PyPI](https://img.shields.io/pypi/v/sanic.svg)](https://pypi.python.org/pypi/sanic/) -[![PyPI](https://img.shields.io/pypi/pyversions/sanic.svg)](https://pypi.python.org/pypi/sanic/) - -Sanic is a Flask-like Python 3.5+ web server that's written to go fast. It's based off the work done by the amazing folks at magicstack, and was inspired by this article: https://magic.io/blog/uvloop-blazing-fast-python-networking/. - -On top of being flask-like, sanic supports async request handlers. This means you can use the new shiny async/await syntax from Python 3.5, making your code non-blocking and speedy. - -## Benchmarks - -All tests were run on a AWS medium instance running ubuntu, using 1 process. Each script delivered a small JSON response and was tested with wrk using 100 connections. Pypy was tested for falcon and flask, but did not speed up requests. - -| Server | Implementation | Requests/sec | Avg Latency | -| ------- | ------------------- | ------------:| -----------:| -| Sanic | Python 3.5 + uvloop | 30,601 | 3.23ms | -| Wheezy | gunicorn + meinheld | 20,244 | 4.94ms | -| Falcon | gunicorn + meinheld | 18,972 | 5.27ms | -| Bottle | gunicorn + meinheld | 13,596 | 7.36ms | -| Flask | gunicorn + meinheld | 4,988 | 20.08ms | -| Kyoukai | Python 3.5 + uvloop | 3,889 | 27.44ms | -| Aiohttp | Python 3.5 + uvloop | 2,979 | 33.42ms | - -## Hello World - -```python -from sanic import Sanic -from sanic.response import json - -app = Sanic(__name__) - -@app.route("/") -async def test(request): - return json({ "hello": "world" }) - -app.run(host="0.0.0.0", port=8000) -``` - -## Installation - * `python -m pip install sanic` - -## Documentation - * [Getting started](docs/getting_started.md) - * [Request Data](docs/request_data.md) - * [Routing](docs/routing.md) - * [Middleware](docs/middleware.md) - * [Exceptions](docs/exceptions.md) - * [Blueprints](docs/blueprints.md) - * [Contributing](docs/contributing.md) - * [License](LICENSE) - -## TODO: - * Streamed file processing - * File output - * Examples of integrations with 3rd-party modules - * RESTful router - -## Limitations: - * No wheels for uvloop and httptools on Windows :( - -## Final Thoughts: - - ▄▄▄▄▄ - ▀▀▀██████▄▄▄ _______________ - ▄▄▄▄▄ █████████▄ / \ - ▀▀▀▀█████▌ ▀▐▄ ▀▐█ | Gotta go fast! | - ▀▀█████▄▄ ▀██████▄██ | _________________/ - ▀▄▄▄▄▄ ▀▀█▄▀█════█▀ |/ - ▀▀▀▄ ▀▀███ ▀ ▄▄ - ▄███▀▀██▄████████▄ ▄▀▀▀▀▀▀█▌ - ██▀▄▄▄██▀▄███▀ ▀▀████ ▄██ - ▄▀▀▀▄██▄▀▀▌████▒▒▒▒▒▒███ ▌▄▄▀ - ▌ ▐▀████▐███▒▒▒▒▒▐██▌ - ▀▄▄▄▄▀ ▀▀████▒▒▒▒▄██▀ - ▀▀█████████▀ - ▄▄██▀██████▀█ - ▄██▀ ▀▀▀ █ - ▄█ ▐▌ - ▄▄▄▄█▌ ▀█▄▄▄▄▀▀▄ - ▌ ▐ ▀▀▄▄▄▀ - ▀▀▄▄▀ diff --git a/README.rst b/README.rst new file mode 100644 index 00000000..01801ddd --- /dev/null +++ b/README.rst @@ -0,0 +1,100 @@ +Sanic +===== + +|Join the chat at https://gitter.im/sanic-python/Lobby| |Build Status| |PyPI| |PyPI version| + +Sanic is a Flask-like Python 3.5+ web server that's written to go fast. It's based on the work done by the amazing folks at magicstack, and was inspired by `this article `_. + +On top of being Flask-like, Sanic supports async request handlers. This means you can use the new shiny async/await syntax from Python 3.5, making your code non-blocking and speedy. + +Sanic is developed `on GitHub `_. Contributions are welcome! + +If you have a project that utilizes Sanic make sure to comment on the `issue `_ that we use to track those projects! + +Hello World Example +------------------- + +.. code:: python + + from sanic import Sanic + from sanic.response import json + + app = Sanic() + + @app.route('/') + async def test(request): + return json({'hello': 'world'}) + + if __name__ == '__main__': + app.run(host='0.0.0.0', port=8000) + +Installation +------------ + +- ``pip install sanic`` + +To install sanic without uvloop or ujson using bash, you can provide either or both of these environmental variables +using any truthy string like `'y', 'yes', 't', 'true', 'on', '1'` and setting the NO_X to true will stop that features +installation. + +- ``SANIC_NO_UVLOOP=true SANIC_NO_UJSON=true pip install sanic`` + + +Documentation +------------- + +`Documentation on Readthedocs `_. + +.. |Join the chat at https://gitter.im/sanic-python/Lobby| image:: https://badges.gitter.im/sanic-python/Lobby.svg + :target: https://gitter.im/sanic-python/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge +.. |Build Status| image:: https://travis-ci.org/channelcat/sanic.svg?branch=master + :target: https://travis-ci.org/channelcat/sanic +.. |Documentation| image:: https://readthedocs.org/projects/sanic/badge/?version=latest + :target: http://sanic.readthedocs.io/en/latest/?badge=latest +.. |PyPI| image:: https://img.shields.io/pypi/v/sanic.svg + :target: https://pypi.python.org/pypi/sanic/ +.. |PyPI version| image:: https://img.shields.io/pypi/pyversions/sanic.svg + :target: https://pypi.python.org/pypi/sanic/ + + +Examples +-------- +`Non-Core examples `_. Examples of plugins and Sanic that are outside the scope of Sanic core. + +`Extensions `_. Sanic extensions created by the community. + +`Projects `_. Sanic in production use. + + +TODO +---- + * http2 + +Limitations +----------- +* No wheels for uvloop and httptools on Windows :( + +Final Thoughts +-------------- + +:: + + ▄▄▄▄▄ + ▀▀▀██████▄▄▄ _______________ + ▄▄▄▄▄ █████████▄ / \ + ▀▀▀▀█████▌ ▀▐▄ ▀▐█ | Gotta go fast! | + ▀▀█████▄▄ ▀██████▄██ | _________________/ + ▀▄▄▄▄▄ ▀▀█▄▀█════█▀ |/ + ▀▀▀▄ ▀▀███ ▀ ▄▄ + ▄███▀▀██▄████████▄ ▄▀▀▀▀▀▀█▌ + ██▀▄▄▄██▀▄███▀ ▀▀████ ▄██ + ▄▀▀▀▄██▄▀▀▌████▒▒▒▒▒▒███ ▌▄▄▀ + ▌ ▐▀████▐███▒▒▒▒▒▐██▌ + ▀▄▄▄▄▀ ▀▀████▒▒▒▒▄██▀ + ▀▀█████████▀ + ▄▄██▀██████▀█ + ▄██▀ ▀▀▀ █ + ▄█ ▐▌ + ▄▄▄▄█▌ ▀█▄▄▄▄▀▀▄ + ▌ ▐ ▀▀▄▄▄▀ + ▀▀▄▄▀ diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..dc7832ff --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,28 @@ +FROM alpine:3.7 + +RUN apk add --no-cache --update \ + curl \ + bash \ + build-base \ + ca-certificates \ + git \ + bzip2-dev \ + linux-headers \ + ncurses-dev \ + openssl \ + openssl-dev \ + readline-dev \ + sqlite-dev + +RUN update-ca-certificates +RUN rm -rf /var/cache/apk/* + +ENV PYENV_ROOT="/root/.pyenv" +ENV PATH="$PYENV_ROOT/bin:$PATH" + +ADD . /app +WORKDIR /app + +RUN /app/docker/bin/install_python.sh 3.5.4 3.6.4 + +ENTRYPOINT ["./docker/bin/entrypoint.sh"] diff --git a/docker/bin/entrypoint.sh b/docker/bin/entrypoint.sh new file mode 100755 index 00000000..762d2155 --- /dev/null +++ b/docker/bin/entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + +eval "$(pyenv init -)" +eval "$(pyenv virtualenv-init -)" +source /root/.pyenv/completions/pyenv.bash + +pip install tox + +exec $@ + diff --git a/docker/bin/install_python.sh b/docker/bin/install_python.sh new file mode 100755 index 00000000..e7c4aa1f --- /dev/null +++ b/docker/bin/install_python.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -e + +export CFLAGS='-O2' +export EXTRA_CFLAGS="-DTHREAD_STACK_SIZE=0x100000" + +curl -L https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/pyenv-installer | bash +eval "$(pyenv init -)" + +for ver in $@ +do + pyenv install $ver +done + +pyenv global $@ +pip install --upgrade pip +pyenv rehash diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..72b82bed --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,225 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " applehelp to make an Apple Help Book" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " epub3 to make an epub3" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " coverage to run coverage check of the documentation (if enabled)" + @echo " dummy to check syntax errors of document sources" + +.PHONY: clean +clean: + rm -rf $(BUILDDIR)/* + +.PHONY: html +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +.PHONY: dirhtml +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +.PHONY: singlehtml +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +.PHONY: pickle +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +.PHONY: json +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +.PHONY: htmlhelp +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +.PHONY: qthelp +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/aiographite.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/aiographite.qhc" + +.PHONY: applehelp +applehelp: + $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp + @echo + @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." + @echo "N.B. You won't be able to view it unless you put it in" \ + "~/Library/Documentation/Help or install it in your application" \ + "bundle." + +.PHONY: devhelp +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/aiographite" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/aiographite" + @echo "# devhelp" + +.PHONY: epub +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +.PHONY: epub3 +epub3: + $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 + @echo + @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." + +.PHONY: latex +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +.PHONY: latexpdf +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: latexpdfja +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +.PHONY: text +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +.PHONY: man +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +.PHONY: texinfo +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +.PHONY: info +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +.PHONY: gettext +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +.PHONY: changes +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +.PHONY: linkcheck +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +.PHONY: doctest +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +.PHONY: coverage +coverage: + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage + @echo "Testing of coverage in the sources finished, look at the " \ + "results in $(BUILDDIR)/coverage/python.txt." + +.PHONY: xml +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +.PHONY: pseudoxml +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." + +.PHONY: dummy +dummy: + $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy + @echo + @echo "Build finished. Dummy builder generates no files." diff --git a/docs/blueprints.md b/docs/blueprints.md deleted file mode 100644 index 7a4567ee..00000000 --- a/docs/blueprints.md +++ /dev/null @@ -1,82 +0,0 @@ -# Blueprints - -Blueprints are objects that can be used for sub-routing within an application. -Instead of adding routes to the application object, blueprints define similar -methods for adding routes, which are then registered with the application in a -flexible and pluggable manner. - -## Why? - -Blueprints are especially useful for larger applications, where your application -logic can be broken down into several groups or areas of responsibility. - -It is also useful for API versioning, where one blueprint may point at -`/v1/`, and another pointing at `/v2/`. - - -## My First Blueprint - -The following shows a very simple blueprint that registers a handler-function at -the root `/` of your application. - -Suppose you save this file as `my_blueprint.py`, this can be imported in your -main application later. - -```python -from sanic.response import json -from sanic import Blueprint - -bp = Blueprint('my_blueprint') - -@bp.route('/') -async def bp_root(): - return json({'my': 'blueprint'}) - -``` - -## Registering Blueprints -Blueprints must be registered with the application. - -```python -from sanic import Sanic -from my_blueprint import bp - -app = Sanic(__name__) -app.register_blueprint(bp) - -app.run(host='0.0.0.0', port=8000, debug=True) -``` - -This will add the blueprint to the application and register any routes defined -by that blueprint. -In this example, the registered routes in the `app.router` will look like: - -```python -[Route(handler=, methods=None, pattern=re.compile('^/$'), parameters=[])] -``` - -## Middleware -Using blueprints allows you to also register middleware globally. - -```python -@bp.middleware -async def halt_request(request): - print("I am a spy") - -@bp.middleware('request') -async def halt_request(request): - return text('I halted the request') - -@bp.middleware('response') -async def halt_response(request, response): - return text('I halted the response') -``` - -## Exceptions -Exceptions can also be applied exclusively to blueprints globally. - -```python -@bp.exception(NotFound) -def ignore_404s(request, exception): - return text("Yep, I totally found the page: {}".format(request.url)) -``` diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..7dd7462c --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# Sanic documentation build configuration file, created by +# sphinx-quickstart on Sun Dec 25 18:07:21 2016. +# +# This file is execfile()d with the current directory set to its +# containing dir. + +import os +import sys + +# Add support for Markdown documentation using Recommonmark +from recommonmark.parser import CommonMarkParser + +# Add support for auto-doc +from recommonmark.transform import AutoStructify + +# Ensure that sanic is present in the path, to allow sphinx-apidoc to +# autogenerate documentation from docstrings +root_directory = os.path.dirname(os.getcwd()) +sys.path.insert(0, root_directory) + +import sanic + +# -- General configuration ------------------------------------------------ + +extensions = ['sphinx.ext.autodoc', 'sphinxcontrib.asyncio'] + +templates_path = ['_templates'] + +# Enable support for both Restructured Text and Markdown +source_parsers = {'.md': CommonMarkParser} +source_suffix = ['.rst', '.md'] + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'Sanic' +copyright = '2016, Sanic contributors' +author = 'Sanic contributors' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = sanic.__version__ +# The full version, including alpha/beta/rc tags. +release = sanic.__version__ + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = 'en' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +# +# modules.rst is generated by sphinx-apidoc but is unused. This suppresses +# a warning about it. +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', 'modules.rst'] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'sphinx_rtd_theme' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# -- Options for HTMLHelp output ------------------------------------------ + +# Output file base name for HTML help builder. +htmlhelp_basename = 'Sanicdoc' + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [(master_doc, 'Sanic.tex', 'Sanic Documentation', + 'Sanic contributors', 'manual'), ] + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [(master_doc, 'sanic', 'Sanic Documentation', [author], 1)] + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'Sanic', 'Sanic Documentation', author, 'Sanic', + 'One line description of project.', 'Miscellaneous'), +] + +# -- Options for Epub output ---------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project +epub_author = author +epub_publisher = author +epub_copyright = copyright + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ['search.html'] + +# -- Custom Settings ------------------------------------------------------- + +suppress_warnings = ['image.nonlocal_uri'] + + +# app setup hook +def setup(app): + app.add_config_value('recommonmark_config', { + 'enable_eval_rst': True, + 'enable_auto_doc_ref': True, + }, True) + app.add_transform(AutoStructify) diff --git a/docs/contributing.md b/docs/contributing.md deleted file mode 100644 index e39a7247..00000000 --- a/docs/contributing.md +++ /dev/null @@ -1,10 +0,0 @@ -# How to contribute to Sanic - -Thank you for your interest! - -## Running tests -* `python -m pip install pytest` -* `python -m pytest tests` - -## Warning -One of the main goals of Sanic is speed. Code that lowers the performance of Sanic without significant gains in usability, security, or features may not be merged. \ No newline at end of file diff --git a/docs/exceptions.md b/docs/exceptions.md deleted file mode 100644 index 4889cd7b..00000000 --- a/docs/exceptions.md +++ /dev/null @@ -1,28 +0,0 @@ -# Exceptions - -Exceptions can be thrown from within request handlers and will automatically be handled by Sanic. Exceptions take a message as their first argument, and can also take a status_code to be passed back in the HTTP response. Check sanic.exceptions for the full list of exceptions to throw. - -## Throwing an exception - -```python -from sanic import Sanic -from sanic.exceptions import ServerError - -@app.route('/killme') -def i_am_ready_to_die(request): - raise ServerError("Something bad happened") -``` - -## Handling Exceptions - -Just use the @exception decorator. The decorator expects a list of exceptions to handle as arguments. You can pass SanicException to catch them all! The exception handler must expect a request and exception object as arguments. - -```python -from sanic import Sanic -from sanic.response import text -from sanic.exceptions import NotFound - -@app.exception(NotFound) -def ignore_404s(request, exception): - return text("Yep, I totally found the page: {}".format(request.url)) -``` \ No newline at end of file diff --git a/docs/getting_started.md b/docs/getting_started.md deleted file mode 100644 index c7a437d3..00000000 --- a/docs/getting_started.md +++ /dev/null @@ -1,25 +0,0 @@ -# Getting Started - -Make sure you have pip and python 3.5 before starting - -## Benchmarks - * Install Sanic - * `python3 -m pip install sanic` - * Edit main.py to include: -```python -from sanic import Sanic -from sanic.response import json - -app = Sanic(__name__) - -@app.route("/") -async def test(request): - return json({ "hello": "world" }) - -app.run(host="0.0.0.0", port=8000, debug=True) -``` - * Run `python3 main.py` - -You now have a working Sanic server! To continue on, check out: - * [Request Data](request_data.md) - * [Routing](routing.md) \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..aaa296ee --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,40 @@ +.. include:: sanic/index.rst + +Guides +====== + +.. toctree:: + :maxdepth: 2 + + sanic/getting_started + sanic/routing + sanic/request_data + sanic/response + sanic/static_files + sanic/exceptions + sanic/middleware + sanic/blueprints + sanic/websocket + sanic/config + sanic/cookies + sanic/decorators + sanic/streaming + sanic/class_based_views + sanic/custom_protocol + sanic/ssl + sanic/logging + sanic/testing + sanic/deploying + sanic/extensions + sanic/contributing + sanic/api_reference + + +Module Documentation +==================== + +.. toctree:: + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..3887c127 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,281 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. epub3 to make an epub3 + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + echo. coverage to run coverage check of the documentation if enabled + echo. dummy to check syntax errors of document sources + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +REM Check if sphinx-build is available and fallback to Python version if any +%SPHINXBUILD% 1>NUL 2>NUL +if errorlevel 9009 goto sphinx_python +goto sphinx_ok + +:sphinx_python + +set SPHINXBUILD=python -m sphinx.__init__ +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +:sphinx_ok + + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\aiographite.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\aiographite.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "epub3" ( + %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %~dp0 + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "coverage" ( + %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage + if errorlevel 1 exit /b 1 + echo. + echo.Testing of coverage in the sources finished, look at the ^ +results in %BUILDDIR%/coverage/python.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +if "%1" == "dummy" ( + %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. Dummy builder generates no files. + goto end +) + +:end diff --git a/docs/middleware.md b/docs/middleware.md deleted file mode 100644 index 0b27443c..00000000 --- a/docs/middleware.md +++ /dev/null @@ -1,29 +0,0 @@ -# Middleware - -Middleware can be executed before or after requests. It is executed in the order it was registered. If middleware returns a response object, the request will stop processing and a response will be returned. - -Middleware is registered via the middleware decorator, and can either be added as 'request' or 'response' middleware, based on the argument provided in the decorator. Response middleware receives both the request and the response as arguments. - -## Examples - -```python -app = Sanic(__name__) - -@app.middleware -async def halt_request(request): - print("I am a spy") - -@app.middleware('request') -async def halt_request(request): - return text('I halted the request') - -@app.middleware('response') -async def halt_response(request, response): - return text('I halted the response') - -@app.route('/') -async def handler(request): - return text('I would like to speak now please') - -app.run(host="0.0.0.0", port=8000) -``` diff --git a/docs/request_data.md b/docs/request_data.md deleted file mode 100644 index bc89bb88..00000000 --- a/docs/request_data.md +++ /dev/null @@ -1,43 +0,0 @@ -# Request Data - -## Properties - -The following request variables are accessible as properties: - -`request.files` (dictionary of File objects) - List of files that have a name, body, and type -`request.json` (any) - JSON body -`request.args` (dict) - Query String variables. Use getlist to get multiple of the same name -`request.form` (dict) - Posted form variables. Use getlist to get multiple of the same name - -See request.py for more information - -## Examples - -```python -from sanic import Sanic -from sanic.response import json - -@app.route("/json") -def post_json(request): - return json({ "received": True, "message": request.json }) - -@app.route("/form") -def post_json(request): - return json({ "received": True, "form_data": request.form, "test": request.form.get('test') }) - -@app.route("/files") -def post_json(request): - test_file = request.files.get('test') - - file_parameters = { - 'body': test_file.body, - 'name': test_file.name, - 'type': test_file.type, - } - - return json({ "received": True, "file_names": request.files.keys(), "test_file_parameters": file_parameters }) - -@app.route("/query_string") -def query_string(request): - return json({ "parsed": True, "args": request.args, "url": request.url, "query_string": request.query_string }) -``` diff --git a/docs/routing.md b/docs/routing.md deleted file mode 100644 index c07e1b81..00000000 --- a/docs/routing.md +++ /dev/null @@ -1,32 +0,0 @@ -# Routing - -Sanic comes with a basic router that supports request parameters. To specify a parameter, surround it with carrots like so: ``. Request parameters will be passed to the request handler functions as keyword arguments. To specify a type, add a :type after the parameter name, in the carrots. If the parameter does not match the type supplied, Sanic will throw a NotFound exception, resulting in a 404 page not found error. - - -## Examples - -```python -from sanic import Sanic -from sanic.response import text - -@app.route('/tag/') -async def person_handler(request, tag): - return text('Tag - {}'.format(tag)) - -@app.route('/number/') -async def person_handler(request, integer_arg): - return text('Integer - {}'.format(integer_arg)) - -@app.route('/number/') -async def person_handler(request, number_arg): - return text('Number - {}'.format(number)) - -@app.route('/person/') -async def person_handler(request, name): - return text('Person - {}'.format(name)) - -@app.route('/folder/') -async def folder_handler(request, folder_id): - return text('Folder - {}'.format(folder_id)) - -``` diff --git a/docs/sanic/api_reference.rst b/docs/sanic/api_reference.rst new file mode 100644 index 00000000..5ca7556a --- /dev/null +++ b/docs/sanic/api_reference.rst @@ -0,0 +1,150 @@ +API Reference +============= + +Submodules +---------- + +sanic.app module +---------------- + +.. automodule:: sanic.app + :members: + :undoc-members: + :show-inheritance: + +sanic.blueprints module +----------------------- + +.. automodule:: sanic.blueprints + :members: + :undoc-members: + :show-inheritance: + +sanic.config module +------------------- + +.. automodule:: sanic.config + :members: + :undoc-members: + :show-inheritance: + +sanic.constants module +---------------------- + +.. automodule:: sanic.constants + :members: + :undoc-members: + :show-inheritance: + +sanic.cookies module +-------------------- + +.. automodule:: sanic.cookies + :members: + :undoc-members: + :show-inheritance: + +sanic.exceptions module +----------------------- + +.. automodule:: sanic.exceptions + :members: + :undoc-members: + :show-inheritance: + +sanic.handlers module +--------------------- + +.. automodule:: sanic.handlers + :members: + :undoc-members: + :show-inheritance: + +sanic.log module +---------------- + +.. automodule:: sanic.log + :members: + :undoc-members: + :show-inheritance: + +sanic.request module +-------------------- + +.. automodule:: sanic.request + :members: + :undoc-members: + :show-inheritance: + +sanic.response module +--------------------- + +.. automodule:: sanic.response + :members: + :undoc-members: + :show-inheritance: + +sanic.router module +------------------- + +.. automodule:: sanic.router + :members: + :undoc-members: + :show-inheritance: + +sanic.server module +------------------- + +.. automodule:: sanic.server + :members: + :undoc-members: + :show-inheritance: + +sanic.static module +------------------- + +.. automodule:: sanic.static + :members: + :undoc-members: + :show-inheritance: + +sanic.testing module +-------------------- + +.. automodule:: sanic.testing + :members: + :undoc-members: + :show-inheritance: + +sanic.views module +------------------ + +.. automodule:: sanic.views + :members: + :undoc-members: + :show-inheritance: + +sanic.websocket module +---------------------- + +.. automodule:: sanic.websocket + :members: + :undoc-members: + :show-inheritance: + +sanic.worker module +------------------- + +.. automodule:: sanic.worker + :members: + :undoc-members: + :show-inheritance: + + +Module contents +--------------- + +.. automodule:: sanic + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/sanic/blueprints.md b/docs/sanic/blueprints.md new file mode 100644 index 00000000..53aef5fd --- /dev/null +++ b/docs/sanic/blueprints.md @@ -0,0 +1,258 @@ +# Blueprints + +Blueprints are objects that can be used for sub-routing within an application. +Instead of adding routes to the application instance, blueprints define similar +methods for adding routes, which are then registered with the application in a +flexible and pluggable manner. + +Blueprints are especially useful for larger applications, where your +application logic can be broken down into several groups or areas of +responsibility. + +## My First Blueprint + +The following shows a very simple blueprint that registers a handler-function at +the root `/` of your application. + +Suppose you save this file as `my_blueprint.py`, which can be imported into your +main application later. + +```python +from sanic.response import json +from sanic import Blueprint + +bp = Blueprint('my_blueprint') + +@bp.route('/') +async def bp_root(request): + return json({'my': 'blueprint'}) + +``` + +## Registering blueprints + +Blueprints must be registered with the application. + +```python +from sanic import Sanic +from my_blueprint import bp + +app = Sanic(__name__) +app.blueprint(bp) + +app.run(host='0.0.0.0', port=8000, debug=True) +``` + +This will add the blueprint to the application and register any routes defined +by that blueprint. In this example, the registered routes in the `app.router` +will look like: + +```python +[Route(handler=, methods=None, pattern=re.compile('^/$'), parameters=[])] +``` + +## Blueprint groups and nesting + +Blueprints may also be registered as part of a list or tuple, where the registrar will recursively cycle through any sub-sequences of blueprints and register them accordingly. The `Blueprint.group` method is provided to simplify this process, allowing a 'mock' backend directory structure mimicking what's seen from the front end. Consider this (quite contrived) example: + +``` +api/ +├──content/ +│ ├──authors.py +│ ├──static.py +│ └──__init__.py +├──info.py +└──__init__.py +app.py +``` + +Initialization of this app's blueprint hierarchy could go as follows: + +```python +# api/content/authors.py +from sanic import Blueprint + +authors = Blueprint('content_authors', url_prefix='/authors') +``` +```python +# api/content/static.py +from sanic import Blueprint + +static = Blueprint('content_static', url_prefix='/static') +``` +```python +# api/content/__init__.py +from sanic import Blueprint + +from .static import static +from .authors import authors + +content = Blueprint.group(assets, authors, url_prefix='/content') +``` +```python +# api/info.py +from sanic import Blueprint + +info = Blueprint('info', url_prefix='/info') +``` +```python +# api/__init__.py +from sanic import Blueprint + +from .content import content +from .info import info + +api = Blueprint.group(content, info, url_prefix='/api') +``` + +And registering these blueprints in `app.py` can now be done like so: + +```python +# app.py +from sanic import Sanic + +from .api import api + +app = Sanic(__name__) + +app.blueprint(api) +``` + +## Using blueprints + +Blueprints have much the same functionality as an application instance. + +### WebSocket routes + +WebSocket handlers can be registered on a blueprint using the `@bp.websocket` +decorator or `bp.add_websocket_route` method. + +### Middleware + +Using blueprints allows you to also register middleware globally. + +```python +@bp.middleware +async def print_on_request(request): + print("I am a spy") + +@bp.middleware('request') +async def halt_request(request): + return text('I halted the request') + +@bp.middleware('response') +async def halt_response(request, response): + return text('I halted the response') +``` + +### Exceptions + +Exceptions can be applied exclusively to blueprints globally. + +```python +@bp.exception(NotFound) +def ignore_404s(request, exception): + return text("Yep, I totally found the page: {}".format(request.url)) +``` + +### Static files + +Static files can be served globally, under the blueprint prefix. + +```python + +# suppose bp.name == 'bp' + +bp.static('/web/path', '/folder/to/serve') +# also you can pass name parameter to it for url_for +bp.static('/web/path', '/folder/to/server', name='uploads') +app.url_for('static', name='bp.uploads', filename='file.txt') == '/bp/web/path/file.txt' + +``` + +## Start and stop + +Blueprints can run functions during the start and stop process of the server. +If running in multiprocessor mode (more than 1 worker), these are triggered +after the workers fork. + +Available events are: + +- `before_server_start`: Executed before the server begins to accept connections +- `after_server_start`: Executed after the server begins to accept connections +- `before_server_stop`: Executed before the server stops accepting connections +- `after_server_stop`: Executed after the server is stopped and all requests are complete + +```python +bp = Blueprint('my_blueprint') + +@bp.listener('before_server_start') +async def setup_connection(app, loop): + global database + database = mysql.connect(host='127.0.0.1'...) + +@bp.listener('after_server_stop') +async def close_connection(app, loop): + await database.close() +``` + +## Use-case: API versioning + +Blueprints can be very useful for API versioning, where one blueprint may point +at `/v1/`, and another pointing at `/v2/`. + +When a blueprint is initialised, it can take an optional `url_prefix` argument, +which will be prepended to all routes defined on the blueprint. This feature +can be used to implement our API versioning scheme. + +```python +# blueprints.py +from sanic.response import text +from sanic import Blueprint + +blueprint_v1 = Blueprint('v1', url_prefix='/v1') +blueprint_v2 = Blueprint('v2', url_prefix='/v2') + +@blueprint_v1.route('/') +async def api_v1_root(request): + return text('Welcome to version 1 of our documentation') + +@blueprint_v2.route('/') +async def api_v2_root(request): + return text('Welcome to version 2 of our documentation') +``` + +When we register our blueprints on the app, the routes `/v1` and `/v2` will now +point to the individual blueprints, which allows the creation of *sub-sites* +for each API version. + +```python +# main.py +from sanic import Sanic +from blueprints import blueprint_v1, blueprint_v2 + +app = Sanic(__name__) +app.blueprint(blueprint_v1, url_prefix='/v1') +app.blueprint(blueprint_v2, url_prefix='/v2') + +app.run(host='0.0.0.0', port=8000, debug=True) +``` + +## URL Building with `url_for` + +If you wish to generate a URL for a route inside of a blueprint, remember that the endpoint name +takes the format `.`. For example: + +```python +@blueprint_v1.route('/') +async def root(request): + url = request.app.url_for('v1.post_handler', post_id=5) # --> '/v1/post/5' + return redirect(url) + + +@blueprint_v1.route('/post/') +async def post_handler(request, post_id): + return text('Post {} in Blueprint V1'.format(post_id)) +``` + + diff --git a/docs/sanic/class_based_views.md b/docs/sanic/class_based_views.md new file mode 100644 index 00000000..c3304df6 --- /dev/null +++ b/docs/sanic/class_based_views.md @@ -0,0 +1,166 @@ +# Class-Based Views + +Class-based views are simply classes which implement response behaviour to +requests. They provide a way to compartmentalise handling of different HTTP +request types at the same endpoint. Rather than defining and decorating three +different handler functions, one for each of an endpoint's supported request +type, the endpoint can be assigned a class-based view. + +## Defining views + +A class-based view should subclass `HTTPMethodView`. You can then implement +class methods for every HTTP request type you want to support. If a request is +received that has no defined method, a `405: Method not allowed` response will +be generated. + +To register a class-based view on an endpoint, the `app.add_route` method is +used. The first argument should be the defined class with the method `as_view` +invoked, and the second should be the URL endpoint. + +The available methods are `get`, `post`, `put`, `patch`, and `delete`. A class +using all these methods would look like the following. + +```python +from sanic import Sanic +from sanic.views import HTTPMethodView +from sanic.response import text + +app = Sanic('some_name') + +class SimpleView(HTTPMethodView): + + def get(self, request): + return text('I am get method') + + def post(self, request): + return text('I am post method') + + def put(self, request): + return text('I am put method') + + def patch(self, request): + return text('I am patch method') + + def delete(self, request): + return text('I am delete method') + +app.add_route(SimpleView.as_view(), '/') + +``` + +You can also use `async` syntax. + +```python +from sanic import Sanic +from sanic.views import HTTPMethodView +from sanic.response import text + +app = Sanic('some_name') + +class SimpleAsyncView(HTTPMethodView): + + async def get(self, request): + return text('I am async get method') + +app.add_route(SimpleAsyncView.as_view(), '/') + +``` + +## URL parameters + +If you need any URL parameters, as discussed in the routing guide, include them +in the method definition. + +```python +class NameView(HTTPMethodView): + + def get(self, request, name): + return text('Hello {}'.format(name)) + +app.add_route(NameView.as_view(), '/') +``` + +## Decorators + +If you want to add any decorators to the class, you can set the `decorators` +class variable. These will be applied to the class when `as_view` is called. + +```python +class ViewWithDecorator(HTTPMethodView): + decorators = [some_decorator_here] + + def get(self, request, name): + return text('Hello I have a decorator') + + def post(self, request, name): + return text("Hello I also have a decorator") + +app.add_route(ViewWithDecorator.as_view(), '/url') +``` + +But if you just want to decorate some functions and not all functions, you can do as follows: + +```python +class ViewWithSomeDecorator(HTTPMethodView): + + @staticmethod + @some_decorator_here + def get(request, name): + return text("Hello I have a decorator") + + def post(self, request, name): + return text("Hello I don't have any decorators") +``` + +## URL Building + +If you wish to build a URL for an HTTPMethodView, remember that the class name will be the endpoint +that you will pass into `url_for`. For example: + +```python +@app.route('/') +def index(request): + url = app.url_for('SpecialClassView') + return redirect(url) + + +class SpecialClassView(HTTPMethodView): + def get(self, request): + return text('Hello from the Special Class View!') + + +app.add_route(SpecialClassView.as_view(), '/special_class_view') +``` + + +## Using CompositionView + +As an alternative to the `HTTPMethodView`, you can use `CompositionView` to +move handler functions outside of the view class. + +Handler functions for each supported HTTP method are defined elsewhere in the +source, and then added to the view using the `CompositionView.add` method. The +first parameter is a list of HTTP methods to handle (e.g. `['GET', 'POST']`), +and the second is the handler function. The following example shows +`CompositionView` usage with both an external handler function and an inline +lambda: + +```python +from sanic import Sanic +from sanic.views import CompositionView +from sanic.response import text + +app = Sanic(__name__) + +def get_handler(request): + return text('I am a get method') + +view = CompositionView() +view.add(['GET'], get_handler) +view.add(['POST', 'PUT'], lambda request: text('I am a post/put method')) + +# Use the new view to handle requests to the base URL +app.add_route(view, '/') +``` + +Note: currently you cannot build a URL for a CompositionView using `url_for`. diff --git a/docs/sanic/config.md b/docs/sanic/config.md new file mode 100644 index 00000000..5d0dc95a --- /dev/null +++ b/docs/sanic/config.md @@ -0,0 +1,119 @@ +# Configuration + +Any reasonably complex application will need configuration that is not baked into the actual code. Settings might be different for different environments or installations. + +## Basics + +Sanic holds the configuration in the `config` attribute of the application object. The configuration object is merely an object that can be modified either using dot-notation or like a dictionary: + +``` +app = Sanic('myapp') +app.config.DB_NAME = 'appdb' +app.config.DB_USER = 'appuser' +``` + +Since the config object actually is a dictionary, you can use its `update` method in order to set several values at once: + +``` +db_settings = { + 'DB_HOST': 'localhost', + 'DB_NAME': 'appdb', + 'DB_USER': 'appuser' +} +app.config.update(db_settings) +``` + +In general the convention is to only have UPPERCASE configuration parameters. The methods described below for loading configuration only look for such uppercase parameters. + +## Loading Configuration + +There are several ways how to load configuration. + +### From Environment Variables + +Any variables defined with the `SANIC_` prefix will be applied to the sanic config. For example, setting `SANIC_REQUEST_TIMEOUT` will be loaded by the application automatically and fed into the `REQUEST_TIMEOUT` config variable. You can pass a different prefix to Sanic: + +```python +app = Sanic(load_env='MYAPP_') +``` + +Then the above variable would be `MYAPP_REQUEST_TIMEOUT`. If you want to disable loading from environment variables you can set it to `False` instead: + +```python +app = Sanic(load_env=False) +``` + +### From an Object + +If there are a lot of configuration values and they have sensible defaults it might be helpful to put them into a module: + +``` +import myapp.default_settings + +app = Sanic('myapp') +app.config.from_object(myapp.default_settings) +``` + +You could use a class or any other object as well. + +### From a File + +Usually you will want to load configuration from a file that is not part of the distributed application. You can load configuration from a file using `from_pyfile(/path/to/config_file)`. However, that requires the program to know the path to the config file. So instead you can specify the location of the config file in an environment variable and tell Sanic to use that to find the config file: + +``` +app = Sanic('myapp') +app.config.from_envvar('MYAPP_SETTINGS') +``` + +Then you can run your application with the `MYAPP_SETTINGS` environment variable set: + +``` +$ MYAPP_SETTINGS=/path/to/config_file python3 myapp.py +INFO: Goin' Fast @ http://0.0.0.0:8000 +``` + +The config files are regular Python files which are executed in order to load them. This allows you to use arbitrary logic for constructing the right configuration. Only uppercase variables are added to the configuration. Most commonly the configuration consists of simple key value pairs: + +``` +# config_file +DB_HOST = 'localhost' +DB_NAME = 'appdb' +DB_USER = 'appuser' +``` + +## Builtin Configuration Values + +Out of the box there are just a few predefined values which can be overwritten when creating the application. + + | Variable | Default | Description | + | ------------------ | --------- | --------------------------------------------- | + | REQUEST_MAX_SIZE | 100000000 | How big a request may be (bytes) | + | REQUEST_TIMEOUT | 60 | How long a request can take to arrive (sec) | + | RESPONSE_TIMEOUT | 60 | How long a response can take to process (sec) | + | KEEP_ALIVE | True | Disables keep-alive when False | + | KEEP_ALIVE_TIMEOUT | 5 | How long to hold a TCP connection open (sec) | + +### The different Timeout variables: + +A request timeout measures the duration of time between the instant when a new open TCP connection is passed to the Sanic backend server, and the instant when the whole HTTP request is received. If the time taken exceeds the `REQUEST_TIMEOUT` value (in seconds), this is considered a Client Error so Sanic generates a HTTP 408 response and sends that to the client. Adjust this value higher if your clients routinely pass very large request payloads or upload requests very slowly. + +A response timeout measures the duration of time between the instant the Sanic server passes the HTTP request to the Sanic App, and the instant a HTTP response is sent to the client. If the time taken exceeds the `RESPONSE_TIMEOUT` value (in seconds), this is considered a Server Error so Sanic generates a HTTP 503 response and sets that to the client. Adjust this value higher if your application is likely to have long-running process that delay the generation of a response. + +### What is Keep Alive? And what does the Keep Alive Timeout value do? + +Keep-Alive is a HTTP feature indroduced in HTTP 1.1. When sending a HTTP request, the client (usually a web browser application) can set a Keep-Alive header to indicate for the http server (Sanic) to not close the TCP connection after it has send the response. This allows the client to reuse the existing TCP connection to send subsequent HTTP requests, and ensures more efficient network traffic for both the client and the server. + +The `KEEP_ALIVE` config variable is set to `True` in Sanic by default. If you don't need this feature in your application, set it to `False` to cause all client connections to close immediately after a response is sent, regardless of the Keep-Alive header on the request. + +The amount of time the server holds the TCP connection open is decided by the server itself. In Sanic, that value is configured using the `KEEP_ALIVE_TIMEOUT` value. By default, it is set to 5 seconds, this is the same default setting as the Apache HTTP server and is a good balance between allowing enough time for the client to send a new request, and not holding open too many connections at once. Do not exceed 75 seconds unless you know your clients are using a browser which supports TCP connections held open for that long. + +For reference: +``` +Apache httpd server default keepalive timeout = 5 seconds +Nginx server default keepalive timeout = 75 seconds +Nginx performance tuning guidelines uses keepalive = 15 seconds +IE (5-9) client hard keepalive limit = 60 seconds +Firefox client hard keepalive limit = 115 seconds +Opera 11 client hard keepalive limit = 120 seconds +Chrome 13+ client keepalive limit > 300+ seconds +``` diff --git a/docs/sanic/contributing.md b/docs/sanic/contributing.md new file mode 100644 index 00000000..95922693 --- /dev/null +++ b/docs/sanic/contributing.md @@ -0,0 +1,62 @@ +# Contributing + +Thank you for your interest! Sanic is always looking for contributors. If you +don't feel comfortable contributing code, adding docstrings to the source files +is very appreciated. + +## Installation + +To develop on sanic (and mainly to just run the tests) it is highly recommend to +install from sources. + +So assume you have already cloned the repo and are in the working directory with +a virtual environment already set up, then run: + +```bash +python setup.py develop && pip install -r requirements-dev.txt +``` + +## Running tests + +To run the tests for sanic it is recommended to use tox like so: + +```bash +tox +``` + +See it's that simple! + +## Pull requests! + +So the pull request approval rules are pretty simple: +1. All pull requests must pass unit tests +* All pull requests must be reviewed and approved by at least +one current collaborator on the project +* All pull requests must pass flake8 checks +* If you decide to remove/change anything from any common interface +a deprecation message should accompany it. +* If you implement a new feature you should have at least one unit +test to accompany it. + +## Documentation + +Sanic's documentation is built +using [sphinx](http://www.sphinx-doc.org/en/1.5.1/). Guides are written in +Markdown and can be found in the `docs` folder, while the module reference is +automatically generated using `sphinx-apidoc`. + +To generate the documentation from scratch: + +```bash +sphinx-apidoc -fo docs/_api/ sanic +sphinx-build -b html docs docs/_build +``` + +The HTML documentation will be created in the `docs/_build` folder. + +## Warning + +One of the main goals of Sanic is speed. Code that lowers the performance of +Sanic without significant gains in usability, security, or features may not be +merged. Please don't let this intimidate you! If you have any concerns about an +idea, open an issue for discussion and help. diff --git a/docs/sanic/cookies.rst b/docs/sanic/cookies.rst new file mode 100644 index 00000000..c4e0c0a1 --- /dev/null +++ b/docs/sanic/cookies.rst @@ -0,0 +1,87 @@ +Cookies +======= + +Cookies are pieces of data which persist inside a user's browser. Sanic can +both read and write cookies, which are stored as key-value pairs. + +.. warning:: + + Cookies can be freely altered by the client. Therefore you cannot just store + data such as login information in cookies as-is, as they can be freely altered + by the client. To ensure data you store in cookies is not forged or tampered + with by the client, use something like `itsdangerous`_ to cryptographically + sign the data. + + +Reading cookies +--------------- + +A user's cookies can be accessed via the ``Request`` object's ``cookies`` dictionary. + +.. code-block:: python + + from sanic.response import text + + @app.route("/cookie") + async def test(request): + test_cookie = request.cookies.get('test') + return text("Test cookie set to: {}".format(test_cookie)) + +Writing cookies +--------------- + +When returning a response, cookies can be set on the ``Response`` object. + +.. code-block:: python + + from sanic.response import text + + @app.route("/cookie") + async def test(request): + response = text("There's a cookie up in this response") + response.cookies['test'] = 'It worked!' + response.cookies['test']['domain'] = '.gotta-go-fast.com' + response.cookies['test']['httponly'] = True + return response + +Deleting cookies +---------------- + +Cookies can be removed semantically or explicitly. + +.. code-block:: python + + from sanic.response import text + + @app.route("/cookie") + async def test(request): + response = text("Time to eat some cookies muahaha") + + # This cookie will be set to expire in 0 seconds + del response.cookies['kill_me'] + + # This cookie will self destruct in 5 seconds + response.cookies['short_life'] = 'Glad to be here' + response.cookies['short_life']['max-age'] = 5 + del response.cookies['favorite_color'] + + # This cookie will remain unchanged + response.cookies['favorite_color'] = 'blue' + response.cookies['favorite_color'] = 'pink' + del response.cookies['favorite_color'] + + return response + +Response cookies can be set like dictionary values and have the following +parameters available: + +- ``expires`` (datetime): The time for the cookie to expire on the client's browser. +- ``path`` (string): The subset of URLs to which this cookie applies. Defaults to /. +- ``comment`` (string): A comment (metadata). +- ``domain`` (string): Specifies the domain for which the cookie is valid. An + explicitly specified domain must always start with a dot. +- ``max-age`` (number): Number of seconds the cookie should live for. +- ``secure`` (boolean): Specifies whether the cookie will only be sent via HTTPS. +- ``httponly`` (boolean): Specifies whether the cookie cannot be read by Javascript. + +.. _itsdangerous: https://pythonhosted.org/itsdangerous/ \ No newline at end of file diff --git a/docs/sanic/custom_protocol.md b/docs/sanic/custom_protocol.md new file mode 100644 index 00000000..8355e8e9 --- /dev/null +++ b/docs/sanic/custom_protocol.md @@ -0,0 +1,72 @@ +# Custom Protocols + +*Note: this is advanced usage, and most readers will not need such functionality.* + +You can change the behavior of Sanic's protocol by specifying a custom +protocol, which should be a subclass +of +[asyncio.protocol](https://docs.python.org/3/library/asyncio-protocol.html#protocol-classes). +This protocol can then be passed as the keyword argument `protocol` to the `sanic.run` method. + +The constructor of the custom protocol class receives the following keyword +arguments from Sanic. + +- `loop`: an `asyncio`-compatible event loop. +- `connections`: a `set` to store protocol objects. When Sanic receives + `SIGINT` or `SIGTERM`, it executes `protocol.close_if_idle` for all protocol + objects stored in this set. +- `signal`: a `sanic.server.Signal` object with the `stopped` attribute. When + Sanic receives `SIGINT` or `SIGTERM`, `signal.stopped` is assigned `True`. +- `request_handler`: a coroutine that takes a `sanic.request.Request` object + and a `response` callback as arguments. +- `error_handler`: a `sanic.exceptions.Handler` which is called when exceptions + are raised. +- `request_timeout`: the number of seconds before a request times out. +- `request_max_size`: an integer specifying the maximum size of a request, in bytes. + +## Example + +An error occurs in the default protocol if a handler function does not return +an `HTTPResponse` object. + +By overriding the `write_response` protocol method, if a handler returns a +string it will be converted to an `HTTPResponse object`. + +```python +from sanic import Sanic +from sanic.server import HttpProtocol +from sanic.response import text + +app = Sanic(__name__) + + +class CustomHttpProtocol(HttpProtocol): + + def __init__(self, *, loop, request_handler, error_handler, + signal, connections, request_timeout, request_max_size): + super().__init__( + loop=loop, request_handler=request_handler, + error_handler=error_handler, signal=signal, + connections=connections, request_timeout=request_timeout, + request_max_size=request_max_size) + + def write_response(self, response): + if isinstance(response, str): + response = text(response) + self.transport.write( + response.output(self.request.version) + ) + self.transport.close() + + +@app.route('/') +async def string(request): + return 'string' + + +@app.route('/1') +async def response(request): + return text('response') + +app.run(host='0.0.0.0', port=8000, protocol=CustomHttpProtocol) +``` diff --git a/docs/sanic/debug_mode.rst b/docs/sanic/debug_mode.rst new file mode 100644 index 00000000..14356855 --- /dev/null +++ b/docs/sanic/debug_mode.rst @@ -0,0 +1,53 @@ +Debug Mode +============= + +When enabling Sanic's debug mode, Sanic will provide a more verbose logging output +and by default will enable the Auto Reload feature. + +.. warning:: + + Sanic's debug more will slow down the server's performance + and is therefore advised to enable it only in development environments. + + +Setting the debug mode +---------------------- + +By setting the ``debug`` mode a more verbose output from Sanic will be outputed +and the Automatic Reloader will be activated. + +.. code-block:: python + + from sanic import Sanic + from sanic.response import json + + app = Sanic() + + @app.route('/') + async def hello_world(request): + return json({"hello": "world"}) + + if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000, debug=True) + + + +Manually setting auto reload +---------------------------- + +Sanic offers a way to enable or disable the Automatic Reloader manually, +the ``auto_reload`` argument will activate or deactivate the Automatic Reloader. + +.. code-block:: python + + from sanic import Sanic + from sanic.response import json + + app = Sanic() + + @app.route('/') + async def hello_world(request): + return json({"hello": "world"}) + + if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000, auto_reload=True) \ No newline at end of file diff --git a/docs/sanic/decorators.md b/docs/sanic/decorators.md new file mode 100644 index 00000000..db2d369b --- /dev/null +++ b/docs/sanic/decorators.md @@ -0,0 +1,39 @@ +# Handler Decorators + +Since Sanic handlers are simple Python functions, you can apply decorators to them in a similar manner to Flask. A typical use case is when you want some code to run before a handler's code is executed. + +## Authorization Decorator + +Let's say you want to check that a user is authorized to access a particular endpoint. You can create a decorator that wraps a handler function, checks a request if the client is authorized to access a resource, and sends the appropriate response. + + +```python +from functools import wraps +from sanic.response import json + +def authorized(): + def decorator(f): + @wraps(f) + async def decorated_function(request, *args, **kwargs): + # run some method that checks the request + # for the client's authorization status + is_authorized = check_request_for_authorization_status(request) + + if is_authorized: + # the user is authorized. + # run the handler method and return the response + response = await f(request, *args, **kwargs) + return response + else: + # the user is not authorized. + return json({'status': 'not_authorized'}, 403) + return decorated_function + return decorator + + +@app.route("/") +@authorized() +async def test(request): + return json({status: 'authorized'}) +``` + diff --git a/docs/sanic/deploying.md b/docs/sanic/deploying.md new file mode 100644 index 00000000..d3652d0d --- /dev/null +++ b/docs/sanic/deploying.md @@ -0,0 +1,78 @@ +# Deploying + +Deploying Sanic is made simple by the inbuilt webserver. After defining an +instance of `sanic.Sanic`, we can call the `run` method with the following +keyword arguments: + +- `host` *(default `"127.0.0.1"`)*: Address to host the server on. +- `port` *(default `8000`)*: Port to host the server on. +- `debug` *(default `False`)*: Enables debug output (slows server). +- `ssl` *(default `None`)*: `SSLContext` for SSL encryption of worker(s). +- `sock` *(default `None`)*: Socket for the server to accept connections from. +- `workers` *(default `1`)*: Number of worker processes to spawn. +- `loop` *(default `None`)*: An `asyncio`-compatible event loop. If none is + specified, Sanic creates its own event loop. +- `protocol` *(default `HttpProtocol`)*: Subclass + of + [asyncio.protocol](https://docs.python.org/3/library/asyncio-protocol.html#protocol-classes). + +## Workers + +By default, Sanic listens in the main process using only one CPU core. To crank +up the juice, just specify the number of workers in the `run` arguments. + +```python +app.run(host='0.0.0.0', port=1337, workers=4) +``` + +Sanic will automatically spin up multiple processes and route traffic between +them. We recommend as many workers as you have available cores. + +## Running via command + +If you like using command line arguments, you can launch a Sanic server by +executing the module. For example, if you initialized Sanic as `app` in a file +named `server.py`, you could run the server like so: + +`python -m sanic server.app --host=0.0.0.0 --port=1337 --workers=4` + +With this way of running sanic, it is not necessary to invoke `app.run` in your +Python file. If you do, make sure you wrap it so that it only executes when +directly run by the interpreter. + +```python +if __name__ == '__main__': + app.run(host='0.0.0.0', port=1337, workers=4) +``` + +## Running via Gunicorn + +[Gunicorn](http://gunicorn.org/) ‘Green Unicorn’ is a WSGI HTTP Server for UNIX. +It’s a pre-fork worker model ported from Ruby’s Unicorn project. + +In order to run Sanic application with Gunicorn, you need to use the special `sanic.worker.GunicornWorker` +for Gunicorn `worker-class` argument: + +``` +gunicorn myapp:app --bind 0.0.0.0:1337 --worker-class sanic.worker.GunicornWorker +``` + +If your application suffers from memory leaks, you can configure Gunicorn to gracefully restart a worker +after it has processed a given number of requests. This can be a convenient way to help limit the effects +of the memory leak. + +See the [Gunicorn Docs](http://docs.gunicorn.org/en/latest/settings.html#max-requests) for more information. + +## Asynchronous support +This is suitable if you *need* to share the sanic process with other applications, in particular the `loop`. +However be advised that this method does not support using multiple processes, and is not the preferred way +to run the app in general. + +Here is an incomplete example (please see `run_async.py` in examples for something more practical): + +```python +server = app.create_server(host="0.0.0.0", port=8000) +loop = asyncio.get_event_loop() +task = asyncio.ensure_future(server) +loop.run_forever() +``` diff --git a/docs/sanic/exceptions.md b/docs/sanic/exceptions.md new file mode 100644 index 00000000..79108032 --- /dev/null +++ b/docs/sanic/exceptions.md @@ -0,0 +1,58 @@ +# Exceptions + +Exceptions can be thrown from within request handlers and will automatically be +handled by Sanic. Exceptions take a message as their first argument, and can +also take a status code to be passed back in the HTTP response. + +## Throwing an exception + +To throw an exception, simply `raise` the relevant exception from the +`sanic.exceptions` module. + +```python +from sanic.exceptions import ServerError + +@app.route('/killme') +async def i_am_ready_to_die(request): + raise ServerError("Something bad happened", status_code=500) +``` + +You can also use the `abort` function with the appropriate status code: + +```python +from sanic.exceptions import abort +from sanic.response import text + +@app.route('/youshallnotpass') +async def no_no(request): + abort(401) + # this won't happen + text("OK") +``` + +## Handling exceptions + +To override Sanic's default handling of an exception, the `@app.exception` +decorator is used. The decorator expects a list of exceptions to handle as +arguments. You can pass `SanicException` to catch them all! The decorated +exception handler function must take a `Request` and `Exception` object as +arguments. + +```python +from sanic.response import text +from sanic.exceptions import NotFound + +@app.exception(NotFound) +async def ignore_404s(request, exception): + return text("Yep, I totally found the page: {}".format(request.url)) +``` + +## Useful exceptions + +Some of the most useful exceptions are presented below: + +- `NotFound`: called when a suitable route for the request isn't found. +- `ServerError`: called when something goes wrong inside the server. This + usually occurs if there is an exception raised in user code. + +See the `sanic.exceptions` module for the full list of exceptions to throw. diff --git a/docs/sanic/extensions.md b/docs/sanic/extensions.md new file mode 100644 index 00000000..01c89f95 --- /dev/null +++ b/docs/sanic/extensions.md @@ -0,0 +1,33 @@ +# Extensions + +A list of Sanic extensions created by the community. +- [Sanic-Plugins-Framework](https://github.com/ashleysommer/sanicpluginsframework): Library for easily creating and using Sanic plugins. +- [Sessions](https://github.com/subyraman/sanic_session): Support for sessions. + Allows using redis, memcache or an in memory store. +- [CORS](https://github.com/ashleysommer/sanic-cors): A port of flask-cors. +- [Compress](https://github.com/subyraman/sanic_compress): Allows you to easily gzip Sanic responses. A port of Flask-Compress. +- [Jinja2](https://github.com/lixxu/sanic-jinja2): Support for Jinja2 template. +- [Sanic JWT](https://github.com/ahopkins/sanic-jwt): Authentication, JWT, and permission scoping for Sanic. +- [OpenAPI/Swagger](https://github.com/channelcat/sanic-openapi): OpenAPI support, plus a Swagger UI. +- [Pagination](https://github.com/lixxu/python-paginate): Simple pagination support. +- [Motor](https://github.com/lixxu/sanic-motor): Simple motor wrapper. +- [Sanic CRUD](https://github.com/Typhon66/sanic_crud): CRUD REST API generation with peewee models. +- [UserAgent](https://github.com/lixxu/sanic-useragent): Add `user_agent` to request +- [Limiter](https://github.com/bohea/sanic-limiter): Rate limiting for sanic. +- [Sanic EnvConfig](https://github.com/jamesstidard/sanic-envconfig): Pull environment variables into your sanic config. +- [Babel](https://github.com/lixxu/sanic-babel): Adds i18n/l10n support to Sanic applications with the help of the +`Babel` library +- [Dispatch](https://github.com/ashleysommer/sanic-dispatcher): A dispatcher inspired by `DispatcherMiddleware` in werkzeug. Can act as a Sanic-to-WSGI adapter. +- [Sanic-OAuth](https://github.com/Sniedes722/Sanic-OAuth): OAuth Library for connecting to & creating your own token providers. +- [sanic-oauth](https://gitlab.com/SirEdvin/sanic-oauth): OAuth Library with many provider and OAuth1/OAuth2 support. +- [Sanic-nginx-docker-example](https://github.com/itielshwartz/sanic-nginx-docker-example): Simple and easy to use example of Sanic behined nginx using docker-compose. +- [sanic-graphql](https://github.com/graphql-python/sanic-graphql): GraphQL integration with Sanic +- [sanic-prometheus](https://github.com/dkruchinin/sanic-prometheus): Prometheus metrics for Sanic +- [Sanic-RestPlus](https://github.com/ashleysommer/sanic-restplus): A port of Flask-RestPlus for Sanic. Full-featured REST API with SwaggerUI generation. +- [sanic-transmute](https://github.com/yunstanford/sanic-transmute): A Sanic extension that generates APIs from python function and classes, and also generates Swagger UI/documentation automatically. +- [pytest-sanic](https://github.com/yunstanford/pytest-sanic): A pytest plugin for Sanic. It helps you to test your code asynchronously. +- [jinja2-sanic](https://github.com/yunstanford/jinja2-sanic): a jinja2 template renderer for Sanic.([Documentation](http://jinja2-sanic.readthedocs.io/en/latest/)) +- [GINO](https://github.com/fantix/gino): An asyncio ORM on top of SQLAlchemy core, delivered with a Sanic extension. ([Documentation](https://python-gino.readthedocs.io/)) +- [Sanic-Auth](https://github.com/pyx/sanic-auth): A minimal backend agnostic session-based user authentication mechanism for Sanic. +- [Sanic-CookieSession](https://github.com/pyx/sanic-cookiesession): A client-side only, cookie-based session, similar to the built-in session in Flask. +- [Sanic-WTF](https://github.com/pyx/sanic-wtf): Sanic-WTF makes using WTForms with Sanic and CSRF (Cross-Site Request Forgery) protection a little bit easier. diff --git a/docs/sanic/getting_started.md b/docs/sanic/getting_started.md new file mode 100644 index 00000000..3e89cc3e --- /dev/null +++ b/docs/sanic/getting_started.md @@ -0,0 +1,28 @@ +# Getting Started + +Make sure you have both [pip](https://pip.pypa.io/en/stable/installing/) and at +least version 3.5 of Python before starting. Sanic uses the new `async`/`await` +syntax, so earlier versions of python won't work. + +1. Install Sanic: `python3 -m pip install sanic` +2. Create a file called `main.py` with the following code: + + ```python + from sanic import Sanic + from sanic.response import json + + app = Sanic() + + @app.route("/") + async def test(request): + return json({"hello": "world"}) + + if __name__ == "__main__": + app.run(host="0.0.0.0", port=8000) + ``` + +3. Run the server: `python3 main.py` +4. Open the address `http://0.0.0.0:8000` in your web browser. You should see + the message *Hello world!*. + +You now have a working Sanic server! diff --git a/docs/sanic/index.rst b/docs/sanic/index.rst new file mode 100644 index 00000000..08797e9f --- /dev/null +++ b/docs/sanic/index.rst @@ -0,0 +1,25 @@ +Sanic +================================= + +Sanic is a Flask-like Python 3.5+ web server that's written to go fast. It's based on the work done by the amazing folks at magicstack, and was inspired by `this article `_. + +On top of being Flask-like, Sanic supports async request handlers. This means you can use the new shiny async/await syntax from Python 3.5, making your code non-blocking and speedy. + +Sanic is developed `on GitHub `_. Contributions are welcome! + +Sanic aspires to be simple +--------------------------- + +.. code:: python + + from sanic import Sanic + from sanic.response import json + + app = Sanic() + + @app.route("/") + async def test(request): + return json({"hello": "world"}) + + if __name__ == "__main__": + app.run(host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/docs/sanic/logging.md b/docs/sanic/logging.md new file mode 100644 index 00000000..49805d0e --- /dev/null +++ b/docs/sanic/logging.md @@ -0,0 +1,85 @@ +# Logging + + +Sanic allows you to do different types of logging (access log, error log) on the requests based on the [python3 logging API](https://docs.python.org/3/howto/logging.html). You should have some basic knowledge on python3 logging if you want to create a new configuration. + +### Quick Start + +A simple example using default settings would be like this: + +```python +from sanic import Sanic + +app = Sanic('test') + +@app.route('/') +async def test(request): + return response.text('Hello World!') + +if __name__ == "__main__": + app.run(debug=True, access_log=True) +``` + +To use your own logging config, simply use `logging.config.dictConfig`, or +pass `log_config` when you initialize `Sanic` app: + +```python +app = Sanic('test', log_config=LOGGING_CONFIG) +``` + +And to close logging, simply assign access_log=False: + +```python +if __name__ == "__main__": + app.run(access_log=False) +``` + +This would skip calling logging functions when handling requests. +And you could even do further in production to gain extra speed: + +```python +if __name__ == "__main__": + # disable debug messages + app.run(debug=False, access_log=False) +``` + +### Configuration + +By default, log_config parameter is set to use sanic.log.LOGGING_CONFIG_DEFAULTS dictionary for configuration. + +There are three `loggers` used in sanic, and **must be defined if you want to create your own logging configuration**: + +- root:
+ Used to log internal messages. + +- sanic.error:
+ Used to log error logs. + +- sanic.access:
+ Used to log access logs. + +#### Log format: + +In addition to default parameters provided by python (asctime, levelname, message), +Sanic provides additional parameters for access logger with: + +- host (str)
+ request.ip + + +- request (str)
+ request.method + " " + request.url + + +- status (int)
+ response.status + + +- byte (int)
+ len(response.body) + + +The default access log format is +```python +%(asctime)s - (%(name)s)[%(levelname)s][%(host)s]: %(request)s %(message)s %(status)d %(byte)d +``` diff --git a/docs/sanic/middleware.md b/docs/sanic/middleware.md new file mode 100644 index 00000000..e041d452 --- /dev/null +++ b/docs/sanic/middleware.md @@ -0,0 +1,147 @@ +# Middleware And Listeners + +Middleware are functions which are executed before or after requests to the +server. They can be used to modify the *request to* or *response from* +user-defined handler functions. + +Additionally, Sanic provides listeners which allow you to run code at various points of your application's lifecycle. + +## Middleware + +There are two types of middleware: request and response. Both are declared +using the `@app.middleware` decorator, with the decorator's parameter being a +string representing its type: `'request'` or `'response'`. + +* Request middleware receives only the `request` as argument. +* Response middleware receives both the `request` and `response`. + +The simplest middleware doesn't modify the request or response at all: + +```python +@app.middleware('request') +async def print_on_request(request): + print("I print when a request is received by the server") + +@app.middleware('response') +async def print_on_response(request, response): + print("I print when a response is returned by the server") +``` + +## Modifying the request or response + +Middleware can modify the request or response parameter it is given, *as long +as it does not return it*. The following example shows a practical use-case for +this. + +```python +app = Sanic(__name__) + +@app.middleware('response') +async def custom_banner(request, response): + response.headers["Server"] = "Fake-Server" + +@app.middleware('response') +async def prevent_xss(request, response): + response.headers["x-xss-protection"] = "1; mode=block" + +app.run(host="0.0.0.0", port=8000) +``` + +The above code will apply the two middleware in order. First, the middleware +**custom_banner** will change the HTTP response header *Server* to +*Fake-Server*, and the second middleware **prevent_xss** will add the HTTP +header for preventing Cross-Site-Scripting (XSS) attacks. These two functions +are invoked *after* a user function returns a response. + +## Responding early + +If middleware returns a `HTTPResponse` object, the request will stop processing +and the response will be returned. If this occurs to a request before the +relevant user route handler is reached, the handler will never be called. +Returning a response will also prevent any further middleware from running. + +```python +@app.middleware('request') +async def halt_request(request): + return text('I halted the request') + +@app.middleware('response') +async def halt_response(request, response): + return text('I halted the response') +``` + +## Listeners + +If you want to execute startup/teardown code as your server starts or closes, you can use the following listeners: + +- `before_server_start` +- `after_server_start` +- `before_server_stop` +- `after_server_stop` + +These listeners are implemented as decorators on functions which accept the app object as well as the asyncio loop. + +For example: + +```python +@app.listener('before_server_start') +async def setup_db(app, loop): + app.db = await db_setup() + +@app.listener('after_server_start') +async def notify_server_started(app, loop): + print('Server successfully started!') + +@app.listener('before_server_stop') +async def notify_server_stopping(app, loop): + print('Server shutting down!') + +@app.listener('after_server_stop') +async def close_db(app, loop): + await app.db.close() +``` + +It's also possible to register a listener using the `register_listener` method. +This may be useful if you define your listeners in another module besides +the one you instantiate your app in. + +```python +app = Sanic() + +async def setup_db(app, loop): + app.db = await db_setup() + +app.register_listener(setup_db, 'before_server_start') + +``` + +If you want to schedule a background task to run after the loop has started, +Sanic provides the `add_task` method to easily do so. + +```python +async def notify_server_started_after_five_seconds(): + await asyncio.sleep(5) + print('Server successfully started!') + +app.add_task(notify_server_started_after_five_seconds()) +``` + +Sanic will attempt to automatically inject the app, passing it as an argument to the task: + +```python +async def notify_server_started_after_five_seconds(app): + await asyncio.sleep(5) + print(app.name) + +app.add_task(notify_server_started_after_five_seconds) +``` + +Or you can pass the app explicitly for the same effect: + +```python +async def notify_server_started_after_five_seconds(app): + await asyncio.sleep(5) + print(app.name) + +app.add_task(notify_server_started_after_five_seconds(app)) +` diff --git a/docs/sanic/request_data.md b/docs/sanic/request_data.md new file mode 100644 index 00000000..a91dd970 --- /dev/null +++ b/docs/sanic/request_data.md @@ -0,0 +1,128 @@ +# Request Data + +When an endpoint receives a HTTP request, the route function is passed a +`Request` object. + +The following variables are accessible as properties on `Request` objects: + +- `json` (any) - JSON body + + ```python + from sanic.response import json + + @app.route("/json") + def post_json(request): + return json({ "received": True, "message": request.json }) + ``` + +- `args` (dict) - Query string variables. A query string is the section of a + URL that resembles `?key1=value1&key2=value2`. If that URL were to be parsed, + the `args` dictionary would look like `{'key1': ['value1'], 'key2': ['value2']}`. + The request's `query_string` variable holds the unparsed string value. + + ```python + from sanic.response import json + + @app.route("/query_string") + def query_string(request): + return json({ "parsed": True, "args": request.args, "url": request.url, "query_string": request.query_string }) + ``` + +- `raw_args` (dict) - On many cases you would need to access the url arguments in + a less packed dictionary. For same previous URL `?key1=value1&key2=value2`, the + `raw_args` dictionary would look like `{'key1': 'value1', 'key2': 'value2'}`. + +- `files` (dictionary of `File` objects) - List of files that have a name, body, and type + + ```python + from sanic.response import json + + @app.route("/files") + def post_json(request): + test_file = request.files.get('test') + + file_parameters = { + 'body': test_file.body, + 'name': test_file.name, + 'type': test_file.type, + } + + return json({ "received": True, "file_names": request.files.keys(), "test_file_parameters": file_parameters }) + ``` + +- `form` (dict) - Posted form variables. + + ```python + from sanic.response import json + + @app.route("/form") + def post_json(request): + return json({ "received": True, "form_data": request.form, "test": request.form.get('test') }) + ``` + +- `body` (bytes) - Posted raw body. This property allows retrieval of the + request's raw data, regardless of content type. + + ```python + from sanic.response import text + + @app.route("/users", methods=["POST",]) + def create_user(request): + return text("You are trying to create a user with the following POST: %s" % request.body) + ``` + +- `headers` (dict) - A case-insensitive dictionary that contains the request headers. + +- `method` (str) - HTTP method of the request (ie `GET`, `POST`). + +- `ip` (str) - IP address of the requester. + +- `port` (str) - Port address of the requester. + +- `socket` (tuple) - (IP, port) of the requester. + +- `app` - a reference to the Sanic application object that is handling this request. This is useful when inside blueprints or other handlers in modules that do not have access to the global `app` object. + + ```python + from sanic.response import json + from sanic import Blueprint + + bp = Blueprint('my_blueprint') + + @bp.route('/') + async def bp_root(request): + if request.app.config['DEBUG']: + return json({'status': 'debug'}) + else: + return json({'status': 'production'}) + + ``` +- `url`: The full URL of the request, ie: `http://localhost:8000/posts/1/?foo=bar` +- `scheme`: The URL scheme associated with the request: `http` or `https` +- `host`: The host associated with the request: `localhost:8080` +- `path`: The path of the request: `/posts/1/` +- `query_string`: The query string of the request: `foo=bar` or a blank string `''` +- `uri_template`: Template for matching route handler: `/posts//` +- `token`: The value of Authorization header: `Basic YWRtaW46YWRtaW4=` + + +## Accessing values using `get` and `getlist` + +The request properties which return a dictionary actually return a subclass of +`dict` called `RequestParameters`. The key difference when using this object is +the distinction between the `get` and `getlist` methods. + +- `get(key, default=None)` operates as normal, except that when the value of + the given key is a list, *only the first item is returned*. +- `getlist(key, default=None)` operates as normal, *returning the entire list*. + +```python +from sanic.request import RequestParameters + +args = RequestParameters() +args['titles'] = ['Post 1', 'Post 2'] + +args.get('titles') # => 'Post 1' + +args.getlist('titles') # => ['Post 1', 'Post 2'] +``` diff --git a/docs/sanic/response.md b/docs/sanic/response.md new file mode 100644 index 00000000..f322f8c8 --- /dev/null +++ b/docs/sanic/response.md @@ -0,0 +1,112 @@ +# Response + +Use functions in `sanic.response` module to create responses. + +## Plain Text + +```python +from sanic import response + + +@app.route('/text') +def handle_request(request): + return response.text('Hello world!') +``` + +## HTML + +```python +from sanic import response + + +@app.route('/html') +def handle_request(request): + return response.html('

Hello world!

') +``` + +## JSON + + +```python +from sanic import response + + +@app.route('/json') +def handle_request(request): + return response.json({'message': 'Hello world!'}) +``` + +## File + +```python +from sanic import response + + +@app.route('/file') +async def handle_request(request): + return await response.file('/srv/www/whatever.png') +``` + +## Streaming + +```python +from sanic import response + +@app.route("/streaming") +async def index(request): + async def streaming_fn(response): + response.write('foo') + response.write('bar') + return response.stream(streaming_fn, content_type='text/plain') +``` + +## File Streaming +For large files, a combination of File and Streaming above +```python +from sanic import response + +@app.route('/big_file.png') +async def handle_request(request): + return await response.file_stream('/srv/www/whatever.png') +``` + +## Redirect + +```python +from sanic import response + + +@app.route('/redirect') +def handle_request(request): + return response.redirect('/json') +``` + +## Raw + +Response without encoding the body + +```python +from sanic import response + + +@app.route('/raw') +def handle_request(request): + return response.raw(b'raw data') +``` + +## Modify headers or status + +To modify headers or status code, pass the `headers` or `status` argument to those functions: + +```python +from sanic import response + + +@app.route('/json') +def handle_request(request): + return response.json( + {'message': 'Hello world!'}, + headers={'X-Served-By': 'sanic'}, + status=200 + ) +``` diff --git a/docs/sanic/routing.md b/docs/sanic/routing.md new file mode 100644 index 00000000..e9cb0aef --- /dev/null +++ b/docs/sanic/routing.md @@ -0,0 +1,335 @@ +# Routing + +Routing allows the user to specify handler functions for different URL endpoints. + +A basic route looks like the following, where `app` is an instance of the +`Sanic` class: + +```python +from sanic.response import json + +@app.route("/") +async def test(request): + return json({ "hello": "world" }) +``` + +When the url `http://server.url/` is accessed (the base url of the server), the +final `/` is matched by the router to the handler function, `test`, which then +returns a JSON object. + +Sanic handler functions must be defined using the `async def` syntax, as they +are asynchronous functions. + +## Request parameters + +Sanic comes with a basic router that supports request parameters. + +To specify a parameter, surround it with angle quotes like so: ``. +Request parameters will be passed to the route handler functions as keyword +arguments. + +```python +from sanic.response import text + +@app.route('/tag/') +async def tag_handler(request, tag): + return text('Tag - {}'.format(tag)) +``` + +To specify a type for the parameter, add a `:type` after the parameter name, +inside the quotes. If the parameter does not match the specified type, Sanic +will throw a `NotFound` exception, resulting in a `404: Page not found` error +on the URL. + +```python +from sanic.response import text + +@app.route('/number/') +async def integer_handler(request, integer_arg): + return text('Integer - {}'.format(integer_arg)) + +@app.route('/number/') +async def number_handler(request, number_arg): + return text('Number - {}'.format(number_arg)) + +@app.route('/person/') +async def person_handler(request, name): + return text('Person - {}'.format(name)) + +@app.route('/folder/') +async def folder_handler(request, folder_id): + return text('Folder - {}'.format(folder_id)) + +``` + +## HTTP request types + +By default, a route defined on a URL will be available for only GET requests to that URL. +However, the `@app.route` decorator accepts an optional parameter, `methods`, +which allows the handler function to work with any of the HTTP methods in the list. + +```python +from sanic.response import text + +@app.route('/post', methods=['POST']) +async def post_handler(request): + return text('POST request - {}'.format(request.json)) + +@app.route('/get', methods=['GET']) +async def get_handler(request): + return text('GET request - {}'.format(request.args)) + +``` + +There is also an optional `host` argument (which can be a list or a string). This restricts a route to the host or hosts provided. If there is a also a route with no host, it will be the default. + +```python +@app.route('/get', methods=['GET'], host='example.com') +async def get_handler(request): + return text('GET request - {}'.format(request.args)) + +# if the host header doesn't match example.com, this route will be used +@app.route('/get', methods=['GET']) +async def get_handler(request): + return text('GET request in default - {}'.format(request.args)) +``` + +There are also shorthand method decorators: + +```python +from sanic.response import text + +@app.post('/post') +async def post_handler(request): + return text('POST request - {}'.format(request.json)) + +@app.get('/get') +async def get_handler(request): + return text('GET request - {}'.format(request.args)) + +``` +## The `add_route` method + +As we have seen, routes are often specified using the `@app.route` decorator. +However, this decorator is really just a wrapper for the `app.add_route` +method, which is used as follows: + +```python +from sanic.response import text + +# Define the handler functions +async def handler1(request): + return text('OK') + +async def handler2(request, name): + return text('Folder - {}'.format(name)) + +async def person_handler2(request, name): + return text('Person - {}'.format(name)) + +# Add each handler function as a route +app.add_route(handler1, '/test') +app.add_route(handler2, '/folder/') +app.add_route(person_handler2, '/person/', methods=['GET']) +``` + +## URL building with `url_for` + +Sanic provides a `url_for` method, to generate URLs based on the handler method name. This is useful if you want to avoid hardcoding url paths into your app; instead, you can just reference the handler name. For example: + +```python +from sanic.response import redirect + +@app.route('/') +async def index(request): + # generate a URL for the endpoint `post_handler` + url = app.url_for('post_handler', post_id=5) + # the URL is `/posts/5`, redirect to it + return redirect(url) + +@app.route('/posts/') +async def post_handler(request, post_id): + return text('Post - {}'.format(post_id)) +``` + +Other things to keep in mind when using `url_for`: + +- Keyword arguments passed to `url_for` that are not request parameters will be included in the URL's query string. For example: +```python +url = app.url_for('post_handler', post_id=5, arg_one='one', arg_two='two') +# /posts/5?arg_one=one&arg_two=two +``` +- Multivalue argument can be passed to `url_for`. For example: +```python +url = app.url_for('post_handler', post_id=5, arg_one=['one', 'two']) +# /posts/5?arg_one=one&arg_one=two +``` +- Also some special arguments (`_anchor`, `_external`, `_scheme`, `_method`, `_server`) passed to `url_for` will have special url building (`_method` is not support now and will be ignored). For example: +```python +url = app.url_for('post_handler', post_id=5, arg_one='one', _anchor='anchor') +# /posts/5?arg_one=one#anchor + +url = app.url_for('post_handler', post_id=5, arg_one='one', _external=True) +# //server/posts/5?arg_one=one +# _external requires passed argument _server or SERVER_NAME in app.config or url will be same as no _external + +url = app.url_for('post_handler', post_id=5, arg_one='one', _scheme='http', _external=True) +# http://server/posts/5?arg_one=one +# when specifying _scheme, _external must be True + +# you can pass all special arguments one time +url = app.url_for('post_handler', post_id=5, arg_one=['one', 'two'], arg_two=2, _anchor='anchor', _scheme='http', _external=True, _server='another_server:8888') +# http://another_server:8888/posts/5?arg_one=one&arg_one=two&arg_two=2#anchor +``` +- All valid parameters must be passed to `url_for` to build a URL. If a parameter is not supplied, or if a parameter does not match the specified type, a `URLBuildError` will be thrown. + +## WebSocket routes + +Routes for the WebSocket protocol can be defined with the `@app.websocket` +decorator: + +```python +@app.websocket('/feed') +async def feed(request, ws): + while True: + data = 'hello!' + print('Sending: ' + data) + await ws.send(data) + data = await ws.recv() + print('Received: ' + data) +``` + +Alternatively, the `app.add_websocket_route` method can be used instead of the +decorator: + +```python +async def feed(request, ws): + pass + +app.add_websocket_route(my_websocket_handler, '/feed') +``` + +Handlers for a WebSocket route are passed the request as first argument, and a +WebSocket protocol object as second argument. The protocol object has `send` +and `recv` methods to send and receive data respectively. + +WebSocket support requires the [websockets](https://github.com/aaugustin/websockets) +package by Aymeric Augustin. + + +## About `strict_slashes` + +You can make `routes` strict to trailing slash or not, it's configurable. + +```python + +# provide default strict_slashes value for all routes +app = Sanic('test_route_strict_slash', strict_slashes=True) + +# you can also overwrite strict_slashes value for specific route +@app.get('/get', strict_slashes=False) +def handler(request): + return text('OK') + +# It also works for blueprints +bp = Blueprint('test_bp_strict_slash', strict_slashes=True) + +@bp.get('/bp/get', strict_slashes=False) +def handler(request): + return text('OK') + +app.blueprint(bp) +``` + +## User defined route name + +You can pass `name` to change the route name to avoid using the default name (`handler.__name__`). + +```python + +app = Sanic('test_named_route') + +@app.get('/get', name='get_handler') +def handler(request): + return text('OK') + +# then you need use `app.url_for('get_handler')` +# instead of # `app.url_for('handler')` + +# It also works for blueprints +bp = Blueprint('test_named_bp') + +@bp.get('/bp/get', name='get_handler') +def handler(request): + return text('OK') + +app.blueprint(bp) + +# then you need use `app.url_for('test_named_bp.get_handler')` +# instead of `app.url_for('test_named_bp.handler')` + +# different names can be used for same url with different methods + +@app.get('/test', name='route_test') +def handler(request): + return text('OK') + +@app.post('/test', name='route_post') +def handler2(request): + return text('OK POST') + +@app.put('/test', name='route_put') +def handler3(request): + return text('OK PUT') + +# below url are the same, you can use any of them +# '/test' +app.url_for('route_test') +# app.url_for('route_post') +# app.url_for('route_put') + +# for same handler name with different methods +# you need specify the name (it's url_for issue) +@app.get('/get') +def handler(request): + return text('OK') + +@app.post('/post', name='post_handler') +def handler(request): + return text('OK') + +# then +# app.url_for('handler') == '/get' +# app.url_for('post_handler') == '/post' +``` + +## Build URL for static files + +You can use `url_for` for static file url building now. +If it's for file directly, `filename` can be ignored. + +```python + +app = Sanic('test_static') +app.static('/static', './static') +app.static('/uploads', './uploads', name='uploads') +app.static('/the_best.png', '/home/ubuntu/test.png', name='best_png') + +bp = Blueprint('bp', url_prefix='bp') +bp.static('/static', './static') +bp.static('/uploads', './uploads', name='uploads') +bp.static('/the_best.png', '/home/ubuntu/test.png', name='best_png') +app.blueprint(bp) + +# then build the url +app.url_for('static', filename='file.txt') == '/static/file.txt' +app.url_for('static', name='static', filename='file.txt') == '/static/file.txt' +app.url_for('static', name='uploads', filename='file.txt') == '/uploads/file.txt' +app.url_for('static', name='best_png') == '/the_best.png' + +# blueprint url building +app.url_for('static', name='bp.static', filename='file.txt') == '/bp/static/file.txt' +app.url_for('static', name='bp.uploads', filename='file.txt') == '/bp/uploads/file.txt' +app.url_for('static', name='bp.best_png') == '/bp/static/the_best.png' + +``` diff --git a/docs/sanic/ssl.rst b/docs/sanic/ssl.rst new file mode 100644 index 00000000..ec0f6516 --- /dev/null +++ b/docs/sanic/ssl.rst @@ -0,0 +1,20 @@ +SSL Example +----------- + +Optionally pass in an SSLContext: + +.. code:: python + + import ssl + context = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH) + context.load_cert_chain("/path/to/cert", keyfile="/path/to/keyfile") + + app.run(host="0.0.0.0", port=8443, ssl=context) + +You can also pass in the locations of a certificate and key as a dictionary: + + +.. code:: python + + ssl = {'cert': "/path/to/cert", 'key': "/path/to/keyfile"} + app.run(host="0.0.0.0", port=8443, ssl=ssl) diff --git a/docs/sanic/static_files.md b/docs/sanic/static_files.md new file mode 100644 index 00000000..3419cad1 --- /dev/null +++ b/docs/sanic/static_files.md @@ -0,0 +1,45 @@ +# Static Files + +Static files and directories, such as an image file, are served by Sanic when +registered with the `app.static` method. The method takes an endpoint URL and a +filename. The file specified will then be accessible via the given endpoint. + +```python +from sanic import Sanic +from sanic.blueprints import Blueprint + +app = Sanic(__name__) + +# Serves files from the static folder to the URL /static +app.static('/static', './static') +# use url_for to build the url, name defaults to 'static' and can be ignored +app.url_for('static', filename='file.txt') == '/static/file.txt' +app.url_for('static', name='static', filename='file.txt') == '/static/file.txt' + +# Serves the file /home/ubuntu/test.png when the URL /the_best.png +# is requested +app.static('/the_best.png', '/home/ubuntu/test.png', name='best_png') + +# you can use url_for to build the static file url +# you can ignore name and filename parameters if you don't define it +app.url_for('static', name='best_png') == '/the_best.png' +app.url_for('static', name='best_png', filename='any') == '/the_best.png' + +# you need define the name for other static files +app.static('/another.png', '/home/ubuntu/another.png', name='another') +app.url_for('static', name='another') == '/another.png' +app.url_for('static', name='another', filename='any') == '/another.png' + +# also, you can use static for blueprint +bp = Blueprint('bp', url_prefix='/bp') +bp.static('/static', './static') + +# servers the file directly +bp.static('/the_best.png', '/home/ubuntu/test.png', name='best_png') +app.blueprint(bp) + +app.url_for('static', name='bp.static', filename='file.txt') == '/bp/static/file.txt' +app.url_for('static', name='bp.best_png') == '/bp/test_best.png' + +app.run(host="0.0.0.0", port=8000) +``` diff --git a/docs/sanic/streaming.md b/docs/sanic/streaming.md new file mode 100644 index 00000000..a785322a --- /dev/null +++ b/docs/sanic/streaming.md @@ -0,0 +1,106 @@ +# Streaming + +## Request Streaming + +Sanic allows you to get request data by stream, as below. When the request ends, `request.stream.get()` returns `None`. Only post, put and patch decorator have stream argument. + +```python +from sanic import Sanic +from sanic.views import CompositionView +from sanic.views import HTTPMethodView +from sanic.views import stream as stream_decorator +from sanic.blueprints import Blueprint +from sanic.response import stream, text + +bp = Blueprint('blueprint_request_stream') +app = Sanic('request_stream') + + +class SimpleView(HTTPMethodView): + + @stream_decorator + async def post(self, request): + result = '' + while True: + body = await request.stream.get() + if body is None: + break + result += body.decode('utf-8') + return text(result) + + +@app.post('/stream', stream=True) +async def handler(request): + async def streaming(response): + while True: + body = await request.stream.get() + if body is None: + break + body = body.decode('utf-8').replace('1', 'A') + response.write(body) + return stream(streaming) + + +@bp.put('/bp_stream', stream=True) +async def bp_handler(request): + result = '' + while True: + body = await request.stream.get() + if body is None: + break + result += body.decode('utf-8').replace('1', 'A') + return text(result) + + +async def post_handler(request): + result = '' + while True: + body = await request.stream.get() + if body is None: + break + result += body.decode('utf-8') + return text(result) + +app.blueprint(bp) +app.add_route(SimpleView.as_view(), '/method_view') +view = CompositionView() +view.add(['POST'], post_handler, stream=True) +app.add_route(view, '/composition_view') + + +if __name__ == '__main__': + app.run(host='127.0.0.1', port=8000) +``` + +## Response Streaming + +Sanic allows you to stream content to the client with the `stream` method. This method accepts a coroutine callback which is passed a `StreamingHTTPResponse` object that is written to. A simple example is like follows: + +```python +from sanic import Sanic +from sanic.response import stream + +app = Sanic(__name__) + +@app.route("/") +async def test(request): + async def sample_streaming_fn(response): + response.write('foo,') + response.write('bar') + + return stream(sample_streaming_fn, content_type='text/csv') +``` + +This is useful in situations where you want to stream content to the client that originates in an external service, like a database. For example, you can stream database records to the client with the asynchronous cursor that `asyncpg` provides: + +```python +@app.route("/") +async def index(request): + async def stream_from_db(response): + conn = await asyncpg.connect(database='test') + async with conn.transaction(): + async for record in conn.cursor('SELECT generate_series(0, 10)'): + response.write(record[0]) + + return stream(stream_from_db) +``` diff --git a/docs/sanic/testing.md b/docs/sanic/testing.md new file mode 100644 index 00000000..00a604bf --- /dev/null +++ b/docs/sanic/testing.md @@ -0,0 +1,127 @@ +# Testing + +Sanic endpoints can be tested locally using the `test_client` object, which +depends on the additional [aiohttp](https://aiohttp.readthedocs.io/en/stable/) +library. + +The `test_client` exposes `get`, `post`, `put`, `delete`, `patch`, `head` and `options` methods +for you to run against your application. A simple example (using pytest) is like follows: + +```python +# Import the Sanic app, usually created with Sanic(__name__) +from external_server import app + +def test_index_returns_200(): + request, response = app.test_client.get('/') + assert response.status == 200 + +def test_index_put_not_allowed(): + request, response = app.test_client.put('/') + assert response.status == 405 +``` + +Internally, each time you call one of the `test_client` methods, the Sanic app is run at `127.0.0.1:42101` and +your test request is executed against your application, using `aiohttp`. + +The `test_client` methods accept the following arguments and keyword arguments: + +- `uri` *(default `'/'`)* A string representing the URI to test. +- `gather_request` *(default `True`)* A boolean which determines whether the + original request will be returned by the function. If set to `True`, the + return value is a tuple of `(request, response)`, if `False` only the + response is returned. +- `server_kwargs` *(default `{}`) a dict of additional arguments to pass into `app.run` before the test request is run. +- `debug` *(default `False`)* A boolean which determines whether to run the server in debug mode. + +The function further takes the `*request_args` and `**request_kwargs`, which are passed directly to the aiohttp ClientSession request. + +For example, to supply data to a GET request, you would do the following: + +```python +def test_get_request_includes_data(): + params = {'key1': 'value1', 'key2': 'value2'} + request, response = app.test_client.get('/', params=params) + assert request.args.get('key1') == 'value1' +``` + +And to supply data to a JSON POST request: + +```python +def test_post_json_request_includes_data(): + data = {'key1': 'value1', 'key2': 'value2'} + request, response = app.test_client.post('/', data=json.dumps(data)) + assert request.json.get('key1') == 'value1' +``` + + +More information about +the available arguments to aiohttp can be found +[in the documentation for ClientSession](https://aiohttp.readthedocs.io/en/stable/client_reference.html#client-session). + + +## pytest-sanic + +[pytest-sanic](https://github.com/yunstanford/pytest-sanic) is a pytest plugin, it helps you to test your code asynchronously. +Just write tests like, + +```python +async def test_sanic_db_find_by_id(app): + """ + Let's assume that, in db we have, + { + "id": "123", + "name": "Kobe Bryant", + "team": "Lakers", + } + """ + doc = await app.db["players"].find_by_id("123") + assert doc.name == "Kobe Bryant" + assert doc.team == "Lakers" +``` + +[pytest-sanic](https://github.com/yunstanford/pytest-sanic) also provides some useful fixtures, like loop, unused_port, +test_server, test_client. + +```python +@pytest.yield_fixture +def app(): + app = Sanic("test_sanic_app") + + @app.route("/test_get", methods=['GET']) + async def test_get(request): + return response.json({"GET": True}) + + @app.route("/test_post", methods=['POST']) + async def test_post(request): + return response.json({"POST": True}) + + yield app + + +@pytest.fixture +def test_cli(loop, app, test_client): + return loop.run_until_complete(test_client(app, protocol=WebSocketProtocol)) + + +######### +# Tests # +######### + +async def test_fixture_test_client_get(test_cli): + """ + GET request + """ + resp = await test_cli.get('/test_get') + assert resp.status == 200 + resp_json = await resp.json() + assert resp_json == {"GET": True} + +async def test_fixture_test_client_post(test_cli): + """ + POST request + """ + resp = await test_cli.post('/test_post') + assert resp.status == 200 + resp_json = await resp.json() + assert resp_json == {"POST": True} +``` diff --git a/docs/sanic/versioning.md b/docs/sanic/versioning.md new file mode 100644 index 00000000..ab6dab22 --- /dev/null +++ b/docs/sanic/versioning.md @@ -0,0 +1,50 @@ +# Versioning + +You can pass the `version` keyword to the route decorators, or to a blueprint initializer. It will result in the `v{version}` url prefix where `{version}` is the version number. + +## Per route + +You can pass a version number to the routes directly. + +```python +from sanic import response + + +@app.route('/text', version=1) +def handle_request(request): + return response.text('Hello world! Version 1') + +@app.route('/text', version=2) +def handle_request(request): + return response.text('Hello world! Version 2') + +app.run(port=80) +``` + +Then with curl: + +```bash +curl localhost/v1/text +curl localhost/v2/text +``` + +## Global blueprint version + +You can also pass a version number to the blueprint, which will apply to all routes. + +```python +from sanic import response +from sanic.blueprints import Blueprint + +bp = Blueprint('test', version=1) + +@bp.route('/html') +def handle_request(request): + return response.html('

Hello world!

') +``` + +Then with curl: + +```bash +curl localhost/v1/html +``` diff --git a/docs/sanic/websocket.rst b/docs/sanic/websocket.rst new file mode 100644 index 00000000..8b813bf8 --- /dev/null +++ b/docs/sanic/websocket.rst @@ -0,0 +1,51 @@ +WebSocket +========= + +Sanic supports websockets, to setup a WebSocket: + +.. code:: python + + from sanic import Sanic + from sanic.response import json + from sanic.websocket import WebSocketProtocol + + app = Sanic() + + @app.websocket('/feed') + async def feed(request, ws): + while True: + data = 'hello!' + print('Sending: ' + data) + await ws.send(data) + data = await ws.recv() + print('Received: ' + data) + + if __name__ == "__main__": + app.run(host="0.0.0.0", port=8000, protocol=WebSocketProtocol) + + +Alternatively, the ``app.add_websocket_route`` method can be used instead of the +decorator: + +.. code:: python + + async def feed(request, ws): + pass + + app.add_websocket_route(feed, '/feed') + + +Handlers for a WebSocket route are passed the request as first argument, and a +WebSocket protocol object as second argument. The protocol object has ``send`` +and ``recv`` methods to send and receive data respectively. + + +You could setup your own WebSocket configuration through ``app.config``, like + +.. code:: python + app.config.WEBSOCKET_MAX_SIZE = 2 ** 20 + app.config.WEBSOCKET_MAX_QUEUE = 32 + app.config.WEBSOCKET_READ_LIMIT = 2 ** 16 + app.config.WEBSOCKET_WRITE_LIMIT = 2 ** 16 + +Find more in ``Configuration`` section. diff --git a/environment.yml b/environment.yml new file mode 100644 index 00000000..1c1dd82f --- /dev/null +++ b/environment.yml @@ -0,0 +1,20 @@ +name: py35 +dependencies: +- openssl=1.0.2g=0 +- pip=8.1.1=py35_0 +- python=3.5.1=0 +- readline=6.2=2 +- setuptools=20.3=py35_0 +- sqlite=3.9.2=0 +- tk=8.5.18=0 +- wheel=0.29.0=py35_0 +- xz=5.0.5=1 +- zlib=1.2.8=0 +- pip: + - uvloop>=0.5.3 + - httptools>=0.0.9 + - ujson>=1.35 + - aiofiles>=0.3.0 + - websockets>=3.2 + - sphinxcontrib-asyncio>=0.2.0 + - https://github.com/channelcat/docutils-fork/zipball/master diff --git a/examples/add_task_sanic.py b/examples/add_task_sanic.py new file mode 100644 index 00000000..52b4e6bb --- /dev/null +++ b/examples/add_task_sanic.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- + +import asyncio + +from sanic import Sanic + +app = Sanic() + + +async def notify_server_started_after_five_seconds(): + await asyncio.sleep(5) + print('Server successfully started!') + +app.add_task(notify_server_started_after_five_seconds()) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8000) diff --git a/examples/authorized_sanic.py b/examples/authorized_sanic.py new file mode 100644 index 00000000..f6b17426 --- /dev/null +++ b/examples/authorized_sanic.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +from sanic import Sanic +from functools import wraps +from sanic.response import json + +app = Sanic() + + +def check_request_for_authorization_status(request): + # Note: Define your check, for instance cookie, session. + flag = True + return flag + + +def authorized(): + def decorator(f): + @wraps(f) + async def decorated_function(request, *args, **kwargs): + # run some method that checks the request + # for the client's authorization status + is_authorized = check_request_for_authorization_status(request) + + if is_authorized: + # the user is authorized. + # run the handler method and return the response + response = await f(request, *args, **kwargs) + return response + else: + # the user is not authorized. + return json({'status': 'not_authorized'}, 403) + return decorated_function + return decorator + + +@app.route("/") +@authorized() +async def test(request): + return json({'status': 'authorized'}) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8000) diff --git a/examples/blueprints.py b/examples/blueprints.py index f612185c..29144c4e 100644 --- a/examples/blueprints.py +++ b/examples/blueprints.py @@ -1,11 +1,10 @@ -from sanic import Sanic -from sanic import Blueprint -from sanic.response import json, text - +from sanic import Blueprint, Sanic +from sanic.response import file, json app = Sanic(__name__) blueprint = Blueprint('name', url_prefix='/my_blueprint') -blueprint2 = Blueprint('name', url_prefix='/my_blueprint2') +blueprint2 = Blueprint('name2', url_prefix='/my_blueprint2') +blueprint3 = Blueprint('name3', url_prefix='/my_blueprint3') @blueprint.route('/foo') @@ -18,7 +17,22 @@ async def foo2(request): return json({'msg': 'hi from blueprint2'}) -app.register_blueprint(blueprint) -app.register_blueprint(blueprint2) +@blueprint3.route('/foo') +async def index(request): + return await file('websocket.html') + + +@app.websocket('/feed') +async def foo3(request, ws): + while True: + data = 'hello!' + print('Sending: ' + data) + await ws.send(data) + data = await ws.recv() + print('Received: ' + data) + +app.blueprint(blueprint) +app.blueprint(blueprint2) +app.blueprint(blueprint3) app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/examples/exception_monitoring.py b/examples/exception_monitoring.py new file mode 100644 index 00000000..02a13e7d --- /dev/null +++ b/examples/exception_monitoring.py @@ -0,0 +1,54 @@ +""" +Example intercepting uncaught exceptions using Sanic's error handler framework. +This may be useful for developers wishing to use Sentry, Airbrake, etc. +or a custom system to log and monitor unexpected errors in production. +First we create our own class inheriting from Handler in sanic.exceptions, +and pass in an instance of it when we create our Sanic instance. Inside this +class' default handler, we can do anything including sending exceptions to +an external service. +""" +from sanic.handlers import ErrorHandler +from sanic.exceptions import SanicException +""" +Imports and code relevant for our CustomHandler class +(Ordinarily this would be in a separate file) +""" + + +class CustomHandler(ErrorHandler): + + def default(self, request, exception): + # Here, we have access to the exception object + # and can do anything with it (log, send to external service, etc) + + # Some exceptions are trivial and built into Sanic (404s, etc) + if not isinstance(exception, SanicException): + print(exception) + + # Then, we must finish handling the exception by returning + # our response to the client + # For this we can just call the super class' default handler + return super().default(request, exception) + + +""" +This is an ordinary Sanic server, with the exception that we set the +server's error_handler to an instance of our CustomHandler +""" + +from sanic import Sanic + +app = Sanic(__name__) + +handler = CustomHandler() +app.error_handler = handler + + +@app.route("/") +async def test(request): + # Here, something occurs which causes an unexpected exception + # This exception will flow to our custom handler. + raise SanicException('You Broke It!') + +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/examples/limit_concurrency.py b/examples/limit_concurrency.py new file mode 100644 index 00000000..f6b4b01a --- /dev/null +++ b/examples/limit_concurrency.py @@ -0,0 +1,38 @@ +from sanic import Sanic +from sanic.response import json + +import asyncio +import aiohttp + +app = Sanic(__name__) + +sem = None + + +@app.listener('before_server_start') +def init(sanic, loop): + global sem + concurrency_per_worker = 4 + sem = asyncio.Semaphore(concurrency_per_worker, loop=loop) + +async def bounded_fetch(session, url): + """ + Use session object to perform 'get' request on url + """ + async with sem, session.get(url) as response: + return await response.json() + + +@app.route("/") +async def test(request): + """ + Download and serve example JSON + """ + url = "https://api.github.com/repos/channelcat/sanic" + + async with aiohttp.ClientSession() as session: + response = await bounded_fetch(session, url) + return json(response) + + +app.run(host="0.0.0.0", port=8000, workers=2) diff --git a/examples/log_request_id.py b/examples/log_request_id.py new file mode 100644 index 00000000..a6dba418 --- /dev/null +++ b/examples/log_request_id.py @@ -0,0 +1,86 @@ +''' +Based on example from https://github.com/Skyscanner/aiotask-context +and `examples/{override_logging,run_async}.py`. + +Needs https://github.com/Skyscanner/aiotask-context/tree/52efbc21e2e1def2d52abb9a8e951f3ce5e6f690 or newer + +$ pip install git+https://github.com/Skyscanner/aiotask-context.git +''' + +import asyncio +import uuid +import logging +from signal import signal, SIGINT + +from sanic import Sanic +from sanic import response + +import uvloop +import aiotask_context as context + +log = logging.getLogger(__name__) + + +class RequestIdFilter(logging.Filter): + def filter(self, record): + record.request_id = context.get('X-Request-ID') + return True + + +LOG_SETTINGS = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'level': 'DEBUG', + 'formatter': 'default', + 'filters': ['requestid'], + }, + }, + 'filters': { + 'requestid': { + '()': RequestIdFilter, + }, + }, + 'formatters': { + 'default': { + 'format': '%(asctime)s %(levelname)s %(name)s:%(lineno)d %(request_id)s | %(message)s', + }, + }, + 'loggers': { + '': { + 'level': 'DEBUG', + 'handlers': ['console'], + 'propagate': True + }, + } +} + + +app = Sanic(__name__, log_config=LOG_SETTINGS) + + +@app.middleware('request') +async def set_request_id(request): + request_id = request.headers.get('X-Request-ID') or str(uuid.uuid4()) + context.set("X-Request-ID", request_id) + + +@app.route("/") +async def test(request): + log.debug('X-Request-ID: %s', context.get('X-Request-ID')) + log.info('Hello from test!') + return response.json({"test": True}) + + +if __name__ == '__main__': + asyncio.set_event_loop(uvloop.new_event_loop()) + server = app.create_server(host="0.0.0.0", port=8000) + loop = asyncio.get_event_loop() + loop.set_task_factory(context.task_factory) + task = asyncio.ensure_future(server) + try: + loop.run_forever() + except: + loop.stop() diff --git a/examples/modify_header_example.py b/examples/modify_header_example.py new file mode 100644 index 00000000..f13e5f00 --- /dev/null +++ b/examples/modify_header_example.py @@ -0,0 +1,28 @@ +""" +Modify header or status in response +""" + +from sanic import Sanic +from sanic import response + +app = Sanic(__name__) + + +@app.route('/') +def handle_request(request): + return response.json( + {'message': 'Hello world!'}, + headers={'X-Served-By': 'sanic'}, + status=200 + ) + + +@app.route('/unauthorized') +def handle_request(request): + return response.json( + {'message': 'You are not authorized'}, + headers={'X-Served-By': 'sanic'}, + status=404 + ) + +app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/examples/override_logging.py b/examples/override_logging.py new file mode 100644 index 00000000..99d26997 --- /dev/null +++ b/examples/override_logging.py @@ -0,0 +1,24 @@ +from sanic import Sanic +from sanic import response +import logging + +logging_format = "[%(asctime)s] %(process)d-%(levelname)s " +logging_format += "%(module)s::%(funcName)s():l%(lineno)d: " +logging_format += "%(message)s" + +logging.basicConfig( + format=logging_format, + level=logging.DEBUG +) +log = logging.getLogger() + +# Set logger to override default basicConfig +sanic = Sanic() + + +@sanic.route("/") +def test(request): + log.info("received request; responding with 'hey'") + return response.text("hey") + +sanic.run(host="0.0.0.0", port=8000) diff --git a/examples/pytest_xdist.py b/examples/pytest_xdist.py new file mode 100644 index 00000000..06730016 --- /dev/null +++ b/examples/pytest_xdist.py @@ -0,0 +1,49 @@ +"""pytest-xdist example for sanic server + +Install testing tools: + + $ pip install pytest pytest-xdist + +Run with xdist params: + + $ pytest examples/pytest_xdist.py -n 8 # 8 workers +""" +import re +from sanic import Sanic +from sanic.response import text +from sanic.testing import PORT as PORT_BASE, SanicTestClient +import pytest + + +@pytest.fixture(scope="session") +def test_port(worker_id): + m = re.search(r'[0-9]+', worker_id) + if m: + num_id = m.group(0) + else: + num_id = 0 + port = PORT_BASE + int(num_id) + return port + + +@pytest.fixture(scope="session") +def app(): + app = Sanic() + + @app.route('/') + async def index(request): + return text('OK') + + return app + + +@pytest.fixture(scope="session") +def client(app, test_port): + return SanicTestClient(app, test_port) + + +@pytest.mark.parametrize('run_id', range(100)) +def test_index(client, run_id): + request, response = client._sanic_endpoint_test('get', '/') + assert response.status == 200 + assert response.text == 'OK' diff --git a/examples/redirect_example.py b/examples/redirect_example.py new file mode 100644 index 00000000..f73ad178 --- /dev/null +++ b/examples/redirect_example.py @@ -0,0 +1,18 @@ +from sanic import Sanic +from sanic import response + +app = Sanic(__name__) + + +@app.route('/') +def handle_request(request): + return response.redirect('/redirect') + + +@app.route('/redirect') +async def test(request): + return response.json({"Redirected": True}) + + +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/examples/request_stream/client.py b/examples/request_stream/client.py new file mode 100644 index 00000000..a59c4c23 --- /dev/null +++ b/examples/request_stream/client.py @@ -0,0 +1,10 @@ +import requests + +# Warning: This is a heavy process. + +data = "" +for i in range(1, 250000): + data += str(i) + +r = requests.post('http://0.0.0.0:8000/stream', data=data) +print(r.text) diff --git a/examples/request_stream/server.py b/examples/request_stream/server.py new file mode 100644 index 00000000..e53a224c --- /dev/null +++ b/examples/request_stream/server.py @@ -0,0 +1,65 @@ +from sanic import Sanic +from sanic.views import CompositionView +from sanic.views import HTTPMethodView +from sanic.views import stream as stream_decorator +from sanic.blueprints import Blueprint +from sanic.response import stream, text + +bp = Blueprint('blueprint_request_stream') +app = Sanic('request_stream') + + +class SimpleView(HTTPMethodView): + + @stream_decorator + async def post(self, request): + result = '' + while True: + body = await request.stream.get() + if body is None: + break + result += body.decode('utf-8') + return text(result) + + +@app.post('/stream', stream=True) +async def handler(request): + async def streaming(response): + while True: + body = await request.stream.get() + if body is None: + break + body = body.decode('utf-8').replace('1', 'A') + response.write(body) + return stream(streaming) + + +@bp.put('/bp_stream', stream=True) +async def bp_handler(request): + result = '' + while True: + body = await request.stream.get() + if body is None: + break + result += body.decode('utf-8').replace('1', 'A') + return text(result) + + +async def post_handler(request): + result = '' + while True: + body = await request.stream.get() + if body is None: + break + result += body.decode('utf-8') + return text(result) + +app.blueprint(bp) +app.add_route(SimpleView.as_view(), '/method_view') +view = CompositionView() +view.add(['POST'], post_handler, stream=True) +app.add_route(view, '/composition_view') + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8000) diff --git a/examples/request_timeout.py b/examples/request_timeout.py new file mode 100644 index 00000000..0c2c489c --- /dev/null +++ b/examples/request_timeout.py @@ -0,0 +1,21 @@ +import asyncio +from sanic import Sanic +from sanic import response +from sanic.config import Config +from sanic.exceptions import RequestTimeout + +Config.REQUEST_TIMEOUT = 1 +app = Sanic(__name__) + + +@app.route('/') +async def test(request): + await asyncio.sleep(3) + return response.text('Hello, world!') + + +@app.exception(RequestTimeout) +def timeout(request, exception): + return response.text('RequestTimeout from error_handler.', 408) + +app.run(host='0.0.0.0', port=8000) diff --git a/examples/run_async.py b/examples/run_async.py new file mode 100644 index 00000000..3d8ab55a --- /dev/null +++ b/examples/run_async.py @@ -0,0 +1,22 @@ +from sanic import Sanic +from sanic import response +from signal import signal, SIGINT +import asyncio +import uvloop + +app = Sanic(__name__) + + +@app.route("/") +async def test(request): + return response.json({"answer": "42"}) + +asyncio.set_event_loop(uvloop.new_event_loop()) +server = app.create_server(host="0.0.0.0", port=8000) +loop = asyncio.get_event_loop() +task = asyncio.ensure_future(server) +signal(SIGINT, lambda s, f: loop.stop()) +try: + loop.run_forever() +except: + loop.stop() diff --git a/examples/simple_async_view.py b/examples/simple_async_view.py new file mode 100644 index 00000000..990aa21a --- /dev/null +++ b/examples/simple_async_view.py @@ -0,0 +1,42 @@ +from sanic import Sanic +from sanic.views import HTTPMethodView +from sanic.response import text + +app = Sanic('some_name') + + +class SimpleView(HTTPMethodView): + + def get(self, request): + return text('I am get method') + + def post(self, request): + return text('I am post method') + + def put(self, request): + return text('I am put method') + + def patch(self, request): + return text('I am patch method') + + def delete(self, request): + return text('I am delete method') + + +class SimpleAsyncView(HTTPMethodView): + + async def get(self, request): + return text('I am async get method') + + async def post(self, request): + return text('I am async post method') + + async def put(self, request): + return text('I am async put method') + + +app.add_route(SimpleView.as_view(), '/') +app.add_route(SimpleAsyncView.as_view(), '/async') + +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/examples/simple_server.py b/examples/simple_server.py index 24e3570f..948090c4 100644 --- a/examples/simple_server.py +++ b/examples/simple_server.py @@ -1,12 +1,13 @@ from sanic import Sanic -from sanic.response import json +from sanic import response app = Sanic(__name__) @app.route("/") async def test(request): - return json({"test": True}) + return response.json({"test": True}) -app.run(host="0.0.0.0", port=8000) +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000) diff --git a/examples/teapot.py b/examples/teapot.py new file mode 100644 index 00000000..897f7836 --- /dev/null +++ b/examples/teapot.py @@ -0,0 +1,13 @@ +from sanic import Sanic +from sanic import response as res + +app = Sanic(__name__) + + +@app.route("/") +async def test(req): + return res.text("I\'m a teapot", status=418) + + +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000) diff --git a/examples/try_everything.py b/examples/try_everything.py index 80358ddb..a775704d 100644 --- a/examples/try_everything.py +++ b/examples/try_everything.py @@ -1,6 +1,8 @@ +import os + from sanic import Sanic -from sanic.log import log -from sanic.response import json, text +from sanic.log import logger as log +from sanic import response from sanic.exceptions import ServerError app = Sanic(__name__) @@ -8,37 +10,50 @@ app = Sanic(__name__) @app.route("/") async def test_async(request): - return json({"test": True}) + return response.json({"test": True}) @app.route("/sync", methods=['GET', 'POST']) def test_sync(request): - return json({"test": True}) + return response.json({"test": True}) -@app.route("/dynamic//") -def test_params(request, name, id): - return text("yeehaww {} {}".format(name, id)) +@app.route("/dynamic//") +def test_params(request, name, i): + return response.text("yeehaww {} {}".format(name, i)) @app.route("/exception") def exception(request): raise ServerError("It's dead jim") + @app.route("/await") async def test_await(request): import asyncio await asyncio.sleep(5) - return text("I'm feeling sleepy") + return response.text("I'm feeling sleepy") +@app.route("/file") +async def test_file(request): + return await response.file(os.path.abspath("setup.py")) + + +@app.route("/file_stream") +async def test_file_stream(request): + return await response.file_stream(os.path.abspath("setup.py"), + chunk_size=1024) + # ----------------------------------------------- # # Exceptions # ----------------------------------------------- # + @app.exception(ServerError) async def test(request, exception): - return json({"exception": "{}".format(exception), "status": exception.status_code}, status=exception.status_code) + return response.json({"exception": "{}".format(exception), "status": exception.status_code}, + status=exception.status_code) # ----------------------------------------------- # @@ -47,29 +62,43 @@ async def test(request, exception): @app.route("/json") def post_json(request): - return json({"received": True, "message": request.json}) + return response.json({"received": True, "message": request.json}) @app.route("/form") -def post_json(request): - return json({"received": True, "form_data": request.form, "test": request.form.get('test')}) +def post_form_json(request): + return response.json({"received": True, "form_data": request.form, "test": request.form.get('test')}) @app.route("/query_string") def query_string(request): - return json({"parsed": True, "args": request.args, "url": request.url, "query_string": request.query_string}) + return response.json({"parsed": True, "args": request.args, "url": request.url, + "query_string": request.query_string}) # ----------------------------------------------- # # Run Server # ----------------------------------------------- # -def after_start(loop): +@app.listener('before_server_start') +def before_start(app, loop): + log.info("SERVER STARTING") + + +@app.listener('after_server_start') +def after_start(app, loop): log.info("OH OH OH OH OHHHHHHHH") -def before_stop(loop): +@app.listener('before_server_stop') +def before_stop(app, loop): + log.info("SERVER STOPPING") + + +@app.listener('after_server_stop') +def after_stop(app, loop): log.info("TRIED EVERYTHING") -app.run(host="0.0.0.0", port=8000, debug=True, after_start=after_start, before_stop=before_stop) +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/examples/unix_socket.py b/examples/unix_socket.py new file mode 100644 index 00000000..08e89445 --- /dev/null +++ b/examples/unix_socket.py @@ -0,0 +1,23 @@ +from sanic import Sanic +from sanic import response +import socket +import os + +app = Sanic(__name__) + + +@app.route("/test") +async def test(request): + return response.text("OK") + +if __name__ == '__main__': + server_address = './uds_socket' + # Make sure the socket does not already exist + try: + os.unlink(server_address) + except OSError: + if os.path.exists(server_address): + raise + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.bind(server_address) + app.run(sock=sock) diff --git a/examples/url_for_example.py b/examples/url_for_example.py new file mode 100644 index 00000000..cb895b0c --- /dev/null +++ b/examples/url_for_example.py @@ -0,0 +1,20 @@ +from sanic import Sanic +from sanic import response + +app = Sanic(__name__) + + +@app.route('/') +async def index(request): + # generate a URL for the endpoint `post_handler` + url = app.url_for('post_handler', post_id=5) + # the URL is `/posts/5`, redirect to it + return response.redirect(url) + + +@app.route('/posts/') +async def post_handler(request, post_id): + return response.text('Post - {}'.format(post_id)) + +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/examples/vhosts.py b/examples/vhosts.py new file mode 100644 index 00000000..a6f946bc --- /dev/null +++ b/examples/vhosts.py @@ -0,0 +1,39 @@ +from sanic import response +from sanic import Sanic +from sanic.blueprints import Blueprint + +# Usage +# curl -H "Host: example.com" localhost:8000 +# curl -H "Host: sub.example.com" localhost:8000 +# curl -H "Host: bp.example.com" localhost:8000/question +# curl -H "Host: bp.example.com" localhost:8000/answer + +app = Sanic() +bp = Blueprint("bp", host="bp.example.com") + + +@app.route('/', host=["example.com", + "somethingelse.com", + "therestofyourdomains.com"]) +async def hello(request): + return response.text("Some defaults") + + +@app.route('/', host="sub.example.com") +async def hello(request): + return response.text("42") + + +@bp.route("/question") +async def hello(request): + return response.text("What is the meaning of life?") + + +@bp.route("/answer") +async def hello(request): + return response.text("42") + +app.blueprint(bp) + +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/examples/websocket.html b/examples/websocket.html new file mode 100644 index 00000000..a3a98f35 --- /dev/null +++ b/examples/websocket.html @@ -0,0 +1,29 @@ + + + + WebSocket demo + + + + + diff --git a/examples/websocket.py b/examples/websocket.py new file mode 100644 index 00000000..9cba083c --- /dev/null +++ b/examples/websocket.py @@ -0,0 +1,24 @@ +from sanic import Sanic +from sanic.response import file + +app = Sanic(__name__) + + +@app.route('/') +async def index(request): + return await file('websocket.html') + + +@app.websocket('/feed') +async def feed(request, ws): + while True: + data = 'hello!' + print('Sending: ' + data) + await ws.send(data) + data = await ws.recv() + print('Received: ' + data) + + +if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000, debug=True) + diff --git a/readthedocs.yml b/readthedocs.yml new file mode 100644 index 00000000..40c09542 --- /dev/null +++ b/readthedocs.yml @@ -0,0 +1,2 @@ +conda: + file: environment.yml \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 66246850..3d94c51d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,10 +1,13 @@ -httptools -ujson -uvloop -aiohttp -pytest +aiofiles +aiohttp>=2.3.0 +chardet<=2.3.0 +beautifulsoup4 coverage +httptools +flake8 +pytest==3.3.2 tox +ujson; sys_platform != "win32" and implementation_name == "cpython" +uvloop; sys_platform != "win32" and implementation_name == "cpython" gunicorn -bottle -kyoukai +multidict>=4.0,<5.0 diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 00000000..e12c1846 --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,4 @@ +sphinx +sphinx_rtd_theme +recommonmark +sphinxcontrib-asyncio diff --git a/requirements.txt b/requirements.txt index 2e0feec8..e320e781 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ +aiofiles httptools -ujson -uvloop \ No newline at end of file +ujson; sys_platform != "win32" and implementation_name == "cpython" +uvloop; sys_platform != "win32" and implementation_name == "cpython" +websockets>=5.0,<6.0 +multidict>=4.0,<5.0 diff --git a/sanic/__init__.py b/sanic/__init__.py index b7be9aaf..78bc7bd9 100644 --- a/sanic/__init__.py +++ b/sanic/__init__.py @@ -1,4 +1,6 @@ -from .sanic import Sanic -from .blueprints import Blueprint +from sanic.app import Sanic +from sanic.blueprints import Blueprint + +__version__ = '0.7.0' __all__ = ['Sanic', 'Blueprint'] diff --git a/sanic/__main__.py b/sanic/__main__.py new file mode 100644 index 00000000..594256f8 --- /dev/null +++ b/sanic/__main__.py @@ -0,0 +1,44 @@ +from argparse import ArgumentParser +from importlib import import_module + +from sanic.log import logger +from sanic.app import Sanic + +if __name__ == "__main__": + parser = ArgumentParser(prog='sanic') + parser.add_argument('--host', dest='host', type=str, default='127.0.0.1') + parser.add_argument('--port', dest='port', type=int, default=8000) + parser.add_argument('--cert', dest='cert', type=str, + help='location of certificate for SSL') + parser.add_argument('--key', dest='key', type=str, + help='location of keyfile for SSL.') + parser.add_argument('--workers', dest='workers', type=int, default=1, ) + parser.add_argument('--debug', dest='debug', action="store_true") + parser.add_argument('module') + args = parser.parse_args() + + try: + module_parts = args.module.split(".") + module_name = ".".join(module_parts[:-1]) + app_name = module_parts[-1] + + module = import_module(module_name) + app = getattr(module, app_name, None) + if not isinstance(app, Sanic): + raise ValueError("Module is not a Sanic app, it is a {}. " + "Perhaps you meant {}.app?" + .format(type(app).__name__, args.module)) + if args.cert is not None or args.key is not None: + ssl = {'cert': args.cert, 'key': args.key} + else: + ssl = None + + app.run(host=args.host, port=args.port, + workers=args.workers, debug=args.debug, ssl=ssl) + except ImportError as e: + logger.error("No module named {} found.\n" + " Example File: project/sanic_server.py -> app\n" + " Example Module: project.sanic_server.app" + .format(e.name)) + except ValueError as e: + logger.error("{}".format(e)) diff --git a/sanic/app.py b/sanic/app.py new file mode 100644 index 00000000..e82028f4 --- /dev/null +++ b/sanic/app.py @@ -0,0 +1,883 @@ +import os +import logging +import logging.config +import re +import warnings +from asyncio import get_event_loop, ensure_future, CancelledError +from collections import deque, defaultdict +from functools import partial +from inspect import getmodulename, isawaitable, signature, stack +from traceback import format_exc +from urllib.parse import urlencode, urlunparse +from ssl import create_default_context, Purpose + +from sanic.config import Config +from sanic.constants import HTTP_METHODS +from sanic.exceptions import ServerError, URLBuildError, SanicException +from sanic.handlers import ErrorHandler +from sanic.log import logger, error_logger, LOGGING_CONFIG_DEFAULTS +from sanic.response import HTTPResponse, StreamingHTTPResponse +from sanic.router import Router +from sanic.server import serve, serve_multiple, HttpProtocol, Signal +from sanic.static import register as static_register +from sanic.testing import SanicTestClient +from sanic.views import CompositionView +from sanic.websocket import WebSocketProtocol, ConnectionClosed +import sanic.reloader_helpers as reloader_helpers + + +class Sanic: + def __init__(self, name=None, router=None, error_handler=None, + load_env=True, request_class=None, + strict_slashes=False, log_config=None, + configure_logging=True): + + # Get name from previous stack frame + if name is None: + frame_records = stack()[1] + name = getmodulename(frame_records[1]) + + # logging + if configure_logging: + logging.config.dictConfig(log_config or LOGGING_CONFIG_DEFAULTS) + + self.name = name + self.router = router or Router() + self.request_class = request_class + self.error_handler = error_handler or ErrorHandler() + self.config = Config(load_env=load_env) + self.request_middleware = deque() + self.response_middleware = deque() + self.blueprints = {} + self._blueprint_order = [] + self.configure_logging = configure_logging + self.debug = None + self.sock = None + self.strict_slashes = strict_slashes + self.listeners = defaultdict(list) + self.is_running = False + self.is_request_stream = False + self.websocket_enabled = False + self.websocket_tasks = set() + + # Register alternative method names + self.go_fast = self.run + + @property + def loop(self): + """Synonymous with asyncio.get_event_loop(). + + Only supported when using the `app.run` method. + """ + if not self.is_running: + raise SanicException( + 'Loop can only be retrieved after the app has started ' + 'running. Not supported with `create_server` function') + return get_event_loop() + + # -------------------------------------------------------------------- # + # Registration + # -------------------------------------------------------------------- # + + def add_task(self, task): + """Schedule a task to run later, after the loop has started. + Different from asyncio.ensure_future in that it does not + also return a future, and the actual ensure_future call + is delayed until before server start. + + :param task: future, couroutine or awaitable + """ + try: + if callable(task): + try: + self.loop.create_task(task(self)) + except TypeError: + self.loop.create_task(task()) + else: + self.loop.create_task(task) + except SanicException: + @self.listener('before_server_start') + def run(app, loop): + if callable(task): + try: + loop.create_task(task(self)) + except TypeError: + loop.create_task(task()) + else: + loop.create_task(task) + + # Decorator + def listener(self, event): + """Create a listener from a decorated function. + + :param event: event to listen to + """ + + def decorator(listener): + self.listeners[event].append(listener) + return listener + + return decorator + + def register_listener(self, listener, event): + """ + Register the listener for a given event. + + Args: + listener: callable i.e. setup_db(app, loop) + event: when to register listener i.e. 'before_server_start' + + Returns: listener + """ + + return self.listener(event)(listener) + + # Decorator + def route(self, uri, methods=frozenset({'GET'}), host=None, + strict_slashes=None, stream=False, version=None, name=None): + """Decorate a function to be registered as a route + + :param uri: path of the URL + :param methods: list or tuple of methods allowed + :param host: + :param strict_slashes: + :param stream: + :param version: + :param name: user defined route name for url_for + :return: decorated function + """ + + # Fix case where the user did not prefix the URL with a / + # and will probably get confused as to why it's not working + if not uri.startswith('/'): + uri = '/' + uri + + if stream: + self.is_request_stream = True + + if strict_slashes is None: + strict_slashes = self.strict_slashes + + def response(handler): + args = [key for key in signature(handler).parameters.keys()] + if args: + if stream: + handler.is_stream = stream + + self.router.add(uri=uri, methods=methods, handler=handler, + host=host, strict_slashes=strict_slashes, + version=version, name=name) + return handler + else: + raise ValueError( + 'Required parameter `request` missing' + 'in the {0}() route?'.format( + handler.__name__)) + + return response + + # Shorthand method decorators + def get(self, uri, host=None, strict_slashes=None, version=None, + name=None): + return self.route(uri, methods=frozenset({"GET"}), host=host, + strict_slashes=strict_slashes, version=version, + name=name) + + def post(self, uri, host=None, strict_slashes=None, stream=False, + version=None, name=None): + return self.route(uri, methods=frozenset({"POST"}), host=host, + strict_slashes=strict_slashes, stream=stream, + version=version, name=name) + + def put(self, uri, host=None, strict_slashes=None, stream=False, + version=None, name=None): + return self.route(uri, methods=frozenset({"PUT"}), host=host, + strict_slashes=strict_slashes, stream=stream, + version=version, name=name) + + def head(self, uri, host=None, strict_slashes=None, version=None, + name=None): + return self.route(uri, methods=frozenset({"HEAD"}), host=host, + strict_slashes=strict_slashes, version=version, + name=name) + + def options(self, uri, host=None, strict_slashes=None, version=None, + name=None): + return self.route(uri, methods=frozenset({"OPTIONS"}), host=host, + strict_slashes=strict_slashes, version=version, + name=name) + + def patch(self, uri, host=None, strict_slashes=None, stream=False, + version=None, name=None): + return self.route(uri, methods=frozenset({"PATCH"}), host=host, + strict_slashes=strict_slashes, stream=stream, + version=version, name=name) + + def delete(self, uri, host=None, strict_slashes=None, version=None, + name=None): + return self.route(uri, methods=frozenset({"DELETE"}), host=host, + strict_slashes=strict_slashes, version=version, + name=name) + + def add_route(self, handler, uri, methods=frozenset({'GET'}), host=None, + strict_slashes=None, version=None, name=None, stream=False): + """A helper method to register class instance or + functions as a handler to the application url + routes. + + :param handler: function or class instance + :param uri: path of the URL + :param methods: list or tuple of methods allowed, these are overridden + if using a HTTPMethodView + :param host: + :param strict_slashes: + :param version: + :param name: user defined route name for url_for + :param stream: boolean specifying if the handler is a stream handler + :return: function or class instance + """ + # Handle HTTPMethodView differently + if hasattr(handler, 'view_class'): + methods = set() + + for method in HTTP_METHODS: + _handler = getattr(handler.view_class, method.lower(), None) + if _handler: + methods.add(method) + if hasattr(_handler, 'is_stream'): + stream = True + + # handle composition view differently + if isinstance(handler, CompositionView): + methods = handler.handlers.keys() + for _handler in handler.handlers.values(): + if hasattr(_handler, 'is_stream'): + stream = True + break + + if strict_slashes is None: + strict_slashes = self.strict_slashes + + self.route(uri=uri, methods=methods, host=host, + strict_slashes=strict_slashes, stream=stream, + version=version, name=name)(handler) + return handler + + # Decorator + def websocket(self, uri, host=None, strict_slashes=None, + subprotocols=None, name=None): + """Decorate a function to be registered as a websocket route + :param uri: path of the URL + :param subprotocols: optional list of strings with the supported + subprotocols + :param host: + :return: decorated function + """ + self.enable_websocket() + + # Fix case where the user did not prefix the URL with a / + # and will probably get confused as to why it's not working + if not uri.startswith('/'): + uri = '/' + uri + + if strict_slashes is None: + strict_slashes = self.strict_slashes + + def response(handler): + async def websocket_handler(request, *args, **kwargs): + request.app = self + try: + protocol = request.transport.get_protocol() + except AttributeError: + # On Python3.5 the Transport classes in asyncio do not + # have a get_protocol() method as in uvloop + protocol = request.transport._protocol + ws = await protocol.websocket_handshake(request, subprotocols) + + # schedule the application handler + # its future is kept in self.websocket_tasks in case it + # needs to be cancelled due to the server being stopped + fut = ensure_future(handler(request, ws, *args, **kwargs)) + self.websocket_tasks.add(fut) + try: + await fut + except (CancelledError, ConnectionClosed): + pass + finally: + self.websocket_tasks.remove(fut) + await ws.close() + + self.router.add(uri=uri, handler=websocket_handler, + methods=frozenset({'GET'}), host=host, + strict_slashes=strict_slashes, name=name) + return handler + + return response + + def add_websocket_route(self, handler, uri, host=None, + strict_slashes=None, subprotocols=None, name=None): + """A helper method to register a function as a websocket route.""" + if strict_slashes is None: + strict_slashes = self.strict_slashes + + return self.websocket(uri, host=host, strict_slashes=strict_slashes, + subprotocols=subprotocols, name=name)(handler) + + def enable_websocket(self, enable=True): + """Enable or disable the support for websocket. + + Websocket is enabled automatically if websocket routes are + added to the application. + """ + if not self.websocket_enabled: + # if the server is stopped, we want to cancel any ongoing + # websocket tasks, to allow the server to exit promptly + @self.listener('before_server_stop') + def cancel_websocket_tasks(app, loop): + for task in self.websocket_tasks: + task.cancel() + + self.websocket_enabled = enable + + def remove_route(self, uri, clean_cache=True, host=None): + self.router.remove(uri, clean_cache, host) + + # Decorator + def exception(self, *exceptions): + """Decorate a function to be registered as a handler for exceptions + + :param exceptions: exceptions + :return: decorated function + """ + + def response(handler): + for exception in exceptions: + if isinstance(exception, (tuple, list)): + for e in exception: + self.error_handler.add(e, handler) + else: + self.error_handler.add(exception, handler) + return handler + + return response + + def register_middleware(self, middleware, attach_to='request'): + if attach_to == 'request': + self.request_middleware.append(middleware) + if attach_to == 'response': + self.response_middleware.appendleft(middleware) + return middleware + + # Decorator + def middleware(self, middleware_or_request): + """Decorate and register middleware to be called before a request. + Can either be called as @app.middleware or @app.middleware('request') + """ + + # Detect which way this was called, @middleware or @middleware('AT') + if callable(middleware_or_request): + return self.register_middleware(middleware_or_request) + + else: + return partial(self.register_middleware, + attach_to=middleware_or_request) + + # Static Files + def static(self, uri, file_or_directory, pattern=r'/?.+', + use_modified_since=True, use_content_range=False, + stream_large_files=False, name='static', host=None, + strict_slashes=None, content_type=None): + """Register a root to serve files from. The input can either be a + file or a directory. See + """ + static_register(self, uri, file_or_directory, pattern, + use_modified_since, use_content_range, + stream_large_files, name, host, strict_slashes, + content_type) + + def blueprint(self, blueprint, **options): + """Register a blueprint on the application. + + :param blueprint: Blueprint object or (list, tuple) thereof + :param options: option dictionary with blueprint defaults + :return: Nothing + """ + if isinstance(blueprint, (list, tuple)): + for item in blueprint: + self.blueprint(item, **options) + return + if blueprint.name in self.blueprints: + assert self.blueprints[blueprint.name] is blueprint, \ + 'A blueprint with the name "%s" is already registered. ' \ + 'Blueprint names must be unique.' % \ + (blueprint.name,) + else: + self.blueprints[blueprint.name] = blueprint + self._blueprint_order.append(blueprint) + blueprint.register(self, options) + + def register_blueprint(self, *args, **kwargs): + # TODO: deprecate 1.0 + if self.debug: + warnings.simplefilter('default') + warnings.warn("Use of register_blueprint will be deprecated in " + "version 1.0. Please use the blueprint method" + " instead", + DeprecationWarning) + return self.blueprint(*args, **kwargs) + + def url_for(self, view_name: str, **kwargs): + """Build a URL based on a view name and the values provided. + + In order to build a URL, all request parameters must be supplied as + keyword arguments, and each parameter must pass the test for the + specified parameter type. If these conditions are not met, a + `URLBuildError` will be thrown. + + Keyword arguments that are not request parameters will be included in + the output URL's query string. + + :param view_name: string referencing the view name + :param \*\*kwargs: keys and values that are used to build request + parameters and query string arguments. + + :return: the built URL + + Raises: + URLBuildError + """ + # find the route by the supplied view name + kw = {} + # special static files url_for + if view_name == 'static': + kw.update(name=kwargs.pop('name', 'static')) + elif view_name.endswith('.static'): # blueprint.static + kwargs.pop('name', None) + kw.update(name=view_name) + + uri, route = self.router.find_route_by_view_name(view_name, **kw) + if not (uri and route): + raise URLBuildError('Endpoint with name `{}` was not found'.format( + view_name)) + + if view_name == 'static' or view_name.endswith('.static'): + filename = kwargs.pop('filename', None) + # it's static folder + if '?@[]{}' + +_Translator = {n: '\\%03o' % n + for n in set(range(256)) - set(map(ord, _UnescapedChars))} +_Translator.update({ + ord('"'): '\\"', + ord('\\'): '\\\\', +}) + + +def _quote(str): + """Quote a string for use in a cookie header. + If the string does not need to be double-quoted, then just return the + string. Otherwise, surround the string in doublequotes and quote + (with a \) special characters. + """ + if str is None or _is_legal_key(str): + return str + else: + return '"' + str.translate(_Translator) + '"' + + +_is_legal_key = re.compile('[%s]+' % re.escape(_LegalChars)).fullmatch + +# ------------------------------------------------------------ # +# Custom SimpleCookie +# ------------------------------------------------------------ # + + +class CookieJar(dict): + """CookieJar dynamically writes headers as cookies are added and removed + It gets around the limitation of one header per name by using the + MultiHeader class to provide a unique key that encodes to Set-Cookie. + """ + + def __init__(self, headers): + super().__init__() + self.headers = headers + self.cookie_headers = {} + self.header_key = "Set-Cookie" + + def __setitem__(self, key, value): + # If this cookie doesn't exist, add it to the header keys + if not self.cookie_headers.get(key): + cookie = Cookie(key, value) + cookie['path'] = '/' + self.cookie_headers[key] = self.header_key + self.headers.add(self.header_key, cookie) + return super().__setitem__(key, cookie) + else: + self[key].value = value + + def __delitem__(self, key): + if key not in self.cookie_headers: + self[key] = '' + self[key]['max-age'] = 0 + else: + cookie_header = self.cookie_headers[key] + # remove it from header + cookies = self.headers.popall(cookie_header) + for cookie in cookies: + if cookie.key != key: + self.headers.add(cookie_header, cookie) + del self.cookie_headers[key] + return super().__delitem__(key) + + +class Cookie(dict): + """A stripped down version of Morsel from SimpleCookie #gottagofast""" + _keys = { + "expires": "expires", + "path": "Path", + "comment": "Comment", + "domain": "Domain", + "max-age": "Max-Age", + "secure": "Secure", + "httponly": "HttpOnly", + "version": "Version", + "samesite": "SameSite", + } + _flags = {'secure', 'httponly'} + + def __init__(self, key, value): + if key in self._keys: + raise KeyError("Cookie name is a reserved word") + if not _is_legal_key(key): + raise KeyError("Cookie key contains illegal characters") + self.key = key + self.value = value + super().__init__() + + def __setitem__(self, key, value): + if key not in self._keys: + raise KeyError("Unknown cookie property") + if value is not False: + return super().__setitem__(key, value) + + def encode(self, encoding): + output = ['%s=%s' % (self.key, _quote(self.value))] + for key, value in self.items(): + if key == 'max-age': + try: + output.append('%s=%d' % (self._keys[key], value)) + except TypeError: + output.append('%s=%s' % (self._keys[key], value)) + elif key == 'expires': + try: + output.append('%s=%s' % ( + self._keys[key], + value.strftime("%a, %d-%b-%Y %T GMT") + )) + except AttributeError: + output.append('%s=%s' % (self._keys[key], value)) + elif key in self._flags and self[key]: + output.append(self._keys[key]) + else: + output.append('%s=%s' % (self._keys[key], value)) + + return "; ".join(output).encode(encoding) diff --git a/sanic/exceptions.py b/sanic/exceptions.py index 3ed5ab25..25dbd47d 100644 --- a/sanic/exceptions.py +++ b/sanic/exceptions.py @@ -1,56 +1,288 @@ -from .response import text -from traceback import format_exc +from sanic.http import STATUS_CODES + +TRACEBACK_STYLE = ''' + +''' + +TRACEBACK_WRAPPER_HTML = ''' + + + {style} + + + {inner_html} +
+

+ {exc_name}: {exc_value} + while handling path {path} +

+
+ + +''' + +TRACEBACK_WRAPPER_INNER_HTML = ''' +

{exc_name}

+

{exc_value}

+
+

Traceback (most recent call last):

+ {frame_html} +
+''' + +TRACEBACK_BORDER = ''' +
+ + The above exception was the direct cause of the + following exception: + +
+''' + +TRACEBACK_LINE_HTML = ''' +
+

+ File {0.filename}, line {0.lineno}, + in {0.name} +

+

{0.line}

+
+''' + +INTERNAL_SERVER_ERROR_HTML = ''' +

Internal Server Error

+

+ The server encountered an internal error and cannot complete + your request. +

+''' + + +_sanic_exceptions = {} + + +def add_status_code(code): + """ + Decorator used for adding exceptions to _sanic_exceptions. + """ + def class_decorator(cls): + cls.status_code = code + _sanic_exceptions[code] = cls + return cls + return class_decorator class SanicException(Exception): + def __init__(self, message, status_code=None): super().__init__(message) + if status_code is not None: self.status_code = status_code +@add_status_code(404) class NotFound(SanicException): - status_code = 404 + pass +@add_status_code(400) class InvalidUsage(SanicException): - status_code = 400 + pass +@add_status_code(405) +class MethodNotSupported(SanicException): + def __init__(self, message, method, allowed_methods): + super().__init__(message) + self.headers = dict() + self.headers["Allow"] = ", ".join(allowed_methods) + if method in ['HEAD', 'PATCH', 'PUT', 'DELETE']: + self.headers['Content-Length'] = 0 + + +@add_status_code(500) class ServerError(SanicException): - status_code = 500 + pass -class Handler: - handlers = None +@add_status_code(503) +class ServiceUnavailable(SanicException): + """The server is currently unavailable (because it is overloaded or + down for maintenance). Generally, this is a temporary state.""" + pass - def __init__(self, sanic): - self.handlers = {} - self.sanic = sanic - def add(self, exception, handler): - self.handlers[exception] = handler +class URLBuildError(ServerError): + pass - def response(self, request, exception): - """ - Fetches and executes an exception handler and returns a response object - :param request: Request - :param exception: Exception to handle - :return: Response object - """ - handler = self.handlers.get(type(exception), self.default) - response = handler(request=request, exception=exception) - return response - def default(self, request, exception): - if issubclass(type(exception), SanicException): - return text( - "Error: {}".format(exception), - status=getattr(exception, 'status_code', 500)) - elif self.sanic.debug: - return text( - "Error: {}\nException: {}".format( - exception, format_exc()), status=500) - else: - return text( - "An error occurred while generating the request", status=500) +class FileNotFound(NotFound): + def __init__(self, message, path, relative_url): + super().__init__(message) + self.path = path + self.relative_url = relative_url + + +@add_status_code(408) +class RequestTimeout(SanicException): + """The Web server (running the Web site) thinks that there has been too + long an interval of time between 1) the establishment of an IP + connection (socket) between the client and the server and + 2) the receipt of any data on that socket, so the server has dropped + the connection. The socket connection has actually been lost - the Web + server has 'timed out' on that particular socket connection. + """ + pass + + +@add_status_code(413) +class PayloadTooLarge(SanicException): + pass + + +class HeaderNotFound(InvalidUsage): + pass + + +@add_status_code(416) +class ContentRangeError(SanicException): + def __init__(self, message, content_range): + super().__init__(message) + self.headers = { + 'Content-Type': 'text/plain', + "Content-Range": "bytes */%s" % (content_range.total,) + } + + +@add_status_code(403) +class Forbidden(SanicException): + pass + + +class InvalidRangeType(ContentRangeError): + pass + + +@add_status_code(401) +class Unauthorized(SanicException): + """ + Unauthorized exception (401 HTTP status code). + + :param message: Message describing the exception. + :param status_code: HTTP Status code. + :param scheme: Name of the authentication scheme to be used. + + When present, kwargs is used to complete the WWW-Authentication header. + + Examples:: + + # With a Basic auth-scheme, realm MUST be present: + raise Unauthorized("Auth required.", + scheme="Basic", + realm="Restricted Area") + + # With a Digest auth-scheme, things are a bit more complicated: + raise Unauthorized("Auth required.", + scheme="Digest", + realm="Restricted Area", + qop="auth, auth-int", + algorithm="MD5", + nonce="abcdef", + opaque="zyxwvu") + + # With a Bearer auth-scheme, realm is optional so you can write: + raise Unauthorized("Auth required.", scheme="Bearer") + + # or, if you want to specify the realm: + raise Unauthorized("Auth required.", + scheme="Bearer", + realm="Restricted Area") + """ + def __init__(self, message, status_code=None, scheme=None, **kwargs): + super().__init__(message, status_code) + + # if auth-scheme is specified, set "WWW-Authenticate" header + if scheme is not None: + values = ['{!s}="{!s}"'.format(k, v) for k, v in kwargs.items()] + challenge = ', '.join(values) + + self.headers = { + "WWW-Authenticate": "{} {}".format(scheme, challenge).rstrip() + } + + +def abort(status_code, message=None): + """ + Raise an exception based on SanicException. Returns the HTTP response + message appropriate for the given status code, unless provided. + + :param status_code: The HTTP status code to return. + :param message: The HTTP response body. Defaults to the messages + in response.py for the given status code. + """ + if message is None: + message = STATUS_CODES.get(status_code) + # These are stored as bytes in the STATUS_CODES dict + message = message.decode('utf8') + sanic_exception = _sanic_exceptions.get(status_code, SanicException) + raise sanic_exception(message=message, status_code=status_code) diff --git a/sanic/handlers.py b/sanic/handlers.py new file mode 100644 index 00000000..81dd38d7 --- /dev/null +++ b/sanic/handlers.py @@ -0,0 +1,171 @@ +import sys +from traceback import format_exc, extract_tb + +from sanic.exceptions import ( + ContentRangeError, + HeaderNotFound, + INTERNAL_SERVER_ERROR_HTML, + InvalidRangeType, + SanicException, + TRACEBACK_LINE_HTML, + TRACEBACK_STYLE, + TRACEBACK_WRAPPER_HTML, + TRACEBACK_WRAPPER_INNER_HTML, + TRACEBACK_BORDER) +from sanic.log import logger +from sanic.response import text, html + + +class ErrorHandler: + handlers = None + cached_handlers = None + _missing = object() + + def __init__(self): + self.handlers = [] + self.cached_handlers = {} + self.debug = False + + def _render_exception(self, exception): + frames = extract_tb(exception.__traceback__) + + frame_html = [] + for frame in frames: + frame_html.append(TRACEBACK_LINE_HTML.format(frame)) + + return TRACEBACK_WRAPPER_INNER_HTML.format( + exc_name=exception.__class__.__name__, + exc_value=exception, + frame_html=''.join(frame_html)) + + def _render_traceback_html(self, exception, request): + exc_type, exc_value, tb = sys.exc_info() + exceptions = [] + + while exc_value: + exceptions.append(self._render_exception(exc_value)) + exc_value = exc_value.__cause__ + + return TRACEBACK_WRAPPER_HTML.format( + style=TRACEBACK_STYLE, + exc_name=exception.__class__.__name__, + exc_value=exception, + inner_html=TRACEBACK_BORDER.join(reversed(exceptions)), + path=request.path) + + def add(self, exception, handler): + self.handlers.append((exception, handler)) + + def lookup(self, exception): + handler = self.cached_handlers.get(exception, self._missing) + if handler is self._missing: + for exception_class, handler in self.handlers: + if isinstance(exception, exception_class): + self.cached_handlers[type(exception)] = handler + return handler + self.cached_handlers[type(exception)] = None + handler = None + return handler + + def response(self, request, exception): + """Fetches and executes an exception handler and returns a response + object + + :param request: Request + :param exception: Exception to handle + :return: Response object + """ + handler = self.lookup(exception) + response = None + try: + if handler: + response = handler(request, exception) + if response is None: + response = self.default(request, exception) + except Exception: + self.log(format_exc()) + if self.debug: + url = getattr(request, 'url', 'unknown') + response_message = ('Exception raised in exception handler ' + '"%s" for uri: "%s"\n%s') + logger.error(response_message, + handler.__name__, url, format_exc()) + + return text(response_message % ( + handler.__name__, url, format_exc()), 500) + else: + return text('An error occurred while handling an error', 500) + return response + + def log(self, message, level='error'): + """ + Override this method in an ErrorHandler subclass to prevent + logging exceptions. + """ + getattr(logger, level)(message) + + def default(self, request, exception): + self.log(format_exc()) + if issubclass(type(exception), SanicException): + return text( + 'Error: {}'.format(exception), + status=getattr(exception, 'status_code', 500), + headers=getattr(exception, 'headers', dict()) + ) + elif self.debug: + html_output = self._render_traceback_html(exception, request) + + response_message = ('Exception occurred while handling uri: ' + '"%s"\n%s') + logger.error(response_message, request.url, format_exc()) + return html(html_output, status=500) + else: + return html(INTERNAL_SERVER_ERROR_HTML, status=500) + + +class ContentRangeHandler: + """Class responsible for parsing request header""" + __slots__ = ('start', 'end', 'size', 'total', 'headers') + + def __init__(self, request, stats): + self.total = stats.st_size + _range = request.headers.get('Range') + if _range is None: + raise HeaderNotFound('Range Header Not Found') + unit, _, value = tuple(map(str.strip, _range.partition('='))) + if unit != 'bytes': + raise InvalidRangeType( + '%s is not a valid Range Type' % (unit,), self) + start_b, _, end_b = tuple(map(str.strip, value.partition('-'))) + try: + self.start = int(start_b) if start_b else None + except ValueError: + raise ContentRangeError( + '\'%s\' is invalid for Content Range' % (start_b,), self) + try: + self.end = int(end_b) if end_b else None + except ValueError: + raise ContentRangeError( + '\'%s\' is invalid for Content Range' % (end_b,), self) + if self.end is None: + if self.start is None: + raise ContentRangeError( + 'Invalid for Content Range parameters', self) + else: + # this case represents `Content-Range: bytes 5-` + self.end = self.total + else: + if self.start is None: + # this case represents `Content-Range: bytes -5` + self.start = self.total - self.end + self.end = self.total + if self.start >= self.end: + raise ContentRangeError( + 'Invalid for Content Range parameters', self) + self.size = self.end - self.start + self.headers = { + 'Content-Range': "bytes %s-%s/%s" % ( + self.start, self.end, self.total)} + + def __bool__(self): + return self.size > 0 diff --git a/sanic/http.py b/sanic/http.py new file mode 100644 index 00000000..482253b3 --- /dev/null +++ b/sanic/http.py @@ -0,0 +1,128 @@ +"""Defines basics of HTTP standard.""" + +STATUS_CODES = { + 100: b'Continue', + 101: b'Switching Protocols', + 102: b'Processing', + 200: b'OK', + 201: b'Created', + 202: b'Accepted', + 203: b'Non-Authoritative Information', + 204: b'No Content', + 205: b'Reset Content', + 206: b'Partial Content', + 207: b'Multi-Status', + 208: b'Already Reported', + 226: b'IM Used', + 300: b'Multiple Choices', + 301: b'Moved Permanently', + 302: b'Found', + 303: b'See Other', + 304: b'Not Modified', + 305: b'Use Proxy', + 307: b'Temporary Redirect', + 308: b'Permanent Redirect', + 400: b'Bad Request', + 401: b'Unauthorized', + 402: b'Payment Required', + 403: b'Forbidden', + 404: b'Not Found', + 405: b'Method Not Allowed', + 406: b'Not Acceptable', + 407: b'Proxy Authentication Required', + 408: b'Request Timeout', + 409: b'Conflict', + 410: b'Gone', + 411: b'Length Required', + 412: b'Precondition Failed', + 413: b'Request Entity Too Large', + 414: b'Request-URI Too Long', + 415: b'Unsupported Media Type', + 416: b'Requested Range Not Satisfiable', + 417: b'Expectation Failed', + 418: b'I\'m a teapot', + 422: b'Unprocessable Entity', + 423: b'Locked', + 424: b'Failed Dependency', + 426: b'Upgrade Required', + 428: b'Precondition Required', + 429: b'Too Many Requests', + 431: b'Request Header Fields Too Large', + 451: b'Unavailable For Legal Reasons', + 500: b'Internal Server Error', + 501: b'Not Implemented', + 502: b'Bad Gateway', + 503: b'Service Unavailable', + 504: b'Gateway Timeout', + 505: b'HTTP Version Not Supported', + 506: b'Variant Also Negotiates', + 507: b'Insufficient Storage', + 508: b'Loop Detected', + 510: b'Not Extended', + 511: b'Network Authentication Required' +} + +# According to https://tools.ietf.org/html/rfc2616#section-7.1 +_ENTITY_HEADERS = frozenset([ + 'allow', + 'content-encoding', + 'content-language', + 'content-length', + 'content-location', + 'content-md5', + 'content-range', + 'content-type', + 'expires', + 'last-modified', + 'extension-header' +]) + +# According to https://tools.ietf.org/html/rfc2616#section-13.5.1 +_HOP_BY_HOP_HEADERS = frozenset([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailers', + 'transfer-encoding', + 'upgrade' +]) + + +def has_message_body(status): + """ + According to the following RFC message body and length SHOULD NOT + be included in responses status 1XX, 204 and 304. + https://tools.ietf.org/html/rfc2616#section-4.4 + https://tools.ietf.org/html/rfc2616#section-4.3 + """ + return status not in (204, 304) and not (100 <= status < 200) + + +def is_entity_header(header): + """Checks if the given header is an Entity Header""" + return header.lower() in _ENTITY_HEADERS + + +def is_hop_by_hop_header(header): + """Checks if the given header is a Hop By Hop header""" + return header.lower() in _HOP_BY_HOP_HEADERS + + +def remove_entity_headers(headers, + allowed=('content-location', 'expires')): + """ + Removes all the entity headers present in the headers given. + According to RFC 2616 Section 10.3.5, + Content-Location and Expires are allowed as for the + "strong cache validator". + https://tools.ietf.org/html/rfc2616#section-10.3.5 + + returns the headers without the entity headers + """ + allowed = set([h.lower() for h in allowed]) + headers = {header: value for header, value in headers.items() + if not is_entity_header(header) + and header.lower() not in allowed} + return headers diff --git a/sanic/log.py b/sanic/log.py index bd2e499e..9c6d868d 100644 --- a/sanic/log.py +++ b/sanic/log.py @@ -1,5 +1,63 @@ import logging +import sys -logging.basicConfig( - level=logging.INFO, format="%(asctime)s: %(levelname)s: %(message)s") -log = logging.getLogger(__name__) + +LOGGING_CONFIG_DEFAULTS = dict( + version=1, + disable_existing_loggers=False, + + loggers={ + "root": { + "level": "INFO", + "handlers": ["console"] + }, + "sanic.error": { + "level": "INFO", + "handlers": ["error_console"], + "propagate": True, + "qualname": "sanic.error" + }, + + "sanic.access": { + "level": "INFO", + "handlers": ["access_console"], + "propagate": True, + "qualname": "sanic.access" + } + }, + handlers={ + "console": { + "class": "logging.StreamHandler", + "formatter": "generic", + "stream": sys.stdout + }, + "error_console": { + "class": "logging.StreamHandler", + "formatter": "generic", + "stream": sys.stderr + }, + "access_console": { + "class": "logging.StreamHandler", + "formatter": "access", + "stream": sys.stdout + }, + }, + formatters={ + "generic": { + "format": "%(asctime)s [%(process)d] [%(levelname)s] %(message)s", + "datefmt": "[%Y-%m-%d %H:%M:%S %z]", + "class": "logging.Formatter" + }, + "access": { + "format": "%(asctime)s - (%(name)s)[%(levelname)s][%(host)s]: " + + "%(request)s %(message)s %(status)d %(byte)d", + "datefmt": "[%Y-%m-%d %H:%M:%S %z]", + "class": "logging.Formatter" + }, + } +) + + +logger = logging.getLogger('root') +error_logger = logging.getLogger('sanic.error') +access_logger = logging.getLogger('sanic.access') diff --git a/sanic/reloader_helpers.py b/sanic/reloader_helpers.py new file mode 100644 index 00000000..73759124 --- /dev/null +++ b/sanic/reloader_helpers.py @@ -0,0 +1,144 @@ +import os +import sys +import signal +import subprocess +from time import sleep +from multiprocessing import Process + + +def _iter_module_files(): + """This iterates over all relevant Python files. + + It goes through all + loaded files from modules, all files in folders of already loaded modules + as well as all files reachable through a package. + """ + # The list call is necessary on Python 3 in case the module + # dictionary modifies during iteration. + for module in list(sys.modules.values()): + if module is None: + continue + filename = getattr(module, '__file__', None) + if filename: + old = None + while not os.path.isfile(filename): + old = filename + filename = os.path.dirname(filename) + if filename == old: + break + else: + if filename[-4:] in ('.pyc', '.pyo'): + filename = filename[:-1] + yield filename + + +def _get_args_for_reloading(): + """Returns the executable.""" + rv = [sys.executable] + rv.extend(sys.argv) + return rv + + +def restart_with_reloader(): + """Create a new process and a subprocess in it with the same arguments as + this one. + """ + args = _get_args_for_reloading() + new_environ = os.environ.copy() + new_environ['SANIC_SERVER_RUNNING'] = 'true' + cmd = ' '.join(args) + worker_process = Process( + target=subprocess.call, args=(cmd,), + kwargs=dict(shell=True, env=new_environ)) + worker_process.start() + return worker_process + + +def kill_process_children_unix(pid): + """Find and kill child processes of a process (maximum two level). + + :param pid: PID of parent process (process ID) + :return: Nothing + """ + root_process_path = "/proc/{pid}/task/{pid}/children".format(pid=pid) + if not os.path.isfile(root_process_path): + return + with open(root_process_path) as children_list_file: + children_list_pid = children_list_file.read().split() + + for child_pid in children_list_pid: + children_proc_path = "/proc/%s/task/%s/children" % \ + (child_pid, child_pid) + if not os.path.isfile(children_proc_path): + continue + with open(children_proc_path) as children_list_file_2: + children_list_pid_2 = children_list_file_2.read().split() + for _pid in children_list_pid_2: + os.kill(int(_pid), signal.SIGTERM) + + +def kill_process_children_osx(pid): + """Find and kill child processes of a process. + + :param pid: PID of parent process (process ID) + :return: Nothing + """ + subprocess.run(['pkill', '-P', str(pid)]) + + +def kill_process_children(pid): + """Find and kill child processes of a process. + + :param pid: PID of parent process (process ID) + :return: Nothing + """ + if sys.platform == 'darwin': + kill_process_children_osx(pid) + elif sys.platform == 'posix': + kill_process_children_unix(pid) + else: + pass # should signal error here + + +def kill_program_completly(proc): + """Kill worker and it's child processes and exit. + + :param proc: worker process (process ID) + :return: Nothing + """ + kill_process_children(proc.pid) + proc.terminate() + os._exit(0) + + +def watchdog(sleep_interval): + """Watch project files, restart worker process if a change happened. + + :param sleep_interval: interval in second. + :return: Nothing + """ + mtimes = {} + worker_process = restart_with_reloader() + signal.signal( + signal.SIGTERM, lambda *args: kill_program_completly(worker_process)) + signal.signal( + signal.SIGINT, lambda *args: kill_program_completly(worker_process)) + while True: + for filename in _iter_module_files(): + try: + mtime = os.stat(filename).st_mtime + except OSError: + continue + + old_time = mtimes.get(filename) + if old_time is None: + mtimes[filename] = mtime + continue + elif mtime > old_time: + kill_process_children(worker_process.pid) + worker_process = restart_with_reloader() + + mtimes[filename] = mtime + break + + sleep(sleep_interval) diff --git a/sanic/request.py b/sanic/request.py index 31b73ed8..c8b470d4 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -1,79 +1,132 @@ +import sys +import json +import socket from cgi import parse_header from collections import namedtuple +from http.cookies import SimpleCookie from httptools import parse_url -from urllib.parse import parse_qs -from ujson import loads as json_loads +from urllib.parse import parse_qs, urlunparse -from .log import log +try: + from ujson import loads as json_loads +except ImportError: + if sys.version_info[:2] == (3, 5): + def json_loads(data): + # on Python 3.5 json.loads only supports str not bytes + return json.loads(data.decode()) + else: + json_loads = json.loads + +from sanic.exceptions import InvalidUsage +from sanic.log import error_logger, logger + +DEFAULT_HTTP_CONTENT_TYPE = "application/octet-stream" + + +# HTTP/1.1: https://www.w3.org/Protocols/rfc2616/rfc2616-sec7.html#sec7.2.1 +# > If the media type remains unknown, the recipient SHOULD treat it +# > as type "application/octet-stream" class RequestParameters(dict): - """ - Hosts a dict with lists as values where get returns the first + """Hosts a dict with lists as values where get returns the first value of the list and getlist returns the whole shebang """ - def __init__(self, *args, **kwargs): - self.super = super() - self.super.__init__(*args, **kwargs) - def get(self, name, default=None): - values = self.super.get(name) - return values[0] if values else default + """Return the first value, either the default or actual""" + return super().get(name, [default])[0] def getlist(self, name, default=None): - return self.super.get(name, default) + """Return the entire list""" + return super().get(name, default) -class Request: - """ - Properties of an HTTP request such as URL, headers, etc. - """ +class Request(dict): + """Properties of an HTTP request such as URL, headers, etc.""" __slots__ = ( - 'url', 'headers', 'version', 'method', - 'query_string', 'body', - 'parsed_json', 'parsed_args', 'parsed_form', 'parsed_files', + 'app', 'headers', 'version', 'method', '_cookies', 'transport', + 'body', 'parsed_json', 'parsed_args', 'parsed_form', 'parsed_files', + '_ip', '_parsed_url', 'uri_template', 'stream', '_remote_addr', + '_socket', '_port', '__weakref__', 'raw_url' ) - def __init__(self, url_bytes, headers, version, method): + def __init__(self, url_bytes, headers, version, method, transport): + self.raw_url = url_bytes # TODO: Content-Encoding detection - url_parsed = parse_url(url_bytes) - self.url = url_parsed.path.decode('utf-8') + self._parsed_url = parse_url(url_bytes) + self.app = None + self.headers = headers self.version = version self.method = method - self.query_string = None - if url_parsed.query: - self.query_string = url_parsed.query.decode('utf-8') + self.transport = transport # Init but do not inhale - self.body = None + self.body = [] self.parsed_json = None self.parsed_form = None self.parsed_files = None self.parsed_args = None + self.uri_template = None + self._cookies = None + self.stream = None + + def __repr__(self): + if self.method is None or not self.path: + return '<{0}>'.format(self.__class__.__name__) + return '<{0}: {1} {2}>'.format(self.__class__.__name__, + self.method, + self.path) + + def __bool__(self): + if self.transport: + return True + return False @property def json(self): - if not self.parsed_json: - try: - self.parsed_json = json_loads(self.body) - except Exception: - pass + if self.parsed_json is None: + self.load_json() + + return self.parsed_json + + def load_json(self, loads=json_loads): + try: + self.parsed_json = loads(self.body) + except Exception: + if not self.body: + return None + raise InvalidUsage("Failed when parsing body as json") return self.parsed_json + @property + def token(self): + """Attempt to return the auth header token. + + :return: token related to request + """ + prefixes = ('Bearer', 'Token') + auth_header = self.headers.get('Authorization') + + if auth_header is not None: + for prefix in prefixes: + if prefix in auth_header: + return auth_header.partition(prefix)[-1].strip() + + return auth_header + @property def form(self): if self.parsed_form is None: - self.parsed_form = {} - self.parsed_files = {} - content_type, parameters = parse_header( - self.headers.get('Content-Type')) + self.parsed_form = RequestParameters() + self.parsed_files = RequestParameters() + content_type = self.headers.get( + 'Content-Type', DEFAULT_HTTP_CONTENT_TYPE) + content_type, parameters = parse_header(content_type) try: - is_url_encoded = ( - content_type == 'application/x-www-form-urlencoded') - if content_type is None or is_url_encoded: + if content_type == 'application/x-www-form-urlencoded': self.parsed_form = RequestParameters( parse_qs(self.body.decode('utf-8'))) elif content_type == 'multipart/form-data': @@ -81,9 +134,8 @@ class Request: boundary = parameters['boundary'].encode('utf-8') self.parsed_form, self.parsed_files = ( parse_multipart_form(self.body, boundary)) - except Exception as e: - log.exception(e) - pass + except Exception: + error_logger.exception("Failed when parsing form") return self.parsed_form @@ -101,28 +153,145 @@ class Request: self.parsed_args = RequestParameters( parse_qs(self.query_string)) else: - self.parsed_args = {} - + self.parsed_args = RequestParameters() return self.parsed_args + @property + def raw_args(self): + return {k: v[0] for k, v in self.args.items()} + + @property + def cookies(self): + if self._cookies is None: + cookie = self.headers.get('Cookie') + if cookie is not None: + cookies = SimpleCookie() + cookies.load(cookie) + self._cookies = {name: cookie.value + for name, cookie in cookies.items()} + else: + self._cookies = {} + return self._cookies + + @property + def ip(self): + if not hasattr(self, '_socket'): + self._get_address() + return self._ip + + @property + def port(self): + if not hasattr(self, '_socket'): + self._get_address() + return self._port + + @property + def socket(self): + if not hasattr(self, '_socket'): + self._get_address() + return self._socket + + def _get_address(self): + sock = self.transport.get_extra_info('socket') + + if sock.family == socket.AF_INET: + self._socket = (self.transport.get_extra_info('peername') or + (None, None)) + self._ip, self._port = self._socket + elif sock.family == socket.AF_INET6: + self._socket = (self.transport.get_extra_info('peername') or + (None, None, None, None)) + self._ip, self._port, *_ = self._socket + else: + self._ip, self._port = (None, None) + + @property + def remote_addr(self): + """Attempt to return the original client ip based on X-Forwarded-For. + + :return: original client ip. + """ + if not hasattr(self, '_remote_addr'): + forwarded_for = self.headers.get('X-Forwarded-For', '').split(',') + remote_addrs = [ + addr for addr in [ + addr.strip() for addr in forwarded_for + ] if addr + ] + if len(remote_addrs) > 0: + self._remote_addr = remote_addrs[0] + else: + self._remote_addr = '' + return self._remote_addr + + @property + def scheme(self): + if self.app.websocket_enabled \ + and self.headers.get('upgrade') == 'websocket': + scheme = 'ws' + else: + scheme = 'http' + + if self.transport.get_extra_info('sslcontext'): + scheme += 's' + + return scheme + + @property + def host(self): + # it appears that httptools doesn't return the host + # so pull it from the headers + return self.headers.get('Host', '') + + @property + def content_type(self): + return self.headers.get('Content-Type', DEFAULT_HTTP_CONTENT_TYPE) + + @property + def match_info(self): + """return matched info after resolving route""" + return self.app.router.get(self)[2] + + @property + def path(self): + return self._parsed_url.path.decode('utf-8') + + @property + def query_string(self): + if self._parsed_url.query: + return self._parsed_url.query.decode('utf-8') + else: + return '' + + @property + def url(self): + return urlunparse(( + self.scheme, + self.host, + self.path, + None, + self.query_string, + None)) + File = namedtuple('File', ['type', 'body', 'name']) def parse_multipart_form(body, boundary): + """Parse a request body and returns fields and files + + :param body: bytes request body + :param boundary: bytes multipart boundary + :return: fields (RequestParameters), files (RequestParameters) """ - Parses a request body and returns fields and files - :param body: Bytes request body - :param boundary: Bytes multipart boundary - :return: fields (dict), files (dict) - """ - files = {} - fields = {} + files = RequestParameters() + fields = RequestParameters() form_parts = body.split(boundary) for form_part in form_parts[1:-1]: file_name = None - file_type = None + content_type = 'text/plain' + content_charset = 'utf-8' field_name = None line_index = 2 line_end_index = 0 @@ -135,22 +304,35 @@ def parse_multipart_form(body, boundary): break colon_index = form_line.index(':') - form_header_field = form_line[0:colon_index] + form_header_field = form_line[0:colon_index].lower() form_header_value, form_parameters = parse_header( form_line[colon_index + 2:]) - if form_header_field == 'Content-Disposition': - if 'filename' in form_parameters: - file_name = form_parameters['filename'] + if form_header_field == 'content-disposition': + file_name = form_parameters.get('filename') field_name = form_parameters.get('name') - elif form_header_field == 'Content-Type': - file_type = form_header_value + elif form_header_field == 'content-type': + content_type = form_header_value + content_charset = form_parameters.get('charset', 'utf-8') - post_data = form_part[line_index:-4] - if file_name or file_type: - files[field_name] = File( - type=file_type, name=file_name, body=post_data) + if field_name: + post_data = form_part[line_index:-4] + if file_name: + form_file = File(type=content_type, + name=file_name, + body=post_data) + if field_name in files: + files[field_name].append(form_file) + else: + files[field_name] = [form_file] + else: + value = post_data.decode(content_charset) + if field_name in fields: + fields[field_name].append(value) + else: + fields[field_name] = [value] else: - fields[field_name] = post_data.decode('utf-8') + logger.debug('Form-data field does not have a \'name\' parameter \ + in the Content-Disposition header') return fields, files diff --git a/sanic/response.py b/sanic/response.py index 2bf9b167..2d2f5b96 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -1,77 +1,372 @@ -import ujson +from mimetypes import guess_type +from os import path +from urllib.parse import quote_plus -STATUS_CODES = { - 200: b'OK', - 400: b'Bad Request', - 401: b'Unauthorized', - 402: b'Payment Required', - 403: b'Forbidden', - 404: b'Not Found', - 405: b'Method Not Allowed', - 500: b'Internal Server Error', - 501: b'Not Implemented', - 502: b'Bad Gateway', - 503: b'Service Unavailable', - 504: b'Gateway Timeout', -} +try: + from ujson import dumps as json_dumps +except BaseException: + from json import dumps as json_dumps + +from aiofiles import open as open_async +from multidict import CIMultiDict + +from sanic import http +from sanic.cookies import CookieJar -class HTTPResponse: - __slots__ = ('body', 'status', 'content_type', 'headers') +class BaseHTTPResponse: + def _encode_body(self, data): + try: + # Try to encode it regularly + return data.encode() + except AttributeError: + # Convert it to a str if you can't + return str(data).encode() + + def _parse_headers(self): + headers = b'' + for name, value in self.headers.items(): + try: + headers += ( + b'%b: %b\r\n' % ( + name.encode(), value.encode('utf-8'))) + except AttributeError: + headers += ( + b'%b: %b\r\n' % ( + str(name).encode(), str(value).encode('utf-8'))) + + return headers + + @property + def cookies(self): + if self._cookies is None: + self._cookies = CookieJar(self.headers) + return self._cookies + + +class StreamingHTTPResponse(BaseHTTPResponse): + __slots__ = ( + 'transport', 'streaming_fn', 'status', + 'content_type', 'headers', '_cookies' + ) + + def __init__(self, streaming_fn, status=200, headers=None, + content_type='text/plain'): + self.content_type = content_type + self.streaming_fn = streaming_fn + self.status = status + self.headers = CIMultiDict(headers or {}) + self._cookies = None + + def write(self, data): + """Writes a chunk of data to the streaming response. + + :param data: bytes-ish data to be written. + """ + if type(data) != bytes: + data = self._encode_body(data) + + self.transport.write( + b"%x\r\n%b\r\n" % (len(data), data)) + + async def stream( + self, version="1.1", keep_alive=False, keep_alive_timeout=None): + """Streams headers, runs the `streaming_fn` callback that writes + content to the response body, then finalizes the response body. + """ + headers = self.get_headers( + version, keep_alive=keep_alive, + keep_alive_timeout=keep_alive_timeout) + self.transport.write(headers) + + await self.streaming_fn(self) + self.transport.write(b'0\r\n\r\n') + + def get_headers( + self, version="1.1", keep_alive=False, keep_alive_timeout=None): + # This is all returned in a kind-of funky way + # We tried to make this as fast as possible in pure python + timeout_header = b'' + if keep_alive and keep_alive_timeout is not None: + timeout_header = b'Keep-Alive: %d\r\n' % keep_alive_timeout + + self.headers['Transfer-Encoding'] = 'chunked' + self.headers.pop('Content-Length', None) + self.headers['Content-Type'] = self.headers.get( + 'Content-Type', self.content_type) + + headers = self._parse_headers() + + if self.status is 200: + status = b'OK' + else: + status = http.STATUS_CODES.get(self.status) + + return (b'HTTP/%b %d %b\r\n' + b'%b' + b'%b\r\n') % ( + version.encode(), + self.status, + status, + timeout_header, + headers + ) + + +class HTTPResponse(BaseHTTPResponse): + __slots__ = ('body', 'status', 'content_type', 'headers', '_cookies') def __init__(self, body=None, status=200, headers=None, content_type='text/plain', body_bytes=b''): self.content_type = content_type if body is not None: - self.body = body.encode('utf-8') + self.body = self._encode_body(body) else: self.body = body_bytes self.status = status - self.headers = headers or {} + self.headers = CIMultiDict(headers or {}) + self._cookies = None - def output(self, version="1.1", keep_alive=False, keep_alive_timeout=None): + def output( + self, version="1.1", keep_alive=False, keep_alive_timeout=None): # This is all returned in a kind-of funky way # We tried to make this as fast as possible in pure python timeout_header = b'' - if keep_alive and keep_alive_timeout: - timeout_header = b'Keep-Alive: timeout=%d\r\n' % keep_alive_timeout + if keep_alive and keep_alive_timeout is not None: + timeout_header = b'Keep-Alive: %d\r\n' % keep_alive_timeout + + body = b'' + if http.has_message_body(self.status): + body = self.body + self.headers['Content-Length'] = self.headers.get( + 'Content-Length', len(self.body)) + + self.headers['Content-Type'] = self.headers.get( + 'Content-Type', self.content_type) + + if self.status in (304, 412): + self.headers = http.remove_entity_headers(self.headers) + + headers = self._parse_headers() + + if self.status is 200: + status = b'OK' + else: + status = http.STATUS_CODES.get(self.status, b'UNKNOWN RESPONSE') - headers = b'' - if self.headers: - headers = b''.join( - b'%b: %b\r\n' % (name.encode(), value.encode('utf-8')) - for name, value in self.headers.items() - ) return (b'HTTP/%b %d %b\r\n' - b'Content-Type: %b\r\n' - b'Content-Length: %d\r\n' b'Connection: %b\r\n' - b'%b%b\r\n' + b'%b' + b'%b\r\n' b'%b') % ( - version.encode(), - self.status, - STATUS_CODES.get(self.status, b'FAIL'), - self.content_type.encode(), - len(self.body), - b'keep-alive' if keep_alive else b'close', - timeout_header, - headers, - self.body - ) + version.encode(), + self.status, + status, + b'keep-alive' if keep_alive else b'close', + timeout_header, + headers, + body + ) + + @property + def cookies(self): + if self._cookies is None: + self._cookies = CookieJar(self.headers) + return self._cookies -def json(body, status=200, headers=None): - return HTTPResponse(ujson.dumps(body), headers=headers, status=status, - content_type="application/json; charset=utf-8") +def json(body, status=200, headers=None, + content_type="application/json", dumps=json_dumps, + **kwargs): + """ + Returns response object with body in json format. + + :param body: Response data to be serialized. + :param status: Response code. + :param headers: Custom Headers. + :param kwargs: Remaining arguments that are passed to the json encoder. + """ + return HTTPResponse(dumps(body, **kwargs), headers=headers, + status=status, content_type=content_type) -def text(body, status=200, headers=None): - return HTTPResponse(body, status=status, headers=headers, - content_type="text/plain; charset=utf-8") +def text(body, status=200, headers=None, + content_type="text/plain; charset=utf-8"): + """ + Returns response object with body in text format. + + :param body: Response data to be encoded. + :param status: Response code. + :param headers: Custom Headers. + :param content_type: the content type (string) of the response + """ + return HTTPResponse( + body, status=status, headers=headers, + content_type=content_type) + + +def raw(body, status=200, headers=None, + content_type="application/octet-stream"): + """ + Returns response object without encoding the body. + + :param body: Response data. + :param status: Response code. + :param headers: Custom Headers. + :param content_type: the content type (string) of the response. + """ + return HTTPResponse(body_bytes=body, status=status, headers=headers, + content_type=content_type) def html(body, status=200, headers=None): + """ + Returns response object with body in html format. + + :param body: Response data to be encoded. + :param status: Response code. + :param headers: Custom Headers. + """ return HTTPResponse(body, status=status, headers=headers, content_type="text/html; charset=utf-8") + + +async def file(location, status=200, mime_type=None, headers=None, + filename=None, _range=None): + """Return a response object with file data. + + :param location: Location of file on system. + :param mime_type: Specific mime_type. + :param headers: Custom Headers. + :param filename: Override filename. + :param _range: + """ + headers = headers or {} + if filename: + headers.setdefault( + 'Content-Disposition', + 'attachment; filename="{}"'.format(filename)) + filename = filename or path.split(location)[-1] + + async with open_async(location, mode='rb') as _file: + if _range: + await _file.seek(_range.start) + out_stream = await _file.read(_range.size) + headers['Content-Range'] = 'bytes %s-%s/%s' % ( + _range.start, _range.end, _range.total) + else: + out_stream = await _file.read() + + mime_type = mime_type or guess_type(filename)[0] or 'text/plain' + return HTTPResponse(status=status, + headers=headers, + content_type=mime_type, + body_bytes=out_stream) + + +async def file_stream(location, status=200, chunk_size=4096, mime_type=None, + headers=None, filename=None, _range=None): + """Return a streaming response object with file data. + + :param location: Location of file on system. + :param chunk_size: The size of each chunk in the stream (in bytes) + :param mime_type: Specific mime_type. + :param headers: Custom Headers. + :param filename: Override filename. + :param _range: + """ + headers = headers or {} + if filename: + headers.setdefault( + 'Content-Disposition', + 'attachment; filename="{}"'.format(filename)) + filename = filename or path.split(location)[-1] + + _file = await open_async(location, mode='rb') + + async def _streaming_fn(response): + nonlocal _file, chunk_size + try: + if _range: + chunk_size = min((_range.size, chunk_size)) + await _file.seek(_range.start) + to_send = _range.size + while to_send > 0: + content = await _file.read(chunk_size) + if len(content) < 1: + break + to_send -= len(content) + response.write(content) + else: + while True: + content = await _file.read(chunk_size) + if len(content) < 1: + break + response.write(content) + finally: + await _file.close() + return # Returning from this fn closes the stream + + mime_type = mime_type or guess_type(filename)[0] or 'text/plain' + if _range: + headers['Content-Range'] = 'bytes %s-%s/%s' % ( + _range.start, _range.end, _range.total) + return StreamingHTTPResponse(streaming_fn=_streaming_fn, + status=status, + headers=headers, + content_type=mime_type) + + +def stream( + streaming_fn, status=200, headers=None, + content_type="text/plain; charset=utf-8"): + """Accepts an coroutine `streaming_fn` which can be used to + write chunks to a streaming response. Returns a `StreamingHTTPResponse`. + + Example usage:: + + @app.route("/") + async def index(request): + async def streaming_fn(response): + await response.write('foo') + await response.write('bar') + + return stream(streaming_fn, content_type='text/plain') + + :param streaming_fn: A coroutine accepts a response and + writes content to that response. + :param mime_type: Specific mime_type. + :param headers: Custom Headers. + """ + return StreamingHTTPResponse( + streaming_fn, + headers=headers, + content_type=content_type, + status=status + ) + + +def redirect(to, headers=None, status=302, + content_type="text/html; charset=utf-8"): + """Abort execution and cause a 302 redirect (by default). + + :param to: path or fully qualified URL to redirect to + :param headers: optional dict of headers to include in the new request + :param status: status code (int) of the new request, defaults to 302 + :param content_type: the content type (string) of the response + :returns: the redirecting Response + """ + headers = headers or {} + + # URL Quote the URL before redirecting + safe_to = quote_plus(to, safe=":/#?&=@[]!$&'()*+,;") + + # According to RFC 7231, a relative URI is now permitted. + headers['Location'] = safe_to + + return HTTPResponse( + status=status, + headers=headers, + content_type=content_type) diff --git a/sanic/router.py b/sanic/router.py index e6c580d7..2864bf84 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -1,146 +1,441 @@ import re -from collections import namedtuple -from .exceptions import NotFound, InvalidUsage +import uuid +from collections import defaultdict, namedtuple +from collections.abc import Iterable +from functools import lru_cache +from urllib.parse import unquote -Route = namedtuple("Route", ['handler', 'methods', 'pattern', 'parameters']) -Parameter = namedtuple("Parameter", ['name', 'cast']) +from sanic.exceptions import NotFound, MethodNotSupported +from sanic.views import CompositionView + +Route = namedtuple( + 'Route', + ['handler', 'methods', 'pattern', 'parameters', 'name', 'uri']) +Parameter = namedtuple('Parameter', ['name', 'cast']) + +REGEX_TYPES = { + 'string': (str, r'[^/]+'), + 'int': (int, r'\d+'), + 'number': (float, r'[0-9\\.]+'), + 'alpha': (str, r'[A-Za-z]+'), + 'path': (str, r'[^/].*?'), + 'uuid': (uuid.UUID, r'[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-' + r'[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}') +} + +ROUTER_CACHE_SIZE = 1024 + + +def url_hash(url): + return url.count('/') + + +class RouteExists(Exception): + pass + + +class RouteDoesNotExist(Exception): + pass class Router: - """ - Router supports basic routing with parameters and method checks + """Router supports basic routing with parameters and method checks + Usage: - @sanic.route('/my/url/', methods=['GET', 'POST', ...]) - def my_route(request, my_parameter): + + .. code-block:: python + + @sanic.route('/my/url/', methods=['GET', 'POST', ...]) + def my_route(request, my_param): + do stuff... + + or + + .. code-block:: python + + @sanic.route('/my/url/', methods['GET', 'POST', ...]) + def my_route_with_type(request, my_param: my_type): do stuff... Parameters will be passed as keyword arguments to the request handling - function provided Parameters can also have a type by appending :type to - the . If no type is provided, a string is expected. A regular - expression can also be passed in as the type - - TODO: - This probably needs optimization for larger sets of routes, - since it checks every route until it finds a match which is bad and - I should feel bad + function. Provided parameters can also have a type by appending :type to + the . Given parameter must be able to be type-casted to this. + If no type is provided, a string is expected. A regular expression can + also be passed in as the type. The argument given to the function will + always be a string, independent of the type. """ - routes = None - regex_types = { - "string": (None, "[^/]+"), - "int": (int, "\d+"), - "number": (float, "[0-9\\.]+"), - "alpha": (None, "[A-Za-z]+"), - } + routes_static = None + routes_dynamic = None + routes_always_check = None + parameter_pattern = re.compile(r'<(.+?)>') def __init__(self): - self.routes = [] + self.routes_all = {} + self.routes_names = {} + self.routes_static_files = {} + self.routes_static = {} + self.routes_dynamic = defaultdict(list) + self.routes_always_check = [] + self.hosts = set() - def add(self, uri, methods, handler): + @classmethod + def parse_parameter_string(cls, parameter_string): + """Parse a parameter string into its constituent name, type, and + pattern + + For example:: + + parse_parameter_string('')` -> + ('param_one', str, '[A-z]') + + :param parameter_string: String to parse + :return: tuple containing + (parameter_name, parameter_type, parameter_pattern) """ - Adds a handler to the route list - :param uri: Path to match - :param methods: Array of accepted method names. - If none are provided, any method is allowed - :param handler: Request handler function. - When executed, it should provide a response object. + # We could receive NAME or NAME:PATTERN + name = parameter_string + pattern = 'string' + if ':' in parameter_string: + name, pattern = parameter_string.split(':', 1) + if not name: + raise ValueError( + "Invalid parameter syntax: {}".format(parameter_string) + ) + + default = (str, pattern) + # Pull from pre-configured types + _type, pattern = REGEX_TYPES.get(pattern, default) + + return name, _type, pattern + + def add(self, uri, methods, handler, host=None, strict_slashes=False, + version=None, name=None): + """Add a handler to the route list + + :param uri: path to match + :param methods: sequence of accepted method names. If none are + provided, any method is allowed + :param handler: request handler function. + When executed, it should provide a response object. + :param strict_slashes: strict to trailing slash + :param version: current version of the route or blueprint. See + docs for further details. :return: Nothing """ + if version is not None: + version = re.escape(str(version).strip('/').lstrip('v')) + uri = "/".join(["/v{}".format(version), uri.lstrip('/')]) + # add regular version + self._add(uri, methods, handler, host, name) + + if strict_slashes: + return + + if not isinstance(host, str) and host is not None: + # we have gotten back to the top of the recursion tree where the + # host was originally a list. By now, we've processed the strict + # slashes logic on the leaf nodes (the individual host strings in + # the list of host) + return + + # Add versions with and without trailing / + slashed_methods = self.routes_all.get(uri + '/', frozenset({})) + unslashed_methods = self.routes_all.get(uri[:-1], frozenset({})) + if isinstance(methods, Iterable): + _slash_is_missing = all(method in slashed_methods for + method in methods) + _without_slash_is_missing = all(method in unslashed_methods for + method in methods) + else: + _slash_is_missing = methods in slashed_methods + _without_slash_is_missing = methods in unslashed_methods + + slash_is_missing = ( + not uri[-1] == '/' and not _slash_is_missing + ) + without_slash_is_missing = ( + uri[-1] == '/' and not + _without_slash_is_missing and not + uri == '/' + ) + # add version with trailing slash + if slash_is_missing: + self._add(uri + '/', methods, handler, host, name) + # add version without trailing slash + elif without_slash_is_missing: + self._add(uri[:-1], methods, handler, host, name) + + def _add(self, uri, methods, handler, host=None, name=None): + """Add a handler to the route list + + :param uri: path to match + :param methods: sequence of accepted method names. If none are + provided, any method is allowed + :param handler: request handler function. + When executed, it should provide a response object. + :param name: user defined route name for url_for + :return: Nothing + """ + if host is not None: + if isinstance(host, str): + uri = host + uri + self.hosts.add(host) + + else: + if not isinstance(host, Iterable): + raise ValueError("Expected either string or Iterable of " + "host strings, not {!r}".format(host)) + + for host_ in host: + self.add(uri, methods, handler, host_, name) + return # Dict for faster lookups of if method allowed - methods_dict = None if methods: - methods_dict = {method: True for method in methods} + methods = frozenset(methods) parameters = [] + properties = {"unhashable": None} def add_parameter(match): - # We could receive NAME or NAME:PATTERN - parts = match.group(1).split(':') - if len(parts) == 2: - parameter_name, parameter_pattern = parts - else: - parameter_name = parts[0] - parameter_pattern = 'string' + name = match.group(1) + name, _type, pattern = self.parse_parameter_string(name) - # Pull from pre-configured types - parameter_regex = self.regex_types.get(parameter_pattern) - if parameter_regex: - parameter_type, parameter_pattern = parameter_regex - else: - parameter_type = None - - parameter = Parameter(name=parameter_name, cast=parameter_type) + parameter = Parameter( + name=name, cast=_type) parameters.append(parameter) - return "({})".format(parameter_pattern) + # Mark the whole route as unhashable if it has the hash key in it + if re.search(r'(^|[^^]){1}/', pattern): + properties['unhashable'] = True + # Mark the route as unhashable if it matches the hash key + elif re.search(r'/', pattern): + properties['unhashable'] = True - pattern_string = re.sub("<(.+?)>", add_parameter, uri) - pattern = re.compile("^{}$".format(pattern_string)) + return '({})'.format(pattern) - route = Route( - handler=handler, methods=methods_dict, pattern=pattern, - parameters=parameters) - self.routes.append(route) + pattern_string = re.sub(self.parameter_pattern, add_parameter, uri) + pattern = re.compile(r'^{}$'.format(pattern_string)) + + def merge_route(route, methods, handler): + # merge to the existing route when possible. + if not route.methods or not methods: + # method-unspecified routes are not mergeable. + raise RouteExists( + "Route already registered: {}".format(uri)) + elif route.methods.intersection(methods): + # already existing method is not overloadable. + duplicated = methods.intersection(route.methods) + raise RouteExists( + "Route already registered: {} [{}]".format( + uri, ','.join(list(duplicated)))) + if isinstance(route.handler, CompositionView): + view = route.handler + else: + view = CompositionView() + view.add(route.methods, route.handler) + view.add(methods, handler) + route = route._replace( + handler=view, methods=methods.union(route.methods)) + return route + + if parameters: + # TODO: This is too complex, we need to reduce the complexity + if properties['unhashable']: + routes_to_check = self.routes_always_check + ndx, route = self.check_dynamic_route_exists( + pattern, routes_to_check, parameters) + else: + routes_to_check = self.routes_dynamic[url_hash(uri)] + ndx, route = self.check_dynamic_route_exists( + pattern, routes_to_check, parameters) + if ndx != -1: + # Pop the ndx of the route, no dups of the same route + routes_to_check.pop(ndx) + else: + route = self.routes_all.get(uri) + + # prefix the handler name with the blueprint name + # if available + # special prefix for static files + is_static = False + if name and name.startswith('_static_'): + is_static = True + name = name.split('_static_', 1)[-1] + + if hasattr(handler, '__blueprintname__'): + handler_name = '{}.{}'.format( + handler.__blueprintname__, name or handler.__name__) + else: + handler_name = name or getattr(handler, '__name__', None) + + if route: + route = merge_route(route, methods, handler) + else: + route = Route( + handler=handler, methods=methods, pattern=pattern, + parameters=parameters, name=handler_name, uri=uri) + + self.routes_all[uri] = route + if is_static: + pair = self.routes_static_files.get(handler_name) + if not (pair and (pair[0] + '/' == uri or uri + '/' == pair[0])): + self.routes_static_files[handler_name] = (uri, route) + + else: + pair = self.routes_names.get(handler_name) + if not (pair and (pair[0] + '/' == uri or uri + '/' == pair[0])): + self.routes_names[handler_name] = (uri, route) + + if properties['unhashable']: + self.routes_always_check.append(route) + elif parameters: + self.routes_dynamic[url_hash(uri)].append(route) + else: + self.routes_static[uri] = route + + @staticmethod + def check_dynamic_route_exists(pattern, routes_to_check, parameters): + for ndx, route in enumerate(routes_to_check): + if route.pattern == pattern and route.parameters == parameters: + return ndx, route + else: + return -1, None + + def remove(self, uri, clean_cache=True, host=None): + if host is not None: + uri = host + uri + try: + route = self.routes_all.pop(uri) + for handler_name, pairs in self.routes_names.items(): + if pairs[0] == uri: + self.routes_names.pop(handler_name) + break + + for handler_name, pairs in self.routes_static_files.items(): + if pairs[0] == uri: + self.routes_static_files.pop(handler_name) + break + + except KeyError: + raise RouteDoesNotExist("Route was not registered: {}".format(uri)) + + if route in self.routes_always_check: + self.routes_always_check.remove(route) + elif url_hash(uri) in self.routes_dynamic \ + and route in self.routes_dynamic[url_hash(uri)]: + self.routes_dynamic[url_hash(uri)].remove(route) + else: + self.routes_static.pop(uri) + + if clean_cache: + self._get.cache_clear() + + @lru_cache(maxsize=ROUTER_CACHE_SIZE) + def find_route_by_view_name(self, view_name, name=None): + """Find a route in the router based on the specified view name. + + :param view_name: string of view name to search by + :param kwargs: additional params, usually for static files + :return: tuple containing (uri, Route) + """ + if not view_name: + return (None, None) + + if view_name == 'static' or view_name.endswith('.static'): + return self.routes_static_files.get(name, (None, None)) + + return self.routes_names.get(view_name, (None, None)) def get(self, request): - """ - Gets a request handler based on the URL of the request, or raises an + """Get a request handler based on the URL of the request, or raises an error + :param request: Request object :return: handler, arguments, keyword arguments """ + # No virtual hosts specified; default behavior + if not self.hosts: + return self._get(request.path, request.method, '') + # virtual hosts specified; try to match route to the host header + try: + return self._get(request.path, request.method, + request.headers.get("Host", '')) + # try default hosts + except NotFound: + return self._get(request.path, request.method, '') - route = None - args = [] - kwargs = {} - for _route in self.routes: - match = _route.pattern.match(request.url) - if match: - for index, parameter in enumerate(_route.parameters, start=1): - value = match.group(index) - if parameter.cast: - kwargs[parameter.name] = parameter.cast(value) - else: - kwargs[parameter.name] = value - route = _route - break + def get_supported_methods(self, url): + """Get a list of supported methods for a url and optional host. + :param url: URL string (including host) + :return: frozenset of supported methods + """ + route = self.routes_all.get(url) + # if methods are None then this logic will prevent an error + return getattr(route, 'methods', None) or frozenset() + + @lru_cache(maxsize=ROUTER_CACHE_SIZE) + def _get(self, url, method, host): + """Get a request handler based on the URL of the request, or raises an + error. Internal method for caching. + + :param url: request URL + :param method: request method + :return: handler, arguments, keyword arguments + """ + url = unquote(host + url) + # Check against known static routes + route = self.routes_static.get(url) + method_not_supported = MethodNotSupported( + 'Method {} not allowed for URL {}'.format(method, url), + method=method, + allowed_methods=self.get_supported_methods(url)) if route: - if route.methods and request.method not in route.methods: - raise InvalidUsage( - "Method {} not allowed for URL {}".format( - request.method, request.url), status_code=405) - return route.handler, args, kwargs + if route.methods and method not in route.methods: + raise method_not_supported + match = route.pattern.match(url) else: - raise NotFound("Requested URL {} not found".format(request.url)) + route_found = False + # Move on to testing all regex routes + for route in self.routes_dynamic[url_hash(url)]: + match = route.pattern.match(url) + route_found |= match is not None + # Do early method checking + if match and method in route.methods: + break + else: + # Lastly, check against all regex routes that cannot be hashed + for route in self.routes_always_check: + match = route.pattern.match(url) + route_found |= match is not None + # Do early method checking + if match and method in route.methods: + break + else: + # Route was found but the methods didn't match + if route_found: + raise method_not_supported + raise NotFound('Requested URL {} not found'.format(url)) + kwargs = {p.name: p.cast(value) + for value, p + in zip(match.groups(1), route.parameters)} + route_handler = route.handler + if hasattr(route_handler, 'handlers'): + route_handler = route_handler.handlers[method] + return route_handler, [], kwargs, route.uri -class SimpleRouter: - """ - Simple router records and reads all routes from a dictionary - It does not support parameters in routes, but is very fast - """ - routes = None - - def __init__(self): - self.routes = {} - - def add(self, uri, methods, handler): - # Dict for faster lookups of method allowed - methods_dict = None - if methods: - methods_dict = {method: True for method in methods} - self.routes[uri] = Route( - handler=handler, methods=methods_dict, pattern=uri, - parameters=None) - - def get(self, request): - route = self.routes.get(request.url) - if route: - if route.methods and request.method not in route.methods: - raise InvalidUsage( - "Method {} not allowed for URL {}".format( - request.method, request.url), status_code=405) - return route.handler, [], {} - else: - raise NotFound("Requested URL {} not found".format(request.url)) + def is_stream_handler(self, request): + """ Handler for request is stream or not. + :param request: Request object + :return: bool + """ + try: + handler = self.get(request)[0] + except (NotFound, MethodNotSupported): + return False + if (hasattr(handler, 'view_class') and + hasattr(handler.view_class, request.method.lower())): + handler = getattr(handler.view_class, request.method.lower()) + return hasattr(handler, 'is_stream') diff --git a/sanic/sanic.py b/sanic/sanic.py deleted file mode 100644 index f67edc7b..00000000 --- a/sanic/sanic.py +++ /dev/null @@ -1,213 +0,0 @@ -import asyncio -from inspect import isawaitable -from traceback import format_exc - -from .config import Config -from .exceptions import Handler -from .log import log, logging -from .response import HTTPResponse -from .router import Router -from .server import serve -from .exceptions import ServerError - - -class Sanic: - def __init__(self, name, router=None, error_handler=None): - self.name = name - self.router = router or Router() - self.error_handler = error_handler or Handler(self) - self.config = Config() - self.request_middleware = [] - self.response_middleware = [] - self.blueprints = {} - self._blueprint_order = [] - - # -------------------------------------------------------------------- # - # Registration - # -------------------------------------------------------------------- # - - # Decorator - def route(self, uri, methods=None): - """ - Decorates a function to be registered as a route - :param uri: path of the URL - :param methods: list or tuple of methods allowed - :return: decorated function - """ - - def response(handler): - self.router.add(uri=uri, methods=methods, handler=handler) - return handler - - return response - - # Decorator - def exception(self, *exceptions): - """ - Decorates a function to be registered as a route - :param uri: path of the URL - :param methods: list or tuple of methods allowed - :return: decorated function - """ - - def response(handler): - for exception in exceptions: - self.error_handler.add(exception, handler) - return handler - - return response - - # Decorator - def middleware(self, *args, **kwargs): - """ - Decorates and registers middleware to be called before a request - can either be called as @app.middleware or @app.middleware('request') - """ - attach_to = 'request' - - def register_middleware(middleware): - if attach_to == 'request': - self.request_middleware.append(middleware) - if attach_to == 'response': - self.response_middleware.append(middleware) - return middleware - - # Detect which way this was called, @middleware or @middleware('AT') - if len(args) == 1 and len(kwargs) == 0 and callable(args[0]): - return register_middleware(args[0]) - else: - attach_to = args[0] - return register_middleware - - def register_blueprint(self, blueprint, **options): - """ - Registers a blueprint on the application. - :param blueprint: Blueprint object - :param options: option dictionary with blueprint defaults - :return: Nothing - """ - if blueprint.name in self.blueprints: - assert self.blueprints[blueprint.name] is blueprint, \ - 'A blueprint with the name "%s" is already registered. ' \ - 'Blueprint names must be unique.' % \ - (blueprint.name,) - else: - self.blueprints[blueprint.name] = blueprint - self._blueprint_order.append(blueprint) - blueprint.register(self, options) - - # -------------------------------------------------------------------- # - # Request Handling - # -------------------------------------------------------------------- # - - async def handle_request(self, request, response_callback): - """ - Takes a request from the HTTP Server and returns a response object to - be sent back The HTTP Server only expects a response object, so - exception handling must be done here - :param request: HTTP Request object - :param response_callback: Response function to be called with the - response as the only argument - :return: Nothing - """ - try: - # Middleware process_request - response = False - # The if improves speed. I don't know why - if self.request_middleware: - for middleware in self.request_middleware: - response = middleware(request) - if isawaitable(response): - response = await response - if response: - break - - # No middleware results - if not response: - # Fetch handler from router - handler, args, kwargs = self.router.get(request) - if handler is None: - raise ServerError( - ("'None' was returned while requesting a " - "handler from the router")) - - # Run response handler - response = handler(request, *args, **kwargs) - if isawaitable(response): - response = await response - - # Middleware process_response - if self.response_middleware: - for middleware in self.response_middleware: - _response = middleware(request, response) - if isawaitable(_response): - _response = await _response - if _response: - response = _response - break - - except Exception as e: - try: - response = self.error_handler.response(request, e) - if isawaitable(response): - response = await response - except Exception as e: - if self.debug: - response = HTTPResponse( - "Error while handling error: {}\nStack: {}".format( - e, format_exc())) - else: - response = HTTPResponse( - "An error occured while handling an error") - - response_callback(response) - - # -------------------------------------------------------------------- # - # Execution - # -------------------------------------------------------------------- # - - def run(self, host="127.0.0.1", port=8000, debug=False, after_start=None, - before_stop=None): - """ - Runs the HTTP Server and listens until keyboard interrupt or term - signal. On termination, drains connections before closing. - :param host: Address to host on - :param port: Port to host on - :param debug: Enables debug output (slows server) - :param after_start: Function to be executed after the server starts - listening - :param before_stop: Function to be executed when a stop signal is - received before it is respected - :return: Nothing - """ - self.error_handler.debug = True - self.debug = debug - - if debug: - log.setLevel(logging.DEBUG) - log.debug(self.config.LOGO) - - # Serve - log.info('Goin\' Fast @ http://{}:{}'.format(host, port)) - - try: - serve( - host=host, - port=port, - debug=debug, - after_start=after_start, - before_stop=before_stop, - request_handler=self.handle_request, - request_timeout=self.config.REQUEST_TIMEOUT, - request_max_size=self.config.REQUEST_MAX_SIZE, - ) - except Exception as e: - log.exception( - 'Experienced exception while trying to serve: {}'.format(e)) - pass - - def stop(self): - """ - This kills the Sanic - """ - asyncio.get_event_loop().stop() diff --git a/sanic/server.py b/sanic/server.py index 0a10f5fd..11e54edc 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -1,16 +1,39 @@ import asyncio +import os +import traceback +from functools import partial from inspect import isawaitable -from signal import SIGINT, SIGTERM +from multiprocessing import Process +from signal import ( + SIGTERM, SIGINT, SIG_IGN, + signal as signal_func, + Signals +) +from socket import ( + socket, + SOL_SOCKET, + SO_REUSEADDR, +) +from time import time -import httptools +from httptools import HttpRequestParser +from httptools.parser.errors import HttpParserError +from multidict import CIMultiDict try: - import uvloop as async_loop + import uvloop + asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) except ImportError: - async_loop = asyncio + pass -from .log import log -from .request import Request +from sanic.log import logger, access_logger +from sanic.response import HTTPResponse +from sanic.request import Request +from sanic.exceptions import ( + RequestTimeout, PayloadTooLarge, InvalidUsage, ServerError, + ServiceUnavailable) + +current_time = None class Signal: @@ -24,48 +47,141 @@ class HttpProtocol(asyncio.Protocol): # request params 'parser', 'request', 'url', 'headers', # request config - 'request_handler', 'request_timeout', 'request_max_size', + 'request_handler', 'request_timeout', 'response_timeout', + 'keep_alive_timeout', 'request_max_size', 'request_class', + 'is_request_stream', 'router', + # enable or disable access log purpose + 'access_log', # connection management - '_total_request_size', '_timeout_handler') + '_total_request_size', '_request_timeout_handler', + '_response_timeout_handler', '_keep_alive_timeout_handler', + '_last_request_time', '_last_response_time', '_is_stream_handler') - def __init__(self, *, loop, request_handler, signal=Signal(), - connections={}, request_timeout=60, - request_max_size=None): + def __init__(self, *, loop, request_handler, error_handler, + signal=Signal(), connections=set(), request_timeout=60, + response_timeout=60, keep_alive_timeout=5, + request_max_size=None, request_class=None, access_log=True, + keep_alive=True, is_request_stream=False, router=None, + state=None, debug=False, **kwargs): self.loop = loop self.transport = None self.request = None self.parser = None self.url = None self.headers = None + self.router = router self.signal = signal + self.access_log = access_log self.connections = connections self.request_handler = request_handler + self.error_handler = error_handler self.request_timeout = request_timeout + self.response_timeout = response_timeout + self.keep_alive_timeout = keep_alive_timeout self.request_max_size = request_max_size + self.request_class = request_class or Request + self.is_request_stream = is_request_stream + self._is_stream_handler = False self._total_request_size = 0 - self._timeout_handler = None + self._request_timeout_handler = None + self._response_timeout_handler = None + self._keep_alive_timeout_handler = None + self._last_request_time = None + self._last_response_time = None + self._request_handler_task = None + self._request_stream_task = None + self._keep_alive = keep_alive + self._header_fragment = b'' + self.state = state if state else {} + if 'requests_count' not in self.state: + self.state['requests_count'] = 0 + self._debug = debug - # -------------------------------------------- # + @property + def keep_alive(self): + return ( + self._keep_alive and + not self.signal.stopped and + self.parser.should_keep_alive()) + # -------------------------------------------- # # Connection # -------------------------------------------- # def connection_made(self, transport): - self.connections[self] = True - self._timeout_handler = self.loop.call_later( - self.request_timeout, self.connection_timeout) + self.connections.add(self) + self._request_timeout_handler = self.loop.call_later( + self.request_timeout, self.request_timeout_callback) self.transport = transport + self._last_request_time = current_time def connection_lost(self, exc): - del self.connections[self] - self._timeout_handler.cancel() - self.cleanup() + self.connections.discard(self) + if self._request_timeout_handler: + self._request_timeout_handler.cancel() + if self._response_timeout_handler: + self._response_timeout_handler.cancel() + if self._keep_alive_timeout_handler: + self._keep_alive_timeout_handler.cancel() - def connection_timeout(self): - self.bail_out("Request timed out, connection closed") + def request_timeout_callback(self): + # See the docstring in the RequestTimeout exception, to see + # exactly what this timeout is checking for. + # Check if elapsed time since request initiated exceeds our + # configured maximum request timeout value + time_elapsed = current_time - self._last_request_time + if time_elapsed < self.request_timeout: + time_left = self.request_timeout - time_elapsed + self._request_timeout_handler = ( + self.loop.call_later(time_left, + self.request_timeout_callback) + ) + else: + if self._request_stream_task: + self._request_stream_task.cancel() + if self._request_handler_task: + self._request_handler_task.cancel() + try: + raise RequestTimeout('Request Timeout') + except RequestTimeout as exception: + self.write_error(exception) - # -------------------------------------------- # + def response_timeout_callback(self): + # Check if elapsed time since response was initiated exceeds our + # configured maximum request timeout value + time_elapsed = current_time - self._last_request_time + if time_elapsed < self.response_timeout: + time_left = self.response_timeout - time_elapsed + self._response_timeout_handler = ( + self.loop.call_later(time_left, + self.response_timeout_callback) + ) + else: + if self._request_stream_task: + self._request_stream_task.cancel() + if self._request_handler_task: + self._request_handler_task.cancel() + try: + raise ServiceUnavailable('Response Timeout') + except ServiceUnavailable as exception: + self.write_error(exception) + def keep_alive_timeout_callback(self): + # Check if elapsed time since last response exceeds our configured + # maximum keep alive timeout value + time_elapsed = current_time - self._last_response_time + if time_elapsed < self.keep_alive_timeout: + time_left = self.keep_alive_timeout - time_elapsed + self._keep_alive_timeout_handler = ( + self.loop.call_later(time_left, + self.keep_alive_timeout_callback) + ) + else: + logger.debug('KeepAlive Timeout. Closing connection.') + self.transport.close() + self.transport = None + + # -------------------------------------------- # # Parsing # -------------------------------------------- # @@ -74,81 +190,263 @@ class HttpProtocol(asyncio.Protocol): # memory limits self._total_request_size += len(data) if self._total_request_size > self.request_max_size: - return self.bail_out( - "Request too large ({}), connection closed".format( - self._total_request_size)) + exception = PayloadTooLarge('Payload Too Large') + self.write_error(exception) # Create parser if this is the first time we're receiving data if self.parser is None: assert self.request is None self.headers = [] - self.parser = httptools.HttpRequestParser(self) + self.parser = HttpRequestParser(self) + + # requests count + self.state['requests_count'] = self.state['requests_count'] + 1 # Parse request chunk or close connection try: self.parser.feed_data(data) - except httptools.parser.errors.HttpParserError as e: - self.bail_out( - "Invalid request data, connection closed ({})".format(e)) + except HttpParserError: + message = 'Bad Request' + if self._debug: + message += '\n' + traceback.format_exc() + exception = InvalidUsage(message) + self.write_error(exception) def on_url(self, url): - self.url = url + if not self.url: + self.url = url + else: + self.url += url def on_header(self, name, value): - if name == b'Content-Length' and int(value) > self.request_max_size: - return self.bail_out( - "Request body too large ({}), connection closed".format(value)) + self._header_fragment += name - self.headers.append((name.decode(), value.decode('utf-8'))) + if value is not None: + if self._header_fragment == b'Content-Length' \ + and int(value) > self.request_max_size: + exception = PayloadTooLarge('Payload Too Large') + self.write_error(exception) + try: + value = value.decode() + except UnicodeDecodeError: + value = value.decode('latin_1') + self.headers.append( + (self._header_fragment.decode().casefold(), value)) + + self._header_fragment = b'' def on_headers_complete(self): - self.request = Request( + self.request = self.request_class( url_bytes=self.url, - headers=dict(self.headers), + headers=CIMultiDict(self.headers), version=self.parser.get_http_version(), - method=self.parser.get_method().decode() + method=self.parser.get_method().decode(), + transport=self.transport ) + # Remove any existing KeepAlive handler here, + # It will be recreated if required on the new request. + if self._keep_alive_timeout_handler: + self._keep_alive_timeout_handler.cancel() + self._keep_alive_timeout_handler = None + if self.is_request_stream: + self._is_stream_handler = self.router.is_stream_handler( + self.request) + if self._is_stream_handler: + self.request.stream = asyncio.Queue() + self.execute_request_handler() def on_body(self, body): - self.request.body = body + if self.is_request_stream and self._is_stream_handler: + self._request_stream_task = self.loop.create_task( + self.request.stream.put(body)) + return + self.request.body.append(body) def on_message_complete(self): - self.loop.create_task( - self.request_handler(self.request, self.write_response)) + # Entire request (headers and whole body) is received. + # We can cancel and remove the request timeout handler now. + if self._request_timeout_handler: + self._request_timeout_handler.cancel() + self._request_timeout_handler = None + if self.is_request_stream and self._is_stream_handler: + self._request_stream_task = self.loop.create_task( + self.request.stream.put(None)) + return + self.request.body = b''.join(self.request.body) + self.execute_request_handler() + + def execute_request_handler(self): + self._response_timeout_handler = self.loop.call_later( + self.response_timeout, self.response_timeout_callback) + self._last_request_time = current_time + self._request_handler_task = self.loop.create_task( + self.request_handler( + self.request, + self.write_response, + self.stream_response)) # -------------------------------------------- # # Responding # -------------------------------------------- # + def log_response(self, response): + if self.access_log: + extra = { + 'status': getattr(response, 'status', 0), + } + + if isinstance(response, HTTPResponse): + extra['byte'] = len(response.body) + else: + extra['byte'] = -1 + + extra['host'] = 'UNKNOWN' + if self.request is not None: + if self.request.ip: + extra['host'] = '{0}:{1}'.format(self.request.ip, + self.request.port) + + extra['request'] = '{0} {1}'.format(self.request.method, + self.request.url) + else: + extra['request'] = 'nil' + + access_logger.info('', extra=extra) def write_response(self, response): + """ + Writes response content synchronously to the transport. + """ + if self._response_timeout_handler: + self._response_timeout_handler.cancel() + self._response_timeout_handler = None try: - keep_alive = all( - [self.parser.should_keep_alive(), self.signal.stopped]) + keep_alive = self.keep_alive self.transport.write( response.output( - self.request.version, keep_alive, self.request_timeout)) - if not keep_alive: - self.transport.close() - else: - self.cleanup() + self.request.version, keep_alive, + self.keep_alive_timeout)) + self.log_response(response) + except AttributeError: + logger.error('Invalid response object for url %s, ' + 'Expected Type: HTTPResponse, Actual Type: %s', + self.url, type(response)) + self.write_error(ServerError('Invalid response type')) + except RuntimeError: + if self._debug: + logger.error('Connection lost before response written @ %s', + self.request.ip) + keep_alive = False except Exception as e: self.bail_out( - "Writing request failed, connection closed {}".format(e)) + "Writing response failed, connection closed {}".format( + repr(e))) + finally: + if not keep_alive: + self.transport.close() + self.transport = None + else: + self._keep_alive_timeout_handler = self.loop.call_later( + self.keep_alive_timeout, + self.keep_alive_timeout_callback) + self._last_response_time = current_time + self.cleanup() - def bail_out(self, message): - log.error(message) - self.transport.close() + async def stream_response(self, response): + """ + Streams a response to the client asynchronously. Attaches + the transport to the response so the response consumer can + write to the response as needed. + """ + if self._response_timeout_handler: + self._response_timeout_handler.cancel() + self._response_timeout_handler = None + try: + keep_alive = self.keep_alive + response.transport = self.transport + await response.stream( + self.request.version, keep_alive, self.keep_alive_timeout) + self.log_response(response) + except AttributeError: + logger.error('Invalid response object for url %s, ' + 'Expected Type: HTTPResponse, Actual Type: %s', + self.url, type(response)) + self.write_error(ServerError('Invalid response type')) + except RuntimeError: + if self._debug: + logger.error('Connection lost before response written @ %s', + self.request.ip) + keep_alive = False + except Exception as e: + self.bail_out( + "Writing response failed, connection closed {}".format( + repr(e))) + finally: + if not keep_alive: + self.transport.close() + self.transport = None + else: + self._keep_alive_timeout_handler = self.loop.call_later( + self.keep_alive_timeout, + self.keep_alive_timeout_callback) + self._last_response_time = current_time + self.cleanup() + + def write_error(self, exception): + # An error _is_ a response. + # Don't throw a response timeout, when a response _is_ given. + if self._response_timeout_handler: + self._response_timeout_handler.cancel() + self._response_timeout_handler = None + response = None + try: + response = self.error_handler.response(self.request, exception) + version = self.request.version if self.request else '1.1' + self.transport.write(response.output(version)) + except RuntimeError: + if self._debug: + logger.error('Connection lost before error written @ %s', + self.request.ip if self.request else 'Unknown') + except Exception as e: + self.bail_out( + "Writing error failed, connection closed {}".format( + repr(e)), from_error=True + ) + finally: + if self.parser and (self.keep_alive + or getattr(response, 'status', 0) == 408): + self.log_response(response) + try: + self.transport.close() + except AttributeError as e: + logger.debug('Connection lost before server could close it.') + + def bail_out(self, message, from_error=False): + if from_error or self.transport.is_closing(): + logger.error("Transport closed @ %s and exception " + "experienced during error handling", + self.transport.get_extra_info('peername')) + logger.debug('Exception:\n%s', traceback.format_exc()) + else: + exception = ServerError(message) + self.write_error(exception) + logger.error(message) def cleanup(self): + """This is called when KeepAlive feature is used, + it resets the connection in order for it to be able + to handle receiving another request on the same connection.""" self.parser = None self.request = None self.url = None self.headers = None + self._request_handler_task = None + self._request_stream_task = None self._total_request_size = 0 + self._is_stream_handler = False def close_if_idle(self): - """ - Close the connection if a request is not being sent or received + """Close the connection if a request is not being sent or received + :return: boolean - True if closed, false if staying open """ if not self.parser: @@ -156,53 +454,174 @@ class HttpProtocol(asyncio.Protocol): return True return False + def close(self): + """ + Force close the connection. + """ + if self.transport is not None: + self.transport.close() + self.transport = None -def serve(host, port, request_handler, after_start=None, before_stop=None, - debug=False, request_timeout=60, - request_max_size=None): - # Create Event Loop - loop = async_loop.new_event_loop() - asyncio.set_event_loop(loop) - # I don't think we take advantage of this - # And it slows everything waaayyy down - # loop.set_debug(debug) - connections = {} - signal = Signal() - server_coroutine = loop.create_server(lambda: HttpProtocol( +def update_current_time(loop): + """Cache the current time, since it is needed at the end of every + keep-alive request to update the request timeout time + + :param loop: + :return: + """ + global current_time + current_time = time() + loop.call_later(1, partial(update_current_time, loop)) + + +def trigger_events(events, loop): + """Trigger event callbacks (functions or async) + + :param events: one or more sync or async functions to execute + :param loop: event loop + """ + for event in events: + result = event(loop) + if isawaitable(result): + loop.run_until_complete(result) + + +def serve(host, port, request_handler, error_handler, before_start=None, + after_start=None, before_stop=None, after_stop=None, debug=False, + request_timeout=60, response_timeout=60, keep_alive_timeout=5, + ssl=None, sock=None, request_max_size=None, reuse_port=False, + loop=None, protocol=HttpProtocol, backlog=100, + register_sys_signals=True, run_multiple=False, run_async=False, + connections=None, signal=Signal(), request_class=None, + access_log=True, keep_alive=True, is_request_stream=False, + router=None, websocket_max_size=None, websocket_max_queue=None, + websocket_read_limit=2 ** 16, websocket_write_limit=2 ** 16, + state=None, graceful_shutdown_timeout=15.0): + """Start asynchronous HTTP Server on an individual process. + + :param host: Address to host on + :param port: Port to host on + :param request_handler: Sanic request handler with middleware + :param error_handler: Sanic error handler with middleware + :param before_start: function to be executed before the server starts + listening. Takes arguments `app` instance and `loop` + :param after_start: function to be executed after the server starts + listening. Takes arguments `app` instance and `loop` + :param before_stop: function to be executed when a stop signal is + received before it is respected. Takes arguments + `app` instance and `loop` + :param after_stop: function to be executed when a stop signal is + received after it is respected. Takes arguments + `app` instance and `loop` + :param debug: enables debug output (slows server) + :param request_timeout: time in seconds + :param response_timeout: time in seconds + :param keep_alive_timeout: time in seconds + :param ssl: SSLContext + :param sock: Socket for the server to accept connections from + :param request_max_size: size in bytes, `None` for no limit + :param reuse_port: `True` for multiple workers + :param loop: asyncio compatible event loop + :param protocol: subclass of asyncio protocol class + :param request_class: Request class to use + :param access_log: disable/enable access log + :param websocket_max_size: enforces the maximum size for + incoming messages in bytes. + :param websocket_max_queue: sets the maximum length of the queue + that holds incoming messages. + :param websocket_read_limit: sets the high-water limit of the buffer for + incoming bytes, the low-water limit is half + the high-water limit. + :param websocket_write_limit: sets the high-water limit of the buffer for + outgoing bytes, the low-water limit is a + quarter of the high-water limit. + :param is_request_stream: disable/enable Request.stream + :param router: Router object + :return: Nothing + """ + if not run_async: + # create new event_loop after fork + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + if debug: + loop.set_debug(debug) + + connections = connections if connections is not None else set() + server = partial( + protocol, loop=loop, connections=connections, signal=signal, request_handler=request_handler, + error_handler=error_handler, request_timeout=request_timeout, + response_timeout=response_timeout, + keep_alive_timeout=keep_alive_timeout, request_max_size=request_max_size, - ), host, port) + request_class=request_class, + access_log=access_log, + keep_alive=keep_alive, + is_request_stream=is_request_stream, + router=router, + websocket_max_size=websocket_max_size, + websocket_max_queue=websocket_max_queue, + websocket_read_limit=websocket_read_limit, + websocket_write_limit=websocket_write_limit, + state=state, + debug=debug, + ) + + server_coroutine = loop.create_server( + server, + host, + port, + ssl=ssl, + reuse_port=reuse_port, + sock=sock, + backlog=backlog + ) + + # Instead of pulling time at the end of every request, + # pull it once per minute + loop.call_soon(partial(update_current_time, loop)) + + if run_async: + return server_coroutine + + trigger_events(before_start, loop) + try: http_server = loop.run_until_complete(server_coroutine) - except Exception as e: - log.error("Unable to start server: {}".format(e)) + except BaseException: + logger.exception("Unable to start server") return - # Run the on_start function if provided - if after_start: - result = after_start(loop) - if isawaitable(result): - loop.run_until_complete(result) + trigger_events(after_start, loop) + + # Ignore SIGINT when run_multiple + if run_multiple: + signal_func(SIGINT, SIG_IGN) # Register signals for graceful termination - for _signal in (SIGINT, SIGTERM): - loop.add_signal_handler(_signal, loop.stop) - + if register_sys_signals: + _singals = (SIGTERM,) if run_multiple else (SIGINT, SIGTERM) + for _signal in _singals: + try: + loop.add_signal_handler(_signal, loop.stop) + except NotImplementedError: + logger.warning('Sanic tried to use loop.add_signal_handler ' + 'but it is not implemented on this platform.') + pid = os.getpid() try: + logger.info('Starting worker [%s]', pid) loop.run_forever() finally: - log.info("Stop requested, draining connections...") + logger.info("Stopping worker [%s]", pid) # Run the on_stop function if provided - if before_stop: - result = before_stop(loop) - if isawaitable(result): - loop.run_until_complete(result) + trigger_events(before_stop, loop) # Wait for event loop to finish and all connections to drain http_server.close() @@ -210,11 +629,79 @@ def serve(host, port, request_handler, after_start=None, before_stop=None, # Complete all tasks on the loop signal.stopped = True - for connection in connections.keys(): + for connection in connections: connection.close_if_idle() - while connections: + # Gracefully shutdown timeout. + # We should provide graceful_shutdown_timeout, + # instead of letting connection hangs forever. + # Let's roughly calcucate time. + start_shutdown = 0 + while connections and (start_shutdown < graceful_shutdown_timeout): loop.run_until_complete(asyncio.sleep(0.1)) + start_shutdown = start_shutdown + 0.1 + + # Force close non-idle connection after waiting for + # graceful_shutdown_timeout + coros = [] + for conn in connections: + if hasattr(conn, "websocket") and conn.websocket: + coros.append( + conn.websocket.close_connection() + ) + else: + conn.close() + + _shutdown = asyncio.gather(*coros, loop=loop) + loop.run_until_complete(_shutdown) + + trigger_events(after_stop, loop) loop.close() - log.info("Server Stopped") + + +def serve_multiple(server_settings, workers): + """Start multiple server processes simultaneously. Stop on interrupt + and terminate signals, and drain connections when complete. + + :param server_settings: kw arguments to be passed to the serve function + :param workers: number of workers to launch + :param stop_event: if provided, is used as a stop signal + :return: + """ + server_settings['reuse_port'] = True + server_settings['run_multiple'] = True + + # Handling when custom socket is not provided. + if server_settings.get('sock') is None: + sock = socket() + sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) + sock.bind((server_settings['host'], server_settings['port'])) + sock.set_inheritable(True) + server_settings['sock'] = sock + server_settings['host'] = None + server_settings['port'] = None + + def sig_handler(signal, frame): + logger.info("Received signal %s. Shutting down.", Signals(signal).name) + for process in processes: + os.kill(process.pid, SIGTERM) + + signal_func(SIGINT, lambda s, f: sig_handler(s, f)) + signal_func(SIGTERM, lambda s, f: sig_handler(s, f)) + + processes = [] + + for _ in range(workers): + process = Process(target=serve, kwargs=server_settings) + process.daemon = True + process.start() + processes.append(process) + + for process in processes: + process.join() + + # the above processes will block this until they're stopped + for process in processes: + process.terminate() + server_settings.get('sock').close() diff --git a/sanic/static.py b/sanic/static.py new file mode 100644 index 00000000..07831390 --- /dev/null +++ b/sanic/static.py @@ -0,0 +1,128 @@ +from mimetypes import guess_type +from os import path +from re import sub +from time import strftime, gmtime +from urllib.parse import unquote + +from aiofiles.os import stat + +from sanic.exceptions import ( + ContentRangeError, + FileNotFound, + HeaderNotFound, + InvalidUsage, +) +from sanic.handlers import ContentRangeHandler +from sanic.response import file, file_stream, HTTPResponse + + +def register(app, uri, file_or_directory, pattern, + use_modified_since, use_content_range, + stream_large_files, name='static', host=None, + strict_slashes=None, content_type=None): + # TODO: Though sanic is not a file server, I feel like we should at least + # make a good effort here. Modified-since is nice, but we could + # also look into etags, expires, and caching + """ + Register a static directory handler with Sanic by adding a route to the + router and registering a handler. + + :param app: Sanic + :param file_or_directory: File or directory path to serve from + :param uri: URL to serve from + :param pattern: regular expression used to match files in the URL + :param use_modified_since: If true, send file modified time, and return + not modified if the browser's matches the + server's + :param use_content_range: If true, process header for range requests + and sends the file part that is requested + :param stream_large_files: If true, use the file_stream() handler rather + than the file() handler to send the file + If this is an integer, this represents the + threshold size to switch to file_stream() + :param name: user defined name used for url_for + :param content_type: user defined content type for header + """ + # If we're not trying to match a file directly, + # serve from the folder + if not path.isfile(file_or_directory): + uri += '' + + async def _handler(request, file_uri=None): + # Using this to determine if the URL is trying to break out of the path + # served. os.path.realpath seems to be very slow + if file_uri and '../' in file_uri: + raise InvalidUsage("Invalid URL") + # Merge served directory and requested file if provided + # Strip all / that in the beginning of the URL to help prevent python + # from herping a derp and treating the uri as an absolute path + root_path = file_path = file_or_directory + if file_uri: + file_path = path.join( + file_or_directory, sub('^[/]*', '', file_uri)) + + # URL decode the path sent by the browser otherwise we won't be able to + # match filenames which got encoded (filenames with spaces etc) + file_path = path.abspath(unquote(file_path)) + if not file_path.startswith(path.abspath(unquote(root_path))): + raise FileNotFound('File not found', + path=file_or_directory, + relative_url=file_uri) + try: + headers = {} + # Check if the client has been sent this file before + # and it has not been modified since + stats = None + if use_modified_since: + stats = await stat(file_path) + modified_since = strftime( + '%a, %d %b %Y %H:%M:%S GMT', gmtime(stats.st_mtime)) + if request.headers.get('If-Modified-Since') == modified_since: + return HTTPResponse(status=304) + headers['Last-Modified'] = modified_since + _range = None + if use_content_range: + _range = None + if not stats: + stats = await stat(file_path) + headers['Accept-Ranges'] = 'bytes' + headers['Content-Length'] = str(stats.st_size) + if request.method != 'HEAD': + try: + _range = ContentRangeHandler(request, stats) + except HeaderNotFound: + pass + else: + del headers['Content-Length'] + for key, value in _range.headers.items(): + headers[key] = value + headers['Content-Type'] = content_type \ + or guess_type(file_path)[0] or 'text/plain' + if request.method == 'HEAD': + return HTTPResponse(headers=headers) + else: + if stream_large_files: + if isinstance(stream_large_files, int): + threshold = stream_large_files + else: + threshold = 1024 * 1024 + + if not stats: + stats = await stat(file_path) + if stats.st_size >= threshold: + return await file_stream(file_path, headers=headers, + _range=_range) + return await file(file_path, headers=headers, _range=_range) + except ContentRangeError: + raise + except Exception: + raise FileNotFound('File not found', + path=file_or_directory, + relative_url=file_uri) + + # special prefix for static files + if not name.startswith('_static_'): + name = '_static_{}'.format(name) + + app.route(uri, methods=['GET', 'HEAD'], name=name, host=host, + strict_slashes=strict_slashes)(_handler) diff --git a/sanic/testing.py b/sanic/testing.py new file mode 100644 index 00000000..3a1d15c5 --- /dev/null +++ b/sanic/testing.py @@ -0,0 +1,121 @@ +import traceback +from json import JSONDecodeError +from sanic.log import logger +from sanic.exceptions import MethodNotSupported +from sanic.response import text + + +HOST = '127.0.0.1' +PORT = 42101 + + +class SanicTestClient: + def __init__(self, app, port=PORT): + self.app = app + self.port = port + + async def _local_request(self, method, uri, cookies=None, *args, **kwargs): + import aiohttp + if uri.startswith(('http:', 'https:', 'ftp:', 'ftps://' '//')): + url = uri + else: + url = 'http://{host}:{port}{uri}'.format( + host=HOST, port=self.port, uri=uri) + + logger.info(url) + conn = aiohttp.TCPConnector(verify_ssl=False) + async with aiohttp.ClientSession( + cookies=cookies, connector=conn) as session: + async with getattr( + session, method.lower())(url, *args, **kwargs) as response: + try: + response.text = await response.text() + except UnicodeDecodeError as e: + response.text = None + + try: + response.json = await response.json() + except (JSONDecodeError, + UnicodeDecodeError, + aiohttp.ClientResponseError): + response.json = None + + response.body = await response.read() + return response + + def _sanic_endpoint_test( + self, method='get', uri='/', gather_request=True, + debug=False, server_kwargs={"auto_reload": False}, + *request_args, **request_kwargs): + results = [None, None] + exceptions = [] + + if gather_request: + def _collect_request(request): + if results[0] is None: + results[0] = request + self.app.request_middleware.appendleft(_collect_request) + + @self.app.exception(MethodNotSupported) + async def error_handler(request, exception): + if request.method in ['HEAD', 'PATCH', 'PUT', 'DELETE']: + return text( + '', exception.status_code, headers=exception.headers + ) + else: + return self.app.error_handler.default(request, exception) + + @self.app.listener('after_server_start') + async def _collect_response(sanic, loop): + try: + response = await self._local_request( + method, uri, *request_args, + **request_kwargs) + results[-1] = response + except Exception as e: + logger.error( + 'Exception:\n{}'.format(traceback.format_exc())) + exceptions.append(e) + self.app.stop() + + self.app.run(host=HOST, debug=debug, port=self.port, **server_kwargs) + self.app.listeners['after_server_start'].pop() + + if exceptions: + raise ValueError("Exception during request: {}".format(exceptions)) + + if gather_request: + try: + request, response = results + return request, response + except BaseException: + raise ValueError( + "Request and response object expected, got ({})".format( + results)) + else: + try: + return results[-1] + except BaseException: + raise ValueError( + "Request object expected, got ({})".format(results)) + + def get(self, *args, **kwargs): + return self._sanic_endpoint_test('get', *args, **kwargs) + + def post(self, *args, **kwargs): + return self._sanic_endpoint_test('post', *args, **kwargs) + + def put(self, *args, **kwargs): + return self._sanic_endpoint_test('put', *args, **kwargs) + + def delete(self, *args, **kwargs): + return self._sanic_endpoint_test('delete', *args, **kwargs) + + def patch(self, *args, **kwargs): + return self._sanic_endpoint_test('patch', *args, **kwargs) + + def options(self, *args, **kwargs): + return self._sanic_endpoint_test('options', *args, **kwargs) + + def head(self, *args, **kwargs): + return self._sanic_endpoint_test('head', *args, **kwargs) diff --git a/sanic/utils.py b/sanic/utils.py deleted file mode 100644 index c39f03ab..00000000 --- a/sanic/utils.py +++ /dev/null @@ -1,54 +0,0 @@ -import aiohttp -from sanic.log import log - -HOST = '127.0.0.1' -PORT = 42101 - - -async def local_request(method, uri, *args, **kwargs): - url = 'http://{host}:{port}{uri}'.format(host=HOST, port=PORT, uri=uri) - log.info(url) - async with aiohttp.ClientSession() as session: - async with getattr(session, method)(url, *args, **kwargs) as response: - response.text = await response.text() - return response - - -def sanic_endpoint_test(app, method='get', uri='/', gather_request=True, - *request_args, **request_kwargs): - results = [] - exceptions = [] - - if gather_request: - @app.middleware - def _collect_request(request): - results.append(request) - - async def _collect_response(loop): - try: - response = await local_request(method, uri, *request_args, - **request_kwargs) - results.append(response) - except Exception as e: - exceptions.append(e) - app.stop() - - app.run(host=HOST, port=42101, after_start=_collect_response) - - if exceptions: - raise ValueError("Exception during request: {}".format(exceptions)) - - if gather_request: - try: - request, response = results - return request, response - except: - raise ValueError( - "request and response object expected, got ({})".format( - results)) - else: - try: - return results[0] - except: - raise ValueError( - "request object expected, got ({})".format(results)) diff --git a/sanic/views.py b/sanic/views.py new file mode 100644 index 00000000..f47f2044 --- /dev/null +++ b/sanic/views.py @@ -0,0 +1,106 @@ +from sanic.exceptions import InvalidUsage +from sanic.constants import HTTP_METHODS + + +class HTTPMethodView: + """Simple class based implementation of view for the sanic. + You should implement methods (get, post, put, patch, delete) for the class + to every HTTP method you want to support. + + For example: + + .. code-block:: python + + class DummyView(HTTPMethodView): + def get(self, request, *args, **kwargs): + return text('I am get method') + def put(self, request, *args, **kwargs): + return text('I am put method') + + etc. + + If someone tries to use a non-implemented method, there will be a + 405 response. + + If you need any url params just mention them in method definition: + + .. code-block:: python + + class DummyView(HTTPMethodView): + def get(self, request, my_param_here, *args, **kwargs): + return text('I am get method with %s' % my_param_here) + + To add the view into the routing you could use + 1) app.add_route(DummyView.as_view(), '/') + 2) app.route('/')(DummyView.as_view()) + + To add any decorator you could set it into decorators variable + """ + + decorators = [] + + def dispatch_request(self, request, *args, **kwargs): + handler = getattr(self, request.method.lower(), None) + return handler(request, *args, **kwargs) + + @classmethod + def as_view(cls, *class_args, **class_kwargs): + """Return view function for use with the routing system, that + dispatches request to appropriate handler method. + """ + def view(*args, **kwargs): + self = view.view_class(*class_args, **class_kwargs) + return self.dispatch_request(*args, **kwargs) + + if cls.decorators: + view.__module__ = cls.__module__ + for decorator in cls.decorators: + view = decorator(view) + + view.view_class = cls + view.__doc__ = cls.__doc__ + view.__module__ = cls.__module__ + view.__name__ = cls.__name__ + return view + + +def stream(func): + func.is_stream = True + return func + + +class CompositionView: + """Simple method-function mapped view for the sanic. + You can add handler functions to methods (get, post, put, patch, delete) + for every HTTP method you want to support. + + For example: + view = CompositionView() + view.add(['GET'], lambda request: text('I am get method')) + view.add(['POST', 'PUT'], lambda request: text('I am post/put method')) + + etc. + + If someone tries to use a non-implemented method, there will be a + 405 response. + """ + + def __init__(self): + self.handlers = {} + + def add(self, methods, handler, stream=False): + if stream: + handler.is_stream = stream + for method in methods: + if method not in HTTP_METHODS: + raise InvalidUsage( + '{} is not a valid HTTP method.'.format(method)) + + if method in self.handlers: + raise InvalidUsage( + 'Method {} is already registered.'.format(method)) + self.handlers[method] = handler + + def __call__(self, request, *args, **kwargs): + handler = self.handlers[request.method.upper()] + return handler(request, *args, **kwargs) diff --git a/sanic/websocket.py b/sanic/websocket.py new file mode 100644 index 00000000..99408af5 --- /dev/null +++ b/sanic/websocket.py @@ -0,0 +1,103 @@ +from sanic.exceptions import InvalidUsage +from sanic.server import HttpProtocol +from httptools import HttpParserUpgrade +from websockets import handshake, WebSocketCommonProtocol, InvalidHandshake +from websockets import ConnectionClosed # noqa + + +class WebSocketProtocol(HttpProtocol): + def __init__(self, *args, websocket_timeout=10, + websocket_max_size=None, + websocket_max_queue=None, + websocket_read_limit=2 ** 16, + websocket_write_limit=2 ** 16, **kwargs): + super().__init__(*args, **kwargs) + self.websocket = None + self.websocket_timeout = websocket_timeout + self.websocket_max_size = websocket_max_size + self.websocket_max_queue = websocket_max_queue + self.websocket_read_limit = websocket_read_limit + self.websocket_write_limit = websocket_write_limit + + # timeouts make no sense for websocket routes + def request_timeout_callback(self): + if self.websocket is None: + super().request_timeout_callback() + + def response_timeout_callback(self): + if self.websocket is None: + super().response_timeout_callback() + + def keep_alive_timeout_callback(self): + if self.websocket is None: + super().keep_alive_timeout_callback() + + def connection_lost(self, exc): + if self.websocket is not None: + self.websocket.connection_lost(exc) + super().connection_lost(exc) + + def data_received(self, data): + if self.websocket is not None: + # pass the data to the websocket protocol + self.websocket.data_received(data) + else: + try: + super().data_received(data) + except HttpParserUpgrade: + # this is okay, it just indicates we've got an upgrade request + pass + + def write_response(self, response): + if self.websocket is not None: + # websocket requests do not write a response + self.transport.close() + else: + super().write_response(response) + + async def websocket_handshake(self, request, subprotocols=None): + # let the websockets package do the handshake with the client + headers = [] + + def get_header(k): + return request.headers.get(k, '') + + def set_header(k, v): + headers.append((k, v)) + + try: + key = handshake.check_request(get_header) + handshake.build_response(set_header, key) + except InvalidHandshake: + raise InvalidUsage('Invalid websocket request') + + subprotocol = None + if subprotocols and 'Sec-Websocket-Protocol' in request.headers: + # select a subprotocol + client_subprotocols = [p.strip() for p in request.headers[ + 'Sec-Websocket-Protocol'].split(',')] + for p in client_subprotocols: + if p in subprotocols: + subprotocol = p + set_header('Sec-Websocket-Protocol', subprotocol) + break + + # write the 101 response back to the client + rv = b'HTTP/1.1 101 Switching Protocols\r\n' + for k, v in headers: + rv += k.encode('utf-8') + b': ' + v.encode('utf-8') + b'\r\n' + rv += b'\r\n' + request.transport.write(rv) + + # hook up the websocket protocol + self.websocket = WebSocketCommonProtocol( + timeout=self.websocket_timeout, + max_size=self.websocket_max_size, + max_queue=self.websocket_max_queue, + read_limit=self.websocket_read_limit, + write_limit=self.websocket_write_limit + ) + self.websocket.subprotocol = subprotocol + self.websocket.connection_made(request.transport) + self.websocket.connection_open() + return self.websocket diff --git a/sanic/worker.py b/sanic/worker.py new file mode 100644 index 00000000..d367a7c3 --- /dev/null +++ b/sanic/worker.py @@ -0,0 +1,210 @@ +import os +import sys +import signal +import asyncio +import logging +import traceback + +try: + import ssl +except ImportError: + ssl = None + +try: + import uvloop + asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) +except ImportError: + pass +import gunicorn.workers.base as base + +from sanic.server import trigger_events, serve, HttpProtocol, Signal +from sanic.websocket import WebSocketProtocol + + +class GunicornWorker(base.Worker): + + http_protocol = HttpProtocol + websocket_protocol = WebSocketProtocol + + def __init__(self, *args, **kw): # pragma: no cover + super().__init__(*args, **kw) + cfg = self.cfg + if cfg.is_ssl: + self.ssl_context = self._create_ssl_context(cfg) + else: + self.ssl_context = None + self.servers = {} + self.connections = set() + self.exit_code = 0 + self.signal = Signal() + + def init_process(self): + # create new event_loop after fork + asyncio.get_event_loop().close() + + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + + super().init_process() + + def run(self): + is_debug = self.log.loglevel == logging.DEBUG + protocol = ( + self.websocket_protocol if self.app.callable.websocket_enabled + else self.http_protocol) + self._server_settings = self.app.callable._helper( + loop=self.loop, + debug=is_debug, + protocol=protocol, + ssl=self.ssl_context, + run_async=True) + self._server_settings['signal'] = self.signal + self._server_settings.pop('sock') + trigger_events(self._server_settings.get('before_start', []), + self.loop) + self._server_settings['before_start'] = () + + self._runner = asyncio.ensure_future(self._run(), loop=self.loop) + try: + self.loop.run_until_complete(self._runner) + self.app.callable.is_running = True + trigger_events(self._server_settings.get('after_start', []), + self.loop) + self.loop.run_until_complete(self._check_alive()) + trigger_events(self._server_settings.get('before_stop', []), + self.loop) + self.loop.run_until_complete(self.close()) + except BaseException: + traceback.print_exc() + finally: + try: + trigger_events(self._server_settings.get('after_stop', []), + self.loop) + except BaseException: + traceback.print_exc() + finally: + self.loop.close() + + sys.exit(self.exit_code) + + async def close(self): + if self.servers: + # stop accepting connections + self.log.info("Stopping server: %s, connections: %s", + self.pid, len(self.connections)) + for server in self.servers: + server.close() + await server.wait_closed() + self.servers.clear() + + # prepare connections for closing + self.signal.stopped = True + for conn in self.connections: + conn.close_if_idle() + + # gracefully shutdown timeout + start_shutdown = 0 + graceful_shutdown_timeout = self.cfg.graceful_timeout + while self.connections and \ + (start_shutdown < graceful_shutdown_timeout): + await asyncio.sleep(0.1) + start_shutdown = start_shutdown + 0.1 + + # Force close non-idle connection after waiting for + # graceful_shutdown_timeout + coros = [] + for conn in self.connections: + if hasattr(conn, "websocket") and conn.websocket: + coros.append( + conn.websocket.close_connection() + ) + else: + conn.close() + _shutdown = asyncio.gather(*coros, loop=self.loop) + await _shutdown + + async def _run(self): + for sock in self.sockets: + state = dict(requests_count=0) + self._server_settings["host"] = None + self._server_settings["port"] = None + server = await serve( + sock=sock, + connections=self.connections, + state=state, + **self._server_settings + ) + self.servers[server] = state + + async def _check_alive(self): + # If our parent changed then we shut down. + pid = os.getpid() + try: + while self.alive: + self.notify() + + req_count = sum( + self.servers[srv]["requests_count"] for srv in self.servers + ) + if self.max_requests and req_count > self.max_requests: + self.alive = False + self.log.info("Max requests exceeded, shutting down: %s", + self) + elif pid == os.getpid() and self.ppid != os.getppid(): + self.alive = False + self.log.info("Parent changed, shutting down: %s", self) + else: + await asyncio.sleep(1.0, loop=self.loop) + except (Exception, BaseException, GeneratorExit, KeyboardInterrupt): + pass + + @staticmethod + def _create_ssl_context(cfg): + """ Creates SSLContext instance for usage in asyncio.create_server. + See ssl.SSLSocket.__init__ for more details. + """ + ctx = ssl.SSLContext(cfg.ssl_version) + ctx.load_cert_chain(cfg.certfile, cfg.keyfile) + ctx.verify_mode = cfg.cert_reqs + if cfg.ca_certs: + ctx.load_verify_locations(cfg.ca_certs) + if cfg.ciphers: + ctx.set_ciphers(cfg.ciphers) + return ctx + + def init_signals(self): + # Set up signals through the event loop API. + + self.loop.add_signal_handler(signal.SIGQUIT, self.handle_quit, + signal.SIGQUIT, None) + + self.loop.add_signal_handler(signal.SIGTERM, self.handle_exit, + signal.SIGTERM, None) + + self.loop.add_signal_handler(signal.SIGINT, self.handle_quit, + signal.SIGINT, None) + + self.loop.add_signal_handler(signal.SIGWINCH, self.handle_winch, + signal.SIGWINCH, None) + + self.loop.add_signal_handler(signal.SIGUSR1, self.handle_usr1, + signal.SIGUSR1, None) + + self.loop.add_signal_handler(signal.SIGABRT, self.handle_abort, + signal.SIGABRT, None) + + # Don't let SIGTERM and SIGUSR1 disturb active requests + # by interrupting system calls + signal.siginterrupt(signal.SIGTERM, False) + signal.siginterrupt(signal.SIGUSR1, False) + + def handle_quit(self, sig, frame): + self.alive = False + self.app.callable.is_running = False + self.cfg.worker_int(self) + + def handle_abort(self, sig, frame): + self.alive = False + self.exit_code = 1 + self.cfg.worker_abort(self) + sys.exit(1) diff --git a/setup.py b/setup.py index 03889fc5..34703ab4 100644 --- a/setup.py +++ b/setup.py @@ -1,28 +1,76 @@ """ Sanic """ +import codecs +import os +import re +from distutils.errors import DistutilsPlatformError +from distutils.util import strtobool + from setuptools import setup -setup( - name='Sanic', - version="0.1.3", - url='http://github.com/channelcat/sanic/', - license='MIT', - author='Channel Cat', - author_email='channelcat@gmail.com', - description='A microframework based on uvloop, httptools, and learnings of flask', - packages=['sanic'], - platforms='any', - install_requires=[ - 'uvloop>=0.5.3', - 'httptools>=0.0.9', - 'ujson>=1.35', - ], - classifiers=[ - 'Development Status :: 2 - Pre-Alpha', + +def open_local(paths, mode='r', encoding='utf8'): + path = os.path.join( + os.path.abspath(os.path.dirname(__file__)), + *paths + ) + + return codecs.open(path, mode, encoding) + + +with open_local(['sanic', '__init__.py'], encoding='latin1') as fp: + try: + version = re.findall(r"^__version__ = '([^']+)'\r?$", + fp.read(), re.M)[0] + except IndexError: + raise RuntimeError('Unable to determine version.') + + +with open_local(['README.rst']) as rm: + long_description = rm.read() + +setup_kwargs = { + 'name': 'sanic', + 'version': version, + 'url': 'http://github.com/channelcat/sanic/', + 'license': 'MIT', + 'author': 'Channel Cat', + 'author_email': 'channelcat@gmail.com', + 'description': ( + 'A microframework based on uvloop, httptools, and learnings of flask'), + 'long_description': long_description, + 'packages': ['sanic'], + 'platforms': 'any', + 'classifiers': [ + 'Development Status :: 4 - Beta', 'Environment :: Web Environment', 'License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', ], -) +} + +env_dependency = '; sys_platform != "win32" and implementation_name == "cpython"' +ujson = 'ujson>=1.35' + env_dependency +uvloop = 'uvloop>=0.5.3' + env_dependency + +requirements = [ + 'httptools>=0.0.9', + uvloop, + ujson, + 'aiofiles>=0.3.0', + 'websockets>=5.0,<6.0', + 'multidict>=4.0,<5.0', +] +if strtobool(os.environ.get("SANIC_NO_UJSON", "no")): + print("Installing without uJSON") + requirements.remove(ujson) + +# 'nt' means windows OS +if strtobool(os.environ.get("SANIC_NO_UVLOOP", "no")): + print("Installing without uvLoop") + requirements.remove(uvloop) + +setup_kwargs['install_requires'] = requirements +setup(**setup_kwargs) diff --git a/tests/certs/selfsigned.cert b/tests/certs/selfsigned.cert new file mode 100644 index 00000000..0dc7b914 --- /dev/null +++ b/tests/certs/selfsigned.cert @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDtTCCAp2gAwIBAgIJAO6wb0FSc/rNMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAlVTMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTcwMzAzMTUyODAzWhcNMTkxMTI4MTUyODAzWjBF +MQswCQYDVQQGEwJVUzETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAsy7Zb3p4yCEnUtPLwqeJrwj9u/ZmcFCrMAktFBx9hG6rY2r7mdB6Bflh +V5cUJXxnsNiDpYcxGhA8kry7pEork1vZ05DyZC9ulVlvxBouVShBcLLwdpaoTGqE +vYtejv6x7ogwMXOjkWWb1WpOv4CVhpeXJ7O/d1uAiYgcUpTpPp4ONG49IAouBHq3 +h+o4nVvNfB0J8gaCtTsTZqi1Wt8WYs3XjxGJaKh//ealfRe1kuv40CWQ8gjaC8/1 +w9pHdom3Wi/RwfDM3+dVGV6M5lAbPXMB4RK17Hk9P3hlJxJOpKBdgcBJPXtNrTwf +qEWWxk2mB/YVyB84AxjkkNoYyi2ggQIDAQABo4GnMIGkMB0GA1UdDgQWBBRa46Ix +9s9tmMqu+Zz1mocHghm4NTB1BgNVHSMEbjBsgBRa46Ix9s9tmMqu+Zz1mocHghm4 +NaFJpEcwRTELMAkGA1UEBhMCVVMxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV +BAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAO6wb0FSc/rNMAwGA1UdEwQF +MAMBAf8wDQYJKoZIhvcNAQELBQADggEBACdrnM8zb7abxAJsU5WLn1IR0f2+EFA7 +ezBEJBM4bn0IZrXuP5ThZ2wieJlshG0C16XN9+zifavHci+AtQwWsB0f/ppHdvWQ +7wt7JN88w+j0DNIYEadRCjWxR3gRAXPgKu3sdyScKFq8MvB49A2EdXRmQSTIM6Fj +teRbE+poxewFT0mhurf3xrtGiSALmv7uAzhRDqpYUzcUlbOGgkyFLYAOOdvZvei+ +mfXDi4HKYxgyv53JxBARMdajnCHXM7zQ6Tjc8j1HRtmDQ3XapUB559KfxfODGQq5 +zmeoZWU4duxcNXJM0Eiz1CJ39JoWwi8sqaGi/oskuyAh7YKyVTn8xa8= +-----END CERTIFICATE----- diff --git a/tests/certs/selfsigned.key b/tests/certs/selfsigned.key new file mode 100644 index 00000000..504ef7da --- /dev/null +++ b/tests/certs/selfsigned.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAsy7Zb3p4yCEnUtPLwqeJrwj9u/ZmcFCrMAktFBx9hG6rY2r7 +mdB6BflhV5cUJXxnsNiDpYcxGhA8kry7pEork1vZ05DyZC9ulVlvxBouVShBcLLw +dpaoTGqEvYtejv6x7ogwMXOjkWWb1WpOv4CVhpeXJ7O/d1uAiYgcUpTpPp4ONG49 +IAouBHq3h+o4nVvNfB0J8gaCtTsTZqi1Wt8WYs3XjxGJaKh//ealfRe1kuv40CWQ +8gjaC8/1w9pHdom3Wi/RwfDM3+dVGV6M5lAbPXMB4RK17Hk9P3hlJxJOpKBdgcBJ +PXtNrTwfqEWWxk2mB/YVyB84AxjkkNoYyi2ggQIDAQABAoIBAFgVasxTf3aaXbNo +7JzXMWb7W4iAG2GRNmZZzHA7hTSKFvS7jc3SX3n6WvDtEvlOi8ay2RyRNgEjBDP6 +VZ/w2jUJjS5k7dN0Qb9nhPr5B9fS/0CAppcVfsx5/KEVFzniWOPyzQYyW7FJKu8h +4G5hrp/Ie4UH5tKtB6YUZB/wliyyQUkAZdBcoy1hfkOZLAXb1oofArKsiQUHIRA5 +th1yyS4cZP8Upngd1EE+d95dFHM2F6iI2lj6DHuu+JxUZ+wKXoNimdG7JniRtIf4 +56GoDov83Ey+XbIS6FSQc9nY0ijBDcubl/yP3roCQpE+MZ9BNEo5uj7YmCtAMYLW +TXTNBGUCgYEA4wdkH1NLdub2NcpqwmSA0AtbRvDkt0XTDWWwmuMr/+xPVa4sUKHs +80THQEX/WAZroP6IPbMP6BJhzb53vECukgC65qPxu6M9D1lBGtglxgen4AMu1bKK +gnM8onwARGIo/2ay6qRRZZCxg0TvBky3hbTcIM2zVrnKU6VVyGKHSV8CgYEAygxs +WQYrACv3XN6ZEzyxy08JgjbcnkPWK/m3VPcyHgdEkDu8+nDdUVdbF/js2JWMMx5g +vrPhZ7jVLOXGcLr5mVU4dG5tW5lU0bMy+YYxpEQDiBKlpXgfOsQnakHj7cCZ6bay +mKjJck2oEAQS9bqOJN/Ts5vhOmc8rmhkO7hnAh8CgYEArhVDy9Vl/1WYo6SD+m1w +bJbYtewPpQzwicxZAFuDqKk+KDf3GRkhBWTO2FUUOB4sN3YVaCI+5zf5MPeE/qAm +fCP9LM+3k6bXMkbBamEljdTfACHQruJJ3T+Z1gn5dnZCc5z/QncfRx8NTtfz5MO8 +0dTeGnVAuBacs0kLHy2WCUcCgYALNBkl7pOf1NBIlAdE686oCV/rmoMtO3G6yoQB +8BsVUy3YGZfnAy8ifYeNkr3/XHuDsiGHMY5EJBmd/be9NID2oaUZv63MsHnljtw6 +vdgu1Z6kgvQwcrK4nXvaBoFPA6kFLp5EnMde0TOKf89VVNzg6pBgmzon9OWGfj9g +mF8N3QKBgQCeoLwxUxpzEA0CPHm7DWF0LefVGllgZ23Eqncdy0QRku5zwwibszbL +sWaR3uDCc3oYcbSGCDVx3cSkvMAJNalc5ZHPfoV9W0+v392/rrExo5iwD8CSoCb2 +gFWkeR7PBrD3NzFzFAWyiudzhBKHfRsB0MpCXbJV/WLqTlGIbEypjg== +-----END RSA PRIVATE KEY----- diff --git a/tests/performance/aiohttp/simple_server.py b/tests/performance/aiohttp/simple_server.py index 8cb97b33..7c61f723 100644 --- a/tests/performance/aiohttp/simple_server.py +++ b/tests/performance/aiohttp/simple_server.py @@ -15,4 +15,4 @@ async def handle(request): app = web.Application(loop=loop) app.router.add_route('GET', '/', handle) -web.run_app(app, port=sys.argv[1]) +web.run_app(app, port=sys.argv[1], access_log=None) diff --git a/tests/performance/falcon/simple_server.py b/tests/performance/falcon/simple_server.py new file mode 100644 index 00000000..4403ac14 --- /dev/null +++ b/tests/performance/falcon/simple_server.py @@ -0,0 +1,11 @@ +# Run with: gunicorn --workers=1 --worker-class=meinheld.gmeinheld.MeinheldWorker falc:app + +import falcon +import ujson as json + +class TestResource: + def on_get(self, req, resp): + resp.body = json.dumps({"test": True}) + +app = falcon.API() +app.add_route('/', TestResource()) diff --git a/tests/performance/golang/golang.http.go b/tests/performance/golang/golang.http.go index fb13cc8b..5aeedb61 100644 --- a/tests/performance/golang/golang.http.go +++ b/tests/performance/golang/golang.http.go @@ -1,16 +1,30 @@ package main import ( - "fmt" - "os" - "net/http" + "encoding/json" + "net/http" + "os" ) +type TestJSONResponse struct { + Test bool +} + func handler(w http.ResponseWriter, r *http.Request) { - fmt.Fprintf(w, "Hi there, I love %s!", r.URL.Path[1:]) + response := TestJSONResponse{true} + + js, err := json.Marshal(response) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(js) } func main() { - http.HandleFunc("/", handler) - http.ListenAndServe(":" + os.Args[1], nil) + http.HandleFunc("/", handler) + http.ListenAndServe(":"+os.Args[1], nil) } diff --git a/tests/performance/sanic/simple_server.py b/tests/performance/sanic/simple_server.py index 823b7b82..5cf86afd 100644 --- a/tests/performance/sanic/simple_server.py +++ b/tests/performance/sanic/simple_server.py @@ -15,5 +15,5 @@ app = Sanic("test") async def test(request): return json({"test": True}) - -app.run(host="0.0.0.0", port=sys.argv[1]) +if __name__ == '__main__': + app.run(host="0.0.0.0", port=sys.argv[1]) diff --git a/tests/performance/tornado/simple_server.py b/tests/performance/tornado/simple_server.py new file mode 100644 index 00000000..32192900 --- /dev/null +++ b/tests/performance/tornado/simple_server.py @@ -0,0 +1,19 @@ +# Run with: python simple_server.py +import ujson +from tornado import ioloop, web + + +class MainHandler(web.RequestHandler): + def get(self): + self.write(ujson.dumps({'test': True})) + + +app = web.Application([ + (r'/', MainHandler) +], debug=False, + compress_response=False, + static_hash_cache=True +) + +app.listen(8000) +ioloop.IOLoop.current().start() diff --git a/tests/static/bp/decode me.txt b/tests/static/bp/decode me.txt new file mode 100644 index 00000000..b1c36682 --- /dev/null +++ b/tests/static/bp/decode me.txt @@ -0,0 +1 @@ +I am just a regular static file that needs to have its uri decoded diff --git a/tests/static/bp/python.png b/tests/static/bp/python.png new file mode 100644 index 00000000..52fda109 Binary files /dev/null and b/tests/static/bp/python.png differ diff --git a/tests/static/bp/test.file b/tests/static/bp/test.file new file mode 100644 index 00000000..0725a6ef --- /dev/null +++ b/tests/static/bp/test.file @@ -0,0 +1 @@ +I am just a regular static file diff --git a/tests/static/decode me.txt b/tests/static/decode me.txt new file mode 100644 index 00000000..b1c36682 --- /dev/null +++ b/tests/static/decode me.txt @@ -0,0 +1 @@ +I am just a regular static file that needs to have its uri decoded diff --git a/tests/static/python.png b/tests/static/python.png new file mode 100644 index 00000000..52fda109 Binary files /dev/null and b/tests/static/python.png differ diff --git a/tests/static/test.file b/tests/static/test.file new file mode 100644 index 00000000..0725a6ef --- /dev/null +++ b/tests/static/test.file @@ -0,0 +1 @@ +I am just a regular static file diff --git a/tests/static/test.html b/tests/static/test.html new file mode 100644 index 00000000..4ba71873 --- /dev/null +++ b/tests/static/test.html @@ -0,0 +1,26 @@ + + +
+                 ▄▄▄▄▄
+        ▀▀▀██████▄▄▄       _______________
+      ▄▄▄▄▄  █████████▄  /                 \
+     ▀▀▀▀█████▌ ▀▐▄ ▀▐█ |   Gotta go fast!  |
+   ▀▀█████▄▄ ▀██████▄██ | _________________/
+   ▀▄▄▄▄▄  ▀▀█▄▀█════█▀ |/
+        ▀▀▀▄  ▀▀███ ▀       ▄▄
+     ▄███▀▀██▄████████▄ ▄▀▀▀▀▀▀█▌
+   ██▀▄▄▄██▀▄███▀ ▀▀████      ▄██
+▄▀▀▀▄██▄▀▀▌████▒▒▒▒▒▒███     ▌▄▄▀
+▌    ▐▀████▐███▒▒▒▒▒▐██▌
+▀▄▄▄▄▀   ▀▀████▒▒▒▒▄██▀
+          ▀▀█████████▀
+        ▄▄██▀██████▀█
+      ▄██▀     ▀▀▀  █
+     ▄█             ▐▌
+ ▄▄▄▄█▌              ▀█▄▄▄▄▀▀▄
+▌     ▐                ▀▀▄▄▄▀
+ ▀▀▄▄▀
+
+
+ + diff --git a/tests/test_bad_request.py b/tests/test_bad_request.py new file mode 100644 index 00000000..bf595085 --- /dev/null +++ b/tests/test_bad_request.py @@ -0,0 +1,21 @@ +import asyncio +from sanic import Sanic + + +def test_bad_request_response(): + app = Sanic('test_bad_request_response') + lines = [] + @app.listener('after_server_start') + async def _request(sanic, loop): + connect = asyncio.open_connection('127.0.0.1', 42101) + reader, writer = await connect + writer.write(b'not http') + while True: + line = await reader.readline() + if not line: + break + lines.append(line) + app.stop() + app.run(host='127.0.0.1', port=42101, debug=False) + assert lines[0] == b'HTTP/1.1 400 Bad Request\r\n' + assert lines[-1] == b'Error: Bad Request' diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 8068160f..37756085 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -1,14 +1,51 @@ +import asyncio +import inspect +import os +import pytest + from sanic import Sanic from sanic.blueprints import Blueprint from sanic.response import json, text -from sanic.utils import sanic_endpoint_test from sanic.exceptions import NotFound, ServerError, InvalidUsage +from sanic.constants import HTTP_METHODS # ------------------------------------------------------------ # # GET # ------------------------------------------------------------ # +def get_file_path(static_file_directory, file_name): + return os.path.join(static_file_directory, file_name) + +def get_file_content(static_file_directory, file_name): + """The content of the static file to check""" + with open(get_file_path(static_file_directory, file_name), 'rb') as file: + return file.read() + +@pytest.mark.parametrize('method', HTTP_METHODS) +def test_versioned_routes_get(method): + app = Sanic('test_shorhand_routes_get') + bp = Blueprint('test_text') + + method = method.lower() + + func = getattr(bp, method) + if callable(func): + @func('/{}'.format(method), version=1) + def handler(request): + return text('OK') + else: + print(func) + raise + + app.blueprint(bp) + + client_method = getattr(app.test_client, method) + + request, response = client_method('/v1/{}'.format(method)) + assert response.status == 200 + + def test_bp(): app = Sanic('test_text') bp = Blueprint('test_text') @@ -17,11 +54,99 @@ def test_bp(): def handler(request): return text('Hello') - app.register_blueprint(bp) - request, response = sanic_endpoint_test(app) + app.blueprint(bp) + request, response = app.test_client.get('/') + assert app.is_request_stream is False assert response.text == 'Hello' +def test_bp_strict_slash(): + app = Sanic('test_route_strict_slash') + bp = Blueprint('test_text') + + @bp.get('/get', strict_slashes=True) + def handler(request): + return text('OK') + + @bp.post('/post/', strict_slashes=True) + def handler(request): + return text('OK') + + app.blueprint(bp) + + request, response = app.test_client.get('/get') + assert response.text == 'OK' + assert response.json == None + + request, response = app.test_client.get('/get/') + assert response.status == 404 + + request, response = app.test_client.post('/post/') + assert response.text == 'OK' + + request, response = app.test_client.post('/post') + assert response.status == 404 + +def test_bp_strict_slash_default_value(): + app = Sanic('test_route_strict_slash') + bp = Blueprint('test_text', strict_slashes=True) + + @bp.get('/get') + def handler(request): + return text('OK') + + @bp.post('/post/') + def handler(request): + return text('OK') + + app.blueprint(bp) + + request, response = app.test_client.get('/get/') + assert response.status == 404 + + request, response = app.test_client.post('/post') + assert response.status == 404 + +def test_bp_strict_slash_without_passing_default_value(): + app = Sanic('test_route_strict_slash') + bp = Blueprint('test_text') + + @bp.get('/get') + def handler(request): + return text('OK') + + @bp.post('/post/') + def handler(request): + return text('OK') + + app.blueprint(bp) + + request, response = app.test_client.get('/get/') + assert response.text == 'OK' + + request, response = app.test_client.post('/post') + assert response.text == 'OK' + +def test_bp_strict_slash_default_value_can_be_overwritten(): + app = Sanic('test_route_strict_slash') + bp = Blueprint('test_text', strict_slashes=True) + + @bp.get('/get', strict_slashes=False) + def handler(request): + return text('OK') + + @bp.post('/post/', strict_slashes=False) + def handler(request): + return text('OK') + + app.blueprint(bp) + + request, response = app.test_client.get('/get/') + assert response.text == 'OK' + + request, response = app.test_client.post('/post') + assert response.text == 'OK' + def test_bp_with_url_prefix(): app = Sanic('test_text') bp = Blueprint('test_text', url_prefix='/test1') @@ -30,8 +155,8 @@ def test_bp_with_url_prefix(): def handler(request): return text('Hello') - app.register_blueprint(bp) - request, response = sanic_endpoint_test(app, uri='/test1/') + app.blueprint(bp) + request, response = app.test_client.get('/test1/') assert response.text == 'Hello' @@ -49,14 +174,84 @@ def test_several_bp_with_url_prefix(): def handler2(request): return text('Hello2') - app.register_blueprint(bp) - app.register_blueprint(bp2) - request, response = sanic_endpoint_test(app, uri='/test1/') + app.blueprint(bp) + app.blueprint(bp2) + request, response = app.test_client.get('/test1/') assert response.text == 'Hello' - request, response = sanic_endpoint_test(app, uri='/test2/') + request, response = app.test_client.get('/test2/') assert response.text == 'Hello2' +def test_bp_with_host(): + app = Sanic('test_bp_host') + bp = Blueprint('test_bp_host', url_prefix='/test1', host="example.com") + + @bp.route('/') + def handler(request): + return text('Hello') + + @bp.route('/', host="sub.example.com") + def handler(request): + return text('Hello subdomain!') + + app.blueprint(bp) + headers = {"Host": "example.com"} + request, response = app.test_client.get( + '/test1/', + headers=headers) + assert response.text == 'Hello' + + headers = {"Host": "sub.example.com"} + request, response = app.test_client.get( + '/test1/', + headers=headers) + + assert response.text == 'Hello subdomain!' + + +def test_several_bp_with_host(): + app = Sanic('test_text') + bp = Blueprint('test_text', + url_prefix='/test', + host="example.com") + bp2 = Blueprint('test_text2', + url_prefix='/test', + host="sub.example.com") + + @bp.route('/') + def handler(request): + return text('Hello') + + @bp2.route('/') + def handler2(request): + return text('Hello2') + + @bp2.route('/other/') + def handler2(request): + return text('Hello3') + + + app.blueprint(bp) + app.blueprint(bp2) + + assert bp.host == "example.com" + headers = {"Host": "example.com"} + request, response = app.test_client.get( + '/test/', + headers=headers) + assert response.text == 'Hello' + + assert bp2.host == "sub.example.com" + headers = {"Host": "sub.example.com"} + request, response = app.test_client.get( + '/test/', + headers=headers) + + assert response.text == 'Hello2' + request, response = app.test_client.get( + '/test/other/', + headers=headers) + assert response.text == 'Hello3' def test_bp_middleware(): app = Sanic('test_middleware') @@ -70,9 +265,9 @@ def test_bp_middleware(): async def handler(request): return text('FAIL') - app.register_blueprint(blueprint) + app.blueprint(blueprint) - request, response = sanic_endpoint_test(app) + request, response = app.test_client.get('/') assert response.status == 200 assert response.text == 'OK' @@ -97,15 +292,229 @@ def test_bp_exception_handler(): def handler_exception(request, exception): return text("OK") - app.register_blueprint(blueprint) + app.blueprint(blueprint) - request, response = sanic_endpoint_test(app, uri='/1') + request, response = app.test_client.get('/1') assert response.status == 400 - request, response = sanic_endpoint_test(app, uri='/2') + request, response = app.test_client.get('/2') assert response.status == 200 assert response.text == 'OK' - request, response = sanic_endpoint_test(app, uri='/3') - assert response.status == 200 \ No newline at end of file + request, response = app.test_client.get('/3') + assert response.status == 200 + +def test_bp_listeners(): + app = Sanic('test_middleware') + blueprint = Blueprint('test_middleware') + + order = [] + + @blueprint.listener('before_server_start') + def handler_1(sanic, loop): + order.append(1) + + @blueprint.listener('after_server_start') + def handler_2(sanic, loop): + order.append(2) + + @blueprint.listener('after_server_start') + def handler_3(sanic, loop): + order.append(3) + + @blueprint.listener('before_server_stop') + def handler_4(sanic, loop): + order.append(5) + + @blueprint.listener('before_server_stop') + def handler_5(sanic, loop): + order.append(4) + + @blueprint.listener('after_server_stop') + def handler_6(sanic, loop): + order.append(6) + + app.blueprint(blueprint) + + request, response = app.test_client.get('/') + + assert order == [1,2,3,4,5,6] + +def test_bp_static(): + current_file = inspect.getfile(inspect.currentframe()) + with open(current_file, 'rb') as file: + current_file_contents = file.read() + + app = Sanic('test_static') + blueprint = Blueprint('test_static') + + blueprint.static('/testing.file', current_file) + + app.blueprint(blueprint) + + request, response = app.test_client.get('/testing.file') + assert response.status == 200 + assert response.body == current_file_contents + +@pytest.mark.parametrize('file_name', ['test.html']) +def test_bp_static_content_type(file_name): + # This is done here, since no other test loads a file here + current_file = inspect.getfile(inspect.currentframe()) + current_directory = os.path.dirname(os.path.abspath(current_file)) + static_directory = os.path.join(current_directory, 'static') + + app = Sanic('test_static') + blueprint = Blueprint('test_static') + blueprint.static( + '/testing.file', + get_file_path(static_directory, file_name), + content_type='text/html; charset=utf-8' + ) + + app.blueprint(blueprint) + + request, response = app.test_client.get('/testing.file') + assert response.status == 200 + assert response.body == get_file_content(static_directory, file_name) + assert response.headers['Content-Type'] == 'text/html; charset=utf-8' + +def test_bp_shorthand(): + app = Sanic('test_shorhand_routes') + blueprint = Blueprint('test_shorhand_routes') + ev = asyncio.Event() + + @blueprint.get('/get') + def handler(request): + assert request.stream is None + return text('OK') + + @blueprint.put('/put') + def handler(request): + assert request.stream is None + return text('OK') + + @blueprint.post('/post') + def handler(request): + assert request.stream is None + return text('OK') + + @blueprint.head('/head') + def handler(request): + assert request.stream is None + return text('OK') + + @blueprint.options('/options') + def handler(request): + assert request.stream is None + return text('OK') + + @blueprint.patch('/patch') + def handler(request): + assert request.stream is None + return text('OK') + + @blueprint.delete('/delete') + def handler(request): + assert request.stream is None + return text('OK') + + @blueprint.websocket('/ws') + async def handler(request, ws): + assert request.stream is None + ev.set() + + app.blueprint(blueprint) + + assert app.is_request_stream is False + + request, response = app.test_client.get('/get') + assert response.text == 'OK' + + request, response = app.test_client.post('/get') + assert response.status == 405 + + request, response = app.test_client.put('/put') + assert response.text == 'OK' + + request, response = app.test_client.get('/post') + assert response.status == 405 + + request, response = app.test_client.post('/post') + assert response.text == 'OK' + + request, response = app.test_client.get('/post') + assert response.status == 405 + + request, response = app.test_client.head('/head') + assert response.status == 200 + + request, response = app.test_client.get('/head') + assert response.status == 405 + + request, response = app.test_client.options('/options') + assert response.text == 'OK' + + request, response = app.test_client.get('/options') + assert response.status == 405 + + request, response = app.test_client.patch('/patch') + assert response.text == 'OK' + + request, response = app.test_client.get('/patch') + assert response.status == 405 + + request, response = app.test_client.delete('/delete') + assert response.text == 'OK' + + request, response = app.test_client.get('/delete') + assert response.status == 405 + + request, response = app.test_client.get('/ws', headers={ + 'Upgrade': 'websocket', + 'Connection': 'upgrade', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': '13'}) + assert response.status == 101 + assert ev.is_set() + +def test_bp_group(): + app = Sanic('test_nested_bp_groups') + + deep_0 = Blueprint('deep_0', url_prefix='/deep') + deep_1 = Blueprint('deep_1', url_prefix = '/deep1') + + @deep_0.route('/') + def handler(request): + return text('D0_OK') + + @deep_1.route('/bottom') + def handler(request): + return text('D1B_OK') + + mid_0 = Blueprint.group(deep_0, deep_1, url_prefix='/mid') + mid_1 = Blueprint('mid_tier', url_prefix='/mid1') + + @mid_1.route('/') + def handler(request): + return text('M1_OK') + + top = Blueprint.group(mid_0, mid_1) + + app.blueprint(top) + + @app.route('/') + def handler(request): + return text('TOP_OK') + + request, response = app.test_client.get('/') + assert response.text == 'TOP_OK' + + request, response = app.test_client.get('/mid1') + assert response.text == 'M1_OK' + + request, response = app.test_client.get('/mid/deep') + assert response.text == 'D0_OK' + + request, response = app.test_client.get('/mid/deep1/bottom') + assert response.text == 'D1B_OK' diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..e393d02b --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,93 @@ +from os import environ +import pytest +from tempfile import NamedTemporaryFile + +from sanic import Sanic + + +def test_load_from_object(): + app = Sanic('test_load_from_object') + class Config: + not_for_config = 'should not be used' + CONFIG_VALUE = 'should be used' + + app.config.from_object(Config) + assert 'CONFIG_VALUE' in app.config + assert app.config.CONFIG_VALUE == 'should be used' + assert 'not_for_config' not in app.config + +def test_auto_load_env(): + environ["SANIC_TEST_ANSWER"] = "42" + app = Sanic() + assert app.config.TEST_ANSWER == 42 + del environ["SANIC_TEST_ANSWER"] + +def test_dont_load_env(): + environ["SANIC_TEST_ANSWER"] = "42" + app = Sanic(load_env=False) + assert getattr(app.config, 'TEST_ANSWER', None) == None + del environ["SANIC_TEST_ANSWER"] + +def test_load_env_prefix(): + environ["MYAPP_TEST_ANSWER"] = "42" + app = Sanic(load_env='MYAPP_') + assert app.config.TEST_ANSWER == 42 + del environ["MYAPP_TEST_ANSWER"] + +def test_load_from_file(): + app = Sanic('test_load_from_file') + config = b""" +VALUE = 'some value' +condition = 1 == 1 +if condition: + CONDITIONAL = 'should be set' + """ + with NamedTemporaryFile() as config_file: + config_file.write(config) + config_file.seek(0) + app.config.from_pyfile(config_file.name) + assert 'VALUE' in app.config + assert app.config.VALUE == 'some value' + assert 'CONDITIONAL' in app.config + assert app.config.CONDITIONAL == 'should be set' + assert 'condition' not in app.config + + +def test_load_from_missing_file(): + app = Sanic('test_load_from_missing_file') + with pytest.raises(IOError): + app.config.from_pyfile('non-existent file') + + +def test_load_from_envvar(): + app = Sanic('test_load_from_envvar') + config = b"VALUE = 'some value'" + with NamedTemporaryFile() as config_file: + config_file.write(config) + config_file.seek(0) + environ['APP_CONFIG'] = config_file.name + app.config.from_envvar('APP_CONFIG') + assert 'VALUE' in app.config + assert app.config.VALUE == 'some value' + + +def test_load_from_missing_envvar(): + app = Sanic('test_load_from_missing_envvar') + with pytest.raises(RuntimeError): + app.config.from_envvar('non-existent variable') + + +def test_overwrite_exisiting_config(): + app = Sanic('test_overwrite_exisiting_config') + app.config.DEFAULT = 1 + class Config: + DEFAULT = 2 + + app.config.from_object(Config) + assert app.config.DEFAULT == 2 + + +def test_missing_config(): + app = Sanic('test_missing_config') + with pytest.raises(AttributeError): + app.config.NON_EXISTENT diff --git a/tests/test_cookies.py b/tests/test_cookies.py new file mode 100644 index 00000000..61f50735 --- /dev/null +++ b/tests/test_cookies.py @@ -0,0 +1,116 @@ +from datetime import datetime, timedelta +from http.cookies import SimpleCookie +from sanic import Sanic +from sanic.response import json, text +import pytest + + +# ------------------------------------------------------------ # +# GET +# ------------------------------------------------------------ # + +def test_cookies(): + app = Sanic('test_text') + + @app.route('/') + def handler(request): + response = text('Cookies are: {}'.format(request.cookies['test'])) + response.cookies['right_back'] = 'at you' + return response + + request, response = app.test_client.get('/', cookies={"test": "working!"}) + response_cookies = SimpleCookie() + response_cookies.load(response.headers.get('Set-Cookie', {})) + + assert response.text == 'Cookies are: working!' + assert response_cookies['right_back'].value == 'at you' + + +@pytest.mark.parametrize("httponly,expected", [ + (False, False), + (True, True), +]) +def test_false_cookies_encoded(httponly, expected): + app = Sanic('test_text') + + @app.route('/') + def handler(request): + response = text('hello cookies') + response.cookies['hello'] = 'world' + response.cookies['hello']['httponly'] = httponly + return text(response.cookies['hello'].encode('utf8')) + + request, response = app.test_client.get('/') + + assert ('HttpOnly' in response.text) == expected + + +@pytest.mark.parametrize("httponly,expected", [ + (False, False), + (True, True), +]) +def test_false_cookies(httponly, expected): + app = Sanic('test_text') + + @app.route('/') + def handler(request): + response = text('hello cookies') + response.cookies['right_back'] = 'at you' + response.cookies['right_back']['httponly'] = httponly + return response + + request, response = app.test_client.get('/') + response_cookies = SimpleCookie() + response_cookies.load(response.headers.get('Set-Cookie', {})) + + assert ('HttpOnly' in response_cookies['right_back'].output()) == expected + +def test_http2_cookies(): + app = Sanic('test_http2_cookies') + + @app.route('/') + async def handler(request): + response = text('Cookies are: {}'.format(request.cookies['test'])) + return response + + headers = {'cookie': 'test=working!'} + request, response = app.test_client.get('/', headers=headers) + + assert response.text == 'Cookies are: working!' + +def test_cookie_options(): + app = Sanic('test_text') + + @app.route('/') + def handler(request): + response = text("OK") + response.cookies['test'] = 'at you' + response.cookies['test']['httponly'] = True + response.cookies['test']['expires'] = datetime.now() + timedelta(seconds=10) + return response + + request, response = app.test_client.get('/') + response_cookies = SimpleCookie() + response_cookies.load(response.headers.get('Set-Cookie', {})) + + assert response_cookies['test'].value == 'at you' + assert response_cookies['test']['httponly'] == True + +def test_cookie_deletion(): + app = Sanic('test_text') + + @app.route('/') + def handler(request): + response = text("OK") + del response.cookies['i_want_to_die'] + response.cookies['i_never_existed'] = 'testing' + del response.cookies['i_never_existed'] + return response + + request, response = app.test_client.get('/') + response_cookies = SimpleCookie() + response_cookies.load(response.headers.get('Set-Cookie', {})) + + assert int(response_cookies['i_want_to_die']['max-age']) == 0 + with pytest.raises(KeyError): + hold_my_beer = response.cookies['i_never_existed'] diff --git a/tests/test_create_task.py b/tests/test_create_task.py new file mode 100644 index 00000000..1517ca8c --- /dev/null +++ b/tests/test_create_task.py @@ -0,0 +1,47 @@ +from sanic import Sanic +from sanic.response import text +from threading import Event +import asyncio +from queue import Queue + + +def test_create_task(): + e = Event() + + async def coro(): + await asyncio.sleep(0.05) + e.set() + + app = Sanic('test_create_task') + app.add_task(coro) + + @app.route('/early') + def not_set(request): + return text(e.is_set()) + + @app.route('/late') + async def set(request): + await asyncio.sleep(0.1) + return text(e.is_set()) + + request, response = app.test_client.get('/early') + assert response.body == b'False' + + request, response = app.test_client.get('/late') + assert response.body == b'True' + +def test_create_task_with_app_arg(): + app = Sanic('test_add_task') + q = Queue() + + @app.route('/') + def not_set(request): + return "hello" + + async def coro(app): + q.put(app.name) + + app.add_task(coro) + + request, response = app.test_client.get('/') + assert q.get() == 'test_add_task' diff --git a/tests/test_custom_protocol.py b/tests/test_custom_protocol.py new file mode 100644 index 00000000..74564012 --- /dev/null +++ b/tests/test_custom_protocol.py @@ -0,0 +1,31 @@ +from sanic import Sanic +from sanic.server import HttpProtocol +from sanic.response import text + +app = Sanic('test_custom_porotocol') + + +class CustomHttpProtocol(HttpProtocol): + + def write_response(self, response): + if isinstance(response, str): + response = text(response) + self.transport.write( + response.output(self.request.version) + ) + self.transport.close() + + +@app.route('/1') +async def handler_1(request): + return 'OK' + + +def test_use_custom_protocol(): + server_kwargs = { + 'protocol': CustomHttpProtocol + } + request, response = app.test_client.get( + '/1', server_kwargs=server_kwargs) + assert response.status == 200 + assert response.text == 'OK' diff --git a/tests/test_dynamic_routes.py b/tests/test_dynamic_routes.py new file mode 100644 index 00000000..950584a8 --- /dev/null +++ b/tests/test_dynamic_routes.py @@ -0,0 +1,44 @@ +from sanic import Sanic +from sanic.response import text +from sanic.router import RouteExists +import pytest + + +@pytest.mark.parametrize("method,attr, expected", [ + ("get", "text", "OK1 test"), + ("post", "text", "OK2 test"), + ("put", "text", "OK2 test"), + ("delete", "status", 405), +]) +def test_overload_dynamic_routes(method, attr, expected): + app = Sanic('test_dynamic_route') + + @app.route('/overload/', methods=['GET']) + async def handler1(request, param): + return text('OK1 ' + param) + + @app.route('/overload/', methods=['POST', 'PUT']) + async def handler2(request, param): + return text('OK2 ' + param) + + request, response = getattr(app.test_client, method)('/overload/test') + assert getattr(response, attr) == expected + + +def test_overload_dynamic_routes_exist(): + app = Sanic('test_dynamic_route') + + @app.route('/overload/', methods=['GET']) + async def handler1(request, param): + return text('OK1 ' + param) + + @app.route('/overload/', methods=['POST', 'PUT']) + async def handler2(request, param): + return text('OK2 ' + param) + + # if this doesn't raise an error, than at least the below should happen: + # assert response.text == 'Duplicated' + with pytest.raises(RouteExists): + @app.route('/overload/', methods=['PUT', 'DELETE']) + async def handler3(request): + return text('Duplicated') diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 28e766cd..b4e2c6ea 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,51 +1,204 @@ +import pytest +from bs4 import BeautifulSoup + from sanic import Sanic from sanic.response import text -from sanic.exceptions import InvalidUsage, ServerError, NotFound -from sanic.utils import sanic_endpoint_test - -# ------------------------------------------------------------ # -# GET -# ------------------------------------------------------------ # - -exception_app = Sanic('test_exceptions') +from sanic.exceptions import InvalidUsage, ServerError, NotFound, Unauthorized +from sanic.exceptions import Forbidden, abort -@exception_app.route('/') -def handler(request): - return text('OK') +class SanicExceptionTestException(Exception): + pass -@exception_app.route('/error') -def handler_error(request): - raise ServerError("OK") +@pytest.fixture(scope='module') +def exception_app(): + app = Sanic('test_exceptions') + + @app.route('/') + def handler(request): + return text('OK') + + @app.route('/error') + def handler_error(request): + raise ServerError("OK") + + @app.route('/404') + def handler_404(request): + raise NotFound("OK") + + @app.route('/403') + def handler_403(request): + raise Forbidden("Forbidden") + + @app.route('/401') + def handler_401(request): + raise Unauthorized("Unauthorized") + + @app.route('/401/basic') + def handler_401_basic(request): + raise Unauthorized("Unauthorized", scheme="Basic", realm="Sanic") + + @app.route('/401/digest') + def handler_401_digest(request): + raise Unauthorized("Unauthorized", + scheme="Digest", + realm="Sanic", + qop="auth, auth-int", + algorithm="MD5", + nonce="abcdef", + opaque="zyxwvu") + + @app.route('/401/bearer') + def handler_401_bearer(request): + raise Unauthorized("Unauthorized", scheme="Bearer") + + @app.route('/invalid') + def handler_invalid(request): + raise InvalidUsage("OK") + + @app.route('/abort/401') + def handler_401_error(request): + abort(401) + + @app.route('/abort') + def handler_500_error(request): + abort(500) + return text("OK") + + @app.route('/divide_by_zero') + def handle_unhandled_exception(request): + 1 / 0 + + @app.route('/error_in_error_handler_handler') + def custom_error_handler(request): + raise SanicExceptionTestException('Dummy message!') + + @app.exception(SanicExceptionTestException) + def error_in_error_handler_handler(request, exception): + 1 / 0 + + return app -@exception_app.route('/404') -def handler_404(request): - raise NotFound("OK") +def test_catch_exception_list(): + app = Sanic('exception_list') + + @app.exception([SanicExceptionTestException, NotFound]) + def exception_list(request, exception): + return text("ok") + + @app.route('/') + def exception(request): + raise SanicExceptionTestException("You won't see me") + + request, response = app.test_client.get('/random') + assert response.text == 'ok' + + request, response = app.test_client.get('/') + assert response.text == 'ok' -@exception_app.route('/invalid') -def handler_invalid(request): - raise InvalidUsage("OK") - - -def test_no_exception(): - request, response = sanic_endpoint_test(exception_app) +def test_no_exception(exception_app): + """Test that a route works without an exception""" + request, response = exception_app.test_client.get('/') assert response.status == 200 assert response.text == 'OK' -def test_server_error_exception(): - request, response = sanic_endpoint_test(exception_app, uri='/error') +def test_server_error_exception(exception_app): + """Test the built-in ServerError exception works""" + request, response = exception_app.test_client.get('/error') assert response.status == 500 -def test_invalid_usage_exception(): - request, response = sanic_endpoint_test(exception_app, uri='/invalid') +def test_invalid_usage_exception(exception_app): + """Test the built-in InvalidUsage exception works""" + request, response = exception_app.test_client.get('/invalid') assert response.status == 400 -def test_not_found_exception(): - request, response = sanic_endpoint_test(exception_app, uri='/404') +def test_not_found_exception(exception_app): + """Test the built-in NotFound exception works""" + request, response = exception_app.test_client.get('/404') assert response.status == 404 + + +def test_forbidden_exception(exception_app): + """Test the built-in Forbidden exception""" + request, response = exception_app.test_client.get('/403') + assert response.status == 403 + + +def test_unauthorized_exception(exception_app): + """Test the built-in Unauthorized exception""" + request, response = exception_app.test_client.get('/401') + assert response.status == 401 + + request, response = exception_app.test_client.get('/401/basic') + assert response.status == 401 + assert response.headers.get('WWW-Authenticate') is not None + assert response.headers.get('WWW-Authenticate') == 'Basic realm="Sanic"' + + request, response = exception_app.test_client.get('/401/digest') + assert response.status == 401 + + auth_header = response.headers.get('WWW-Authenticate') + assert auth_header is not None + assert auth_header.startswith('Digest') + assert 'qop="auth, auth-int"' in auth_header + assert 'algorithm="MD5"' in auth_header + assert 'nonce="abcdef"' in auth_header + assert 'opaque="zyxwvu"' in auth_header + + request, response = exception_app.test_client.get('/401/bearer') + assert response.status == 401 + assert response.headers.get('WWW-Authenticate') == "Bearer" + + +def test_handled_unhandled_exception(exception_app): + """Test that an exception not built into sanic is handled""" + request, response = exception_app.test_client.get('/divide_by_zero') + assert response.status == 500 + soup = BeautifulSoup(response.body, 'html.parser') + assert soup.h1.text == 'Internal Server Error' + + message = " ".join(soup.p.text.split()) + assert message == ( + "The server encountered an internal error and " + "cannot complete your request.") + + +def test_exception_in_exception_handler(exception_app): + """Test that an exception thrown in an error handler is handled""" + request, response = exception_app.test_client.get( + '/error_in_error_handler_handler') + assert response.status == 500 + assert response.body == b'An error occurred while handling an error' + + +def test_exception_in_exception_handler_debug_off(exception_app): + """Test that an exception thrown in an error handler is handled""" + request, response = exception_app.test_client.get( + '/error_in_error_handler_handler', + debug=False) + assert response.status == 500 + assert response.body == b'An error occurred while handling an error' + + +def test_exception_in_exception_handler_debug_on(exception_app): + """Test that an exception thrown in an error handler is handled""" + request, response = exception_app.test_client.get( + '/error_in_error_handler_handler', + debug=True) + assert response.status == 500 + assert response.body.startswith(b'Exception raised in exception ') + + +def test_abort(exception_app): + """Test the abort function""" + request, response = exception_app.test_client.get('/abort/401') + assert response.status == 401 + + request, response = exception_app.test_client.get('/abort') + assert response.status == 500 diff --git a/tests/test_exceptions_handler.py b/tests/test_exceptions_handler.py index 2e8bc359..6a959382 100644 --- a/tests/test_exceptions_handler.py +++ b/tests/test_exceptions_handler.py @@ -1,7 +1,8 @@ from sanic import Sanic from sanic.response import text from sanic.exceptions import InvalidUsage, ServerError, NotFound -from sanic.utils import sanic_endpoint_test +from sanic.handlers import ErrorHandler +from bs4 import BeautifulSoup exception_handler_app = Sanic('test_exception_handler') @@ -21,29 +22,133 @@ def handler_3(request): raise NotFound("OK") +@exception_handler_app.route('/4') +def handler_4(request): + foo = bar # noqa -- F821 undefined name 'bar' is done to throw exception + return text(foo) + + +@exception_handler_app.route('/5') +def handler_5(request): + class CustomServerError(ServerError): + pass + raise CustomServerError('Custom server error') + + +@exception_handler_app.route('/6/') +def handler_6(request, arg): + try: + foo = 1 / arg + except Exception as e: + raise e from ValueError("{}".format(arg)) + return text(foo) + + @exception_handler_app.exception(NotFound, ServerError) def handler_exception(request, exception): return text("OK") def test_invalid_usage_exception_handler(): - request, response = sanic_endpoint_test(exception_handler_app, uri='/1') + request, response = exception_handler_app.test_client.get('/1') assert response.status == 400 def test_server_error_exception_handler(): - request, response = sanic_endpoint_test(exception_handler_app, uri='/2') + request, response = exception_handler_app.test_client.get('/2') assert response.status == 200 assert response.text == 'OK' def test_not_found_exception_handler(): - request, response = sanic_endpoint_test(exception_handler_app, uri='/3') + request, response = exception_handler_app.test_client.get('/3') assert response.status == 200 def test_text_exception__handler(): - request, response = sanic_endpoint_test( - exception_handler_app, uri='/random') + request, response = exception_handler_app.test_client.get('/random') assert response.status == 200 assert response.text == 'OK' + + +def test_html_traceback_output_in_debug_mode(): + request, response = exception_handler_app.test_client.get( + '/4', debug=True) + assert response.status == 500 + soup = BeautifulSoup(response.body, 'html.parser') + html = str(soup) + + assert 'response = handler(request, *args, **kwargs)' in html + assert 'handler_4' in html + assert 'foo = bar' in html + + summary_text = " ".join(soup.select('.summary')[0].text.split()) + assert ( + "NameError: name 'bar' " + "is not defined while handling path /4") == summary_text + + +def test_inherited_exception_handler(): + request, response = exception_handler_app.test_client.get('/5') + assert response.status == 200 + + +def test_chained_exception_handler(): + request, response = exception_handler_app.test_client.get( + '/6/0', debug=True) + assert response.status == 500 + + soup = BeautifulSoup(response.body, 'html.parser') + html = str(soup) + + assert 'response = handler(request, *args, **kwargs)' in html + assert 'handler_6' in html + assert 'foo = 1 / arg' in html + assert 'ValueError' in html + assert 'The above exception was the direct cause' in html + + summary_text = " ".join(soup.select('.summary')[0].text.split()) + assert ( + "ZeroDivisionError: division by zero " + "while handling path /6/0") == summary_text + + +def test_exception_handler_lookup(): + class CustomError(Exception): + pass + + class CustomServerError(ServerError): + pass + + def custom_error_handler(): + pass + + def server_error_handler(): + pass + + def import_error_handler(): + pass + + try: + ModuleNotFoundError + except: + class ModuleNotFoundError(ImportError): + pass + + handler = ErrorHandler() + handler.add(ImportError, import_error_handler) + handler.add(CustomError, custom_error_handler) + handler.add(ServerError, server_error_handler) + + assert handler.lookup(ImportError()) == import_error_handler + assert handler.lookup(ModuleNotFoundError()) == import_error_handler + assert handler.lookup(CustomError()) == custom_error_handler + assert handler.lookup(ServerError('Error')) == server_error_handler + assert handler.lookup(CustomServerError('Error')) == server_error_handler + + # once again to ensure there is no caching bug + assert handler.lookup(ImportError()) == import_error_handler + assert handler.lookup(ModuleNotFoundError()) == import_error_handler + assert handler.lookup(CustomError()) == custom_error_handler + assert handler.lookup(ServerError('Error')) == server_error_handler + assert handler.lookup(CustomServerError('Error')) == server_error_handler diff --git a/tests/test_keep_alive_timeout.py b/tests/test_keep_alive_timeout.py new file mode 100644 index 00000000..2a9e93a2 --- /dev/null +++ b/tests/test_keep_alive_timeout.py @@ -0,0 +1,281 @@ +from json import JSONDecodeError +from sanic import Sanic +import asyncio +from asyncio import sleep as aio_sleep +from sanic.response import text +from sanic.config import Config +from sanic import server +import aiohttp +from aiohttp import TCPConnector +from sanic.testing import SanicTestClient, HOST, PORT + + +class ReuseableTCPConnector(TCPConnector): + def __init__(self, *args, **kwargs): + super(ReuseableTCPConnector, self).__init__(*args, **kwargs) + self.old_proto = None + + if aiohttp.__version__ >= '3.0': + + async def connect(self, req, traces=None): + new_conn = await super(ReuseableTCPConnector, self)\ + .connect(req, traces=traces) + if self.old_proto is not None: + if self.old_proto != new_conn._protocol: + raise RuntimeError( + "We got a new connection, wanted the same one!") + print(new_conn.__dict__) + self.old_proto = new_conn._protocol + return new_conn + else: + + async def connect(self, req): + new_conn = await super(ReuseableTCPConnector, self)\ + .connect(req) + if self.old_proto is not None: + if self.old_proto != new_conn._protocol: + raise RuntimeError( + "We got a new connection, wanted the same one!") + print(new_conn.__dict__) + self.old_proto = new_conn._protocol + return new_conn + + +class ReuseableSanicTestClient(SanicTestClient): + def __init__(self, app, loop=None): + super(ReuseableSanicTestClient, self).__init__(app) + if loop is None: + loop = asyncio.get_event_loop() + self._loop = loop + self._server = None + self._tcp_connector = None + self._session = None + + # Copied from SanicTestClient, but with some changes to reuse the + # same loop for the same app. + def _sanic_endpoint_test( + self, method='get', uri='/', gather_request=True, + debug=False, server_kwargs={}, + *request_args, **request_kwargs): + loop = self._loop + results = [None, None] + exceptions = [] + do_kill_server = request_kwargs.pop('end_server', False) + if gather_request: + def _collect_request(request): + if results[0] is None: + results[0] = request + + self.app.request_middleware.appendleft(_collect_request) + + @self.app.listener('after_server_start') + async def _collect_response(loop): + try: + if do_kill_server: + request_kwargs['end_session'] = True + response = await self._local_request( + method, uri, *request_args, + **request_kwargs) + results[-1] = response + except Exception as e2: + import traceback + traceback.print_tb(e2.__traceback__) + exceptions.append(e2) + # Don't stop here! self.app.stop() + + if self._server is not None: + _server = self._server + else: + _server_co = self.app.create_server(host=HOST, debug=debug, + port=PORT, **server_kwargs) + + server.trigger_events( + self.app.listeners['before_server_start'], loop) + + try: + loop._stopping = False + http_server = loop.run_until_complete(_server_co) + except Exception as e1: + import traceback + traceback.print_tb(e1.__traceback__) + raise e1 + self._server = _server = http_server + server.trigger_events( + self.app.listeners['after_server_start'], loop) + self.app.listeners['after_server_start'].pop() + + if do_kill_server: + try: + _server.close() + self._server = None + loop.run_until_complete(_server.wait_closed()) + self.app.stop() + except Exception as e3: + import traceback + traceback.print_tb(e3.__traceback__) + exceptions.append(e3) + if exceptions: + raise ValueError( + "Exception during request: {}".format(exceptions)) + + if gather_request: + self.app.request_middleware.pop() + try: + request, response = results + return request, response + except: + raise ValueError( + "Request and response object expected, got ({})".format( + results)) + else: + try: + return results[-1] + except: + raise ValueError( + "Request object expected, got ({})".format(results)) + + # Copied from SanicTestClient, but with some changes to reuse the + # same TCPConnection and the sane ClientSession more than once. + # Note, you cannot use the same session if you are in a _different_ + # loop, so the changes above are required too. + async def _local_request(self, method, uri, cookies=None, *args, + **kwargs): + request_keepalive = kwargs.pop('request_keepalive', + Config.KEEP_ALIVE_TIMEOUT) + if uri.startswith(('http:', 'https:', 'ftp:', 'ftps://' '//')): + url = uri + else: + url = 'http://{host}:{port}{uri}'.format( + host=HOST, port=self.port, uri=uri) + do_kill_session = kwargs.pop('end_session', False) + if self._session: + session = self._session + else: + if self._tcp_connector: + conn = self._tcp_connector + else: + conn = ReuseableTCPConnector(verify_ssl=False, + loop=self._loop, + keepalive_timeout= + request_keepalive) + self._tcp_connector = conn + session = aiohttp.ClientSession(cookies=cookies, + connector=conn, + loop=self._loop) + self._session = session + + async with getattr(session, method.lower())( + url, *args, **kwargs) as response: + try: + response.text = await response.text() + except UnicodeDecodeError: + response.text = None + + try: + response.json = await response.json() + except (JSONDecodeError, + UnicodeDecodeError, + aiohttp.ClientResponseError): + response.json = None + + response.body = await response.read() + if do_kill_session: + await session.close() + self._session = None + return response + + +Config.KEEP_ALIVE_TIMEOUT = 2 +Config.KEEP_ALIVE = True +keep_alive_timeout_app_reuse = Sanic('test_ka_timeout_reuse') +keep_alive_app_client_timeout = Sanic('test_ka_client_timeout') +keep_alive_app_server_timeout = Sanic('test_ka_server_timeout') + + +@keep_alive_timeout_app_reuse.route('/1') +async def handler1(request): + return text('OK') + + +@keep_alive_app_client_timeout.route('/1') +async def handler2(request): + return text('OK') + + +@keep_alive_app_server_timeout.route('/1') +async def handler3(request): + return text('OK') + + +def test_keep_alive_timeout_reuse(): + """If the server keep-alive timeout and client keep-alive timeout are + both longer than the delay, the client _and_ server will successfully + reuse the existing connection.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + client = ReuseableSanicTestClient(keep_alive_timeout_app_reuse, loop) + headers = { + 'Connection': 'keep-alive' + } + request, response = client.get('/1', headers=headers) + assert response.status == 200 + assert response.text == 'OK' + loop.run_until_complete(aio_sleep(1)) + request, response = client.get('/1', end_server=True) + assert response.status == 200 + assert response.text == 'OK' + + +def test_keep_alive_client_timeout(): + """If the server keep-alive timeout is longer than the client + keep-alive timeout, client will try to create a new connection here.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + client = ReuseableSanicTestClient(keep_alive_app_client_timeout, + loop) + headers = { + 'Connection': 'keep-alive' + } + request, response = client.get('/1', headers=headers, + request_keepalive=1) + assert response.status == 200 + assert response.text == 'OK' + loop.run_until_complete(aio_sleep(2)) + exception = None + try: + request, response = client.get('/1', end_server=True, + request_keepalive=1) + except ValueError as e: + exception = e + assert exception is not None + assert isinstance(exception, ValueError) + assert "got a new connection" in exception.args[0] + + +def test_keep_alive_server_timeout(): + """If the client keep-alive timeout is longer than the server + keep-alive timeout, the client will either a 'Connection reset' error + _or_ a new connection. Depending on how the event-loop handles the + broken server connection.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + client = ReuseableSanicTestClient(keep_alive_app_server_timeout, + loop) + headers = { + 'Connection': 'keep-alive' + } + request, response = client.get('/1', headers=headers, + request_keepalive=60) + assert response.status == 200 + assert response.text == 'OK' + loop.run_until_complete(aio_sleep(3)) + exception = None + try: + request, response = client.get('/1', request_keepalive=60, + end_server=True) + except ValueError as e: + exception = e + assert exception is not None + assert isinstance(exception, ValueError) + assert "Connection reset" in exception.args[0] or \ + "got a new connection" in exception.args[0] diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 00000000..1a040a5c --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,106 @@ +import uuid +import logging + +from io import StringIO +from importlib import reload + +import pytest +from unittest.mock import Mock + +import sanic +from sanic.response import text +from sanic.log import LOGGING_CONFIG_DEFAULTS +from sanic import Sanic + + +logging_format = '''module: %(module)s; \ +function: %(funcName)s(); \ +message: %(message)s''' + + +def reset_logging(): + logging.shutdown() + reload(logging) + + +def test_log(): + log_stream = StringIO() + for handler in logging.root.handlers[:]: + logging.root.removeHandler(handler) + logging.basicConfig( + format=logging_format, + level=logging.DEBUG, + stream=log_stream + ) + log = logging.getLogger() + app = Sanic('test_logging') + rand_string = str(uuid.uuid4()) + + @app.route('/') + def handler(request): + log.info(rand_string) + return text('hello') + + request, response = app.test_client.get('/') + log_text = log_stream.getvalue() + assert rand_string in log_text + + +def test_logging_defaults(): + reset_logging() + app = Sanic("test_logging") + + for fmt in [h.formatter for h in logging.getLogger('root').handlers]: + assert fmt._fmt == LOGGING_CONFIG_DEFAULTS['formatters']['generic']['format'] + + for fmt in [h.formatter for h in logging.getLogger('sanic.error').handlers]: + assert fmt._fmt == LOGGING_CONFIG_DEFAULTS['formatters']['generic']['format'] + + for fmt in [h.formatter for h in logging.getLogger('sanic.access').handlers]: + assert fmt._fmt == LOGGING_CONFIG_DEFAULTS['formatters']['access']['format'] + + +def test_logging_pass_customer_logconfig(): + reset_logging() + + modified_config = LOGGING_CONFIG_DEFAULTS + modified_config['formatters']['generic']['format'] = '%(asctime)s - (%(name)s)[%(levelname)s]: %(message)s' + modified_config['formatters']['access']['format'] = '%(asctime)s - (%(name)s)[%(levelname)s]: %(message)s' + + app = Sanic("test_logging", log_config=modified_config) + + for fmt in [h.formatter for h in logging.getLogger('root').handlers]: + assert fmt._fmt == modified_config['formatters']['generic']['format'] + + for fmt in [h.formatter for h in logging.getLogger('sanic.error').handlers]: + assert fmt._fmt == modified_config['formatters']['generic']['format'] + + for fmt in [h.formatter for h in logging.getLogger('sanic.access').handlers]: + assert fmt._fmt == modified_config['formatters']['access']['format'] + + +@pytest.mark.parametrize('debug', (True, False, )) +def test_log_connection_lost(debug, monkeypatch): + """ Should not log Connection lost exception on non debug """ + app = Sanic('connection_lost') + stream = StringIO() + root = logging.getLogger('root') + root.addHandler(logging.StreamHandler(stream)) + monkeypatch.setattr(sanic.server, 'logger', root) + + @app.route('/conn_lost') + async def conn_lost(request): + response = text('Ok') + response.output = Mock(side_effect=RuntimeError) + return response + + with pytest.raises(ValueError): + # catch ValueError: Exception during request + app.test_client.get('/conn_lost', debug=debug) + + log = stream.getvalue() + + if debug: + assert 'Connection lost before response written @' in log + else: + assert 'Connection lost before response written @' not in log diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 1b338d31..4d4d6901 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -2,7 +2,7 @@ from json import loads as json_loads, dumps as json_dumps from sanic import Sanic from sanic.request import Request from sanic.response import json, text, HTTPResponse -from sanic.utils import sanic_endpoint_test +from sanic.exceptions import NotFound # ------------------------------------------------------------ # @@ -22,7 +22,7 @@ def test_middleware_request(): async def handler(request): return text('OK') - request, response = sanic_endpoint_test(app) + request, response = app.test_client.get('/') assert response.text == 'OK' assert type(results[0]) is Request @@ -46,14 +46,35 @@ def test_middleware_response(): async def handler(request): return text('OK') - request, response = sanic_endpoint_test(app) + request, response = app.test_client.get('/') assert response.text == 'OK' assert type(results[0]) is Request assert type(results[1]) is Request - assert issubclass(type(results[2]), HTTPResponse) + assert isinstance(results[2], HTTPResponse) +def test_middleware_response_exception(): + app = Sanic('test_middleware_response_exception') + result = {'status_code': None} + + @app.middleware('response') + async def process_response(request, response): + result['status_code'] = response.status + return response + + @app.exception(NotFound) + async def error_handler(request, exception): + return text('OK', exception.status_code) + + @app.route('/') + async def handler(request): + return text('FAIL') + + request, response = app.test_client.get('/page_not_found') + assert response.text == 'OK' + assert result['status_code'] == 404 + def test_middleware_override_request(): app = Sanic('test_middleware_override_request') @@ -65,7 +86,7 @@ def test_middleware_override_request(): async def handler(request): return text('FAIL') - response = sanic_endpoint_test(app, gather_request=False) + response = app.test_client.get('/', gather_request=False) assert response.status == 200 assert response.text == 'OK' @@ -82,7 +103,47 @@ def test_middleware_override_response(): async def handler(request): return text('FAIL') - request, response = sanic_endpoint_test(app) + request, response = app.test_client.get('/') assert response.status == 200 assert response.text == 'OK' + + + +def test_middleware_order(): + app = Sanic('test_middleware_order') + + order = [] + + @app.middleware('request') + async def request1(request): + order.append(1) + + @app.middleware('request') + async def request2(request): + order.append(2) + + @app.middleware('request') + async def request3(request): + order.append(3) + + @app.middleware('response') + async def response1(request, response): + order.append(6) + + @app.middleware('response') + async def response2(request, response): + order.append(5) + + @app.middleware('response') + async def response3(request, response): + order.append(4) + + @app.route('/') + async def handler(request): + return text('OK') + + request, response = app.test_client.get('/') + + assert response.status == 200 + assert order == [1,2,3,4,5,6] diff --git a/tests/test_multiprocessing.py b/tests/test_multiprocessing.py new file mode 100644 index 00000000..7d94a972 --- /dev/null +++ b/tests/test_multiprocessing.py @@ -0,0 +1,25 @@ +import multiprocessing +import random +import signal + +from sanic import Sanic +from sanic.testing import HOST, PORT + + +def test_multiprocessing(): + """Tests that the number of children we produce is correct""" + # Selects a number at random so we can spot check + num_workers = random.choice(range(2, multiprocessing.cpu_count() * 2 + 1)) + app = Sanic('test_multiprocessing') + process_list = set() + + def stop_on_alarm(*args): + for process in multiprocessing.active_children(): + process_list.add(process.pid) + process.terminate() + + signal.signal(signal.SIGALRM, stop_on_alarm) + signal.alarm(3) + app.run(HOST, PORT, workers=num_workers) + + assert len(process_list) == num_workers diff --git a/tests/test_named_routes.py b/tests/test_named_routes.py new file mode 100644 index 00000000..ca377e8d --- /dev/null +++ b/tests/test_named_routes.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import asyncio +import pytest + +from sanic import Sanic +from sanic.blueprints import Blueprint +from sanic.response import text +from sanic.exceptions import URLBuildError +from sanic.constants import HTTP_METHODS + + +# ------------------------------------------------------------ # +# UTF-8 +# ------------------------------------------------------------ # + +@pytest.mark.parametrize('method', HTTP_METHODS) +def test_versioned_named_routes_get(method): + app = Sanic('test_shorhand_routes_get') + bp = Blueprint('test_bp', url_prefix='/bp') + + method = method.lower() + route_name = 'route_{}'.format(method) + route_name2 = 'route2_{}'.format(method) + + func = getattr(app, method) + if callable(func): + @func('/{}'.format(method), version=1, name=route_name) + def handler(request): + return text('OK') + else: + print(func) + raise + + func = getattr(bp, method) + if callable(func): + @func('/{}'.format(method), version=1, name=route_name2) + def handler2(request): + return text('OK') + + else: + print(func) + raise + + app.blueprint(bp) + + assert app.router.routes_all['/v1/{}'.format(method)].name == route_name + + route = app.router.routes_all['/v1/bp/{}'.format(method)] + assert route.name == 'test_bp.{}'.format(route_name2) + + assert app.url_for(route_name) == '/v1/{}'.format(method) + url = app.url_for('test_bp.{}'.format(route_name2)) + assert url == '/v1/bp/{}'.format(method) + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_shorthand_default_routes_get(): + app = Sanic('test_shorhand_routes_get') + + @app.get('/get') + def handler(request): + return text('OK') + + assert app.router.routes_all['/get'].name == 'handler' + assert app.url_for('handler') == '/get' + + +def test_shorthand_named_routes_get(): + app = Sanic('test_shorhand_routes_get') + bp = Blueprint('test_bp', url_prefix='/bp') + + @app.get('/get', name='route_get') + def handler(request): + return text('OK') + + @bp.get('/get', name='route_bp') + def handler2(request): + return text('Blueprint') + + app.blueprint(bp) + + assert app.router.routes_all['/get'].name == 'route_get' + assert app.url_for('route_get') == '/get' + with pytest.raises(URLBuildError): + app.url_for('handler') + + assert app.router.routes_all['/bp/get'].name == 'test_bp.route_bp' + assert app.url_for('test_bp.route_bp') == '/bp/get' + with pytest.raises(URLBuildError): + app.url_for('test_bp.handler2') + + +def test_shorthand_named_routes_post(): + app = Sanic('test_shorhand_routes_post') + + @app.post('/post', name='route_name') + def handler(request): + return text('OK') + + assert app.router.routes_all['/post'].name == 'route_name' + assert app.url_for('route_name') == '/post' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_shorthand_named_routes_put(): + app = Sanic('test_shorhand_routes_put') + + @app.put('/put', name='route_put') + def handler(request): + assert request.stream is None + return text('OK') + + assert app.is_request_stream is False + assert app.router.routes_all['/put'].name == 'route_put' + assert app.url_for('route_put') == '/put' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_shorthand_named_routes_delete(): + app = Sanic('test_shorhand_routes_delete') + + @app.delete('/delete', name='route_delete') + def handler(request): + assert request.stream is None + return text('OK') + + assert app.is_request_stream is False + assert app.router.routes_all['/delete'].name == 'route_delete' + assert app.url_for('route_delete') == '/delete' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_shorthand_named_routes_patch(): + app = Sanic('test_shorhand_routes_patch') + + @app.patch('/patch', name='route_patch') + def handler(request): + assert request.stream is None + return text('OK') + + assert app.is_request_stream is False + assert app.router.routes_all['/patch'].name == 'route_patch' + assert app.url_for('route_patch') == '/patch' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_shorthand_named_routes_head(): + app = Sanic('test_shorhand_routes_head') + + @app.head('/head', name='route_head') + def handler(request): + assert request.stream is None + return text('OK') + + assert app.is_request_stream is False + assert app.router.routes_all['/head'].name == 'route_head' + assert app.url_for('route_head') == '/head' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_shorthand_named_routes_options(): + app = Sanic('test_shorhand_routes_options') + + @app.options('/options', name='route_options') + def handler(request): + assert request.stream is None + return text('OK') + + assert app.is_request_stream is False + assert app.router.routes_all['/options'].name == 'route_options' + assert app.url_for('route_options') == '/options' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_named_static_routes(): + app = Sanic('test_dynamic_route') + + @app.route('/test', name='route_test') + async def handler1(request): + return text('OK1') + + @app.route('/pizazz', name='route_pizazz') + async def handler2(request): + return text('OK2') + + assert app.router.routes_all['/test'].name == 'route_test' + assert app.router.routes_static['/test'].name == 'route_test' + assert app.url_for('route_test') == '/test' + with pytest.raises(URLBuildError): + app.url_for('handler1') + + assert app.router.routes_all['/pizazz'].name == 'route_pizazz' + assert app.router.routes_static['/pizazz'].name == 'route_pizazz' + assert app.url_for('route_pizazz') == '/pizazz' + with pytest.raises(URLBuildError): + app.url_for('handler2') + + +def test_named_dynamic_route(): + app = Sanic('test_dynamic_route') + + results = [] + + @app.route('/folder/', name='route_dynamic') + async def handler(request, name): + results.append(name) + return text('OK') + + assert app.router.routes_all['/folder/'].name == 'route_dynamic' + assert app.url_for('route_dynamic', name='test') == '/folder/test' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_dynamic_named_route_regex(): + app = Sanic('test_dynamic_route_regex') + + @app.route('/folder/', name='route_re') + async def handler(request, folder_id): + return text('OK') + + route = app.router.routes_all['/folder/'] + assert route.name == 'route_re' + assert app.url_for('route_re', folder_id='test') == '/folder/test' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_dynamic_named_route_path(): + app = Sanic('test_dynamic_route_path') + + @app.route('//info', name='route_dynamic_path') + async def handler(request, path): + return text('OK') + + route = app.router.routes_all['//info'] + assert route.name == 'route_dynamic_path' + assert app.url_for('route_dynamic_path', path='path/1') == '/path/1/info' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_dynamic_named_route_unhashable(): + app = Sanic('test_dynamic_route_unhashable') + + @app.route('/folder//end/', + name='route_unhashable') + async def handler(request, unhashable): + return text('OK') + + route = app.router.routes_all['/folder//end/'] + assert route.name == 'route_unhashable' + url = app.url_for('route_unhashable', unhashable='test/asdf') + assert url == '/folder/test/asdf/end' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_websocket_named_route(): + app = Sanic('test_websocket_route') + ev = asyncio.Event() + + @app.websocket('/ws', name='route_ws') + async def handler(request, ws): + assert ws.subprotocol is None + ev.set() + + assert app.router.routes_all['/ws'].name == 'route_ws' + assert app.url_for('route_ws') == '/ws' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_websocket_named_route_with_subprotocols(): + app = Sanic('test_websocket_route') + results = [] + + @app.websocket('/ws', subprotocols=['foo', 'bar'], name='route_ws') + async def handler(request, ws): + results.append(ws.subprotocol) + + assert app.router.routes_all['/ws'].name == 'route_ws' + assert app.url_for('route_ws') == '/ws' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_static_add_named_route(): + app = Sanic('test_static_add_route') + + async def handler1(request): + return text('OK1') + + async def handler2(request): + return text('OK2') + + app.add_route(handler1, '/test', name='route_test') + app.add_route(handler2, '/test2', name='route_test2') + + assert app.router.routes_all['/test'].name == 'route_test' + assert app.router.routes_static['/test'].name == 'route_test' + assert app.url_for('route_test') == '/test' + with pytest.raises(URLBuildError): + app.url_for('handler1') + + assert app.router.routes_all['/test2'].name == 'route_test2' + assert app.router.routes_static['/test2'].name == 'route_test2' + assert app.url_for('route_test2') == '/test2' + with pytest.raises(URLBuildError): + app.url_for('handler2') + + +def test_dynamic_add_named_route(): + app = Sanic('test_dynamic_add_route') + + results = [] + + async def handler(request, name): + results.append(name) + return text('OK') + + app.add_route(handler, '/folder/', name='route_dynamic') + assert app.router.routes_all['/folder/'].name == 'route_dynamic' + assert app.url_for('route_dynamic', name='test') == '/folder/test' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_dynamic_add_named_route_unhashable(): + app = Sanic('test_dynamic_add_route_unhashable') + + async def handler(request, unhashable): + return text('OK') + + app.add_route(handler, '/folder//end/', + name='route_unhashable') + route = app.router.routes_all['/folder//end/'] + assert route.name == 'route_unhashable' + url = app.url_for('route_unhashable', unhashable='folder1') + assert url == '/folder/folder1/end' + with pytest.raises(URLBuildError): + app.url_for('handler') + + +def test_overload_routes(): + app = Sanic('test_dynamic_route') + + @app.route('/overload', methods=['GET'], name='route_first') + async def handler1(request): + return text('OK1') + + @app.route('/overload', methods=['POST', 'PUT'], name='route_second') + async def handler1(request): + return text('OK2') + + request, response = app.test_client.get(app.url_for('route_first')) + assert response.text == 'OK1' + + request, response = app.test_client.post(app.url_for('route_first')) + assert response.text == 'OK2' + + request, response = app.test_client.put(app.url_for('route_first')) + assert response.text == 'OK2' + + request, response = app.test_client.get(app.url_for('route_second')) + assert response.text == 'OK1' + + request, response = app.test_client.post(app.url_for('route_second')) + assert response.text == 'OK2' + + request, response = app.test_client.put(app.url_for('route_second')) + assert response.text == 'OK2' + + assert app.router.routes_all['/overload'].name == 'route_first' + with pytest.raises(URLBuildError): + app.url_for('handler1') + + assert app.url_for('route_first') == '/overload' + assert app.url_for('route_second') == app.url_for('route_first') diff --git a/tests/test_payload_too_large.py b/tests/test_payload_too_large.py new file mode 100644 index 00000000..ecac605c --- /dev/null +++ b/tests/test_payload_too_large.py @@ -0,0 +1,49 @@ +from sanic import Sanic +from sanic.exceptions import PayloadTooLarge +from sanic.response import text + + +def test_payload_too_large_from_error_handler(): + data_received_app = Sanic('data_received') + data_received_app.config.REQUEST_MAX_SIZE = 1 + + @data_received_app.route('/1') + async def handler1(request): + return text('OK') + + @data_received_app.exception(PayloadTooLarge) + def handler_exception(request, exception): + return text('Payload Too Large from error_handler.', 413) + + response = data_received_app.test_client.get('/1', gather_request=False) + assert response.status == 413 + assert response.text == 'Payload Too Large from error_handler.' + + +def test_payload_too_large_at_data_received_default(): + data_received_default_app = Sanic('data_received_default') + data_received_default_app.config.REQUEST_MAX_SIZE = 1 + + @data_received_default_app.route('/1') + async def handler2(request): + return text('OK') + + response = data_received_default_app.test_client.get( + '/1', gather_request=False) + assert response.status == 413 + assert response.text == 'Error: Payload Too Large' + + +def test_payload_too_large_at_on_header_default(): + on_header_default_app = Sanic('on_header') + on_header_default_app.config.REQUEST_MAX_SIZE = 500 + + @on_header_default_app.post('/1') + async def handler3(request): + return text('OK') + + data = 'a' * 1000 + response = on_header_default_app.test_client.post( + '/1', gather_request=False, data=data) + assert response.status == 413 + assert response.text == 'Error: Payload Too Large' diff --git a/tests/test_redirect.py b/tests/test_redirect.py new file mode 100644 index 00000000..5fdec2a6 --- /dev/null +++ b/tests/test_redirect.py @@ -0,0 +1,111 @@ +import pytest + +from sanic import Sanic +from sanic.response import text, redirect + + +@pytest.fixture +def redirect_app(): + app = Sanic('test_redirection') + + @app.route('/redirect_init') + async def redirect_init(request): + return redirect("/redirect_target") + + @app.route('/redirect_init_with_301') + async def redirect_init_with_301(request): + return redirect("/redirect_target", status=301) + + @app.route('/redirect_target') + async def redirect_target(request): + return text('OK') + + @app.route('/1') + def handler(request): + return redirect('/2') + + @app.route('/2') + def handler(request): + return redirect('/3') + + @app.route('/3') + def handler(request): + return text('OK') + + @app.route('/redirect_with_header_injection') + async def redirect_with_header_injection(request): + return redirect("/unsafe\ntest-header: test-value\n\ntest-body") + + return app + + +def test_redirect_default_302(redirect_app): + """ + We expect a 302 default status code and the headers to be set. + """ + request, response = redirect_app.test_client.get( + '/redirect_init', + allow_redirects=False) + + assert response.status == 302 + assert response.headers["Location"] == "/redirect_target" + assert response.headers["Content-Type"] == 'text/html; charset=utf-8' + + +def test_redirect_headers_none(redirect_app): + request, response = redirect_app.test_client.get( + uri="/redirect_init", + headers=None, + allow_redirects=False) + + assert response.status == 302 + assert response.headers["Location"] == "/redirect_target" + + +def test_redirect_with_301(redirect_app): + """ + Test redirection with a different status code. + """ + request, response = redirect_app.test_client.get( + "/redirect_init_with_301", + allow_redirects=False) + + assert response.status == 301 + assert response.headers["Location"] == "/redirect_target" + + +def test_get_then_redirect_follow_redirect(redirect_app): + """ + With `allow_redirects` we expect a 200. + """ + request, response = redirect_app.test_client.get( + "/redirect_init", + allow_redirects=True) + + assert response.status == 200 + assert response.text == 'OK' + + +def test_chained_redirect(redirect_app): + """Test test_client is working for redirection""" + request, response = redirect_app.test_client.get('/1') + assert request.url.endswith('/1') + assert response.status == 200 + assert response.text == 'OK' + try: + assert response.url.endswith('/3') + except AttributeError: + assert response.url.path.endswith('/3') + + +def test_redirect_with_header_injection(redirect_app): + """ + Test redirection to a URL with header and body injections. + """ + request, response = redirect_app.test_client.get( + "/redirect_with_header_injection", + allow_redirects=False) + + assert response.status == 302 + assert "test-header" not in response.headers + assert not response.text.startswith('test-body') diff --git a/tests/test_request_data.py b/tests/test_request_data.py new file mode 100644 index 00000000..f795ff1f --- /dev/null +++ b/tests/test_request_data.py @@ -0,0 +1,47 @@ +import random + +from sanic import Sanic +from sanic.response import json + +try: + from ujson import loads +except ImportError: + from json import loads + + +def test_storage(): + app = Sanic('test_text') + + @app.middleware('request') + def store(request): + request['user'] = 'sanic' + request['sidekick'] = 'tails' + del request['sidekick'] + + @app.route('/') + def handler(request): + return json({'user': request.get('user'), 'sidekick': request.get('sidekick')}) + + request, response = app.test_client.get('/') + + response_json = loads(response.text) + assert response_json['user'] == 'sanic' + assert response_json.get('sidekick') is None + + +def test_app_injection(): + app = Sanic('test_app_injection') + expected = random.choice(range(0, 100)) + + @app.listener('after_server_start') + async def inject_data(app, loop): + app.injected = expected + + @app.get('/') + async def handler(request): + return json({'injected': request.app.injected}) + + request, response = app.test_client.get('/') + + response_json = loads(response.text) + assert response_json['injected'] == expected diff --git a/tests/test_request_stream.py b/tests/test_request_stream.py new file mode 100644 index 00000000..4ca4e44e --- /dev/null +++ b/tests/test_request_stream.py @@ -0,0 +1,463 @@ +import asyncio +from sanic import Sanic +from sanic.blueprints import Blueprint +from sanic.views import CompositionView +from sanic.views import HTTPMethodView +from sanic.views import stream as stream_decorator +from sanic.response import stream, text + +data = "abc" * 100000 + + +def test_request_stream_method_view(): + '''for self.is_request_stream = True''' + + app = Sanic('test_request_stream_method_view') + + class SimpleView(HTTPMethodView): + + def get(self, request): + assert request.stream is None + return text('OK') + + @stream_decorator + async def post(self, request): + assert isinstance(request.stream, asyncio.Queue) + result = '' + while True: + body = await request.stream.get() + if body is None: + break + result += body.decode('utf-8') + return text(result) + + app.add_route(SimpleView.as_view(), '/method_view') + + assert app.is_request_stream is True + + request, response = app.test_client.get('/method_view') + assert response.status == 200 + assert response.text == 'OK' + + request, response = app.test_client.post('/method_view', data=data) + assert response.status == 200 + assert response.text == data + + +def test_request_stream_app(): + '''for self.is_request_stream = True and decorators''' + + app = Sanic('test_request_stream_app') + + @app.get('/get') + async def get(request): + assert request.stream is None + return text('GET') + + @app.head('/head') + async def head(request): + assert request.stream is None + return text('HEAD') + + @app.delete('/delete') + async def delete(request): + assert request.stream is None + return text('DELETE') + + @app.options('/options') + async def options(request): + assert request.stream is None + return text('OPTIONS') + + @app.post('/_post/') + async def _post(request, id): + assert request.stream is None + return text('_POST') + + @app.post('/post/', stream=True) + async def post(request, id): + assert isinstance(request.stream, asyncio.Queue) + + async def streaming(response): + while True: + body = await request.stream.get() + if body is None: + break + response.write(body.decode('utf-8')) + return stream(streaming) + + @app.put('/_put') + async def _put(request): + assert request.stream is None + return text('_PUT') + + @app.put('/put', stream=True) + async def put(request): + assert isinstance(request.stream, asyncio.Queue) + + async def streaming(response): + while True: + body = await request.stream.get() + if body is None: + break + response.write(body.decode('utf-8')) + return stream(streaming) + + @app.patch('/_patch') + async def _patch(request): + assert request.stream is None + return text('_PATCH') + + @app.patch('/patch', stream=True) + async def patch(request): + assert isinstance(request.stream, asyncio.Queue) + + async def streaming(response): + while True: + body = await request.stream.get() + if body is None: + break + response.write(body.decode('utf-8')) + return stream(streaming) + + assert app.is_request_stream is True + + request, response = app.test_client.get('/get') + assert response.status == 200 + assert response.text == 'GET' + + request, response = app.test_client.head('/head') + assert response.status == 200 + assert response.text == '' + + request, response = app.test_client.delete('/delete') + assert response.status == 200 + assert response.text == 'DELETE' + + request, response = app.test_client.options('/options') + assert response.status == 200 + assert response.text == 'OPTIONS' + + request, response = app.test_client.post('/_post/1', data=data) + assert response.status == 200 + assert response.text == '_POST' + + request, response = app.test_client.post('/post/1', data=data) + assert response.status == 200 + assert response.text == data + + request, response = app.test_client.put('/_put', data=data) + assert response.status == 200 + assert response.text == '_PUT' + + request, response = app.test_client.put('/put', data=data) + assert response.status == 200 + assert response.text == data + + request, response = app.test_client.patch('/_patch', data=data) + assert response.status == 200 + assert response.text == '_PATCH' + + request, response = app.test_client.patch('/patch', data=data) + assert response.status == 200 + assert response.text == data + + +def test_request_stream_handle_exception(): + '''for handling exceptions properly''' + + app = Sanic('test_request_stream_exception') + + @app.post('/post/', stream=True) + async def post(request, id): + assert isinstance(request.stream, asyncio.Queue) + + async def streaming(response): + while True: + body = await request.stream.get() + if body is None: + break + response.write(body.decode('utf-8')) + return stream(streaming) + + # 404 + request, response = app.test_client.post('/in_valid_post', data=data) + assert response.status == 404 + assert response.text == 'Error: Requested URL /in_valid_post not found' + + # 405 + request, response = app.test_client.get('/post/random_id', data=data) + assert response.status == 405 + assert response.text == 'Error: Method GET not allowed for URL /post/random_id' + + +def test_request_stream_blueprint(): + '''for self.is_request_stream = True''' + + app = Sanic('test_request_stream_blueprint') + bp = Blueprint('test_blueprint_request_stream_blueprint') + + @app.get('/get') + async def get(request): + assert request.stream is None + return text('GET') + + @bp.head('/head') + async def head(request): + assert request.stream is None + return text('HEAD') + + @bp.delete('/delete') + async def delete(request): + assert request.stream is None + return text('DELETE') + + @bp.options('/options') + async def options(request): + assert request.stream is None + return text('OPTIONS') + + @bp.post('/_post/') + async def _post(request, id): + assert request.stream is None + return text('_POST') + + @bp.post('/post/', stream=True) + async def post(request, id): + assert isinstance(request.stream, asyncio.Queue) + + async def streaming(response): + while True: + body = await request.stream.get() + if body is None: + break + response.write(body.decode('utf-8')) + return stream(streaming) + + @bp.put('/_put') + async def _put(request): + assert request.stream is None + return text('_PUT') + + @bp.put('/put', stream=True) + async def put(request): + assert isinstance(request.stream, asyncio.Queue) + + async def streaming(response): + while True: + body = await request.stream.get() + if body is None: + break + response.write(body.decode('utf-8')) + return stream(streaming) + + @bp.patch('/_patch') + async def _patch(request): + assert request.stream is None + return text('_PATCH') + + @bp.patch('/patch', stream=True) + async def patch(request): + assert isinstance(request.stream, asyncio.Queue) + + async def streaming(response): + while True: + body = await request.stream.get() + if body is None: + break + response.write(body.decode('utf-8')) + return stream(streaming) + + app.blueprint(bp) + + assert app.is_request_stream is True + + request, response = app.test_client.get('/get') + assert response.status == 200 + assert response.text == 'GET' + + request, response = app.test_client.head('/head') + assert response.status == 200 + assert response.text == '' + + request, response = app.test_client.delete('/delete') + assert response.status == 200 + assert response.text == 'DELETE' + + request, response = app.test_client.options('/options') + assert response.status == 200 + assert response.text == 'OPTIONS' + + request, response = app.test_client.post('/_post/1', data=data) + assert response.status == 200 + assert response.text == '_POST' + + request, response = app.test_client.post('/post/1', data=data) + assert response.status == 200 + assert response.text == data + + request, response = app.test_client.put('/_put', data=data) + assert response.status == 200 + assert response.text == '_PUT' + + request, response = app.test_client.put('/put', data=data) + assert response.status == 200 + assert response.text == data + + request, response = app.test_client.patch('/_patch', data=data) + assert response.status == 200 + assert response.text == '_PATCH' + + request, response = app.test_client.patch('/patch', data=data) + assert response.status == 200 + assert response.text == data + + +def test_request_stream_composition_view(): + '''for self.is_request_stream = True''' + + app = Sanic('test_request_stream__composition_view') + + def get_handler(request): + assert request.stream is None + return text('OK') + + async def post_handler(request): + assert isinstance(request.stream, asyncio.Queue) + result = '' + while True: + body = await request.stream.get() + if body is None: + break + result += body.decode('utf-8') + return text(result) + + view = CompositionView() + view.add(['GET'], get_handler) + view.add(['POST'], post_handler, stream=True) + app.add_route(view, '/composition_view') + + assert app.is_request_stream is True + + request, response = app.test_client.get('/composition_view') + assert response.status == 200 + assert response.text == 'OK' + + request, response = app.test_client.post('/composition_view', data=data) + assert response.status == 200 + assert response.text == data + + +def test_request_stream(): + '''test for complex application''' + + bp = Blueprint('test_blueprint_request_stream') + app = Sanic('test_request_stream') + + class SimpleView(HTTPMethodView): + + def get(self, request): + assert request.stream is None + return text('OK') + + @stream_decorator + async def post(self, request): + assert isinstance(request.stream, asyncio.Queue) + result = '' + while True: + body = await request.stream.get() + if body is None: + break + result += body.decode('utf-8') + return text(result) + + @app.post('/stream', stream=True) + async def handler(request): + assert isinstance(request.stream, asyncio.Queue) + + async def streaming(response): + while True: + body = await request.stream.get() + if body is None: + break + response.write(body.decode('utf-8')) + return stream(streaming) + + @app.get('/get') + async def get(request): + assert request.stream is None + return text('OK') + + @bp.post('/bp_stream', stream=True) + async def bp_stream(request): + assert isinstance(request.stream, asyncio.Queue) + result = '' + while True: + body = await request.stream.get() + if body is None: + break + result += body.decode('utf-8') + return text(result) + + @bp.get('/bp_get') + async def bp_get(request): + assert request.stream is None + return text('OK') + + def get_handler(request): + assert request.stream is None + return text('OK') + + async def post_handler(request): + assert isinstance(request.stream, asyncio.Queue) + result = '' + while True: + body = await request.stream.get() + if body is None: + break + result += body.decode('utf-8') + return text(result) + + app.add_route(SimpleView.as_view(), '/method_view') + + view = CompositionView() + view.add(['GET'], get_handler) + view.add(['POST'], post_handler, stream=True) + + app.blueprint(bp) + + app.add_route(view, '/composition_view') + + assert app.is_request_stream is True + + request, response = app.test_client.get('/method_view') + assert response.status == 200 + assert response.text == 'OK' + + request, response = app.test_client.post('/method_view', data=data) + assert response.status == 200 + assert response.text == data + + request, response = app.test_client.get('/composition_view') + assert response.status == 200 + assert response.text == 'OK' + + request, response = app.test_client.post('/composition_view', data=data) + assert response.status == 200 + assert response.text == data + + request, response = app.test_client.get('/get') + assert response.status == 200 + assert response.text == 'OK' + + request, response = app.test_client.post('/stream', data=data) + assert response.status == 200 + assert response.text == data + + request, response = app.test_client.get('/bp_get') + assert response.status == 200 + assert response.text == 'OK' + + request, response = app.test_client.post('/bp_stream', data=data) + assert response.status == 200 + assert response.text == data diff --git a/tests/test_request_timeout.py b/tests/test_request_timeout.py new file mode 100644 index 00000000..b3eb78aa --- /dev/null +++ b/tests/test_request_timeout.py @@ -0,0 +1,193 @@ +from json import JSONDecodeError + +from sanic import Sanic +import asyncio +from sanic.response import text +from sanic.config import Config +import aiohttp +from aiohttp import TCPConnector +from sanic.testing import SanicTestClient, HOST, PORT + + +class DelayableTCPConnector(TCPConnector): + + class RequestContextManager(object): + def __new__(cls, req, delay): + cls = super(DelayableTCPConnector.RequestContextManager, cls).\ + __new__(cls) + cls.req = req + cls.send_task = None + cls.resp = None + cls.orig_send = getattr(req, 'send') + cls.orig_start = None + cls.delay = delay + cls._acting_as = req + return cls + + def __getattr__(self, item): + acting_as = self._acting_as + return getattr(acting_as, item) + + async def start(self, connection, read_until_eof=False): + if self.send_task is None: + raise RuntimeError("do a send() before you do a start()") + resp = await self.send_task + self.send_task = None + self.resp = resp + self._acting_as = self.resp + self.orig_start = getattr(resp, 'start') + + try: + ret = await self.orig_start(connection, + read_until_eof) + except Exception as e: + raise e + return ret + + def close(self): + if self.resp is not None: + self.resp.close() + if self.send_task is not None: + self.send_task.cancel() + + async def delayed_send(self, *args, **kwargs): + req = self.req + if self.delay and self.delay > 0: + #sync_sleep(self.delay) + await asyncio.sleep(self.delay) + t = req.loop.time() + print("sending at {}".format(t), flush=True) + conn = next(iter(args)) # first arg is connection + if aiohttp.__version__ >= "3.1.0": + try: + delayed_resp = await self.orig_send(*args, **kwargs) + except Exception as e: + return aiohttp.ClientResponse(req.method, req.url, + writer=None, continue100=None, timer=None, + request_info=None, auto_decompress=None, traces=[], + loop=req.loop, session=None) + else: + try: + delayed_resp = self.orig_send(*args, **kwargs) + except Exception as e: + return aiohttp.ClientResponse(req.method, req.url) + return delayed_resp + + if aiohttp.__version__ >= "3.1.0": + # aiohttp changed the request.send method to async + async def send(self, *args, **kwargs): + gen = self.delayed_send(*args, **kwargs) + task = self.req.loop.create_task(gen) + self.send_task = task + self._acting_as = task + return self + else: + def send(self, *args, **kwargs): + gen = self.delayed_send(*args, **kwargs) + task = self.req.loop.create_task(gen) + self.send_task = task + self._acting_as = task + return self + + def __init__(self, *args, **kwargs): + _post_connect_delay = kwargs.pop('post_connect_delay', 0) + _pre_request_delay = kwargs.pop('pre_request_delay', 0) + super(DelayableTCPConnector, self).__init__(*args, **kwargs) + self._post_connect_delay = _post_connect_delay + self._pre_request_delay = _pre_request_delay + + if aiohttp.__version__ >= '3.0': + + async def connect(self, req, traces=None): + d_req = DelayableTCPConnector.\ + RequestContextManager(req, self._pre_request_delay) + conn = await super(DelayableTCPConnector, self).connect(req, traces=traces) + if self._post_connect_delay and self._post_connect_delay > 0: + await asyncio.sleep(self._post_connect_delay, + loop=self._loop) + req.send = d_req.send + t = req.loop.time() + print("Connected at {}".format(t), flush=True) + return conn + else: + + async def connect(self, req): + d_req = DelayableTCPConnector.\ + RequestContextManager(req, self._pre_request_delay) + conn = await super(DelayableTCPConnector, self).connect(req) + if self._post_connect_delay and self._post_connect_delay > 0: + await asyncio.sleep(self._post_connect_delay, + loop=self._loop) + req.send = d_req.send + t = req.loop.time() + print("Connected at {}".format(t), flush=True) + return conn + + +class DelayableSanicTestClient(SanicTestClient): + def __init__(self, app, loop, request_delay=1): + super(DelayableSanicTestClient, self).__init__(app) + self._request_delay = request_delay + self._loop = None + + async def _local_request(self, method, uri, cookies=None, *args, + **kwargs): + if self._loop is None: + self._loop = asyncio.get_event_loop() + if uri.startswith(('http:', 'https:', 'ftp:', 'ftps://' '//')): + url = uri + else: + url = 'http://{host}:{port}{uri}'.format( + host=HOST, port=self.port, uri=uri) + conn = DelayableTCPConnector(pre_request_delay=self._request_delay, + verify_ssl=False, loop=self._loop) + async with aiohttp.ClientSession(cookies=cookies, connector=conn, + loop=self._loop) as session: + # Insert a delay after creating the connection + # But before sending the request. + + async with getattr(session, method.lower())( + url, *args, **kwargs) as response: + try: + response.text = await response.text() + except UnicodeDecodeError: + response.text = None + + try: + response.json = await response.json() + except (JSONDecodeError, + UnicodeDecodeError, + aiohttp.ClientResponseError): + response.json = None + + response.body = await response.read() + return response + + +Config.REQUEST_TIMEOUT = 2 +request_timeout_default_app = Sanic('test_request_timeout_default') +request_no_timeout_app = Sanic('test_request_no_timeout') + + +@request_timeout_default_app.route('/1') +async def handler1(request): + return text('OK') + + +@request_no_timeout_app.route('/1') +async def handler2(request): + return text('OK') + + +def test_default_server_error_request_timeout(): + client = DelayableSanicTestClient(request_timeout_default_app, None, 3) + request, response = client.get('/1') + assert response.status == 408 + assert response.text == 'Error: Request Timeout' + + +def test_default_server_error_request_dont_timeout(): + client = DelayableSanicTestClient(request_no_timeout_app, None, 1) + request, response = client.get('/1') + assert response.status == 200 + assert response.text == 'OK' diff --git a/tests/test_requests.py b/tests/test_requests.py index 42dc3e8e..2a91fb9b 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -1,7 +1,15 @@ from json import loads as json_loads, dumps as json_dumps +from urllib.parse import urlparse +import os +import ssl + +import pytest + from sanic import Sanic +from sanic.exceptions import ServerError from sanic.response import json, text -from sanic.utils import sanic_endpoint_test +from sanic.request import DEFAULT_HTTP_CONTENT_TYPE +from sanic.testing import HOST, PORT # ------------------------------------------------------------ # @@ -15,10 +23,20 @@ def test_sync(): def handler(request): return text('Hello') - request, response = sanic_endpoint_test(app) + request, response = app.test_client.get('/') assert response.text == 'Hello' +def test_remote_address(): + app = Sanic('test_text') + + @app.route('/') + def handler(request): + return text("{}".format(request.ip)) + + request, response = app.test_client.get('/') + + assert response.text == '127.0.0.1' def test_text(): app = Sanic('test_text') @@ -27,11 +45,52 @@ def test_text(): async def handler(request): return text('Hello') - request, response = sanic_endpoint_test(app) + request, response = app.test_client.get('/') assert response.text == 'Hello' +def test_headers(): + app = Sanic('test_text') + + @app.route('/') + async def handler(request): + headers = {"spam": "great"} + return text('Hello', headers=headers) + + request, response = app.test_client.get('/') + + assert response.headers.get('spam') == 'great' + + +def test_non_str_headers(): + app = Sanic('test_text') + + @app.route('/') + async def handler(request): + headers = {"answer": 42} + return text('Hello', headers=headers) + + request, response = app.test_client.get('/') + + assert response.headers.get('answer') == '42' + +def test_invalid_response(): + app = Sanic('test_invalid_response') + + @app.exception(ServerError) + def handler_exception(request, exception): + return text('Internal Server Error.', 500) + + @app.route('/') + async def handler(request): + return 'This should fail' + + request, response = app.test_client.get('/') + assert response.status == 500 + assert response.text == "Internal Server Error." + + def test_json(): app = Sanic('test_json') @@ -39,16 +98,39 @@ def test_json(): async def handler(request): return json({"test": True}) - request, response = sanic_endpoint_test(app) + request, response = app.test_client.get('/') - try: - results = json_loads(response.text) - except: - raise ValueError("Expected JSON response but got '{}'".format(response)) + results = json_loads(response.text) assert results.get('test') == True +def test_empty_json(): + app = Sanic('test_json') + + @app.route('/') + async def handler(request): + assert request.json is None + return json(request.json) + + request, response = app.test_client.get('/') + assert response.status == 200 + assert response.text == 'null' + + +def test_invalid_json(): + app = Sanic('test_json') + + @app.route('/') + async def handler(request): + return json(request.json) + + data = "I am not json" + request, response = app.test_client.get('/', data=data) + + assert response.status == 400 + + def test_query_string(): app = Sanic('test_query_string') @@ -56,12 +138,130 @@ def test_query_string(): async def handler(request): return text('OK') - request, response = sanic_endpoint_test(app, params=[("test1", 1), ("test2", "false"), ("test2", "true")]) + request, response = app.test_client.get( + '/', params=[("test1", "1"), ("test2", "false"), ("test2", "true")]) assert request.args.get('test1') == '1' assert request.args.get('test2') == 'false' +def test_uri_template(): + app = Sanic('test_uri_template') + + @app.route('/foo//bar/') + async def handler(request): + return text('OK') + + request, response = app.test_client.get('/foo/123/bar/baz') + assert request.uri_template == '/foo//bar/' + + +def test_token(): + app = Sanic('test_post_token') + + @app.route('/') + async def handler(request): + return text('OK') + + # uuid4 generated token. + token = 'a1d895e0-553a-421a-8e22-5ff8ecb48cbf' + headers = { + 'content-type': 'application/json', + 'Authorization': '{}'.format(token) + } + + request, response = app.test_client.get('/', headers=headers) + + assert request.token == token + + token = 'a1d895e0-553a-421a-8e22-5ff8ecb48cbf' + headers = { + 'content-type': 'application/json', + 'Authorization': 'Token {}'.format(token) + } + + request, response = app.test_client.get('/', headers=headers) + + assert request.token == token + + token = 'a1d895e0-553a-421a-8e22-5ff8ecb48cbf' + headers = { + 'content-type': 'application/json', + 'Authorization': 'Bearer {}'.format(token) + } + + request, response = app.test_client.get('/', headers=headers) + + assert request.token == token + + # no Authorization headers + headers = { + 'content-type': 'application/json' + } + + request, response = app.test_client.get('/', headers=headers) + + assert request.token is None + + +def test_content_type(): + app = Sanic('test_content_type') + + @app.route('/') + async def handler(request): + return text(request.content_type) + + request, response = app.test_client.get('/') + assert request.content_type == DEFAULT_HTTP_CONTENT_TYPE + assert response.text == DEFAULT_HTTP_CONTENT_TYPE + + headers = { + 'content-type': 'application/json', + } + request, response = app.test_client.get('/', headers=headers) + assert request.content_type == 'application/json' + assert response.text == 'application/json' + + +def test_remote_addr(): + app = Sanic('test_content_type') + + @app.route('/') + async def handler(request): + return text(request.remote_addr) + + headers = { + 'X-Forwarded-For': '127.0.0.1, 127.0.1.2' + } + request, response = app.test_client.get('/', headers=headers) + assert request.remote_addr == '127.0.0.1' + assert response.text == '127.0.0.1' + + request, response = app.test_client.get('/') + assert request.remote_addr == '' + assert response.text == '' + + headers = { + 'X-Forwarded-For': '127.0.0.1, , ,,127.0.1.2' + } + request, response = app.test_client.get('/', headers=headers) + assert request.remote_addr == '127.0.0.1' + assert response.text == '127.0.0.1' + + +def test_match_info(): + app = Sanic('test_match_info') + + @app.route('/api/v1/user//') + async def handler(request, user_id): + return json(request.match_info) + + request, response = app.test_client.get('/api/v1/user/sanic_user/') + + assert request.match_info == {"user_id": "sanic_user"} + assert json_loads(response.text) == {"user_id": "sanic_user"} + + # ------------------------------------------------------------ # # POST # ------------------------------------------------------------ # @@ -69,14 +269,115 @@ def test_query_string(): def test_post_json(): app = Sanic('test_post_json') - @app.route('/') + @app.route('/', methods=['POST']) async def handler(request): return text('OK') payload = {'test': 'OK'} headers = {'content-type': 'application/json'} - request, response = sanic_endpoint_test(app, data=json_dumps(payload), headers=headers) + request, response = app.test_client.post( + '/', data=json_dumps(payload), headers=headers) assert request.json.get('test') == 'OK' assert response.text == 'OK' + + +def test_post_form_urlencoded(): + app = Sanic('test_post_form_urlencoded') + + @app.route('/', methods=['POST']) + async def handler(request): + return text('OK') + + payload = 'test=OK' + headers = {'content-type': 'application/x-www-form-urlencoded'} + + request, response = app.test_client.post('/', data=payload, headers=headers) + + assert request.form.get('test') == 'OK' + + +@pytest.mark.parametrize( + 'payload', [ + '------sanic\r\n' \ + 'Content-Disposition: form-data; name="test"\r\n' \ + '\r\n' \ + 'OK\r\n' \ + '------sanic--\r\n', + '------sanic\r\n' \ + 'content-disposition: form-data; name="test"\r\n' \ + '\r\n' \ + 'OK\r\n' \ + '------sanic--\r\n', + ]) +def test_post_form_multipart_form_data(payload): + app = Sanic('test_post_form_multipart_form_data') + + @app.route('/', methods=['POST']) + async def handler(request): + return text('OK') + + headers = {'content-type': 'multipart/form-data; boundary=----sanic'} + + request, response = app.test_client.post(data=payload, headers=headers) + + assert request.form.get('test') == 'OK' + + +@pytest.mark.parametrize( + 'path,query,expected_url', [ + ('/foo', '', 'http://{}:{}/foo'), + ('/bar/baz', '', 'http://{}:{}/bar/baz'), + ('/moo/boo', 'arg1=val1', 'http://{}:{}/moo/boo?arg1=val1') + ]) +def test_url_attributes_no_ssl(path, query, expected_url): + app = Sanic('test_url_attrs_no_ssl') + + async def handler(request): + return text('OK') + + app.add_route(handler, path) + + request, response = app.test_client.get(path + '?{}'.format(query)) + assert request.url == expected_url.format(HOST, PORT) + + parsed = urlparse(request.url) + + assert parsed.scheme == request.scheme + assert parsed.path == request.path + assert parsed.query == request.query_string + assert parsed.netloc == request.host + + +@pytest.mark.parametrize( + 'path,query,expected_url', [ + ('/foo', '', 'https://{}:{}/foo'), + ('/bar/baz', '', 'https://{}:{}/bar/baz'), + ('/moo/boo', 'arg1=val1', 'https://{}:{}/moo/boo?arg1=val1') + ]) +def test_url_attributes_with_ssl(path, query, expected_url): + app = Sanic('test_url_attrs_with_ssl') + + current_dir = os.path.dirname(os.path.realpath(__file__)) + context = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH) + context.load_cert_chain( + os.path.join(current_dir, 'certs/selfsigned.cert'), + keyfile=os.path.join(current_dir, 'certs/selfsigned.key')) + + async def handler(request): + return text('OK') + + app.add_route(handler, path) + + request, response = app.test_client.get( + 'https://{}:{}'.format(HOST, PORT) + path + '?{}'.format(query), + server_kwargs={'ssl': context}) + assert request.url == expected_url.format(HOST, PORT) + + parsed = urlparse(request.url) + + assert parsed.scheme == request.scheme + assert parsed.path == request.path + assert parsed.query == request.query_string + assert parsed.netloc == request.host diff --git a/tests/test_response.py b/tests/test_response.py new file mode 100644 index 00000000..96c06ce6 --- /dev/null +++ b/tests/test_response.py @@ -0,0 +1,359 @@ +import asyncio +import inspect +import os +from aiofiles import os as async_os +from mimetypes import guess_type +from urllib.parse import unquote + +import pytest +from random import choice + +from sanic import Sanic +from sanic.response import HTTPResponse, stream, StreamingHTTPResponse, file, file_stream, json +from sanic.testing import HOST, PORT +from unittest.mock import MagicMock + +JSON_DATA = {'ok': True} + + +def test_response_body_not_a_string(): + """Test when a response body sent from the application is not a string""" + app = Sanic('response_body_not_a_string') + random_num = choice(range(1000)) + + @app.route('/hello') + async def hello_route(request): + return HTTPResponse(body=random_num) + + request, response = app.test_client.get('/hello') + assert response.text == str(random_num) + + +async def sample_streaming_fn(response): + response.write('foo,') + await asyncio.sleep(.001) + response.write('bar') + + +def test_method_not_allowed(): + app = Sanic('method_not_allowed') + + @app.get('/') + async def test(request): + return response.json({'hello': 'world'}) + + request, response = app.test_client.head('/') + assert response.headers['Allow'] == 'GET' + + request, response = app.test_client.post('/') + assert response.headers['Allow'] == 'GET' + + + @app.post('/') + async def test(request): + return response.json({'hello': 'world'}) + + request, response = app.test_client.head('/') + assert response.status == 405 + assert set(response.headers['Allow'].split(', ')) == set(['GET', 'POST']) + assert response.headers['Content-Length'] == '0' + + request, response = app.test_client.patch('/') + assert response.status == 405 + assert set(response.headers['Allow'].split(', ')) == set(['GET', 'POST']) + assert response.headers['Content-Length'] == '0' + + +def test_response_header(): + app = Sanic('test_response_header') + @app.get('/') + async def test(request): + return json({ + "ok": True + }, headers={ + 'CONTENT-TYPE': 'application/json' + }) + + request, response = app.test_client.get('/') + assert dict(response.headers) == { + 'Connection': 'keep-alive', + 'Keep-Alive': '2', + 'Content-Length': '11', + 'Content-Type': 'application/json', + } + + +@pytest.fixture +def json_app(): + app = Sanic('json') + + @app.route("/") + async def test(request): + return json(JSON_DATA) + + @app.get("/no-content") + async def no_content_handler(request): + return json(JSON_DATA, status=204) + + @app.get("/no-content/unmodified") + async def no_content_unmodified_handler(request): + return json(None, status=304) + + @app.get("/unmodified") + async def unmodified_handler(request): + return json(JSON_DATA, status=304) + + @app.delete("/") + async def delete_handler(request): + return json(None, status=204) + + return app + + +def test_json_response(json_app): + from sanic.response import json_dumps + request, response = json_app.test_client.get('/') + assert response.status == 200 + assert response.text == json_dumps(JSON_DATA) + assert response.json == JSON_DATA + + +def test_no_content(json_app): + request, response = json_app.test_client.get('/no-content') + assert response.status == 204 + assert response.text == '' + assert 'Content-Length' not in response.headers + + request, response = json_app.test_client.get('/no-content/unmodified') + assert response.status == 304 + assert response.text == '' + assert 'Content-Length' not in response.headers + assert 'Content-Type' not in response.headers + + request, response = json_app.test_client.get('/unmodified') + assert response.status == 304 + assert response.text == '' + assert 'Content-Length' not in response.headers + assert 'Content-Type' not in response.headers + + request, response = json_app.test_client.delete('/') + assert response.status == 204 + assert response.text == '' + assert 'Content-Length' not in response.headers + + +@pytest.fixture +def streaming_app(): + app = Sanic('streaming') + + @app.route("/") + async def test(request): + return stream(sample_streaming_fn, content_type='text/csv') + + return app + + +def test_streaming_adds_correct_headers(streaming_app): + request, response = streaming_app.test_client.get('/') + assert response.headers['Transfer-Encoding'] == 'chunked' + assert response.headers['Content-Type'] == 'text/csv' + + +def test_streaming_returns_correct_content(streaming_app): + request, response = streaming_app.test_client.get('/') + assert response.text == 'foo,bar' + + +@pytest.mark.parametrize('status', [200, 201, 400, 401]) +def test_stream_response_status_returns_correct_headers(status): + response = StreamingHTTPResponse(sample_streaming_fn, status=status) + headers = response.get_headers() + assert b"HTTP/1.1 %s" % str(status).encode() in headers + + +@pytest.mark.parametrize('keep_alive_timeout', [10, 20, 30]) +def test_stream_response_keep_alive_returns_correct_headers( + keep_alive_timeout): + response = StreamingHTTPResponse(sample_streaming_fn) + headers = response.get_headers( + keep_alive=True, keep_alive_timeout=keep_alive_timeout) + + assert b"Keep-Alive: %s\r\n" % str(keep_alive_timeout).encode() in headers + + +def test_stream_response_includes_chunked_header(): + response = StreamingHTTPResponse(sample_streaming_fn) + headers = response.get_headers() + assert b"Transfer-Encoding: chunked\r\n" in headers + + +def test_stream_response_writes_correct_content_to_transport(streaming_app): + response = StreamingHTTPResponse(sample_streaming_fn) + response.transport = MagicMock(asyncio.Transport) + + @streaming_app.listener('after_server_start') + async def run_stream(app, loop): + await response.stream() + assert response.transport.write.call_args_list[1][0][0] == ( + b'4\r\nfoo,\r\n' + ) + + assert response.transport.write.call_args_list[2][0][0] == ( + b'3\r\nbar\r\n' + ) + + assert response.transport.write.call_args_list[3][0][0] == ( + b'0\r\n\r\n' + ) + + app.stop() + + streaming_app.run(host=HOST, port=PORT) + + +@pytest.fixture +def static_file_directory(): + """The static directory to serve""" + current_file = inspect.getfile(inspect.currentframe()) + current_directory = os.path.dirname(os.path.abspath(current_file)) + static_directory = os.path.join(current_directory, 'static') + return static_directory + + +def get_file_content(static_file_directory, file_name): + """The content of the static file to check""" + with open(os.path.join(static_file_directory, file_name), 'rb') as file: + return file.read() + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt', 'python.png']) +@pytest.mark.parametrize('status', [200, 401]) +def test_file_response(file_name, static_file_directory, status): + app = Sanic('test_file_helper') + + @app.route('/files/', methods=['GET']) + def file_route(request, filename): + file_path = os.path.join(static_file_directory, filename) + file_path = os.path.abspath(unquote(file_path)) + return file(file_path, status=status, + mime_type=guess_type(file_path)[0] or 'text/plain') + + request, response = app.test_client.get('/files/{}'.format(file_name)) + assert response.status == status + assert response.body == get_file_content(static_file_directory, file_name) + assert 'Content-Disposition' not in response.headers + + +@pytest.mark.parametrize('source,dest', [ + ('test.file', 'my_file.txt'), ('decode me.txt', 'readme.md'), ('python.png', 'logo.png')]) +def test_file_response_custom_filename(source, dest, static_file_directory): + app = Sanic('test_file_helper') + + @app.route('/files/', methods=['GET']) + def file_route(request, filename): + file_path = os.path.join(static_file_directory, filename) + file_path = os.path.abspath(unquote(file_path)) + return file(file_path, filename=dest) + + request, response = app.test_client.get('/files/{}'.format(source)) + assert response.status == 200 + assert response.body == get_file_content(static_file_directory, source) + assert response.headers['Content-Disposition'] == 'attachment; filename="{}"'.format(dest) + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt']) +def test_file_head_response(file_name, static_file_directory): + app = Sanic('test_file_helper') + + @app.route('/files/', methods=['GET', 'HEAD']) + async def file_route(request, filename): + file_path = os.path.join(static_file_directory, filename) + file_path = os.path.abspath(unquote(file_path)) + stats = await async_os.stat(file_path) + headers = dict() + headers['Accept-Ranges'] = 'bytes' + headers['Content-Length'] = str(stats.st_size) + if request.method == "HEAD": + return HTTPResponse( + headers=headers, + content_type=guess_type(file_path)[0] or 'text/plain') + else: + return file(file_path, headers=headers, + mime_type=guess_type(file_path)[0] or 'text/plain') + + request, response = app.test_client.head('/files/{}'.format(file_name)) + assert response.status == 200 + assert 'Accept-Ranges' in response.headers + assert 'Content-Length' in response.headers + assert int(response.headers[ + 'Content-Length']) == len( + get_file_content(static_file_directory, file_name)) + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt', 'python.png']) +def test_file_stream_response(file_name, static_file_directory): + app = Sanic('test_file_helper') + + @app.route('/files/', methods=['GET']) + def file_route(request, filename): + file_path = os.path.join(static_file_directory, filename) + file_path = os.path.abspath(unquote(file_path)) + return file_stream(file_path, chunk_size=32, + mime_type=guess_type(file_path)[0] or 'text/plain') + + request, response = app.test_client.get('/files/{}'.format(file_name)) + assert response.status == 200 + assert response.body == get_file_content(static_file_directory, file_name) + assert 'Content-Disposition' not in response.headers + + +@pytest.mark.parametrize('source,dest', [ + ('test.file', 'my_file.txt'), ('decode me.txt', 'readme.md'), ('python.png', 'logo.png')]) +def test_file_stream_response_custom_filename(source, dest, static_file_directory): + app = Sanic('test_file_helper') + + @app.route('/files/', methods=['GET']) + def file_route(request, filename): + file_path = os.path.join(static_file_directory, filename) + file_path = os.path.abspath(unquote(file_path)) + return file_stream(file_path, chunk_size=32, filename=dest) + + request, response = app.test_client.get('/files/{}'.format(source)) + assert response.status == 200 + assert response.body == get_file_content(static_file_directory, source) + assert response.headers['Content-Disposition'] == 'attachment; filename="{}"'.format(dest) + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt']) +def test_file_stream_head_response(file_name, static_file_directory): + app = Sanic('test_file_helper') + + @app.route('/files/', methods=['GET', 'HEAD']) + async def file_route(request, filename): + file_path = os.path.join(static_file_directory, filename) + file_path = os.path.abspath(unquote(file_path)) + headers = dict() + headers['Accept-Ranges'] = 'bytes' + if request.method == "HEAD": + # Return a normal HTTPResponse, not a + # StreamingHTTPResponse for a HEAD request + stats = await async_os.stat(file_path) + headers['Content-Length'] = str(stats.st_size) + return HTTPResponse( + headers=headers, + content_type=guess_type(file_path)[0] or 'text/plain') + else: + return file_stream(file_path, chunk_size=32, headers=headers, + mime_type=guess_type(file_path)[0] or 'text/plain') + + request, response = app.test_client.head('/files/{}'.format(file_name)) + assert response.status == 200 + # A HEAD request should never be streamed/chunked. + if 'Transfer-Encoding' in response.headers: + assert response.headers['Transfer-Encoding'] != "chunked" + assert 'Accept-Ranges' in response.headers + # A HEAD request should get the Content-Length too + assert 'Content-Length' in response.headers + assert int(response.headers[ + 'Content-Length']) == len( + get_file_content(static_file_directory, file_name)) diff --git a/tests/test_response_timeout.py b/tests/test_response_timeout.py new file mode 100644 index 00000000..bf55a42e --- /dev/null +++ b/tests/test_response_timeout.py @@ -0,0 +1,38 @@ +from sanic import Sanic +import asyncio +from sanic.response import text +from sanic.exceptions import ServiceUnavailable +from sanic.config import Config + +Config.RESPONSE_TIMEOUT = 1 +response_timeout_app = Sanic('test_response_timeout') +response_timeout_default_app = Sanic('test_response_timeout_default') + + +@response_timeout_app.route('/1') +async def handler_1(request): + await asyncio.sleep(2) + return text('OK') + + +@response_timeout_app.exception(ServiceUnavailable) +def handler_exception(request, exception): + return text('Response Timeout from error_handler.', 503) + + +def test_server_error_response_timeout(): + request, response = response_timeout_app.test_client.get('/1') + assert response.status == 503 + assert response.text == 'Response Timeout from error_handler.' + + +@response_timeout_default_app.route('/1') +async def handler_2(request): + await asyncio.sleep(2) + return text('OK') + + +def test_default_server_error_response_timeout(): + request, response = response_timeout_default_app.test_client.get('/1') + assert response.status == 503 + assert response.text == 'Error: Response Timeout' diff --git a/tests/test_routes.py b/tests/test_routes.py index 640f3422..146db97c 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -1,13 +1,331 @@ -from json import loads as json_loads, dumps as json_dumps +import asyncio +import pytest + from sanic import Sanic -from sanic.response import json, text -from sanic.utils import sanic_endpoint_test +from sanic.response import text, json +from sanic.router import RouteExists, RouteDoesNotExist +from sanic.constants import HTTP_METHODS # ------------------------------------------------------------ # # UTF-8 # ------------------------------------------------------------ # +@pytest.mark.parametrize('method', HTTP_METHODS) +def test_versioned_routes_get(method): + app = Sanic('test_shorhand_routes_get') + + method = method.lower() + + func = getattr(app, method) + if callable(func): + @func('/{}'.format(method), version=1) + def handler(request): + return text('OK') + else: + print(func) + raise + + client_method = getattr(app.test_client, method) + + request, response = client_method('/v1/{}'.format(method)) + assert response.status == 200 + + +def test_shorthand_routes_get(): + app = Sanic('test_shorhand_routes_get') + + @app.get('/get') + def handler(request): + return text('OK') + + request, response = app.test_client.get('/get') + assert response.text == 'OK' + + request, response = app.test_client.post('/get') + assert response.status == 405 + + +def test_shorthand_routes_multiple(): + app = Sanic('test_shorthand_routes_multiple') + + @app.get('/get') + def get_handler(request): + return text('OK') + + @app.options('/get') + def options_handler(request): + return text('') + + request, response = app.test_client.get('/get/') + assert response.status == 200 + assert response.text == 'OK' + + request, response = app.test_client.options('/get/') + assert response.status == 200 + + +def test_route_strict_slash(): + app = Sanic('test_route_strict_slash') + + @app.get('/get', strict_slashes=True) + def handler(request): + assert request.stream is None + return text('OK') + + @app.post('/post/', strict_slashes=True) + def handler(request): + assert request.stream is None + return text('OK') + + assert app.is_request_stream is False + + request, response = app.test_client.get('/get') + assert response.text == 'OK' + + request, response = app.test_client.get('/get/') + assert response.status == 404 + + request, response = app.test_client.post('/post/') + assert response.text == 'OK' + + request, response = app.test_client.post('/post') + assert response.status == 404 + + +def test_route_invalid_parameter_syntax(): + with pytest.raises(ValueError): + app = Sanic('test_route_invalid_param_syntax') + + @app.get('/get/<:string>', strict_slashes=True) + def handler(request): + return text('OK') + + request, response = app.test_client.get('/get') + + +def test_route_strict_slash_default_value(): + app = Sanic('test_route_strict_slash', strict_slashes=True) + + @app.get('/get') + def handler(request): + return text('OK') + + request, response = app.test_client.get('/get/') + assert response.status == 404 + + +def test_route_strict_slash_without_passing_default_value(): + app = Sanic('test_route_strict_slash') + + @app.get('/get') + def handler(request): + return text('OK') + + request, response = app.test_client.get('/get/') + assert response.text == 'OK' + + +def test_route_strict_slash_default_value_can_be_overwritten(): + app = Sanic('test_route_strict_slash', strict_slashes=True) + + @app.get('/get', strict_slashes=False) + def handler(request): + return text('OK') + + request, response = app.test_client.get('/get/') + assert response.text == 'OK' + + +def test_route_slashes_overload(): + app = Sanic('test_route_slashes_overload') + + @app.get('/hello/') + def handler(request): + return text('OK') + + @app.post('/hello/') + def handler(request): + return text('OK') + + request, response = app.test_client.get('/hello') + assert response.text == 'OK' + + request, response = app.test_client.get('/hello/') + assert response.text == 'OK' + + request, response = app.test_client.post('/hello') + assert response.text == 'OK' + + request, response = app.test_client.post('/hello/') + assert response.text == 'OK' + + +def test_route_optional_slash(): + app = Sanic('test_route_optional_slash') + + @app.get('/get') + def handler(request): + return text('OK') + + request, response = app.test_client.get('/get') + assert response.text == 'OK' + + request, response = app.test_client.get('/get/') + assert response.text == 'OK' + +def test_route_strict_slashes_set_to_false_and_host_is_a_list(): + #Part of regression test for issue #1120 + app = Sanic('test_route_strict_slashes_set_to_false_and_host_is_a_list') + + site1 = 'localhost:{}'.format(app.test_client.port) + + #before fix, this raises a RouteExists error + @app.get('/get', host=[site1, 'site2.com'], strict_slashes=False) + def handler(request): + return text('OK') + + request, response = app.test_client.get('http://' + site1 + '/get') + assert response.text == 'OK' + + @app.post('/post', host=[site1, 'site2.com'], strict_slashes=False) + def handler(request): + return text('OK') + + request, response = app.test_client.post('http://' + site1 +'/post') + assert response.text == 'OK' + + @app.put('/put', host=[site1, 'site2.com'], strict_slashes=False) + def handler(request): + return text('OK') + + request, response = app.test_client.put('http://' + site1 +'/put') + assert response.text == 'OK' + + @app.delete('/delete', host=[site1, 'site2.com'], strict_slashes=False) + def handler(request): + return text('OK') + + request, response = app.test_client.delete('http://' + site1 +'/delete') + assert response.text == 'OK' + +def test_shorthand_routes_post(): + app = Sanic('test_shorhand_routes_post') + + @app.post('/post') + def handler(request): + return text('OK') + + request, response = app.test_client.post('/post') + assert response.text == 'OK' + + request, response = app.test_client.get('/post') + assert response.status == 405 + + +def test_shorthand_routes_put(): + app = Sanic('test_shorhand_routes_put') + + @app.put('/put') + def handler(request): + assert request.stream is None + return text('OK') + + assert app.is_request_stream is False + + request, response = app.test_client.put('/put') + assert response.text == 'OK' + + request, response = app.test_client.get('/put') + assert response.status == 405 + + +def test_shorthand_routes_delete(): + app = Sanic('test_shorhand_routes_delete') + + @app.delete('/delete') + def handler(request): + assert request.stream is None + return text('OK') + + assert app.is_request_stream is False + + request, response = app.test_client.delete('/delete') + assert response.text == 'OK' + + request, response = app.test_client.get('/delete') + assert response.status == 405 + + +def test_shorthand_routes_patch(): + app = Sanic('test_shorhand_routes_patch') + + @app.patch('/patch') + def handler(request): + assert request.stream is None + return text('OK') + + assert app.is_request_stream is False + + request, response = app.test_client.patch('/patch') + assert response.text == 'OK' + + request, response = app.test_client.get('/patch') + assert response.status == 405 + + +def test_shorthand_routes_head(): + app = Sanic('test_shorhand_routes_head') + + @app.head('/head') + def handler(request): + assert request.stream is None + return text('OK') + + assert app.is_request_stream is False + + request, response = app.test_client.head('/head') + assert response.status == 200 + + request, response = app.test_client.get('/head') + assert response.status == 405 + + +def test_shorthand_routes_options(): + app = Sanic('test_shorhand_routes_options') + + @app.options('/options') + def handler(request): + assert request.stream is None + return text('OK') + + assert app.is_request_stream is False + + request, response = app.test_client.options('/options') + assert response.status == 200 + + request, response = app.test_client.get('/options') + assert response.status == 405 + + +def test_static_routes(): + app = Sanic('test_dynamic_route') + + @app.route('/test') + async def handler1(request): + return text('OK1') + + @app.route('/pizazz') + async def handler2(request): + return text('OK2') + + request, response = app.test_client.get('/test') + assert response.text == 'OK1' + + request, response = app.test_client.get('/pizazz') + assert response.text == 'OK2' + + def test_dynamic_route(): app = Sanic('test_dynamic_route') @@ -18,7 +336,7 @@ def test_dynamic_route(): results.append(name) return text('OK') - request, response = sanic_endpoint_test(app, uri='/folder/test123') + request, response = app.test_client.get('/folder/test123') assert response.text == 'OK' assert results[0] == 'test123' @@ -34,12 +352,12 @@ def test_dynamic_route_string(): results.append(name) return text('OK') - request, response = sanic_endpoint_test(app, uri='/folder/test123') + request, response = app.test_client.get('/folder/test123') assert response.text == 'OK' assert results[0] == 'test123' - request, response = sanic_endpoint_test(app, uri='/folder/favicon.ico') + request, response = app.test_client.get('/folder/favicon.ico') assert response.text == 'OK' assert results[1] == 'favicon.ico' @@ -55,16 +373,16 @@ def test_dynamic_route_int(): results.append(folder_id) return text('OK') - request, response = sanic_endpoint_test(app, uri='/folder/12345') + request, response = app.test_client.get('/folder/12345') assert response.text == 'OK' assert type(results[0]) is int - request, response = sanic_endpoint_test(app, uri='/folder/asdf') + request, response = app.test_client.get('/folder/asdf') assert response.status == 404 def test_dynamic_route_number(): - app = Sanic('test_dynamic_route_int') + app = Sanic('test_dynamic_route_number') results = [] @@ -73,32 +391,599 @@ def test_dynamic_route_number(): results.append(weight) return text('OK') - request, response = sanic_endpoint_test(app, uri='/weight/12345') + request, response = app.test_client.get('/weight/12345') assert response.text == 'OK' assert type(results[0]) is float - request, response = sanic_endpoint_test(app, uri='/weight/1234.56') + request, response = app.test_client.get('/weight/1234.56') assert response.status == 200 - request, response = sanic_endpoint_test(app, uri='/weight/1234-56') + request, response = app.test_client.get('/weight/1234-56') assert response.status == 404 def test_dynamic_route_regex(): - app = Sanic('test_dynamic_route_int') + app = Sanic('test_dynamic_route_regex') @app.route('/folder/') async def handler(request, folder_id): return text('OK') - request, response = sanic_endpoint_test(app, uri='/folder/test') + request, response = app.test_client.get('/folder/test') assert response.status == 200 - request, response = sanic_endpoint_test(app, uri='/folder/test1') + request, response = app.test_client.get('/folder/test1') assert response.status == 404 - request, response = sanic_endpoint_test(app, uri='/folder/test-123') + request, response = app.test_client.get('/folder/test-123') assert response.status == 404 - request, response = sanic_endpoint_test(app, uri='/folder/') + request, response = app.test_client.get('/folder/') assert response.status == 200 + + +def test_dynamic_route_uuid(): + import uuid + app = Sanic('test_dynamic_route_uuid') + + results = [] + + @app.route('/quirky/') + async def handler(request, unique_id): + results.append(unique_id) + return text('OK') + + request, response = app.test_client.get('/quirky/123e4567-e89b-12d3-a456-426655440000') + assert response.text == 'OK' + assert type(results[0]) is uuid.UUID + + request, response = app.test_client.get('/quirky/{}'.format(uuid.uuid4())) + assert response.status == 200 + + request, response = app.test_client.get('/quirky/non-existing') + assert response.status == 404 + + +def test_dynamic_route_path(): + app = Sanic('test_dynamic_route_path') + + @app.route('//info') + async def handler(request, path): + return text('OK') + + request, response = app.test_client.get('/path/1/info') + assert response.status == 200 + + request, response = app.test_client.get('/info') + assert response.status == 404 + + @app.route('/') + async def handler1(request, path): + return text('OK') + + request, response = app.test_client.get('/info') + assert response.status == 200 + + request, response = app.test_client.get('/whatever/you/set') + assert response.status == 200 + + +def test_dynamic_route_unhashable(): + app = Sanic('test_dynamic_route_unhashable') + + @app.route('/folder//end/') + async def handler(request, unhashable): + return text('OK') + + request, response = app.test_client.get('/folder/test/asdf/end/') + assert response.status == 200 + + request, response = app.test_client.get('/folder/test///////end/') + assert response.status == 200 + + request, response = app.test_client.get('/folder/test/end/') + assert response.status == 200 + + request, response = app.test_client.get('/folder/test/nope/') + assert response.status == 404 + + +def test_websocket_route(): + app = Sanic('test_websocket_route') + ev = asyncio.Event() + + @app.websocket('/ws') + async def handler(request, ws): + assert ws.subprotocol is None + ev.set() + + request, response = app.test_client.get('/ws', headers={ + 'Upgrade': 'websocket', + 'Connection': 'upgrade', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': '13'}) + assert response.status == 101 + assert ev.is_set() + + +def test_websocket_route_with_subprotocols(): + app = Sanic('test_websocket_route') + results = [] + + @app.websocket('/ws', subprotocols=['foo', 'bar']) + async def handler(request, ws): + results.append(ws.subprotocol) + + request, response = app.test_client.get('/ws', headers={ + 'Upgrade': 'websocket', + 'Connection': 'upgrade', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': '13', + 'Sec-WebSocket-Protocol': 'bar'}) + assert response.status == 101 + + request, response = app.test_client.get('/ws', headers={ + 'Upgrade': 'websocket', + 'Connection': 'upgrade', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': '13', + 'Sec-WebSocket-Protocol': 'bar, foo'}) + assert response.status == 101 + + request, response = app.test_client.get('/ws', headers={ + 'Upgrade': 'websocket', + 'Connection': 'upgrade', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': '13', + 'Sec-WebSocket-Protocol': 'baz'}) + assert response.status == 101 + + request, response = app.test_client.get('/ws', headers={ + 'Upgrade': 'websocket', + 'Connection': 'upgrade', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': '13'}) + assert response.status == 101 + + assert results == ['bar', 'bar', None, None] + + +def test_route_duplicate(): + app = Sanic('test_route_duplicate') + + with pytest.raises(RouteExists): + @app.route('/test') + async def handler1(request): + pass + + @app.route('/test') + async def handler2(request): + pass + + with pytest.raises(RouteExists): + @app.route('/test//') + async def handler1(request, dynamic): + pass + + @app.route('/test//') + async def handler2(request, dynamic): + pass + + +def test_method_not_allowed(): + app = Sanic('test_method_not_allowed') + + @app.route('/test', methods=['GET']) + async def handler(request): + return text('OK') + + request, response = app.test_client.get('/test') + assert response.status == 200 + + request, response = app.test_client.post('/test') + assert response.status == 405 + + +def test_static_add_route(): + app = Sanic('test_static_add_route') + + async def handler1(request): + return text('OK1') + + async def handler2(request): + return text('OK2') + + app.add_route(handler1, '/test') + app.add_route(handler2, '/test2') + + request, response = app.test_client.get('/test') + assert response.text == 'OK1' + + request, response = app.test_client.get('/test2') + assert response.text == 'OK2' + + +def test_dynamic_add_route(): + app = Sanic('test_dynamic_add_route') + + results = [] + + async def handler(request, name): + results.append(name) + return text('OK') + + app.add_route(handler, '/folder/') + request, response = app.test_client.get('/folder/test123') + + assert response.text == 'OK' + assert results[0] == 'test123' + + +def test_dynamic_add_route_string(): + app = Sanic('test_dynamic_add_route_string') + + results = [] + + async def handler(request, name): + results.append(name) + return text('OK') + + app.add_route(handler, '/folder/') + request, response = app.test_client.get('/folder/test123') + + assert response.text == 'OK' + assert results[0] == 'test123' + + request, response = app.test_client.get('/folder/favicon.ico') + + assert response.text == 'OK' + assert results[1] == 'favicon.ico' + + +def test_dynamic_add_route_int(): + app = Sanic('test_dynamic_add_route_int') + + results = [] + + async def handler(request, folder_id): + results.append(folder_id) + return text('OK') + + app.add_route(handler, '/folder/') + + request, response = app.test_client.get('/folder/12345') + assert response.text == 'OK' + assert type(results[0]) is int + + request, response = app.test_client.get('/folder/asdf') + assert response.status == 404 + + +def test_dynamic_add_route_number(): + app = Sanic('test_dynamic_add_route_number') + + results = [] + + async def handler(request, weight): + results.append(weight) + return text('OK') + + app.add_route(handler, '/weight/') + + request, response = app.test_client.get('/weight/12345') + assert response.text == 'OK' + assert type(results[0]) is float + + request, response = app.test_client.get('/weight/1234.56') + assert response.status == 200 + + request, response = app.test_client.get('/weight/1234-56') + assert response.status == 404 + + +def test_dynamic_add_route_regex(): + app = Sanic('test_dynamic_route_int') + + async def handler(request, folder_id): + return text('OK') + + app.add_route(handler, '/folder/') + + request, response = app.test_client.get('/folder/test') + assert response.status == 200 + + request, response = app.test_client.get('/folder/test1') + assert response.status == 404 + + request, response = app.test_client.get('/folder/test-123') + assert response.status == 404 + + request, response = app.test_client.get('/folder/') + assert response.status == 200 + + +def test_dynamic_add_route_unhashable(): + app = Sanic('test_dynamic_add_route_unhashable') + + async def handler(request, unhashable): + return text('OK') + + app.add_route(handler, '/folder//end/') + + request, response = app.test_client.get('/folder/test/asdf/end/') + assert response.status == 200 + + request, response = app.test_client.get('/folder/test///////end/') + assert response.status == 200 + + request, response = app.test_client.get('/folder/test/end/') + assert response.status == 200 + + request, response = app.test_client.get('/folder/test/nope/') + assert response.status == 404 + + +def test_add_route_duplicate(): + app = Sanic('test_add_route_duplicate') + + with pytest.raises(RouteExists): + async def handler1(request): + pass + + async def handler2(request): + pass + + app.add_route(handler1, '/test') + app.add_route(handler2, '/test') + + with pytest.raises(RouteExists): + async def handler1(request, dynamic): + pass + + async def handler2(request, dynamic): + pass + + app.add_route(handler1, '/test//') + app.add_route(handler2, '/test//') + + +def test_add_route_method_not_allowed(): + app = Sanic('test_add_route_method_not_allowed') + + async def handler(request): + return text('OK') + + app.add_route(handler, '/test', methods=['GET']) + + request, response = app.test_client.get('/test') + assert response.status == 200 + + request, response = app.test_client.post('/test') + assert response.status == 405 + + +def test_remove_static_route(): + app = Sanic('test_remove_static_route') + + async def handler1(request): + return text('OK1') + + async def handler2(request): + return text('OK2') + + app.add_route(handler1, '/test') + app.add_route(handler2, '/test2') + + request, response = app.test_client.get('/test') + assert response.status == 200 + + request, response = app.test_client.get('/test2') + assert response.status == 200 + + app.remove_route('/test') + app.remove_route('/test2') + + request, response = app.test_client.get('/test') + assert response.status == 404 + + request, response = app.test_client.get('/test2') + assert response.status == 404 + + +def test_remove_dynamic_route(): + app = Sanic('test_remove_dynamic_route') + + async def handler(request, name): + return text('OK') + + app.add_route(handler, '/folder/') + + request, response = app.test_client.get('/folder/test123') + assert response.status == 200 + + app.remove_route('/folder/') + request, response = app.test_client.get('/folder/test123') + assert response.status == 404 + + +def test_remove_inexistent_route(): + app = Sanic('test_remove_inexistent_route') + + with pytest.raises(RouteDoesNotExist): + app.remove_route('/test') + + +def test_removing_slash(): + app = Sanic(__name__) + + @app.get('/rest/') + def get(_): + pass + + @app.post('/rest/') + def post(_): + pass + + assert len(app.router.routes_all.keys()) == 2 + + +def test_remove_unhashable_route(): + app = Sanic('test_remove_unhashable_route') + + async def handler(request, unhashable): + return text('OK') + + app.add_route(handler, '/folder//end/') + + request, response = app.test_client.get('/folder/test/asdf/end/') + assert response.status == 200 + + request, response = app.test_client.get('/folder/test///////end/') + assert response.status == 200 + + request, response = app.test_client.get('/folder/test/end/') + assert response.status == 200 + + app.remove_route('/folder//end/') + + request, response = app.test_client.get('/folder/test/asdf/end/') + assert response.status == 404 + + request, response = app.test_client.get('/folder/test///////end/') + assert response.status == 404 + + request, response = app.test_client.get('/folder/test/end/') + assert response.status == 404 + + +def test_remove_route_without_clean_cache(): + app = Sanic('test_remove_static_route') + + async def handler(request): + return text('OK') + + app.add_route(handler, '/test') + + request, response = app.test_client.get('/test') + assert response.status == 200 + + app.remove_route('/test', clean_cache=True) + app.remove_route('/test/', clean_cache=True) + + request, response = app.test_client.get('/test') + assert response.status == 404 + + app.add_route(handler, '/test') + + request, response = app.test_client.get('/test') + assert response.status == 200 + + app.remove_route('/test', clean_cache=False) + + request, response = app.test_client.get('/test') + assert response.status == 200 + + +def test_overload_routes(): + app = Sanic('test_dynamic_route') + + @app.route('/overload', methods=['GET']) + async def handler1(request): + return text('OK1') + + @app.route('/overload', methods=['POST', 'PUT']) + async def handler2(request): + return text('OK2') + + request, response = app.test_client.get('/overload') + assert response.text == 'OK1' + + request, response = app.test_client.post('/overload') + assert response.text == 'OK2' + + request, response = app.test_client.put('/overload') + assert response.text == 'OK2' + + request, response = app.test_client.delete('/overload') + assert response.status == 405 + + with pytest.raises(RouteExists): + @app.route('/overload', methods=['PUT', 'DELETE']) + async def handler3(request): + return text('Duplicated') + + +def test_unmergeable_overload_routes(): + app = Sanic('test_dynamic_route') + + @app.route('/overload_whole', methods=None) + async def handler1(request): + return text('OK1') + + with pytest.raises(RouteExists): + @app.route('/overload_whole', methods=['POST', 'PUT']) + async def handler2(request): + return text('Duplicated') + + request, response = app.test_client.get('/overload_whole') + assert response.text == 'OK1' + + request, response = app.test_client.post('/overload_whole') + assert response.text == 'OK1' + + @app.route('/overload_part', methods=['GET']) + async def handler1(request): + return text('OK1') + + with pytest.raises(RouteExists): + @app.route('/overload_part') + async def handler2(request): + return text('Duplicated') + + request, response = app.test_client.get('/overload_part') + assert response.text == 'OK1' + + request, response = app.test_client.post('/overload_part') + assert response.status == 405 + + +def test_unicode_routes(): + app = Sanic('test_unicode_routes') + + @app.get('/你好') + def handler1(request): + return text('OK1') + + request, response = app.test_client.get('/你好') + assert response.text == 'OK1' + + @app.route('/overload/', methods=['GET']) + async def handler2(request, param): + return text('OK2 ' + param) + + request, response = app.test_client.get('/overload/你好') + assert response.text == 'OK2 你好' + + +def test_uri_with_different_method_and_different_params(): + app = Sanic('test_uri') + + @app.route('/ads/', methods=['GET']) + async def ad_get(request, ad_id): + return json({'ad_id': ad_id}) + + @app.route('/ads/', methods=['POST']) + async def ad_post(request, action): + return json({'action': action}) + + request, response = app.test_client.get('/ads/1234') + assert response.status == 200 + assert response.json == { + 'ad_id': '1234' + } + + request, response = app.test_client.post('/ads/post') + assert response.status == 200 + assert response.json == { + 'action': 'post' + } diff --git a/tests/test_server_events.py b/tests/test_server_events.py new file mode 100644 index 00000000..c2ccc7dc --- /dev/null +++ b/tests/test_server_events.py @@ -0,0 +1,95 @@ +from io import StringIO +from random import choice +from string import ascii_letters +import signal + +import pytest + +from sanic import Sanic +from sanic.testing import HOST, PORT + +AVAILABLE_LISTENERS = [ + 'before_server_start', + 'after_server_start', + 'before_server_stop', + 'after_server_stop' +] + + +def create_listener(listener_name, in_list): + async def _listener(app, loop): + print('DEBUG MESSAGE FOR PYTEST for {}'.format(listener_name)) + in_list.insert(0, app.name + listener_name) + return _listener + + +def start_stop_app(random_name_app, **run_kwargs): + + def stop_on_alarm(signum, frame): + raise KeyboardInterrupt('SIGINT for sanic to stop gracefully') + + signal.signal(signal.SIGALRM, stop_on_alarm) + signal.alarm(1) + try: + random_name_app.run(HOST, PORT, **run_kwargs) + except KeyboardInterrupt: + pass + + +@pytest.mark.parametrize('listener_name', AVAILABLE_LISTENERS) +def test_single_listener(listener_name): + """Test that listeners on their own work""" + random_name_app = Sanic(''.join( + [choice(ascii_letters) for _ in range(choice(range(5, 10)))])) + output = list() + # Register listener + random_name_app.listener(listener_name)( + create_listener(listener_name, output)) + start_stop_app(random_name_app) + assert random_name_app.name + listener_name == output.pop() + + +@pytest.mark.parametrize('listener_name', AVAILABLE_LISTENERS) +def test_register_listener(listener_name): + """ + Test that listeners on their own work with + app.register_listener method + """ + random_name_app = Sanic(''.join( + [choice(ascii_letters) for _ in range(choice(range(5, 10)))])) + output = list() + # Register listener + listener = create_listener(listener_name, output) + random_name_app.register_listener(listener, + event=listener_name) + start_stop_app(random_name_app) + assert random_name_app.name + listener_name == output.pop() + + +def test_all_listeners(): + random_name_app = Sanic(''.join( + [choice(ascii_letters) for _ in range(choice(range(5, 10)))])) + output = list() + for listener_name in AVAILABLE_LISTENERS: + listener = create_listener(listener_name, output) + random_name_app.listener(listener_name)(listener) + start_stop_app(random_name_app) + for listener_name in AVAILABLE_LISTENERS: + assert random_name_app.name + listener_name == output.pop() + + +async def test_trigger_before_events_create_server(): + + class MySanicDb: + pass + + app = Sanic("test_sanic_app") + + @app.listener('before_server_start') + async def init_db(app, loop): + app.db = MySanicDb() + + await app.create_server() + + assert hasattr(app, "db") + assert isinstance(app.db, MySanicDb) diff --git a/tests/test_signal_handlers.py b/tests/test_signal_handlers.py new file mode 100644 index 00000000..bee4f8e7 --- /dev/null +++ b/tests/test_signal_handlers.py @@ -0,0 +1,50 @@ +from sanic import Sanic +from sanic.response import HTTPResponse +from sanic.testing import HOST, PORT +from unittest.mock import MagicMock +import asyncio +from queue import Queue + + +async def stop(app, loop): + await asyncio.sleep(0.1) + app.stop() + +calledq = Queue() + +def set_loop(app, loop): + loop.add_signal_handler = MagicMock() + +def after(app, loop): + calledq.put(loop.add_signal_handler.called) + +def test_register_system_signals(): + """Test if sanic register system signals""" + app = Sanic('test_register_system_signals') + + @app.route('/hello') + async def hello_route(request): + return HTTPResponse() + + app.listener('after_server_start')(stop) + app.listener('before_server_start')(set_loop) + app.listener('after_server_stop')(after) + + app.run(HOST, PORT) + assert calledq.get() == True + + +def test_dont_register_system_signals(): + """Test if sanic don't register system signals""" + app = Sanic('test_register_system_signals') + + @app.route('/hello') + async def hello_route(request): + return HTTPResponse() + + app.listener('after_server_start')(stop) + app.listener('before_server_start')(set_loop) + app.listener('after_server_stop')(after) + + app.run(HOST, PORT, register_sys_signals=False) + assert calledq.get() == False diff --git a/tests/test_static.py b/tests/test_static.py new file mode 100644 index 00000000..3335e248 --- /dev/null +++ b/tests/test_static.py @@ -0,0 +1,195 @@ +import inspect +import os + +import pytest + +from sanic import Sanic + + +@pytest.fixture(scope='module') +def static_file_directory(): + """The static directory to serve""" + current_file = inspect.getfile(inspect.currentframe()) + current_directory = os.path.dirname(os.path.abspath(current_file)) + static_directory = os.path.join(current_directory, 'static') + return static_directory + + +def get_file_path(static_file_directory, file_name): + return os.path.join(static_file_directory, file_name) + + +def get_file_content(static_file_directory, file_name): + """The content of the static file to check""" + with open(get_file_path(static_file_directory, file_name), 'rb') as file: + return file.read() + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt', 'python.png']) +def test_static_file(static_file_directory, file_name): + app = Sanic('test_static') + app.static( + '/testing.file', get_file_path(static_file_directory, file_name)) + + request, response = app.test_client.get('/testing.file') + assert response.status == 200 + assert response.body == get_file_content(static_file_directory, file_name) + + +@pytest.mark.parametrize('file_name', ['test.html']) +def test_static_file_content_type(static_file_directory, file_name): + app = Sanic('test_static') + app.static( + '/testing.file', + get_file_path(static_file_directory, file_name), + content_type='text/html; charset=utf-8' + ) + + request, response = app.test_client.get('/testing.file') + assert response.status == 200 + assert response.body == get_file_content(static_file_directory, file_name) + assert response.headers['Content-Type'] == 'text/html; charset=utf-8' + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt']) +@pytest.mark.parametrize('base_uri', ['/static', '', '/dir']) +def test_static_directory(file_name, base_uri, static_file_directory): + + app = Sanic('test_static') + app.static(base_uri, static_file_directory) + + request, response = app.test_client.get( + uri='{}/{}'.format(base_uri, file_name)) + assert response.status == 200 + assert response.body == get_file_content(static_file_directory, file_name) + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt']) +def test_static_head_request(file_name, static_file_directory): + app = Sanic('test_static') + app.static( + '/testing.file', get_file_path(static_file_directory, file_name), + use_content_range=True) + + request, response = app.test_client.head('/testing.file') + assert response.status == 200 + assert 'Accept-Ranges' in response.headers + assert 'Content-Length' in response.headers + assert int(response.headers[ + 'Content-Length']) == len( + get_file_content(static_file_directory, file_name)) + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt']) +def test_static_content_range_correct(file_name, static_file_directory): + app = Sanic('test_static') + app.static( + '/testing.file', get_file_path(static_file_directory, file_name), + use_content_range=True) + + headers = { + 'Range': 'bytes=12-19' + } + request, response = app.test_client.get('/testing.file', headers=headers) + assert response.status == 200 + assert 'Content-Length' in response.headers + assert 'Content-Range' in response.headers + static_content = bytes(get_file_content( + static_file_directory, file_name))[12:19] + assert int(response.headers[ + 'Content-Length']) == len(static_content) + assert response.body == static_content + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt']) +def test_static_content_range_front(file_name, static_file_directory): + app = Sanic('test_static') + app.static( + '/testing.file', get_file_path(static_file_directory, file_name), + use_content_range=True) + + headers = { + 'Range': 'bytes=12-' + } + request, response = app.test_client.get('/testing.file', headers=headers) + assert response.status == 200 + assert 'Content-Length' in response.headers + assert 'Content-Range' in response.headers + static_content = bytes(get_file_content( + static_file_directory, file_name))[12:] + assert int(response.headers[ + 'Content-Length']) == len(static_content) + assert response.body == static_content + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt']) +def test_static_content_range_back(file_name, static_file_directory): + app = Sanic('test_static') + app.static( + '/testing.file', get_file_path(static_file_directory, file_name), + use_content_range=True) + + headers = { + 'Range': 'bytes=-12' + } + request, response = app.test_client.get('/testing.file', headers=headers) + assert response.status == 200 + assert 'Content-Length' in response.headers + assert 'Content-Range' in response.headers + static_content = bytes(get_file_content( + static_file_directory, file_name))[-12:] + assert int(response.headers[ + 'Content-Length']) == len(static_content) + assert response.body == static_content + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt']) +def test_static_content_range_empty(file_name, static_file_directory): + app = Sanic('test_static') + app.static( + '/testing.file', get_file_path(static_file_directory, file_name), + use_content_range=True) + + request, response = app.test_client.get('/testing.file') + assert response.status == 200 + assert 'Content-Length' in response.headers + assert 'Content-Range' not in response.headers + assert int(response.headers[ + 'Content-Length']) == len(get_file_content(static_file_directory, file_name)) + assert response.body == bytes( + get_file_content(static_file_directory, file_name)) + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt']) +def test_static_content_range_error(file_name, static_file_directory): + app = Sanic('test_static') + app.static( + '/testing.file', get_file_path(static_file_directory, file_name), + use_content_range=True) + + headers = { + 'Range': 'bytes=1-0' + } + request, response = app.test_client.get('/testing.file', headers=headers) + assert response.status == 416 + assert 'Content-Length' in response.headers + assert 'Content-Range' in response.headers + assert response.headers['Content-Range'] == "bytes */%s" % ( + len(get_file_content(static_file_directory, file_name)),) + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt', 'python.png']) +def test_static_file_specified_host(static_file_directory, file_name): + app = Sanic('test_static') + app.static( + '/testing.file', + get_file_path(static_file_directory, file_name), + host="www.example.com" + ) + + headers = {"Host": "www.example.com"} + request, response = app.test_client.get('/testing.file', headers=headers) + assert response.status == 200 + assert response.body == get_file_content(static_file_directory, file_name) + request, response = app.test_client.get('/testing.file') + assert response.status == 404 diff --git a/tests/test_url_building.py b/tests/test_url_building.py new file mode 100644 index 00000000..98bbc20a --- /dev/null +++ b/tests/test_url_building.py @@ -0,0 +1,302 @@ +import pytest as pytest +from urllib.parse import urlsplit, parse_qsl + +from sanic import Sanic +from sanic.response import text +from sanic.views import HTTPMethodView +from sanic.blueprints import Blueprint +from sanic.testing import PORT as test_port, HOST as test_host +from sanic.exceptions import URLBuildError + +import string + +URL_FOR_ARGS1 = dict(arg1=['v1', 'v2']) +URL_FOR_VALUE1 = '/myurl?arg1=v1&arg1=v2' +URL_FOR_ARGS2 = dict(arg1=['v1', 'v2'], _anchor='anchor') +URL_FOR_VALUE2 = '/myurl?arg1=v1&arg1=v2#anchor' +URL_FOR_ARGS3 = dict(arg1='v1', _anchor='anchor', _scheme='http', + _server='{}:{}'.format(test_host, test_port), _external=True) +URL_FOR_VALUE3 = 'http://{}:{}/myurl?arg1=v1#anchor'.format(test_host, test_port) +URL_FOR_ARGS4 = dict(arg1='v1', _anchor='anchor', _external=True, + _server='http://{}:{}'.format(test_host, test_port)) +URL_FOR_VALUE4 = 'http://{}:{}/myurl?arg1=v1#anchor'.format(test_host, test_port) + + +def _generate_handlers_from_names(app, l): + for name in l: + # this is the easiest way to generate functions with dynamic names + exec('@app.route(name)\ndef {}(request):\n\treturn text("{}")'.format( + name, name)) + + +@pytest.fixture +def simple_app(): + app = Sanic('simple_app') + handler_names = list(string.ascii_letters) + + _generate_handlers_from_names(app, handler_names) + + return app + + +def test_simple_url_for_getting(simple_app): + for letter in string.ascii_letters: + url = simple_app.url_for(letter) + + assert url == '/{}'.format(letter) + request, response = simple_app.test_client.get(url) + assert response.status == 200 + assert response.text == letter + + +@pytest.mark.parametrize('args,url', + [(URL_FOR_ARGS1, URL_FOR_VALUE1), + (URL_FOR_ARGS2, URL_FOR_VALUE2), + (URL_FOR_ARGS3, URL_FOR_VALUE3), + (URL_FOR_ARGS4, URL_FOR_VALUE4)]) +def test_simple_url_for_getting_with_more_params(args, url): + app = Sanic('more_url_build') + + @app.route('/myurl') + def passes(request): + return text('this should pass') + + assert url == app.url_for('passes', **args) + request, response = app.test_client.get(url) + assert response.status == 200 + assert response.text == 'this should pass' + + +def test_fails_if_endpoint_not_found(): + app = Sanic('fail_url_build') + + @app.route('/fail') + def fail(request): + return text('this should fail') + + with pytest.raises(URLBuildError) as e: + app.url_for('passes') + + assert str(e.value) == 'Endpoint with name `passes` was not found' + + +def test_fails_url_build_if_param_not_passed(): + url = '/' + + for letter in string.ascii_letters: + url += '<{}>/'.format(letter) + + app = Sanic('fail_url_build') + + @app.route(url) + def fail(request): + return text('this should fail') + + fail_args = list(string.ascii_letters) + fail_args.pop() + + fail_kwargs = {l: l for l in fail_args} + + with pytest.raises(URLBuildError) as e: + app.url_for('fail', **fail_kwargs) + + assert 'Required parameter `Z` was not passed to url_for' in str(e.value) + + +def test_fails_url_build_if_params_not_passed(): + app = Sanic('fail_url_build') + + @app.route('/fail') + def fail(request): + return text('this should fail') + + with pytest.raises(ValueError) as e: + app.url_for('fail', _scheme='http') + + assert str(e.value) == 'When specifying _scheme, _external must be True' + + +COMPLEX_PARAM_URL = ( + '///' + '//') +PASSING_KWARGS = { + 'foo': 4, 'four_letter_string': 'woof', + 'two_letter_string': 'ba', 'normal_string': 'normal', + 'some_number': '1.001'} +EXPECTED_BUILT_URL = '/4/woof/ba/normal/1.001' + + +def test_fails_with_int_message(): + app = Sanic('fail_url_build') + + @app.route(COMPLEX_PARAM_URL) + def fail(request): + return text('this should fail') + + failing_kwargs = dict(PASSING_KWARGS) + failing_kwargs['foo'] = 'not_int' + + with pytest.raises(URLBuildError) as e: + app.url_for('fail', **failing_kwargs) + + expected_error = ( + 'Value "not_int" for parameter `foo` ' + 'does not match pattern for type `int`: \d+') + assert str(e.value) == expected_error + + +def test_fails_with_two_letter_string_message(): + app = Sanic('fail_url_build') + + @app.route(COMPLEX_PARAM_URL) + def fail(request): + return text('this should fail') + + failing_kwargs = dict(PASSING_KWARGS) + failing_kwargs['two_letter_string'] = 'foobar' + + with pytest.raises(URLBuildError) as e: + app.url_for('fail', **failing_kwargs) + + expected_error = ( + 'Value "foobar" for parameter `two_letter_string` ' + 'does not satisfy pattern [A-z]{2}') + + assert str(e.value) == expected_error + + +def test_fails_with_number_message(): + app = Sanic('fail_url_build') + + @app.route(COMPLEX_PARAM_URL) + def fail(request): + return text('this should fail') + + failing_kwargs = dict(PASSING_KWARGS) + failing_kwargs['some_number'] = 'foo' + + with pytest.raises(URLBuildError) as e: + app.url_for('fail', **failing_kwargs) + + expected_error = ( + 'Value "foo" for parameter `some_number` ' + 'does not match pattern for type `float`: [0-9\\\\.]+') + + assert str(e.value) == expected_error + + +def test_adds_other_supplied_values_as_query_string(): + app = Sanic('passes') + + @app.route(COMPLEX_PARAM_URL) + def passes(request): + return text('this should pass') + + new_kwargs = dict(PASSING_KWARGS) + new_kwargs['added_value_one'] = 'one' + new_kwargs['added_value_two'] = 'two' + + url = app.url_for('passes', **new_kwargs) + + query = dict(parse_qsl(urlsplit(url).query)) + + assert query['added_value_one'] == 'one' + assert query['added_value_two'] == 'two' + + +@pytest.fixture +def blueprint_app(): + app = Sanic('blueprints') + + first_print = Blueprint('first', url_prefix='/first') + second_print = Blueprint('second', url_prefix='/second') + + @first_print.route('/foo') + def foo(request): + return text('foo from first') + + @first_print.route('/foo/') + def foo_with_param(request, param): + return text( + 'foo from first : {}'.format(param)) + + @second_print.route('/foo') # noqa + def foo(request): + return text('foo from second') + + @second_print.route('/foo/') # noqa + def foo_with_param(request, param): + return text( + 'foo from second : {}'.format(param)) + + app.blueprint(first_print) + app.blueprint(second_print) + + return app + + +def test_blueprints_are_named_correctly(blueprint_app): + first_url = blueprint_app.url_for('first.foo') + assert first_url == '/first/foo' + + second_url = blueprint_app.url_for('second.foo') + assert second_url == '/second/foo' + + +def test_blueprints_work_with_params(blueprint_app): + first_url = blueprint_app.url_for('first.foo_with_param', param='bar') + assert first_url == '/first/foo/bar' + + second_url = blueprint_app.url_for('second.foo_with_param', param='bar') + assert second_url == '/second/foo/bar' + + +@pytest.fixture +def methodview_app(): + app = Sanic('methodview') + + class ViewOne(HTTPMethodView): + def get(self, request): + return text('I am get method') + + def post(self, request): + return text('I am post method') + + def put(self, request): + return text('I am put method') + + def patch(self, request): + return text('I am patch method') + + def delete(self, request): + return text('I am delete method') + + app.add_route(ViewOne.as_view('view_one'), '/view_one') + + class ViewTwo(HTTPMethodView): + def get(self, request): + return text('I am get method') + + def post(self, request): + return text('I am post method') + + def put(self, request): + return text('I am put method') + + def patch(self, request): + return text('I am patch method') + + def delete(self, request): + return text('I am delete method') + + app.add_route(ViewTwo.as_view(), '/view_two') + + return app + + +def test_methodview_naming(methodview_app): + viewone_url = methodview_app.url_for('ViewOne') + viewtwo_url = methodview_app.url_for('ViewTwo') + + assert viewone_url == '/view_one' + assert viewtwo_url == '/view_two' diff --git a/tests/test_url_for_static.py b/tests/test_url_for_static.py new file mode 100644 index 00000000..d1d8fc9b --- /dev/null +++ b/tests/test_url_for_static.py @@ -0,0 +1,446 @@ +import inspect +import os + +import pytest + +from sanic import Sanic +from sanic.blueprints import Blueprint + + +@pytest.fixture(scope='module') +def static_file_directory(): + """The static directory to serve""" + current_file = inspect.getfile(inspect.currentframe()) + current_directory = os.path.dirname(os.path.abspath(current_file)) + static_directory = os.path.join(current_directory, 'static') + return static_directory + + +def get_file_path(static_file_directory, file_name): + return os.path.join(static_file_directory, file_name) + + +def get_file_content(static_file_directory, file_name): + """The content of the static file to check""" + with open(get_file_path(static_file_directory, file_name), 'rb') as file: + return file.read() + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt', 'python.png']) +def test_static_file(static_file_directory, file_name): + app = Sanic('test_static') + app.static( + '/testing.file', get_file_path(static_file_directory, file_name)) + app.static( + '/testing2.file', get_file_path(static_file_directory, file_name), + name='testing_file') + + uri = app.url_for('static') + uri2 = app.url_for('static', filename='any') + uri3 = app.url_for('static', name='static', filename='any') + + assert uri == '/testing.file' + assert uri == uri2 + assert uri2 == uri3 + + request, response = app.test_client.get(uri) + assert response.status == 200 + assert response.body == get_file_content(static_file_directory, file_name) + + bp = Blueprint('test_bp_static', url_prefix='/bp') + + bp.static('/testing.file', get_file_path(static_file_directory, file_name)) + bp.static('/testing2.file', + get_file_path(static_file_directory, file_name), + name='testing_file') + + app.blueprint(bp) + + uri = app.url_for('static', name='test_bp_static.static') + uri2 = app.url_for('static', name='test_bp_static.static', filename='any') + uri3 = app.url_for('test_bp_static.static') + uri4 = app.url_for('test_bp_static.static', name='any') + uri5 = app.url_for('test_bp_static.static', filename='any') + uri6 = app.url_for('test_bp_static.static', name='any', filename='any') + + assert uri == '/bp/testing.file' + assert uri == uri2 + assert uri2 == uri3 + assert uri3 == uri4 + assert uri4 == uri5 + assert uri5 == uri6 + + request, response = app.test_client.get(uri) + assert response.status == 200 + assert response.body == get_file_content(static_file_directory, file_name) + + # test for other parameters + uri = app.url_for('static', _external=True, _server='http://localhost') + assert uri == 'http://localhost/testing.file' + + uri = app.url_for('static', name='test_bp_static.static', + _external=True, _server='http://localhost') + assert uri == 'http://localhost/bp/testing.file' + + # test for defined name + uri = app.url_for('static', name='testing_file') + assert uri == '/testing2.file' + + request, response = app.test_client.get(uri) + assert response.status == 200 + assert response.body == get_file_content(static_file_directory, file_name) + + uri = app.url_for('static', name='test_bp_static.testing_file') + assert uri == '/bp/testing2.file' + assert uri == app.url_for('static', name='test_bp_static.testing_file', + filename='any') + + request, response = app.test_client.get(uri) + assert response.status == 200 + assert response.body == get_file_content(static_file_directory, file_name) + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt']) +@pytest.mark.parametrize('base_uri', ['/static', '', '/dir']) +def test_static_directory(file_name, base_uri, static_file_directory): + + app = Sanic('test_static') + app.static(base_uri, static_file_directory) + base_uri2 = base_uri + '/2' + app.static(base_uri2, static_file_directory, name='uploads') + + uri = app.url_for('static', name='static', filename=file_name) + assert uri == '{}/{}'.format(base_uri, file_name) + + request, response = app.test_client.get(uri) + assert response.status == 200 + assert response.body == get_file_content(static_file_directory, file_name) + + uri2 = app.url_for('static', name='static', filename='/' + file_name) + uri3 = app.url_for('static', filename=file_name) + uri4 = app.url_for('static', filename='/' + file_name) + uri5 = app.url_for('static', name='uploads', filename=file_name) + uri6 = app.url_for('static', name='uploads', filename='/' + file_name) + + assert uri == uri2 + assert uri2 == uri3 + assert uri3 == uri4 + + assert uri5 == '{}/{}'.format(base_uri2, file_name) + assert uri5 == uri6 + + bp = Blueprint('test_bp_static', url_prefix='/bp') + + bp.static(base_uri, static_file_directory) + bp.static(base_uri2, static_file_directory, name='uploads') + app.blueprint(bp) + + uri = app.url_for('static', name='test_bp_static.static', + filename=file_name) + uri2 = app.url_for('static', name='test_bp_static.static', + filename='/' + file_name) + + uri4 = app.url_for('static', name='test_bp_static.uploads', + filename=file_name) + uri5 = app.url_for('static', name='test_bp_static.uploads', + filename='/' + file_name) + + assert uri == '/bp{}/{}'.format(base_uri, file_name) + assert uri == uri2 + + assert uri4 == '/bp{}/{}'.format(base_uri2, file_name) + assert uri4 == uri5 + + request, response = app.test_client.get(uri) + assert response.status == 200 + assert response.body == get_file_content(static_file_directory, file_name) + + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt']) +def test_static_head_request(file_name, static_file_directory): + app = Sanic('test_static') + app.static( + '/testing.file', get_file_path(static_file_directory, file_name), + use_content_range=True) + + bp = Blueprint('test_bp_static', url_prefix='/bp') + bp.static('/testing.file', get_file_path(static_file_directory, file_name), + use_content_range=True) + app.blueprint(bp) + + uri = app.url_for('static') + assert uri == '/testing.file' + assert uri == app.url_for('static', name='static') + assert uri == app.url_for('static', name='static', filename='any') + + request, response = app.test_client.head(uri) + assert response.status == 200 + assert 'Accept-Ranges' in response.headers + assert 'Content-Length' in response.headers + assert int(response.headers[ + 'Content-Length']) == len( + get_file_content(static_file_directory, file_name)) + + # blueprint + uri = app.url_for('static', name='test_bp_static.static') + assert uri == '/bp/testing.file' + assert uri == app.url_for('static', name='test_bp_static.static', + filename='any') + + request, response = app.test_client.head(uri) + assert response.status == 200 + assert 'Accept-Ranges' in response.headers + assert 'Content-Length' in response.headers + assert int(response.headers[ + 'Content-Length']) == len( + get_file_content(static_file_directory, file_name)) + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt']) +def test_static_content_range_correct(file_name, static_file_directory): + app = Sanic('test_static') + app.static( + '/testing.file', get_file_path(static_file_directory, file_name), + use_content_range=True) + + bp = Blueprint('test_bp_static', url_prefix='/bp') + bp.static('/testing.file', get_file_path(static_file_directory, file_name), + use_content_range=True) + app.blueprint(bp) + + headers = { + 'Range': 'bytes=12-19' + } + uri = app.url_for('static') + assert uri == '/testing.file' + assert uri == app.url_for('static', name='static') + assert uri == app.url_for('static', name='static', filename='any') + + request, response = app.test_client.get(uri, headers=headers) + assert response.status == 200 + assert 'Content-Length' in response.headers + assert 'Content-Range' in response.headers + static_content = bytes(get_file_content( + static_file_directory, file_name))[12:19] + assert int(response.headers[ + 'Content-Length']) == len(static_content) + assert response.body == static_content + + # blueprint + uri = app.url_for('static', name='test_bp_static.static') + assert uri == '/bp/testing.file' + assert uri == app.url_for('static', name='test_bp_static.static', + filename='any') + assert uri == app.url_for('test_bp_static.static') + assert uri == app.url_for('test_bp_static.static', name='any') + assert uri == app.url_for('test_bp_static.static', filename='any') + assert uri == app.url_for('test_bp_static.static', name='any', + filename='any') + + request, response = app.test_client.get(uri, headers=headers) + assert response.status == 200 + assert 'Content-Length' in response.headers + assert 'Content-Range' in response.headers + static_content = bytes(get_file_content( + static_file_directory, file_name))[12:19] + assert int(response.headers[ + 'Content-Length']) == len(static_content) + assert response.body == static_content + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt']) +def test_static_content_range_front(file_name, static_file_directory): + app = Sanic('test_static') + app.static( + '/testing.file', get_file_path(static_file_directory, file_name), + use_content_range=True) + + bp = Blueprint('test_bp_static', url_prefix='/bp') + bp.static('/testing.file', get_file_path(static_file_directory, file_name), + use_content_range=True) + app.blueprint(bp) + + headers = { + 'Range': 'bytes=12-' + } + uri = app.url_for('static') + assert uri == '/testing.file' + assert uri == app.url_for('static', name='static') + assert uri == app.url_for('static', name='static', filename='any') + + request, response = app.test_client.get(uri, headers=headers) + assert response.status == 200 + assert 'Content-Length' in response.headers + assert 'Content-Range' in response.headers + static_content = bytes(get_file_content( + static_file_directory, file_name))[12:] + assert int(response.headers[ + 'Content-Length']) == len(static_content) + assert response.body == static_content + + # blueprint + uri = app.url_for('static', name='test_bp_static.static') + assert uri == '/bp/testing.file' + assert uri == app.url_for('static', name='test_bp_static.static', + filename='any') + assert uri == app.url_for('test_bp_static.static') + assert uri == app.url_for('test_bp_static.static', name='any') + assert uri == app.url_for('test_bp_static.static', filename='any') + assert uri == app.url_for('test_bp_static.static', name='any', + filename='any') + + request, response = app.test_client.get(uri, headers=headers) + assert response.status == 200 + assert 'Content-Length' in response.headers + assert 'Content-Range' in response.headers + static_content = bytes(get_file_content( + static_file_directory, file_name))[12:] + assert int(response.headers[ + 'Content-Length']) == len(static_content) + assert response.body == static_content + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt']) +def test_static_content_range_back(file_name, static_file_directory): + app = Sanic('test_static') + app.static( + '/testing.file', get_file_path(static_file_directory, file_name), + use_content_range=True) + + bp = Blueprint('test_bp_static', url_prefix='/bp') + bp.static('/testing.file', get_file_path(static_file_directory, file_name), + use_content_range=True) + app.blueprint(bp) + + headers = { + 'Range': 'bytes=-12' + } + uri = app.url_for('static') + assert uri == '/testing.file' + assert uri == app.url_for('static', name='static') + assert uri == app.url_for('static', name='static', filename='any') + + request, response = app.test_client.get(uri, headers=headers) + assert response.status == 200 + assert 'Content-Length' in response.headers + assert 'Content-Range' in response.headers + static_content = bytes(get_file_content( + static_file_directory, file_name))[-12:] + assert int(response.headers[ + 'Content-Length']) == len(static_content) + assert response.body == static_content + + # blueprint + uri = app.url_for('static', name='test_bp_static.static') + assert uri == '/bp/testing.file' + assert uri == app.url_for('static', name='test_bp_static.static', + filename='any') + assert uri == app.url_for('test_bp_static.static') + assert uri == app.url_for('test_bp_static.static', name='any') + assert uri == app.url_for('test_bp_static.static', filename='any') + assert uri == app.url_for('test_bp_static.static', name='any', + filename='any') + + request, response = app.test_client.get(uri, headers=headers) + assert response.status == 200 + assert 'Content-Length' in response.headers + assert 'Content-Range' in response.headers + static_content = bytes(get_file_content( + static_file_directory, file_name))[-12:] + assert int(response.headers[ + 'Content-Length']) == len(static_content) + assert response.body == static_content + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt']) +def test_static_content_range_empty(file_name, static_file_directory): + app = Sanic('test_static') + app.static( + '/testing.file', get_file_path(static_file_directory, file_name), + use_content_range=True) + + bp = Blueprint('test_bp_static', url_prefix='/bp') + bp.static('/testing.file', get_file_path(static_file_directory, file_name), + use_content_range=True) + app.blueprint(bp) + + uri = app.url_for('static') + assert uri == '/testing.file' + assert uri == app.url_for('static', name='static') + assert uri == app.url_for('static', name='static', filename='any') + + request, response = app.test_client.get(uri) + assert response.status == 200 + assert 'Content-Length' in response.headers + assert 'Content-Range' not in response.headers + assert int(response.headers[ + 'Content-Length']) == len(get_file_content(static_file_directory, file_name)) + assert response.body == bytes( + get_file_content(static_file_directory, file_name)) + + # blueprint + uri = app.url_for('static', name='test_bp_static.static') + assert uri == '/bp/testing.file' + assert uri == app.url_for('static', name='test_bp_static.static', + filename='any') + assert uri == app.url_for('test_bp_static.static') + assert uri == app.url_for('test_bp_static.static', name='any') + assert uri == app.url_for('test_bp_static.static', filename='any') + assert uri == app.url_for('test_bp_static.static', name='any', + filename='any') + + request, response = app.test_client.get(uri) + assert response.status == 200 + assert 'Content-Length' in response.headers + assert 'Content-Range' not in response.headers + assert int(response.headers[ + 'Content-Length']) == len(get_file_content(static_file_directory, file_name)) + assert response.body == bytes( + get_file_content(static_file_directory, file_name)) + + +@pytest.mark.parametrize('file_name', ['test.file', 'decode me.txt']) +def test_static_content_range_error(file_name, static_file_directory): + app = Sanic('test_static') + app.static( + '/testing.file', get_file_path(static_file_directory, file_name), + use_content_range=True) + + bp = Blueprint('test_bp_static', url_prefix='/bp') + bp.static('/testing.file', get_file_path(static_file_directory, file_name), + use_content_range=True) + app.blueprint(bp) + + headers = { + 'Range': 'bytes=1-0' + } + uri = app.url_for('static') + assert uri == '/testing.file' + assert uri == app.url_for('static', name='static') + assert uri == app.url_for('static', name='static', filename='any') + + request, response = app.test_client.get(uri, headers=headers) + assert response.status == 416 + assert 'Content-Length' in response.headers + assert 'Content-Range' in response.headers + assert response.headers['Content-Range'] == "bytes */%s" % ( + len(get_file_content(static_file_directory, file_name)),) + + # blueprint + uri = app.url_for('static', name='test_bp_static.static') + assert uri == '/bp/testing.file' + assert uri == app.url_for('static', name='test_bp_static.static', + filename='any') + assert uri == app.url_for('test_bp_static.static') + assert uri == app.url_for('test_bp_static.static', name='any') + assert uri == app.url_for('test_bp_static.static', filename='any') + assert uri == app.url_for('test_bp_static.static', name='any', + filename='any') + + request, response = app.test_client.get(uri, headers=headers) + assert response.status == 416 + assert 'Content-Length' in response.headers + assert 'Content-Range' in response.headers + assert response.headers['Content-Range'] == "bytes */%s" % ( + len(get_file_content(static_file_directory, file_name)),) diff --git a/tests/test_utf8.py b/tests/test_utf8.py index 8530aeac..e1602958 100644 --- a/tests/test_utf8.py +++ b/tests/test_utf8.py @@ -1,7 +1,6 @@ from json import loads as json_loads, dumps as json_dumps from sanic import Sanic from sanic.response import json, text -from sanic.utils import sanic_endpoint_test # ------------------------------------------------------------ # @@ -15,7 +14,7 @@ def test_utf8_query_string(): async def handler(request): return text('OK') - request, response = sanic_endpoint_test(app, params=[("utf8", '✓')]) + request, response = app.test_client.get('/', params=[("utf8", '✓')]) assert request.args.get('utf8') == '✓' @@ -26,7 +25,7 @@ def test_utf8_response(): async def handler(request): return text('✓') - request, response = sanic_endpoint_test(app) + request, response = app.test_client.get('/') assert response.text == '✓' @@ -38,7 +37,7 @@ def skip_test_utf8_route(): return text('OK') # UTF-8 Paths are not supported - request, response = sanic_endpoint_test(app, route='/✓', uri='/✓') + request, response = app.test_client.get('/✓') assert response.text == 'OK' @@ -52,7 +51,9 @@ def test_utf8_post_json(): payload = {'test': '✓'} headers = {'content-type': 'application/json'} - request, response = sanic_endpoint_test(app, data=json_dumps(payload), headers=headers) + request, response = app.test_client.get( + '/', + data=json_dumps(payload), headers=headers) assert request.json.get('test') == '✓' assert response.text == 'OK' diff --git a/tests/test_vhosts.py b/tests/test_vhosts.py new file mode 100644 index 00000000..2b88bff3 --- /dev/null +++ b/tests/test_vhosts.py @@ -0,0 +1,56 @@ +from sanic import Sanic +from sanic.response import json, text + + +def test_vhosts(): + app = Sanic('test_vhosts') + + @app.route('/', host="example.com") + async def handler(request): + return text("You're at example.com!") + + @app.route('/', host="subdomain.example.com") + async def handler(request): + return text("You're at subdomain.example.com!") + + headers = {"Host": "example.com"} + request, response = app.test_client.get('/', headers=headers) + assert response.text == "You're at example.com!" + + headers = {"Host": "subdomain.example.com"} + request, response = app.test_client.get('/', headers=headers) + assert response.text == "You're at subdomain.example.com!" + + +def test_vhosts_with_list(): + app = Sanic('test_vhosts') + + @app.route('/', host=["hello.com", "world.com"]) + async def handler(request): + return text("Hello, world!") + + headers = {"Host": "hello.com"} + request, response = app.test_client.get('/', headers=headers) + assert response.text == "Hello, world!" + + headers = {"Host": "world.com"} + request, response = app.test_client.get('/', headers=headers) + assert response.text == "Hello, world!" + +def test_vhosts_with_defaults(): + app = Sanic('test_vhosts') + + @app.route('/', host="hello.com") + async def handler(request): + return text("Hello, world!") + + @app.route('/') + async def handler(request): + return text("default") + + headers = {"Host": "hello.com"} + request, response = app.test_client.get('/', headers=headers) + assert response.text == "Hello, world!" + + request, response = app.test_client.get('/') + assert response.text == "default" diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 00000000..71d32a7f --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,269 @@ +import pytest as pytest + +from sanic import Sanic +from sanic.exceptions import InvalidUsage +from sanic.response import text, HTTPResponse +from sanic.views import HTTPMethodView, CompositionView +from sanic.blueprints import Blueprint +from sanic.request import Request +from sanic.constants import HTTP_METHODS + + +@pytest.mark.parametrize('method', HTTP_METHODS) +def test_methods(method): + app = Sanic('test_methods') + + class DummyView(HTTPMethodView): + + async def get(self, request): + assert request.stream is None + return text('', headers={'method': 'GET'}) + + def post(self, request): + return text('', headers={'method': 'POST'}) + + async def put(self, request): + return text('', headers={'method': 'PUT'}) + + def head(self, request): + return text('', headers={'method': 'HEAD'}) + + def options(self, request): + return text('', headers={'method': 'OPTIONS'}) + + async def patch(self, request): + return text('', headers={'method': 'PATCH'}) + + def delete(self, request): + return text('', headers={'method': 'DELETE'}) + + app.add_route(DummyView.as_view(), '/') + assert app.is_request_stream is False + + request, response = getattr(app.test_client, method.lower())('/') + assert response.headers['method'] == method + + +def test_unexisting_methods(): + app = Sanic('test_unexisting_methods') + + class DummyView(HTTPMethodView): + + def get(self, request): + return text('I am get method') + + app.add_route(DummyView.as_view(), '/') + request, response = app.test_client.get('/') + assert response.text == 'I am get method' + request, response = app.test_client.post('/') + assert response.text == 'Error: Method POST not allowed for URL /' + + +def test_argument_methods(): + app = Sanic('test_argument_methods') + + class DummyView(HTTPMethodView): + + def get(self, request, my_param_here): + return text('I am get method with %s' % my_param_here) + + app.add_route(DummyView.as_view(), '/') + + request, response = app.test_client.get('/test123') + + assert response.text == 'I am get method with test123' + + +def test_with_bp(): + app = Sanic('test_with_bp') + bp = Blueprint('test_text') + + class DummyView(HTTPMethodView): + + def get(self, request): + assert request.stream is None + return text('I am get method') + + bp.add_route(DummyView.as_view(), '/') + + app.blueprint(bp) + request, response = app.test_client.get('/') + + assert app.is_request_stream is False + assert response.text == 'I am get method' + + +def test_with_bp_with_url_prefix(): + app = Sanic('test_with_bp_with_url_prefix') + bp = Blueprint('test_text', url_prefix='/test1') + + class DummyView(HTTPMethodView): + + def get(self, request): + return text('I am get method') + + bp.add_route(DummyView.as_view(), '/') + + app.blueprint(bp) + request, response = app.test_client.get('/test1/') + + assert response.text == 'I am get method' + + +def test_with_middleware(): + app = Sanic('test_with_middleware') + + class DummyView(HTTPMethodView): + + def get(self, request): + return text('I am get method') + + app.add_route(DummyView.as_view(), '/') + + results = [] + + @app.middleware + async def handler(request): + results.append(request) + + request, response = app.test_client.get('/') + + assert response.text == 'I am get method' + assert type(results[0]) is Request + + +def test_with_middleware_response(): + app = Sanic('test_with_middleware_response') + + results = [] + + @app.middleware('request') + async def process_response(request): + results.append(request) + + @app.middleware('response') + async def process_response(request, response): + results.append(request) + results.append(response) + + class DummyView(HTTPMethodView): + + def get(self, request): + return text('I am get method') + + app.add_route(DummyView.as_view(), '/') + + request, response = app.test_client.get('/') + + assert response.text == 'I am get method' + assert type(results[0]) is Request + assert type(results[1]) is Request + assert isinstance(results[2], HTTPResponse) + + +def test_with_custom_class_methods(): + app = Sanic('test_with_custom_class_methods') + + class DummyView(HTTPMethodView): + global_var = 0 + + def _iternal_method(self): + self.global_var += 10 + + def get(self, request): + self._iternal_method() + return text('I am get method and global var is {}'.format(self.global_var)) + + app.add_route(DummyView.as_view(), '/') + request, response = app.test_client.get('/') + assert response.text == 'I am get method and global var is 10' + + +def test_with_decorator(): + app = Sanic('test_with_decorator') + + results = [] + + def stupid_decorator(view): + def decorator(*args, **kwargs): + results.append(1) + return view(*args, **kwargs) + return decorator + + class DummyView(HTTPMethodView): + decorators = [stupid_decorator] + + def get(self, request): + return text('I am get method') + + app.add_route(DummyView.as_view(), '/') + request, response = app.test_client.get('/') + assert response.text == 'I am get method' + assert results[0] == 1 + + +def test_composition_view_rejects_incorrect_methods(): + def foo(request): + return text('Foo') + + view = CompositionView() + + with pytest.raises(InvalidUsage) as e: + view.add(['GET', 'FOO'], foo) + + assert str(e.value) == 'FOO is not a valid HTTP method.' + + +def test_composition_view_rejects_duplicate_methods(): + def foo(request): + return text('Foo') + + view = CompositionView() + + with pytest.raises(InvalidUsage) as e: + view.add(['GET', 'POST', 'GET'], foo) + + assert str(e.value) == 'Method GET is already registered.' + + +@pytest.mark.parametrize('method', HTTP_METHODS) +def test_composition_view_runs_methods_as_expected(method): + app = Sanic('test_composition_view') + + view = CompositionView() + + def first(request): + assert request.stream is None + return text('first method') + view.add(['GET', 'POST', 'PUT'], first) + view.add(['DELETE', 'PATCH'], lambda x: text('second method')) + + app.add_route(view, '/') + assert app.is_request_stream is False + + if method in ['GET', 'POST', 'PUT']: + request, response = getattr(app.test_client, method.lower())('/') + assert response.text == 'first method' + + if method in ['DELETE', 'PATCH']: + request, response = getattr(app.test_client, method.lower())('/') + assert response.text == 'second method' + + +@pytest.mark.parametrize('method', HTTP_METHODS) +def test_composition_view_rejects_invalid_methods(method): + app = Sanic('test_composition_view') + + view = CompositionView() + view.add(['GET', 'POST', 'PUT'], lambda x: text('first method')) + + app.add_route(view, '/') + + if method in ['GET', 'POST', 'PUT']: + request, response = getattr(app.test_client, method.lower())('/') + assert response.status == 200 + assert response.text == 'first method' + + if method in ['DELETE', 'PATCH']: + request, response = getattr(app.test_client, method.lower())('/') + assert response.status == 405 diff --git a/tests/test_worker.py b/tests/test_worker.py new file mode 100644 index 00000000..0dcd1f38 --- /dev/null +++ b/tests/test_worker.py @@ -0,0 +1,135 @@ +import time +import json +import shlex +import subprocess +import urllib.request +from unittest import mock +from sanic.worker import GunicornWorker +from sanic.app import Sanic +import asyncio +import logging +import pytest + + +@pytest.fixture(scope='module') +def gunicorn_worker(): + command = 'gunicorn --bind 127.0.0.1:1337 --worker-class sanic.worker.GunicornWorker examples.simple_server:app' + worker = subprocess.Popen(shlex.split(command)) + time.sleep(3) + yield + worker.kill() + + +def test_gunicorn_worker(gunicorn_worker): + with urllib.request.urlopen('http://localhost:1337/') as f: + res = json.loads(f.read(100).decode()) + assert res['test'] + + +class GunicornTestWorker(GunicornWorker): + + def __init__(self): + self.app = mock.Mock() + self.app.callable = Sanic("test_gunicorn_worker") + self.servers = {} + self.exit_code = 0 + self.cfg = mock.Mock() + self.notify = mock.Mock() + + +@pytest.fixture +def worker(): + return GunicornTestWorker() + + +def test_worker_init_process(worker): + with mock.patch('sanic.worker.asyncio') as mock_asyncio: + try: + worker.init_process() + except TypeError: + pass + + assert mock_asyncio.get_event_loop.return_value.close.called + assert mock_asyncio.new_event_loop.called + assert mock_asyncio.set_event_loop.called + + +def test_worker_init_signals(worker): + worker.loop = mock.Mock() + worker.init_signals() + assert worker.loop.add_signal_handler.called + + +def test_handle_abort(worker): + with mock.patch('sanic.worker.sys') as mock_sys: + worker.handle_abort(object(), object()) + assert not worker.alive + assert worker.exit_code == 1 + mock_sys.exit.assert_called_with(1) + + +def test_handle_quit(worker): + worker.handle_quit(object(), object()) + assert not worker.alive + assert worker.exit_code == 0 + + +def test_run_max_requests_exceeded(worker): + loop = asyncio.new_event_loop() + worker.ppid = 1 + worker.alive = True + sock = mock.Mock() + sock.cfg_addr = ('localhost', 8080) + worker.sockets = [sock] + worker.wsgi = mock.Mock() + worker.connections = set() + worker.log = mock.Mock() + worker.loop = loop + worker.servers = { + "server1": {"requests_count": 14}, + "server2": {"requests_count": 15}, + } + worker.max_requests = 10 + worker._run = mock.Mock(wraps=asyncio.coroutine(lambda *a, **kw: None)) + + # exceeding request count + _runner = asyncio.ensure_future(worker._check_alive(), loop=loop) + loop.run_until_complete(_runner) + + assert worker.alive == False + worker.notify.assert_called_with() + worker.log.info.assert_called_with("Max requests exceeded, shutting down: %s", + worker) + +def test_worker_close(worker): + loop = asyncio.new_event_loop() + asyncio.sleep = mock.Mock(wraps=asyncio.coroutine(lambda *a, **kw: None)) + worker.ppid = 1 + worker.pid = 2 + worker.cfg.graceful_timeout = 1.0 + worker.signal = mock.Mock() + worker.signal.stopped = False + worker.wsgi = mock.Mock() + conn = mock.Mock() + conn.websocket = mock.Mock() + conn.websocket.close_connection = mock.Mock( + wraps=asyncio.coroutine(lambda *a, **kw: None) + ) + worker.connections = set([conn]) + worker.log = mock.Mock() + worker.loop = loop + server = mock.Mock() + server.close = mock.Mock(wraps=lambda *a, **kw: None) + server.wait_closed = mock.Mock(wraps=asyncio.coroutine(lambda *a, **kw: None)) + worker.servers = { + server: {"requests_count": 14}, + } + worker.max_requests = 10 + + # close worker + _close = asyncio.ensure_future(worker.close(), loop=loop) + loop.run_until_complete(_close) + + assert worker.signal.stopped == True + assert conn.websocket.close_connection.called == True + assert len(worker.servers) == 0 diff --git a/tox.ini b/tox.ini index 258395ed..cb8e4fc9 100644 --- a/tox.ini +++ b/tox.ini @@ -1,34 +1,36 @@ [tox] - -envlist = py35, report +envlist = py35, py36, py37, {py35,py36,py37}-no-ext, flake8, check [testenv] - +usedevelop = True +setenv = + {py35,py36,py37}-no-ext: SANIC_NO_UJSON=1 + {py35,py36,py37}-no-ext: SANIC_NO_UVLOOP=1 deps = - aiohttp - pytest - # pytest-cov coverage + pytest==3.3.2 + pytest-cov + pytest-sanic + pytest-sugar + aiohttp>=2.3,<=3.2.1 + chardet<=2.3.0 + beautifulsoup4 + gunicorn +commands = + pytest tests --cov sanic --cov-report= {posargs} + - coverage combine --append + coverage report -m + +[testenv:flake8] +deps = + flake8 commands = - coverage run -m pytest tests {posargs} - mv .coverage .coverage.{envname} - -basepython: - py35: python3.5 - -whitelist_externals = - coverage - mv - echo - -[testenv:report] + flake8 sanic +[testenv:check] +deps = + docutils + pygments commands = - coverage combine - coverage report - coverage html - echo "Open file://{toxinidir}/coverage/index.html" - -basepython = - python3.5 \ No newline at end of file + python setup.py check -r -s