diff --git a/.gitignore b/.gitignore index 73e923d3..4a834a7a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ settings.py docs/_build/ docs/_api/ build/* +.DS_Store diff --git a/.travis.yml b/.travis.yml index d8e17093..afac549a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,5 @@ sudo: false +dist: precise language: python cache: directories: 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 index 7682c1b6..e52d6670 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,7 @@ include README.rst +include MANIFEST.in +include LICENSE +include setup.py recursive-exclude * __pycache__ recursive-exclude * *.py[co] diff --git a/README.rst b/README.rst index 8a7b2706..10bc8920 100644 --- a/README.rst +++ b/README.rst @@ -11,34 +11,6 @@ Sanic is developed `on GitHub `_. Contribu If you have a project that utilizes Sanic make sure to comment on the `issue `_ that we use to track those projects! -Benchmarks ----------- - -All tests were run on an 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 | 33,342 | 2.96ms | -+-----------+-----------------------+----------------+---------------+ -| 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 | -+-----------+-----------------------+----------------+---------------+ -| Tornado | Python 3.5 | 2,138 | 46.66ms | -+-----------+-----------------------+----------------+---------------+ - Hello World Example ------------------- @@ -59,13 +31,13 @@ Hello World Example Installation ------------ -- ``python -m pip install sanic`` +- ``pip install sanic`` To install sanic without uvloop or json 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 python -m pip install sanic`` +- ``SANIC_NO_UVLOOP=true SANIC_NO_UJSON=true pip install sanic`` Documentation @@ -83,6 +55,16 @@ Documentation :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 ---- diff --git a/docs/Makefile b/docs/Makefile index ef166d7d..72b82bed 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,20 +1,225 @@ -# Minimal makefile for Sphinx documentation +# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build -SPHINXPROJ = Sanic -SOURCEDIR = . +PAPER = BUILDDIR = _build -# Put it first so that "make" without argument is like "make help". +# 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: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + @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: help Makefile +.PHONY: clean +clean: + rm -rf $(BUILDDIR)/* -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file +.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/conf.py b/docs/conf.py index c97f3c19..7dd7462c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,6 +13,9 @@ 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()) @@ -22,7 +25,7 @@ import sanic # -- General configuration ------------------------------------------------ -extensions = ['sphinx.ext.autodoc'] +extensions = ['sphinx.ext.autodoc', 'sphinxcontrib.asyncio'] templates_path = ['_templates'] @@ -140,3 +143,12 @@ 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/index.rst b/docs/index.rst index 80e7e70f..9f4fa00c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,6 +16,7 @@ Guides sanic/blueprints sanic/config sanic/cookies + sanic/decorators sanic/streaming sanic/class_based_views sanic/custom_protocol @@ -25,6 +26,7 @@ Guides sanic/deploying sanic/extensions sanic/contributing + sanic/api_reference Module Documentation @@ -33,4 +35,5 @@ Module Documentation .. toctree:: * :ref:`genindex` +* :ref:`modindex` * :ref:`search` diff --git a/docs/make.bat b/docs/make.bat index 54191087..3887c127 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -1,19 +1,64 @@ @ECHO OFF -pushd %~dp0 - REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) -set SOURCEDIR=. set BUILDDIR=_build -set SPHINXPROJ=Sanic +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 -%SPHINXBUILD% >NUL 2>NUL +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 @@ -26,11 +71,211 @@ if errorlevel 9009 ( exit /b 1 ) -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% -goto end +:sphinx_ok -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +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 -popd 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 index b4fd06d7..1a7c5293 100644 --- a/docs/sanic/blueprints.md +++ b/docs/sanic/blueprints.md @@ -93,7 +93,14 @@ def ignore_404s(request, exception): Static files can be served globally, under the blueprint prefix. ```python -bp.static('/folder/to/serve', '/web/path') + +# 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 @@ -172,7 +179,7 @@ takes the format `.`. For example: ```python @blueprint_v1.route('/') async def root(request): - url = app.url_for('v1.post_handler', post_id=5) # --> '/v1/post/5' + url = request.app.url_for('v1.post_handler', post_id=5) # --> '/v1/post/5' return redirect(url) diff --git a/docs/sanic/config.md b/docs/sanic/config.md index ab63f7c8..5d0dc95a 100644 --- a/docs/sanic/config.md +++ b/docs/sanic/config.md @@ -29,9 +29,15 @@ In general the convention is to only have UPPERCASE configuration parameters. Th There are several ways how to load configuration. -### From environment variables. +### 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. You can pass the `load_env` boolean to the Sanic constructor to override that: +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) @@ -79,8 +85,35 @@ DB_USER = 'appuser' 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 (sec) | - | KEEP_ALIVE | True | Disables keep-alive when False | + | 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/extensions.md b/docs/sanic/extensions.md index 92b61f8c..03feb90c 100644 --- a/docs/sanic/extensions.md +++ b/docs/sanic/extensions.md @@ -1,12 +1,13 @@ # 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. +- [JWT](https://github.com/ahopkins/sanic-jwt): Authentication extension for JSON Web Tokens (JWT). - [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. @@ -24,3 +25,4 @@ A list of Sanic extensions created by the community. - [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/)) diff --git a/docs/sanic/getting_started.md b/docs/sanic/getting_started.md index 04d22248..3e89cc3e 100644 --- a/docs/sanic/getting_started.md +++ b/docs/sanic/getting_started.md @@ -9,15 +9,16 @@ syntax, so earlier versions of python won't work. ```python from sanic import Sanic - from sanic.response import text + from sanic.response import json - app = Sanic(__name__) + app = Sanic() @app.route("/") async def test(request): - return text('Hello world!') + return json({"hello": "world"}) - app.run(host="0.0.0.0", port=8000, debug=True) + if __name__ == "__main__": + app.run(host="0.0.0.0", port=8000) ``` 3. Run the server: `python3 main.py` diff --git a/docs/sanic/logging.md b/docs/sanic/logging.md index eb807388..49805d0e 100644 --- a/docs/sanic/logging.md +++ b/docs/sanic/logging.md @@ -9,12 +9,6 @@ A simple example using default settings would be like this: ```python from sanic import Sanic -from sanic.config import LOGGING - -# The default logging handlers are ['accessStream', 'errorStream'] -# but we change it to use other handlers here for demo purpose -LOGGING['loggers']['network']['handlers'] = [ - 'accessSysLog', 'errorSysLog'] app = Sanic('test') @@ -23,14 +17,21 @@ async def test(request): return response.text('Hello World!') if __name__ == "__main__": - app.run(log_config=LOGGING) + app.run(debug=True, access_log=True) ``` -And to close logging, simply assign log_config=None: +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(log_config=None) + app.run(access_log=False) ``` This would skip calling logging functions when handling requests. @@ -38,64 +39,29 @@ And you could even do further in production to gain extra speed: ```python if __name__ == "__main__": - # disable internal messages - app.run(debug=False, log_config=None) + # disable debug messages + app.run(debug=False, access_log=False) ``` ### Configuration -By default, log_config parameter is set to use sanic.config.LOGGING dictionary for configuration. The default configuration provides several predefined `handlers`: +By default, log_config parameter is set to use sanic.log.LOGGING_CONFIG_DEFAULTS dictionary for configuration. -- internal (using [logging.StreamHandler](https://docs.python.org/3/library/logging.handlers.html#logging.StreamHandler))
- For internal information console outputs. +There are three `loggers` used in sanic, and **must be defined if you want to create your own logging configuration**: - -- accessStream (using [logging.StreamHandler](https://docs.python.org/3/library/logging.handlers.html#logging.StreamHandler))
- For requests information logging in console - - -- errorStream (using [logging.StreamHandler](https://docs.python.org/3/library/logging.handlers.html#logging.StreamHandler))
- For error message and traceback logging in console. - - -- accessSysLog (using [logging.handlers.SysLogHandler](https://docs.python.org/3/library/logging.handlers.html#logging.handlers.SysLogHandler))
- For requests information logging to syslog. - Currently supports Windows (via localhost:514), Darwin (/var/run/syslog), - Linux (/dev/log) and FreeBSD (/dev/log).
- You would not be able to access this property if the directory doesn't exist. - (Notice that in Docker you have to enable everything by yourself) - - -- errorSysLog (using [logging.handlers.SysLogHandler](https://docs.python.org/3/library/logging.handlers.html#logging.handlers.SysLogHandler))
- For error message and traceback logging to syslog. - Currently supports Windows (via localhost:514), Darwin (/var/run/syslog), - Linux (/dev/log) and FreeBSD (/dev/log).
- You would not be able to access this property if the directory doesn't exist. - (Notice that in Docker you have to enable everything by yourself) - - -And `filters`: - -- accessFilter (using sanic.log.DefaultFilter)
- The filter that allows only levels in `DEBUG`, `INFO`, and `NONE(0)` - - -- errorFilter (using sanic.log.DefaultFilter)
- The filter that allows only levels in `WARNING`, `ERROR`, and `CRITICAL` - -There are two `loggers` used in sanic, and **must be defined if you want to create your own logging configuration**: - -- sanic:
+- root:
Used to log internal messages. +- sanic.error:
+ Used to log error logs. -- network:
- Used to log requests from network, and any information from those requests. +- 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 network logger with accessFilter: +Sanic provides additional parameters for access logger with: - host (str)
request.ip diff --git a/docs/sanic/middleware.md b/docs/sanic/middleware.md index b2e8b45a..1a7f9d86 100644 --- a/docs/sanic/middleware.md +++ b/docs/sanic/middleware.md @@ -4,7 +4,7 @@ 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 providers listeners which allow you to run code at various points of your application's lifecycle. +Additionally, Sanic provides listeners which allow you to run code at various points of your application's lifecycle. ## Middleware diff --git a/docs/sanic/request_data.md b/docs/sanic/request_data.md index bf5ae4a8..e778faf6 100644 --- a/docs/sanic/request_data.md +++ b/docs/sanic/request_data.md @@ -71,6 +71,8 @@ The following variables are accessible as properties on `Request` objects: 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. + - `ip` (str) - IP address 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. @@ -95,6 +97,7 @@ The following variables are accessible as properties on `Request` objects: - `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` diff --git a/docs/sanic/routing.md b/docs/sanic/routing.md index 9b4d060f..98179e17 100644 --- a/docs/sanic/routing.md +++ b/docs/sanic/routing.md @@ -215,3 +215,120 @@ 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/static_files.md b/docs/sanic/static_files.md index f0ce9d78..3419cad1 100644 --- a/docs/sanic/static_files.md +++ b/docs/sanic/static_files.md @@ -6,16 +6,40 @@ 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') +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) ``` - -Note: currently you cannot build a URL for a static file using `url_for`. diff --git a/docs/sanic/testing.md b/docs/sanic/testing.md index b8427a00..0aca9184 100644 --- a/docs/sanic/testing.md +++ b/docs/sanic/testing.md @@ -59,7 +59,7 @@ 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 [pytest-sanic](https://github.com/yunstanford/pytest-sanic) is a pytest plugin, it helps you to test your code asynchronously. Just write tests like, 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/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/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/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 2bc1c7b3..76967be1 100644 --- a/examples/try_everything.py +++ b/examples/try_everything.py @@ -18,7 +18,7 @@ def test_sync(request): return response.json({"test": True}) -@app.route("/dynamic//") +@app.route("/dynamic//") def test_params(request, name, i): return response.text("yeehaww {} {}".format(name, i)) diff --git a/requirements-docs.txt b/requirements-docs.txt index efa74079..e12c1846 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,3 +1,4 @@ sphinx sphinx_rtd_theme recommonmark +sphinxcontrib-asyncio diff --git a/sanic/__init__.py b/sanic/__init__.py index 4cc0710f..8f35a283 100644 --- a/sanic/__init__.py +++ b/sanic/__init__.py @@ -1,6 +1,6 @@ from sanic.app import Sanic from sanic.blueprints import Blueprint -__version__ = '0.5.4' +__version__ = '0.6.0' __all__ = ['Sanic', 'Blueprint'] diff --git a/sanic/__main__.py b/sanic/__main__.py index cc580566..594256f8 100644 --- a/sanic/__main__.py +++ b/sanic/__main__.py @@ -1,7 +1,7 @@ from argparse import ArgumentParser from importlib import import_module -from sanic.log import log +from sanic.log import logger from sanic.app import Sanic if __name__ == "__main__": @@ -36,9 +36,9 @@ if __name__ == "__main__": app.run(host=args.host, port=args.port, workers=args.workers, debug=args.debug, ssl=ssl) except ImportError as e: - log.error("No module named {} found.\n" - " Example File: project/sanic_server.py -> app\n" - " Example Module: project.sanic_server.app" - .format(e.name)) + 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: - log.error("{}".format(e)) + logger.error("{}".format(e)) diff --git a/sanic/app.py b/sanic/app.py index 5b071200..8f70b6e7 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -10,11 +10,11 @@ from traceback import format_exc from urllib.parse import urlencode, urlunparse from ssl import create_default_context, Purpose -from sanic.config import Config, LOGGING +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 log +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 @@ -28,43 +28,33 @@ class Sanic: def __init__(self, name=None, router=None, error_handler=None, load_env=True, request_class=None, - log_config=LOGGING): - if log_config: - logging.config.dictConfig(log_config) - # Only set up a default log handler if the - # end-user application didn't set anything up. - if not (logging.root.handlers and - log.level == logging.NOTSET and - log_config): - formatter = logging.Formatter( - "%(asctime)s: %(levelname)s: %(message)s") - handler = logging.StreamHandler() - handler.setFormatter(formatter) - log.addHandler(handler) - log.setLevel(logging.INFO) + strict_slashes=False, log_config=None): # Get name from previous stack frame if name is None: frame_records = stack()[1] name = getmodulename(frame_records[1]) + # 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.log_config = log_config self.request_middleware = deque() self.response_middleware = deque() self.blueprints = {} self._blueprint_order = [] 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 = [] + self.websocket_tasks = set() # Register alternative method names self.go_fast = self.run @@ -113,7 +103,7 @@ class Sanic: # Decorator def route(self, uri, methods=frozenset({'GET'}), host=None, - strict_slashes=False, stream=False): + strict_slashes=None, stream=False, version=None, name=None): """Decorate a function to be registered as a route :param uri: path of the URL @@ -121,6 +111,8 @@ class Sanic: :param host: :param strict_slashes: :param stream: + :param version: + :param name: user defined route name for url_for :return: decorated function """ @@ -132,46 +124,64 @@ class Sanic: if stream: self.is_request_stream = True + if strict_slashes is None: + strict_slashes = self.strict_slashes + def response(handler): if stream: handler.is_stream = stream self.router.add(uri=uri, methods=methods, handler=handler, - host=host, strict_slashes=strict_slashes) + host=host, strict_slashes=strict_slashes, + version=version, name=name) return handler return response # Shorthand method decorators - def get(self, uri, host=None, strict_slashes=False): + 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) + strict_slashes=strict_slashes, version=version, + name=name) - def post(self, uri, host=None, strict_slashes=False, stream=False): + 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) + strict_slashes=strict_slashes, stream=stream, + version=version, name=name) - def put(self, uri, host=None, strict_slashes=False, stream=False): + 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) + strict_slashes=strict_slashes, stream=stream, + version=version, name=name) - def head(self, uri, host=None, strict_slashes=False): + 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) + strict_slashes=strict_slashes, version=version, + name=name) - def options(self, uri, host=None, strict_slashes=False): + 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) + strict_slashes=strict_slashes, version=version, + name=name) - def patch(self, uri, host=None, strict_slashes=False, stream=False): + 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) + strict_slashes=strict_slashes, stream=stream, + version=version, name=name) - def delete(self, uri, host=None, strict_slashes=False): + 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) + strict_slashes=strict_slashes, version=version, + name=name) def add_route(self, handler, uri, methods=frozenset({'GET'}), host=None, - strict_slashes=False): + strict_slashes=None, version=None, name=None): """A helper method to register class instance or functions as a handler to the application url routes. @@ -181,6 +191,9 @@ class Sanic: :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 :return: function or class instance """ stream = False @@ -203,14 +216,21 @@ class Sanic: 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)(handler) + strict_slashes=strict_slashes, stream=stream, + version=version, name=name)(handler) return handler # Decorator - def websocket(self, uri, host=None, strict_slashes=False): + 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 """ @@ -221,6 +241,9 @@ class Sanic: 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 @@ -230,13 +253,13 @@ class Sanic: # 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) + 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.append(fut) + self.websocket_tasks.add(fut) try: await fut except (CancelledError, ConnectionClosed): @@ -246,16 +269,19 @@ class Sanic: self.router.add(uri=uri, handler=websocket_handler, methods=frozenset({'GET'}), host=host, - strict_slashes=strict_slashes) + strict_slashes=strict_slashes, name=name) return handler return response def add_websocket_route(self, handler, uri, host=None, - strict_slashes=False): + strict_slashes=None, name=None): """A helper method to register a function as a websocket route.""" - return self.websocket(uri, host=host, - strict_slashes=strict_slashes)(handler) + if strict_slashes is None: + strict_slashes = self.strict_slashes + + return self.websocket(uri, host=host, strict_slashes=strict_slashes, + name=name)(handler) def enable_websocket(self, enable=True): """Enable or disable the support for websocket. @@ -319,13 +345,13 @@ class Sanic: # Static Files def static(self, uri, file_or_directory, pattern=r'/?.+', use_modified_since=True, use_content_range=False, - stream_large_files=False): + stream_large_files=False, name='static', host=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) + stream_large_files, name, host) def blueprint(self, blueprint, **options): """Register a blueprint on the application. @@ -375,12 +401,31 @@ class Sanic: URLBuildError """ # find the route by the supplied view name - uri, route = self.router.find_route_by_view_name(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) - if not uri or not route: - raise URLBuildError( - 'Endpoint with name `{}` was not found'.format( - 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 ' If the media type remains unknown, the recipient SHOULD treat it # > as type "application/octet-stream" @@ -45,7 +46,7 @@ class Request(dict): __slots__ = ( 'app', 'headers', 'version', 'method', '_cookies', 'transport', 'body', 'parsed_json', 'parsed_args', 'parsed_form', 'parsed_files', - '_ip', '_parsed_url', 'uri_template', 'stream' + '_ip', '_parsed_url', 'uri_template', 'stream', '_remote_addr' ) def __init__(self, url_bytes, headers, version, method, transport): @@ -68,15 +69,27 @@ class Request(dict): 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) + @property def json(self): if self.parsed_json is None: - try: - self.parsed_json = json_loads(self.body) - except Exception: - if not self.body: - return None - raise InvalidUsage("Failed when parsing body as json") + 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 @@ -114,7 +127,7 @@ class Request(dict): self.parsed_form, self.parsed_files = ( parse_multipart_form(self.body, boundary)) except Exception: - log.exception("Failed when parsing form") + error_logger.exception("Failed when parsing form") return self.parsed_form @@ -142,7 +155,7 @@ class Request(dict): @property def cookies(self): if self._cookies is None: - cookie = self.headers.get('Cookie') or self.headers.get('cookie') + cookie = self.headers.get('Cookie') if cookie is not None: cookies = SimpleCookie() cookies.load(cookie) @@ -159,6 +172,25 @@ class Request(dict): (None, None)) return self._ip + @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 \ diff --git a/sanic/response.py b/sanic/response.py index ea233d9a..582e11cf 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -56,6 +56,7 @@ ALL_STATUS_CODES = { 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', @@ -63,6 +64,7 @@ ALL_STATUS_CODES = { 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', @@ -109,8 +111,9 @@ class BaseHTTPResponse: class StreamingHTTPResponse(BaseHTTPResponse): __slots__ = ( - 'transport', 'streaming_fn', - 'status', 'content_type', 'headers', '_cookies') + 'transport', 'streaming_fn', 'status', + 'content_type', 'headers', '_cookies' + ) def __init__(self, streaming_fn, status=200, headers=None, content_type='text/plain'): @@ -234,15 +237,17 @@ class HTTPResponse(BaseHTTPResponse): def json(body, status=200, headers=None, - content_type="application/json", **kwargs): + 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(json_dumps(body, **kwargs), headers=headers, + return HTTPResponse(dumps(body, **kwargs), headers=headers, status=status, content_type=content_type) @@ -250,6 +255,7 @@ 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. @@ -264,6 +270,7 @@ 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. @@ -276,6 +283,7 @@ def raw(body, status=200, headers=None, 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. @@ -284,15 +292,22 @@ def html(body, status=200, headers=None): content_type="text/html; charset=utf-8") -async def file(location, mime_type=None, headers=None, _range=None): +async def file( + location, 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: """ - filename = path.split(location)[-1] + 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: @@ -304,24 +319,30 @@ async def file(location, mime_type=None, headers=None, _range=None): out_stream = await _file.read() mime_type = mime_type or guess_type(filename)[0] or 'text/plain' - return HTTPResponse(status=200, headers=headers, content_type=mime_type, body_bytes=out_stream) -async def file_stream(location, chunk_size=4096, mime_type=None, headers=None, - _range=None): +async def file_stream( + location, 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: """ - filename = path.split(location)[-1] + 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') diff --git a/sanic/router.py b/sanic/router.py index 691f1388..21c98766 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -67,6 +67,8 @@ class Router: def __init__(self): self.routes_all = {} + self.routes_names = {} + self.routes_static_files = {} self.routes_static = {} self.routes_dynamic = defaultdict(list) self.routes_always_check = [] @@ -91,6 +93,10 @@ class Router: 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 @@ -98,32 +104,8 @@ class Router: return name, _type, pattern - def add(self, uri, methods, handler, host=None, strict_slashes=False): - - # add regular version - self._add(uri, methods, handler, host) - - if strict_slashes: - return - - # Add versions with and without trailing / - slash_is_missing = ( - not uri[-1] == '/' - and not self.routes_all.get(uri + '/', False) - ) - without_slash_is_missing = ( - uri[-1] == '/' - and not self.routes_all.get(uri[:-1], False) - and not uri == '/' - ) - # add version with trailing slash - if slash_is_missing: - self._add(uri + '/', methods, handler, host) - # add version without trailing slash - elif without_slash_is_missing: - self._add(uri[:-1], methods, handler, host) - - def _add(self, uri, methods, handler, host=None): + 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 @@ -131,6 +113,47 @@ class Router: 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: + if uri.startswith('/'): + uri = "/".join(["/v{}".format(str(version)), uri[1:]]) + else: + uri = "/".join(["/v{}".format(str(version)), uri]) + # add regular version + self._add(uri, methods, handler, host, name) + + if strict_slashes: + return + + # Add versions with and without trailing / + slash_is_missing = ( + not uri[-1] == '/' and not self.routes_all.get(uri + '/', False) + ) + without_slash_is_missing = ( + uri[-1] == '/' and not + self.routes_all.get(uri[:-1], False) 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: @@ -144,7 +167,7 @@ class Router: "host strings, not {!r}".format(host)) for host_ in host: - self.add(uri, methods, handler, host_) + self.add(uri, methods, handler, host_, name) return # Dict for faster lookups of if method allowed @@ -212,22 +235,38 @@ class Router: 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: - # prefix the handler name with the blueprint name - # if available - if hasattr(handler, '__blueprintname__'): - handler_name = '{}.{}'.format( - handler.__blueprintname__, handler.__name__) - else: - handler_name = getattr(handler, '__name__', None) - 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: @@ -248,6 +287,16 @@ class Router: 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)) @@ -263,20 +312,20 @@ class Router: self._get.cache_clear() @lru_cache(maxsize=ROUTER_CACHE_SIZE) - def find_route_by_view_name(self, view_name): + 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) - for uri, route in self.routes_all.items(): - if route.name == view_name: - return uri, route + if view_name == 'static' or view_name.endswith('.static'): + return self.routes_static_files.get(name, (None, None)) - return (None, None) + return self.routes_names.get(view_name, (None, None)) def get(self, request): """Get a request handler based on the URL of the request, or raises an diff --git a/sanic/server.py b/sanic/server.py index 2ee48688..049440dd 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -24,11 +24,12 @@ try: except ImportError: async_loop = asyncio -from sanic.log import log, netlog +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) + RequestTimeout, PayloadTooLarge, InvalidUsage, ServerError, + ServiceUnavailable) current_time = None @@ -63,17 +64,20 @@ class HttpProtocol(asyncio.Protocol): # request params 'parser', 'request', 'url', 'headers', # request config - 'request_handler', 'request_timeout', 'request_max_size', - 'request_class', 'is_request_stream', 'router', - # enable or disable access log / error log purpose - 'has_log', + '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', '_last_communication_time', - '_is_stream_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, error_handler, signal=Signal(), connections=set(), request_timeout=60, - request_max_size=None, request_class=None, has_log=True, + 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 @@ -84,18 +88,23 @@ class HttpProtocol(asyncio.Protocol): self.headers = None self.router = router self.signal = signal - self.has_log = has_log + 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 @@ -118,29 +127,72 @@ class HttpProtocol(asyncio.Protocol): def connection_made(self, transport): self.connections.add(self) - self._timeout_handler = self.loop.call_later( - self.request_timeout, self.connection_timeout) + 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): self.connections.discard(self) - self._timeout_handler.cancel() + 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): - # Check if + 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._timeout_handler = ( - self.loop.call_later(time_left, self.connection_timeout)) + 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() - exception = RequestTimeout('Request Timeout') - self.write_error(exception) + 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: + 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.info('KeepAlive Timeout. Closing connection.') + self.transport.close() + self.transport = None # -------------------------------------------- # # Parsing @@ -187,10 +239,12 @@ class HttpProtocol(asyncio.Protocol): 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.decode())) + (self._header_fragment.decode().casefold(), value)) self._header_fragment = b'' @@ -202,6 +256,11 @@ class HttpProtocol(asyncio.Protocol): 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) @@ -217,6 +276,11 @@ class HttpProtocol(asyncio.Protocol): self.request.body.append(body) def on_message_complete(self): + # 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)) @@ -225,6 +289,9 @@ class HttpProtocol(asyncio.Protocol): 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, @@ -234,35 +301,52 @@ class HttpProtocol(asyncio.Protocol): # -------------------------------------------- # # 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 + + if self.request is not None: + extra['host'] = '{0}:{1}'.format(self.request.ip[0], + self.request.ip[1]) + extra['request'] = '{0} {1}'.format(self.request.method, + self.request.url) + else: + extra['host'] = 'UNKNOWN' + 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 = self.keep_alive self.transport.write( response.output( self.request.version, keep_alive, - self.request_timeout)) - if self.has_log: - netlog.info('', extra={ - 'status': response.status, - 'byte': len(response.body), - 'host': '{0}:{1}'.format(self.request.ip[0], - self.request.ip[1]), - 'request': '{0} {1}'.format(self.request.method, - self.request.url) - }) + self.keep_alive_timeout)) + self.log_response(response) except AttributeError: - log.error( - ('Invalid response object for url {}, ' - 'Expected Type: HTTPResponse, Actual Type: {}').format( - self.url, type(response))) + 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: - log.error( - 'Connection lost before response written @ {}'.format( - self.request.ip)) + 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( @@ -270,8 +354,12 @@ class HttpProtocol(asyncio.Protocol): finally: if not keep_alive: self.transport.close() + self.transport = None else: - self._last_request_time = current_time + 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() async def stream_response(self, response): @@ -280,31 +368,25 @@ class HttpProtocol(asyncio.Protocol): 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.request_timeout) - if self.has_log: - netlog.info('', extra={ - 'status': response.status, - 'byte': -1, - 'host': '{0}:{1}'.format(self.request.ip[0], - self.request.ip[1]), - 'request': '{0} {1}'.format(self.request.method, - self.request.url) - }) + self.request.version, keep_alive, self.keep_alive_timeout) + self.log_response(response) except AttributeError: - log.error( - ('Invalid response object for url {}, ' - 'Expected Type: HTTPResponse, Actual Type: {}').format( - self.url, type(response))) + 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: - log.error( - 'Connection lost before response written @ {}'.format( - self.request.ip)) + 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( @@ -312,55 +394,55 @@ class HttpProtocol(asyncio.Protocol): finally: if not keep_alive: self.transport.close() + self.transport = None else: - self._last_request_time = current_time + 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: - log.error( - 'Connection lost before error written @ {}'.format( - self.request.ip if self.request else 'Unknown')) + 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) + "Writing error failed, connection closed {}".format( + repr(e)), from_error=True + ) finally: - if self.has_log: - extra = { - 'status': response.status, - 'host': '', - 'request': str(self.request) + str(self.url) - } - if response and isinstance(response, HTTPResponse): - extra['byte'] = len(response.body) - else: - extra['byte'] = -1 - if self.request: - extra['host'] = '%s:%d' % self.request.ip, - extra['request'] = '%s %s' % (self.request.method, - self.url) - netlog.info('', extra=extra) + if self.parser and (self.keep_alive + or getattr(response, 'status', 0) == 408): + self.log_response(response) self.transport.close() def bail_out(self, message, from_error=False): if from_error or self.transport.is_closing(): - log.error( - ("Transport closed @ {} and exception " - "experienced during error handling").format( - self.transport.get_extra_info('peername'))) - log.debug( - 'Exception:\n{}'.format(traceback.format_exc())) + 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) - log.error(message) + 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 @@ -415,12 +497,13 @@ def trigger_events(events, loop): 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, ssl=None, sock=None, request_max_size=None, - reuse_port=False, loop=None, protocol=HttpProtocol, backlog=100, + 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_async=False, connections=None, - signal=Signal(), request_class=None, has_log=True, keep_alive=True, - is_request_stream=False, router=None, websocket_max_size=None, - websocket_max_queue=None, state=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, state=None, graceful_shutdown_timeout=15.0): """Start asynchronous HTTP Server on an individual process. @@ -440,6 +523,8 @@ def serve(host, port, request_handler, error_handler, before_start=None, `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 @@ -447,7 +532,7 @@ def serve(host, port, request_handler, error_handler, before_start=None, :param loop: asyncio compatible event loop :param protocol: subclass of asyncio protocol class :param request_class: Request class to use - :param has_log: disable/enable access log and error log + :param access_log: disable/enable access log :param is_request_stream: disable/enable Request.stream :param router: Router object :return: Nothing @@ -468,9 +553,11 @@ def serve(host, port, request_handler, error_handler, before_start=None, 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, request_class=request_class, - has_log=has_log, + access_log=access_log, keep_alive=keep_alive, is_request_stream=is_request_stream, router=router, @@ -502,7 +589,7 @@ def serve(host, port, request_handler, error_handler, before_start=None, try: http_server = loop.run_until_complete(server_coroutine) except: - log.exception("Unable to start server") + logger.exception("Unable to start server") return trigger_events(after_start, loop) @@ -513,14 +600,14 @@ def serve(host, port, request_handler, error_handler, before_start=None, try: loop.add_signal_handler(_signal, loop.stop) except NotImplementedError: - log.warn('Sanic tried to use loop.add_signal_handler but it is' - ' not implemented on this platform.') + logger.warning('Sanic tried to use loop.add_signal_handler ' + 'but it is not implemented on this platform.') pid = os.getpid() try: - log.info('Starting worker [{}]'.format(pid)) + logger.info('Starting worker [%s]', pid) loop.run_forever() finally: - log.info("Stopping worker [{}]".format(pid)) + logger.info("Stopping worker [%s]", pid) # Run the on_stop function if provided trigger_events(before_stop, loop) @@ -582,8 +669,7 @@ def serve_multiple(server_settings, workers): server_settings['port'] = None def sig_handler(signal, frame): - log.info("Received signal {}. Shutting down.".format( - Signals(signal).name)) + logger.info("Received signal %s. Shutting down.", Signals(signal).name) for process in processes: os.kill(process.pid, SIGINT) diff --git a/sanic/static.py b/sanic/static.py index 36cb47db..1ebd7291 100644 --- a/sanic/static.py +++ b/sanic/static.py @@ -18,7 +18,7 @@ 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): + stream_large_files, name='static', host=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 @@ -39,6 +39,7 @@ def register(app, uri, file_or_directory, pattern, 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 """ # If we're not trying to match a file directly, # serve from the folder @@ -117,4 +118,8 @@ def register(app, uri, file_or_directory, pattern, path=file_or_directory, relative_url=file_uri) - app.route(uri, methods=['GET', 'HEAD'])(_handler) + # special prefix for static files + if not name.startswith('_static_'): + name = '_static_{}'.format(name) + + app.route(uri, methods=['GET', 'HEAD'], name=name, host=host)(_handler) diff --git a/sanic/testing.py b/sanic/testing.py index de26d025..5d233d7b 100644 --- a/sanic/testing.py +++ b/sanic/testing.py @@ -1,7 +1,7 @@ import traceback from json import JSONDecodeError -from sanic.log import log +from sanic.log import logger HOST = '127.0.0.1' PORT = 42101 @@ -19,7 +19,7 @@ class SanicTestClient: url = 'http://{host}:{port}{uri}'.format( host=HOST, port=PORT, uri=uri) - log.info(url) + logger.info(url) conn = aiohttp.TCPConnector(verify_ssl=False) async with aiohttp.ClientSession( cookies=cookies, connector=conn) as session: @@ -61,7 +61,7 @@ class SanicTestClient: **request_kwargs) results[-1] = response except Exception as e: - log.error( + logger.error( 'Exception:\n{}'.format(traceback.format_exc())) exceptions.append(e) self.app.stop() diff --git a/sanic/websocket.py b/sanic/websocket.py index 94320a5e..37b13f3c 100644 --- a/sanic/websocket.py +++ b/sanic/websocket.py @@ -13,10 +13,18 @@ class WebSocketProtocol(HttpProtocol): self.websocket_max_size = websocket_max_size self.websocket_max_queue = websocket_max_queue - def connection_timeout(self): - # timeouts make no sense for websocket routes + # timeouts make no sense for websocket routes + def request_timeout_callback(self): if self.websocket is None: - super().connection_timeout() + 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: @@ -41,7 +49,7 @@ class WebSocketProtocol(HttpProtocol): else: super().write_response(response) - async def websocket_handshake(self, request): + async def websocket_handshake(self, request, subprotocols=None): # let the websockets package do the handshake with the client headers = [] @@ -57,6 +65,17 @@ class WebSocketProtocol(HttpProtocol): 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: @@ -69,5 +88,6 @@ class WebSocketProtocol(HttpProtocol): max_size=self.websocket_max_size, max_queue=self.websocket_max_queue ) + self.websocket.subprotocol = subprotocol self.websocket.connection_made(request.transport) return self.websocket diff --git a/sanic/worker.py b/sanic/worker.py index 9ca9de90..a102fb72 100644 --- a/sanic/worker.py +++ b/sanic/worker.py @@ -3,6 +3,7 @@ import sys import signal import asyncio import logging +import traceback try: import ssl @@ -73,10 +74,16 @@ class GunicornWorker(base.Worker): trigger_events(self._server_settings.get('before_stop', []), self.loop) self.loop.run_until_complete(self.close()) + except: + traceback.print_exc() finally: - trigger_events(self._server_settings.get('after_stop', []), - self.loop) - self.loop.close() + try: + trigger_events(self._server_settings.get('after_stop', []), + self.loop) + except: + traceback.print_exc() + finally: + self.loop.close() sys.exit(self.exit_code) @@ -139,8 +146,8 @@ class GunicornWorker(base.Worker): ) if self.max_requests and req_count > self.max_requests: self.alive = False - self.log.info( - "Max requests exceeded, shutting down: %s", self) + 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) 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/test_blueprints.py b/tests/test_blueprints.py index 9ab387be..7e713da6 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -1,16 +1,42 @@ import asyncio import inspect +import pytest from sanic import Sanic from sanic.blueprints import Blueprint from sanic.response import json, text from sanic.exceptions import NotFound, ServerError, InvalidUsage +from sanic.constants import HTTP_METHODS # ------------------------------------------------------------ # # GET # ------------------------------------------------------------ # +@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') @@ -52,6 +78,65 @@ def test_bp_strict_slash(): 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') diff --git a/tests/test_config.py b/tests/test_config.py index aa7a0e4d..e393d02b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -19,15 +19,21 @@ def test_load_from_object(): def test_auto_load_env(): environ["SANIC_TEST_ANSWER"] = "42" app = Sanic() - assert app.config.TEST_ANSWER == "42" + assert app.config.TEST_ANSWER == 42 del environ["SANIC_TEST_ANSWER"] -def test_auto_load_env(): +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""" diff --git a/tests/test_cookies.py b/tests/test_cookies.py index d88288ee..84b493cb 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -25,6 +25,25 @@ def test_cookies(): 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), @@ -34,7 +53,7 @@ def test_false_cookies(httponly, expected): @app.route('/') def handler(request): - response = text('Cookies are: {}'.format(request.cookies['test'])) + response = text('hello cookies') response.cookies['right_back'] = 'at you' response.cookies['right_back']['httponly'] = httponly return response @@ -43,7 +62,7 @@ def test_false_cookies(httponly, expected): response_cookies = SimpleCookie() response_cookies.load(response.headers.get('Set-Cookie', {})) - 'HttpOnly' in response_cookies == expected + assert ('HttpOnly' in response_cookies['right_back'].output()) == expected def test_http2_cookies(): app = Sanic('test_http2_cookies') diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 45bdab88..c535059c 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -31,24 +31,36 @@ def exception_app(): 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", "Basic", "Sanic") + raise Unauthorized("Unauthorized", scheme="Basic", realm="Sanic") @app.route('/401/digest') def handler_401_digest(request): - challenge = { - "qop": "auth, auth-int", - "algorithm": "MD5", - "nonce": "abcdef", - "opaque": "zyxwvu", - } - raise Unauthorized("Unauthorized", "Digest", "Sanic", challenge) + 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_invalid(request): + abort(401) + @app.route('/abort') def handler_invalid(request): abort(500) @@ -117,9 +129,12 @@ def test_forbidden_exception(exception_app): 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 @@ -127,7 +142,7 @@ def test_unauthorized_exception(exception_app): 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') @@ -136,6 +151,10 @@ def test_unauthorized_exception(exception_app): 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""" @@ -178,5 +197,8 @@ def test_exception_in_exception_handler_debug_off(exception_app): 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 006c2cc4..6a959382 100644 --- a/tests/test_exceptions_handler.py +++ b/tests/test_exceptions_handler.py @@ -24,7 +24,7 @@ def handler_3(request): @exception_handler_app.route('/4') def handler_4(request): - foo = bar + foo = bar # noqa -- F821 undefined name 'bar' is done to throw exception return text(foo) diff --git a/tests/test_keep_alive_timeout.py b/tests/test_keep_alive_timeout.py new file mode 100644 index 00000000..15f6d705 --- /dev/null +++ b/tests/test_keep_alive_timeout.py @@ -0,0 +1,269 @@ +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 + + @asyncio.coroutine + def connect(self, req): + new_conn = yield from 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=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: + 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 index fc26ca93..e95b7ce5 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -1,15 +1,28 @@ -import asyncio import uuid -from sanic.response import text -from sanic import Sanic -from io import StringIO 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[:]: @@ -32,5 +45,63 @@ def test_log(): log_text = log_stream.getvalue() assert rand_string in log_text -if __name__ == "__main__": - test_log() + +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 log.startswith( + 'Connection lost before response written @') + else: + assert 'Connection lost before response written @' not in log 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_request_timeout.py b/tests/test_request_timeout.py index 404aec12..a1d8a885 100644 --- a/tests/test_request_timeout.py +++ b/tests/test_request_timeout.py @@ -1,38 +1,163 @@ +from json import JSONDecodeError + from sanic import Sanic import asyncio from sanic.response import text -from sanic.exceptions import RequestTimeout from sanic.config import Config +import aiohttp +from aiohttp import TCPConnector +from sanic.testing import SanicTestClient, HOST, PORT -Config.REQUEST_TIMEOUT = 1 -request_timeout_app = Sanic('test_request_timeout') + +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) + + @asyncio.coroutine + 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 = yield from self.send_task + self.send_task = None + self.resp = resp + self._acting_as = self.resp + self.orig_start = getattr(resp, 'start') + + try: + ret = yield from 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() + + @asyncio.coroutine + def delayed_send(self, *args, **kwargs): + req = self.req + if self.delay and self.delay > 0: + #sync_sleep(self.delay) + _ = yield from asyncio.sleep(self.delay) + t = req.loop.time() + print("sending at {}".format(t), flush=True) + conn = next(iter(args)) # first arg is connection + try: + delayed_resp = self.orig_send(*args, **kwargs) + except Exception as e: + return aiohttp.ClientResponse(req.method, req.url) + return delayed_resp + + 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 + + @asyncio.coroutine + def connect(self, req): + d_req = DelayableTCPConnector.\ + RequestContextManager(req, self._pre_request_delay) + conn = yield from super(DelayableTCPConnector, self).connect(req) + if self._post_connect_delay and self._post_connect_delay > 0: + _ = yield from 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=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_timeout_app.route('/1') -async def handler_1(request): - await asyncio.sleep(2) - return text('OK') - - -@request_timeout_app.exception(RequestTimeout) -def handler_exception(request, exception): - return text('Request Timeout from error_handler.', 408) - - -def test_server_error_request_timeout(): - request, response = request_timeout_app.test_client.get('/1') - assert response.status == 408 - assert response.text == 'Request Timeout from error_handler.' +request_no_timeout_app = Sanic('test_request_no_timeout') @request_timeout_default_app.route('/1') -async def handler_2(request): - await asyncio.sleep(2) +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(): - request, response = request_timeout_default_app.test_client.get('/1') + 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 997f32cb..f0696c7f 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -211,6 +211,32 @@ def test_content_type(): 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') @@ -259,6 +285,7 @@ def test_post_form_urlencoded(): assert request.form.get('test') == 'OK' + @pytest.mark.parametrize( 'payload', [ '------sanic\r\n' \ diff --git a/tests/test_response.py b/tests/test_response.py index fb213b56..910c4e80 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -149,7 +149,22 @@ def test_file_response(file_name, static_file_directory): 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_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): @@ -191,7 +206,22 @@ def test_file_stream_response(file_name, static_file_directory): 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): 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 4afb4a9c..b4ed7cf3 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -4,12 +4,33 @@ import pytest from sanic import Sanic from sanic.response import text 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') @@ -50,6 +71,46 @@ def test_route_strict_slash(): 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_optional_slash(): app = Sanic('test_route_optional_slash') @@ -320,6 +381,7 @@ def test_websocket_route(): @app.websocket('/ws') async def handler(request, ws): + assert ws.subprotocol is None ev.set() request, response = app.test_client.get('/ws', headers={ @@ -331,6 +393,48 @@ def test_websocket_route(): 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') diff --git a/tests/test_server_events.py b/tests/test_server_events.py index 0dcaba1c..d78f0aed 100644 --- a/tests/test_server_events.py +++ b/tests/test_server_events.py @@ -59,3 +59,20 @@ def test_all_listeners(): 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_static.py b/tests/test_static.py index 091d63a4..6252b1c1 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -161,3 +161,20 @@ def test_static_content_range_error(file_name, static_file_directory): 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(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 index f234efda..fe31f658 100644 --- a/tests/test_url_building.py +++ b/tests/test_url_building.py @@ -17,6 +17,9 @@ URL_FOR_VALUE2 = '/myurl?arg1=v1&arg1=v2#anchor' URL_FOR_ARGS3 = dict(arg1='v1', _anchor='anchor', _scheme='http', _server='localhost:{}'.format(test_port), _external=True) URL_FOR_VALUE3 = 'http://localhost:{}/myurl?arg1=v1#anchor'.format(test_port) +URL_FOR_ARGS4 = dict(arg1='v1', _anchor='anchor', _external=True, + _server='http://localhost:{}'.format(test_port),) +URL_FOR_VALUE4 = 'http://localhost:{}/myurl?arg1=v1#anchor'.format(test_port) def _generate_handlers_from_names(app, l): @@ -49,7 +52,8 @@ def test_simple_url_for_getting(simple_app): @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_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') 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/tox.ini b/tox.ini index de1dc2d5..ff43a139 100644 --- a/tox.ini +++ b/tox.ini @@ -10,13 +10,16 @@ deps = coverage pytest pytest-cov + pytest-sanic pytest-sugar aiohttp==1.3.5 chardet<=2.3.0 beautifulsoup4 gunicorn commands = - pytest tests --cov sanic --cov-report term-missing {posargs} + pytest tests --cov sanic --cov-report= {posargs} + - coverage combine --append + coverage report -m [testenv:flake8] deps =