diff --git a/README.rst b/README.rst index 8a7b2706..410bd0b8 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 ------------------- 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..e254c183 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()) @@ -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 5fe20e54..b4fd06d7 100644 --- a/docs/sanic/blueprints.md +++ b/docs/sanic/blueprints.md @@ -169,7 +169,7 @@ app.run(host='0.0.0.0', port=8000, debug=True) If you wish to generate a URL for a route inside of a blueprint, remember that the endpoint name takes the format `.`. For example: -``` +```python @blueprint_v1.route('/') async def root(request): url = app.url_for('v1.post_handler', post_id=5) # --> '/v1/post/5' diff --git a/docs/sanic/config.md b/docs/sanic/config.md index 2152a16c..ab63f7c8 100644 --- a/docs/sanic/config.md +++ b/docs/sanic/config.md @@ -31,10 +31,10 @@ There are several ways how to load configuration. ### From environment variables. -Any variables defined with the `SANIC_` prefix will be applied to the sanic config. For example, setting `SANIC_REQUEST_TIMEOUT` will be loaded by the application automatically. You can pass the `load_vars` 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. You can pass the `load_env` boolean to the Sanic constructor to override that: ```python -app = Sanic(load_vars=False) +app = Sanic(load_env=False) ``` ### From an Object diff --git a/docs/sanic/cookies.md b/docs/sanic/cookies.md deleted file mode 100644 index e71bcc47..00000000 --- a/docs/sanic/cookies.md +++ /dev/null @@ -1,75 +0,0 @@ -# Cookies - -Cookies are pieces of data which persist inside a user's browser. Sanic can -both read and write cookies, which are stored as key-value pairs. - -## Reading cookies - -A user's cookies can be accessed via the `Request` object's `cookies` dictionary. - -```python -from sanic.response import text - -@app.route("/cookie") -async def test(request): - test_cookie = request.cookies.get('test') - return text("Test cookie set to: {}".format(test_cookie)) -``` - -## Writing cookies - -When returning a response, cookies can be set on the `Response` object. - -```python -from sanic.response import text - -@app.route("/cookie") -async def test(request): - response = text("There's a cookie up in this response") - response.cookies['test'] = 'It worked!' - response.cookies['test']['domain'] = '.gotta-go-fast.com' - response.cookies['test']['httponly'] = True - return response -``` - -## Deleting cookies - -Cookies can be removed semantically or explicitly. - -```python -from sanic.response import text - -@app.route("/cookie") -async def test(request): - response = text("Time to eat some cookies muahaha") - - # This cookie will be set to expire in 0 seconds - del response.cookies['kill_me'] - - # This cookie will self destruct in 5 seconds - response.cookies['short_life'] = 'Glad to be here' - response.cookies['short_life']['max-age'] = 5 - del response.cookies['favorite_color'] - - # This cookie will remain unchanged - response.cookies['favorite_color'] = 'blue' - response.cookies['favorite_color'] = 'pink' - del response.cookies['favorite_color'] - - return response -``` - -Response cookies can be set like dictionary values and have the following -parameters available: - -- `expires` (datetime): The time for the cookie to expire on the - client's browser. -- `path` (string): The subset of URLs to which this cookie applies. Defaults to /. -- `comment` (string): A comment (metadata). -- `domain` (string): Specifies the domain for which the cookie is valid. An - explicitly specified domain must always start with a dot. -- `max-age` (number): Number of seconds the cookie should live for. -- `secure` (boolean): Specifies whether the cookie will only be sent via - HTTPS. -- `httponly` (boolean): Specifies whether the cookie cannot be read by - Javascript. diff --git a/docs/sanic/cookies.rst b/docs/sanic/cookies.rst new file mode 100644 index 00000000..c4e0c0a1 --- /dev/null +++ b/docs/sanic/cookies.rst @@ -0,0 +1,87 @@ +Cookies +======= + +Cookies are pieces of data which persist inside a user's browser. Sanic can +both read and write cookies, which are stored as key-value pairs. + +.. warning:: + + Cookies can be freely altered by the client. Therefore you cannot just store + data such as login information in cookies as-is, as they can be freely altered + by the client. To ensure data you store in cookies is not forged or tampered + with by the client, use something like `itsdangerous`_ to cryptographically + sign the data. + + +Reading cookies +--------------- + +A user's cookies can be accessed via the ``Request`` object's ``cookies`` dictionary. + +.. code-block:: python + + from sanic.response import text + + @app.route("/cookie") + async def test(request): + test_cookie = request.cookies.get('test') + return text("Test cookie set to: {}".format(test_cookie)) + +Writing cookies +--------------- + +When returning a response, cookies can be set on the ``Response`` object. + +.. code-block:: python + + from sanic.response import text + + @app.route("/cookie") + async def test(request): + response = text("There's a cookie up in this response") + response.cookies['test'] = 'It worked!' + response.cookies['test']['domain'] = '.gotta-go-fast.com' + response.cookies['test']['httponly'] = True + return response + +Deleting cookies +---------------- + +Cookies can be removed semantically or explicitly. + +.. code-block:: python + + from sanic.response import text + + @app.route("/cookie") + async def test(request): + response = text("Time to eat some cookies muahaha") + + # This cookie will be set to expire in 0 seconds + del response.cookies['kill_me'] + + # This cookie will self destruct in 5 seconds + response.cookies['short_life'] = 'Glad to be here' + response.cookies['short_life']['max-age'] = 5 + del response.cookies['favorite_color'] + + # This cookie will remain unchanged + response.cookies['favorite_color'] = 'blue' + response.cookies['favorite_color'] = 'pink' + del response.cookies['favorite_color'] + + return response + +Response cookies can be set like dictionary values and have the following +parameters available: + +- ``expires`` (datetime): The time for the cookie to expire on the client's browser. +- ``path`` (string): The subset of URLs to which this cookie applies. Defaults to /. +- ``comment`` (string): A comment (metadata). +- ``domain`` (string): Specifies the domain for which the cookie is valid. An + explicitly specified domain must always start with a dot. +- ``max-age`` (number): Number of seconds the cookie should live for. +- ``secure`` (boolean): Specifies whether the cookie will only be sent via HTTPS. +- ``httponly`` (boolean): Specifies whether the cookie cannot be read by Javascript. + +.. _itsdangerous: https://pythonhosted.org/itsdangerous/ \ No newline at end of file diff --git a/docs/sanic/deploying.md b/docs/sanic/deploying.md index 391c10da..d3652d0d 100644 --- a/docs/sanic/deploying.md +++ b/docs/sanic/deploying.md @@ -57,6 +57,12 @@ for Gunicorn `worker-class` argument: gunicorn myapp:app --bind 0.0.0.0:1337 --worker-class sanic.worker.GunicornWorker ``` +If your application suffers from memory leaks, you can configure Gunicorn to gracefully restart a worker +after it has processed a given number of requests. This can be a convenient way to help limit the effects +of the memory leak. + +See the [Gunicorn Docs](http://docs.gunicorn.org/en/latest/settings.html#max-requests) for more information. + ## Asynchronous support This is suitable if you *need* to share the sanic process with other applications, in particular the `loop`. However be advised that this method does not support using multiple processes, and is not the preferred way diff --git a/docs/sanic/extensions.md b/docs/sanic/extensions.md index da49da89..92b61f8c 100644 --- a/docs/sanic/extensions.md +++ b/docs/sanic/extensions.md @@ -23,3 +23,4 @@ A list of Sanic extensions created by the community. - [sanic-prometheus](https://github.com/dkruchinin/sanic-prometheus): Prometheus metrics for Sanic - [Sanic-RestPlus](https://github.com/ashleysommer/sanic-restplus): A port of Flask-RestPlus for Sanic. Full-featured REST API with SwaggerUI generation. - [sanic-transmute](https://github.com/yunstanford/sanic-transmute): A Sanic extension that generates APIs from python function and classes, and also generates Swagger UI/documentation automatically. +- [pytest-sanic](https://github.com/yunstanford/pytest-sanic): A pytest plugin for Sanic. It helps you to test your code asynchronously. diff --git a/docs/sanic/routing.md b/docs/sanic/routing.md index 9b4d060f..e039e249 100644 --- a/docs/sanic/routing.md +++ b/docs/sanic/routing.md @@ -214,4 +214,3 @@ and `recv` methods to send and receive data respectively. WebSocket support requires the [websockets](https://github.com/aaugustin/websockets) package by Aymeric Augustin. - diff --git a/docs/sanic/testing.md b/docs/sanic/testing.md index d4f61b4c..b8427a00 100644 --- a/docs/sanic/testing.md +++ b/docs/sanic/testing.md @@ -57,3 +57,71 @@ def test_post_json_request_includes_data(): More information about the available arguments to aiohttp can be found [in the documentation for ClientSession](https://aiohttp.readthedocs.io/en/stable/client_reference.html#client-session). + + +# pytest-sanic + +[pytest-sanic](https://github.com/yunstanford/pytest-sanic) is a pytest plugin, it helps you to test your code asynchronously. +Just write tests like, + +```python +async def test_sanic_db_find_by_id(app): + """ + Let's assume that, in db we have, + { + "id": "123", + "name": "Kobe Bryant", + "team": "Lakers", + } + """ + doc = await app.db["players"].find_by_id("123") + assert doc.name == "Kobe Bryant" + assert doc.team == "Lakers" +``` + +[pytest-sanic](https://github.com/yunstanford/pytest-sanic) also provides some useful fixtures, like loop, unused_port, +test_server, test_client. + +```python +@pytest.yield_fixture +def app(): + app = Sanic("test_sanic_app") + + @app.route("/test_get", methods=['GET']) + async def test_get(request): + return response.json({"GET": True}) + + @app.route("/test_post", methods=['POST']) + async def test_post(request): + return response.json({"POST": True}) + + yield app + + +@pytest.fixture +def test_cli(loop, app, test_client): + return loop.run_until_complete(test_client(app, protocol=WebSocketProtocol)) + + +######### +# Tests # +######### + +async def test_fixture_test_client_get(test_cli): + """ + GET request + """ + resp = await test_cli.get('/test_get') + assert resp.status == 200 + resp_json = await resp.json() + assert resp_json == {"GET": True} + +async def test_fixture_test_client_post(test_cli): + """ + POST request + """ + resp = await test_cli.post('/test_post') + assert resp.status == 200 + resp_json = await resp.json() + assert resp_json == {"POST": True} +``` diff --git a/docs/sanic/versioning.md b/docs/sanic/versioning.md new file mode 100644 index 00000000..85cbd278 --- /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', verion=1) +def handle_request(request): + return response.text('Hello world! Version 1') + +@app.route('/text', verion=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/sanic/app.py b/sanic/app.py index ff680d9c..f1e8be7e 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -113,7 +113,7 @@ class Sanic: # Decorator def route(self, uri, methods=frozenset({'GET'}), host=None, - strict_slashes=False, stream=False): + strict_slashes=False, stream=False, version=None): """Decorate a function to be registered as a route :param uri: path of the URL @@ -136,42 +136,49 @@ class Sanic: 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) return handler return response # Shorthand method decorators - def get(self, uri, host=None, strict_slashes=False): + def get(self, uri, host=None, strict_slashes=False, version=None): return self.route(uri, methods=frozenset({"GET"}), host=host, - strict_slashes=strict_slashes) + strict_slashes=strict_slashes, version=version) - def post(self, uri, host=None, strict_slashes=False, stream=False): + def post(self, uri, host=None, strict_slashes=False, stream=False, + version=None): return self.route(uri, methods=frozenset({"POST"}), host=host, - strict_slashes=strict_slashes, stream=stream) + strict_slashes=strict_slashes, stream=stream, + version=version) - def put(self, uri, host=None, strict_slashes=False, stream=False): + def put(self, uri, host=None, strict_slashes=False, stream=False, + version=None): return self.route(uri, methods=frozenset({"PUT"}), host=host, - strict_slashes=strict_slashes, stream=stream) + strict_slashes=strict_slashes, stream=stream, + version=version) - def head(self, uri, host=None, strict_slashes=False): + def head(self, uri, host=None, strict_slashes=False, version=None): return self.route(uri, methods=frozenset({"HEAD"}), host=host, - strict_slashes=strict_slashes) + strict_slashes=strict_slashes, version=version) - def options(self, uri, host=None, strict_slashes=False): + def options(self, uri, host=None, strict_slashes=False, version=None): return self.route(uri, methods=frozenset({"OPTIONS"}), host=host, - strict_slashes=strict_slashes) + strict_slashes=strict_slashes, version=version) - def patch(self, uri, host=None, strict_slashes=False, stream=False): + def patch(self, uri, host=None, strict_slashes=False, stream=False, + version=None): return self.route(uri, methods=frozenset({"PATCH"}), host=host, - strict_slashes=strict_slashes, stream=stream) + strict_slashes=strict_slashes, stream=stream, + version=version) - def delete(self, uri, host=None, strict_slashes=False): + def delete(self, uri, host=None, strict_slashes=False, version=None): return self.route(uri, methods=frozenset({"DELETE"}), host=host, - strict_slashes=strict_slashes) + strict_slashes=strict_slashes, version=version) def add_route(self, handler, uri, methods=frozenset({'GET'}), host=None, - strict_slashes=False): + strict_slashes=False, version=None): """A helper method to register class instance or functions as a handler to the application url routes. @@ -204,7 +211,8 @@ class Sanic: break self.route(uri=uri, methods=methods, host=host, - strict_slashes=strict_slashes, stream=stream)(handler) + strict_slashes=strict_slashes, stream=stream, + version=version)(handler) return handler # Decorator @@ -701,7 +709,8 @@ class Sanic: 'backlog': backlog, 'has_log': has_log, 'websocket_max_size': self.config.WEBSOCKET_MAX_SIZE, - 'websocket_max_queue': self.config.WEBSOCKET_MAX_QUEUE + 'websocket_max_queue': self.config.WEBSOCKET_MAX_QUEUE, + 'graceful_shutdown_timeout': self.config.GRACEFUL_SHUTDOWN_TIMEOUT } # -------------------------------------------- # diff --git a/sanic/blueprints.py b/sanic/blueprints.py index b3866cbd..0e97903b 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -4,8 +4,8 @@ from sanic.constants import HTTP_METHODS from sanic.views import CompositionView FutureRoute = namedtuple('Route', - ['handler', 'uri', 'methods', - 'host', 'strict_slashes', 'stream']) + ['handler', 'uri', 'methods', 'host', + 'strict_slashes', 'stream', 'version']) FutureListener = namedtuple('Listener', ['handler', 'uri', 'methods', 'host']) FutureMiddleware = namedtuple('Route', ['middleware', 'args', 'kwargs']) FutureException = namedtuple('Route', ['handler', 'args', 'kwargs']) @@ -14,7 +14,7 @@ FutureStatic = namedtuple('Route', class Blueprint: - def __init__(self, name, url_prefix=None, host=None): + def __init__(self, name, url_prefix=None, host=None, version=None): """Create a new blueprint :param name: unique name of the blueprint @@ -30,6 +30,7 @@ class Blueprint: self.listeners = defaultdict(list) self.middlewares = [] self.statics = [] + self.version = version def register(self, app, options): """Register the blueprint to the sanic app.""" @@ -43,12 +44,16 @@ class Blueprint: future.handler.__blueprintname__ = self.name # Prepend the blueprint URI prefix if available uri = url_prefix + future.uri if url_prefix else future.uri + + version = future.version or self.version + app.route( uri=uri[1:] if uri.startswith('//') else uri, methods=future.methods, host=future.host or self.host, strict_slashes=future.strict_slashes, - stream=future.stream + stream=future.stream, + version=version )(future.handler) for future in self.websocket_routes: @@ -89,7 +94,7 @@ class Blueprint: app.listener(event)(listener) def route(self, uri, methods=frozenset({'GET'}), host=None, - strict_slashes=False, stream=False): + strict_slashes=False, stream=False, version=None): """Create a blueprint route from a decorated function. :param uri: endpoint at which the route will be accessible. @@ -97,13 +102,13 @@ class Blueprint: """ def decorator(handler): route = FutureRoute( - handler, uri, methods, host, strict_slashes, stream) + handler, uri, methods, host, strict_slashes, stream, version) self.routes.append(route) return handler return decorator def add_route(self, handler, uri, methods=frozenset({'GET'}), host=None, - strict_slashes=False): + strict_slashes=False, version=None): """Create a blueprint route from a function. :param handler: function for handling uri requests. Accepts function, @@ -125,21 +130,22 @@ class Blueprint: methods = handler.handlers.keys() self.route(uri=uri, methods=methods, host=host, - strict_slashes=strict_slashes)(handler) + strict_slashes=strict_slashes, version=version)(handler) return handler - def websocket(self, uri, host=None, strict_slashes=False): + def websocket(self, uri, host=None, strict_slashes=False, version=None): """Create a blueprint websocket route from a decorated function. :param uri: endpoint at which the route will be accessible. """ def decorator(handler): - route = FutureRoute(handler, uri, [], host, strict_slashes, False) + route = FutureRoute(handler, uri, [], host, strict_slashes, + False, version) self.websocket_routes.append(route) return handler return decorator - def add_websocket_route(self, handler, uri, host=None): + def add_websocket_route(self, handler, uri, host=None, version=None): """Create a blueprint websocket route from a function. :param handler: function for handling uri requests. Accepts function, @@ -147,7 +153,7 @@ class Blueprint: :param uri: endpoint at which the route will be accessible. :return: function or class instance """ - self.websocket(uri=uri, host=host)(handler) + self.websocket(uri=uri, host=host, version=version)(handler) return handler def listener(self, event): @@ -193,30 +199,36 @@ class Blueprint: self.statics.append(static) # Shorthand method decorators - def get(self, uri, host=None, strict_slashes=False): + def get(self, uri, host=None, strict_slashes=False, version=None): return self.route(uri, methods=["GET"], host=host, - strict_slashes=strict_slashes) + strict_slashes=strict_slashes, version=version) - def post(self, uri, host=None, strict_slashes=False, stream=False): + def post(self, uri, host=None, strict_slashes=False, stream=False, + version=None): return self.route(uri, methods=["POST"], host=host, - strict_slashes=strict_slashes, stream=stream) + strict_slashes=strict_slashes, stream=stream, + version=version) - def put(self, uri, host=None, strict_slashes=False, stream=False): + def put(self, uri, host=None, strict_slashes=False, stream=False, + version=None): return self.route(uri, methods=["PUT"], host=host, - strict_slashes=strict_slashes, stream=stream) + strict_slashes=strict_slashes, stream=stream, + version=version) - def head(self, uri, host=None, strict_slashes=False): + def head(self, uri, host=None, strict_slashes=False, version=None): return self.route(uri, methods=["HEAD"], host=host, - strict_slashes=strict_slashes) + strict_slashes=strict_slashes, version=version) - def options(self, uri, host=None, strict_slashes=False): + def options(self, uri, host=None, strict_slashes=False, version=None): return self.route(uri, methods=["OPTIONS"], host=host, - strict_slashes=strict_slashes) + strict_slashes=strict_slashes, version=version) - def patch(self, uri, host=None, strict_slashes=False, stream=False): + def patch(self, uri, host=None, strict_slashes=False, stream=False, + version=None): return self.route(uri, methods=["PATCH"], host=host, - strict_slashes=strict_slashes, stream=stream) + strict_slashes=strict_slashes, stream=stream, + version=version) - def delete(self, uri, host=None, strict_slashes=False): + def delete(self, uri, host=None, strict_slashes=False, version=None): return self.route(uri, methods=["DELETE"], host=host, - strict_slashes=strict_slashes) + strict_slashes=strict_slashes, version=version) diff --git a/sanic/config.py b/sanic/config.py index e3563bc1..6ffcf7a1 100644 --- a/sanic/config.py +++ b/sanic/config.py @@ -12,11 +12,12 @@ _address_dict = { 'Windows': ('localhost', 514), 'Darwin': '/var/run/syslog', 'Linux': '/dev/log', - 'FreeBSD': '/dev/log' + 'FreeBSD': '/var/run/log' } LOGGING = { 'version': 1, + 'disable_existing_loggers': False, 'filters': { 'accessFilter': { '()': DefaultFilter, @@ -127,6 +128,7 @@ class Config(dict): self.KEEP_ALIVE = keep_alive self.WEBSOCKET_MAX_SIZE = 2 ** 20 # 1 megabytes self.WEBSOCKET_MAX_QUEUE = 32 + self.GRACEFUL_SHUTDOWN_TIMEOUT = 15.0 # 15 sec if load_env: self.load_environment_vars() @@ -195,10 +197,16 @@ class Config(dict): def load_environment_vars(self): """ - Looks for any SANIC_ prefixed environment variables and applies + Looks for any ``SANIC_`` prefixed environment variables and applies them to the configuration if present. """ for k, v in os.environ.items(): if k.startswith(SANIC_PREFIX): _, config_key = k.split(SANIC_PREFIX, 1) - self[config_key] = v + try: + self[config_key] = int(v) + except ValueError: + try: + self[config_key] = float(v) + except ValueError: + self[config_key] = v diff --git a/sanic/exceptions.py b/sanic/exceptions.py index d05342fa..21ab2a94 100644 --- a/sanic/exceptions.py +++ b/sanic/exceptions.py @@ -194,6 +194,11 @@ class ContentRangeError(SanicException): } +@add_status_code(403) +class Forbidden(SanicException): + pass + + class InvalidRangeType(ContentRangeError): pass @@ -205,8 +210,8 @@ class Unauthorized(SanicException): :param scheme: Name of the authentication scheme to be used. :param challenge: A dict containing values to add to the WWW-Authenticate - header that is generated. This is especially useful when dealing with the - Digest scheme. (optional) + header that is generated. This is especially useful when dealing with + the Digest scheme. (optional) Examples:: @@ -227,7 +232,6 @@ class Unauthorized(SanicException): # With a Bearer auth-scheme, realm is optional: challenge = {"realm": "Restricted Area"} raise Unauthorized("Auth required.", "Bearer", challenge) - """ pass @@ -249,9 +253,10 @@ def abort(status_code, message=None): """ Raise an exception based on SanicException. Returns the HTTP response message appropriate for the given status code, unless provided. + :param status_code: The HTTP status code to return. :param message: The HTTP response body. Defaults to the messages - in response.py for the given status code. + in response.py for the given status code. """ if message is None: message = COMMON_STATUS_CODES.get(status_code, diff --git a/sanic/request.py b/sanic/request.py index 3cc9c10b..27ff011e 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -45,7 +45,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): @@ -86,11 +86,15 @@ class Request(dict): :return: token related to request """ + prefixes = ('Bearer', 'Token') auth_header = self.headers.get('Authorization') - if auth_header is not None and 'Token ' in auth_header: - return auth_header.partition('Token ')[-1] - else: - return auth_header + + if auth_header is not None: + for prefix in prefixes: + if prefix in auth_header: + return auth_header.partition(prefix)[-1].strip() + + return auth_header @property def form(self): @@ -138,7 +142,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) @@ -155,6 +159,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 \ @@ -234,15 +257,15 @@ def parse_multipart_form(body, boundary): break colon_index = form_line.index(':') - form_header_field = form_line[0:colon_index] + form_header_field = form_line[0:colon_index].lower() form_header_value, form_parameters = parse_header( form_line[colon_index + 2:]) - if form_header_field == 'Content-Disposition': + if form_header_field == 'content-disposition': if 'filename' in form_parameters: file_name = form_parameters['filename'] field_name = form_parameters.get('name') - elif form_header_field == 'Content-Type': + elif form_header_field == 'content-type': file_type = form_header_value post_data = form_part[line_index:-4] diff --git a/sanic/response.py b/sanic/response.py index ea233d9a..f4fb1ea6 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -237,6 +237,7 @@ def json(body, status=200, headers=None, content_type="application/json", **kwargs): """ Returns response object with body in json format. + :param body: Response data to be serialized. :param status: Response code. :param headers: Custom Headers. @@ -250,6 +251,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 +266,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 +279,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. diff --git a/sanic/router.py b/sanic/router.py index 691f1388..efc48f37 100644 --- a/sanic/router.py +++ b/sanic/router.py @@ -98,8 +98,25 @@ class Router: return name, _type, pattern - def add(self, uri, methods, handler, host=None, strict_slashes=False): + def add(self, uri, methods, handler, host=None, strict_slashes=False, + version=None): + """Add a handler to the route list + :param uri: path to match + :param methods: sequence of accepted method names. If none are + provided, any method is allowed + :param handler: request handler function. + When executed, it should provide a response object. + :param strict_slashes: strict to trailing slash + :param version: current version of the route or blueprint. See + docs for further details. + :return: Nothing + """ + if version is not None: + 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) diff --git a/sanic/server.py b/sanic/server.py index f3106226..2ee48688 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -75,7 +75,7 @@ class HttpProtocol(asyncio.Protocol): signal=Signal(), connections=set(), request_timeout=60, request_max_size=None, request_class=None, has_log=True, keep_alive=True, is_request_stream=False, router=None, - **kwargs): + state=None, debug=False, **kwargs): self.loop = loop self.transport = None self.request = None @@ -99,12 +99,18 @@ class HttpProtocol(asyncio.Protocol): self._request_handler_task = None self._request_stream_task = None self._keep_alive = keep_alive + self._header_fragment = b'' + self.state = state if state else {} + if 'requests_count' not in self.state: + self.state['requests_count'] = 0 + self._debug = debug @property def keep_alive(self): - return (self._keep_alive - and not self.signal.stopped - and self.parser.should_keep_alive()) + return ( + self._keep_alive and + not self.signal.stopped and + self.parser.should_keep_alive()) # -------------------------------------------- # # Connection @@ -154,22 +160,39 @@ class HttpProtocol(asyncio.Protocol): self.headers = [] self.parser = HttpRequestParser(self) + # requests count + self.state['requests_count'] = self.state['requests_count'] + 1 + # Parse request chunk or close connection try: self.parser.feed_data(data) except HttpParserError: - exception = InvalidUsage('Bad Request') + message = 'Bad Request' + if self._debug: + message += '\n' + traceback.format_exc() + exception = InvalidUsage(message) self.write_error(exception) def on_url(self, url): - self.url = url + if not self.url: + self.url = url + else: + self.url += url def on_header(self, name, value): - if name == b'Content-Length' and int(value) > self.request_max_size: - exception = PayloadTooLarge('Payload Too Large') - self.write_error(exception) + self._header_fragment += name - self.headers.append((name.decode().casefold(), value.decode())) + if value is not None: + if self._header_fragment == b'Content-Length' \ + and int(value) > self.request_max_size: + exception = PayloadTooLarge('Payload Too Large') + self.write_error(exception) + + self.headers.append( + (self._header_fragment.decode().casefold(), + value.decode())) + + self._header_fragment = b'' def on_headers_complete(self): self.request = self.request_class( @@ -357,6 +380,14 @@ class HttpProtocol(asyncio.Protocol): return True return False + def close(self): + """ + Force close the connection. + """ + if self.transport is not None: + self.transport.close() + self.transport = None + def update_current_time(loop): """Cache the current time, since it is needed at the end of every @@ -389,7 +420,8 @@ def serve(host, port, request_handler, error_handler, before_start=None, 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): + websocket_max_queue=None, state=None, + graceful_shutdown_timeout=15.0): """Start asynchronous HTTP Server on an individual process. :param host: Address to host on @@ -427,8 +459,6 @@ def serve(host, port, request_handler, error_handler, before_start=None, if debug: loop.set_debug(debug) - trigger_events(before_start, loop) - connections = connections if connections is not None else set() server = partial( protocol, @@ -445,7 +475,9 @@ def serve(host, port, request_handler, error_handler, before_start=None, is_request_stream=is_request_stream, router=router, websocket_max_size=websocket_max_size, - websocket_max_queue=websocket_max_queue + websocket_max_queue=websocket_max_queue, + state=state, + debug=debug, ) server_coroutine = loop.create_server( @@ -457,6 +489,7 @@ def serve(host, port, request_handler, error_handler, before_start=None, sock=sock, backlog=backlog ) + # Instead of pulling time at the end of every request, # pull it once per minute loop.call_soon(partial(update_current_time, loop)) @@ -464,6 +497,8 @@ def serve(host, port, request_handler, error_handler, before_start=None, if run_async: return server_coroutine + trigger_events(before_start, loop) + try: http_server = loop.run_until_complete(server_coroutine) except: @@ -499,8 +534,26 @@ def serve(host, port, request_handler, error_handler, before_start=None, for connection in connections: connection.close_if_idle() - while connections: + # Gracefully shutdown timeout. + # We should provide graceful_shutdown_timeout, + # instead of letting connection hangs forever. + # Let's roughly calcucate time. + start_shutdown = 0 + while connections and (start_shutdown < graceful_shutdown_timeout): loop.run_until_complete(asyncio.sleep(0.1)) + start_shutdown = start_shutdown + 0.1 + + # Force close non-idle connection after waiting for + # graceful_shutdown_timeout + coros = [] + for conn in connections: + if hasattr(conn, "websocket") and conn.websocket: + coros.append(conn.websocket.close_connection(force=True)) + else: + conn.close() + + _shutdown = asyncio.gather(*coros, loop=loop) + loop.run_until_complete(_shutdown) trigger_events(after_stop, loop) diff --git a/sanic/worker.py b/sanic/worker.py index 1d3e384b..9f950c34 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 @@ -29,7 +30,7 @@ class GunicornWorker(base.Worker): self.ssl_context = self._create_ssl_context(cfg) else: self.ssl_context = None - self.servers = [] + self.servers = {} self.connections = set() self.exit_code = 0 self.signal = Signal() @@ -69,10 +70,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) @@ -91,16 +98,37 @@ class GunicornWorker(base.Worker): for conn in self.connections: conn.close_if_idle() - while self.connections: + # gracefully shutdown timeout + start_shutdown = 0 + graceful_shutdown_timeout = self.cfg.graceful_timeout + while self.connections and \ + (start_shutdown < graceful_shutdown_timeout): await asyncio.sleep(0.1) + start_shutdown = start_shutdown + 0.1 + + # Force close non-idle connection after waiting for + # graceful_shutdown_timeout + coros = [] + for conn in self.connections: + if hasattr(conn, "websocket") and conn.websocket: + coros.append(conn.websocket.close_connection(force=True)) + else: + conn.close() + _shutdown = asyncio.gather(*coros, loop=self.loop) + await _shutdown async def _run(self): for sock in self.sockets: - self.servers.append(await serve( + state = dict(requests_count=0) + self._server_settings["host"] = None + self._server_settings["port"] = None + server = await serve( sock=sock, connections=self.connections, + state=state, **self._server_settings - )) + ) + self.servers[server] = state async def _check_alive(self): # If our parent changed then we shut down. @@ -109,7 +137,15 @@ class GunicornWorker(base.Worker): while self.alive: self.notify() - if pid == os.getpid() and self.ppid != os.getppid(): + req_count = sum( + self.servers[srv]["requests_count"] for srv in self.servers + ) + if self.max_requests and req_count > self.max_requests: + self.alive = False + self.log.info( + "Max requests exceeded, shutting down: %s", self + ) + elif pid == os.getpid() and self.ppid != os.getppid(): self.alive = False self.log.info("Parent changed, shutting down: %s", self) else: @@ -166,3 +202,4 @@ class GunicornWorker(base.Worker): self.alive = False self.exit_code = 1 self.cfg.worker_abort(self) + sys.exit(1) diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 9ab387be..5cb356c2 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') diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index db1fc246..620e7891 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -4,7 +4,7 @@ from bs4 import BeautifulSoup from sanic import Sanic from sanic.response import text from sanic.exceptions import InvalidUsage, ServerError, NotFound, Unauthorized -from sanic.exceptions import abort +from sanic.exceptions import Forbidden, abort class SanicExceptionTestException(Exception): @@ -27,6 +27,10 @@ def exception_app(): def handler_404(request): raise NotFound("OK") + @app.route('/403') + def handler_403(request): + raise Forbidden("Forbidden") + @app.route('/401/basic') def handler_401_basic(request): raise Unauthorized("Unauthorized", "Basic", {"realm": "Sanic"}) @@ -113,6 +117,12 @@ def test_not_found_exception(exception_app): assert response.status == 404 +def test_forbidden_exception(exception_app): + """Test the built-in Forbidden exception""" + request, response = exception_app.test_client.get('/403') + assert response.status == 403 + + def test_unauthorized_exception(exception_app): """Test the built-in Unauthorized exception""" request, response = exception_app.test_client.get('/401/basic') diff --git a/tests/test_requests.py b/tests/test_requests.py index 2351a3b0..f0696c7f 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -175,7 +175,7 @@ def test_token(): token = 'a1d895e0-553a-421a-8e22-5ff8ecb48cbf' headers = { 'content-type': 'application/json', - 'Authorization': 'Bearer Token {}'.format(token) + 'Authorization': 'Bearer {}'.format(token) } request, response = app.test_client.get('/', headers=headers) @@ -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') @@ -260,19 +286,26 @@ def test_post_form_urlencoded(): assert request.form.get('test') == 'OK' -def test_post_form_multipart_form_data(): +@pytest.mark.parametrize( + 'payload', [ + '------sanic\r\n' \ + 'Content-Disposition: form-data; name="test"\r\n' \ + '\r\n' \ + 'OK\r\n' \ + '------sanic--\r\n', + '------sanic\r\n' \ + 'content-disposition: form-data; name="test"\r\n' \ + '\r\n' \ + 'OK\r\n' \ + '------sanic--\r\n', + ]) +def test_post_form_multipart_form_data(payload): app = Sanic('test_post_form_multipart_form_data') @app.route('/', methods=['POST']) async def handler(request): return text('OK') - payload = '------sanic\r\n' \ - 'Content-Disposition: form-data; name="test"\r\n' \ - '\r\n' \ - 'OK\r\n' \ - '------sanic--\r\n' - headers = {'content-type': 'multipart/form-data; boundary=----sanic'} request, response = app.test_client.post(data=payload, headers=headers) diff --git a/tests/test_routes.py b/tests/test_routes.py index 4afb4a9c..04a682a0 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') diff --git a/tests/test_worker.py b/tests/test_worker.py index 2c1a0123..e2b301ec 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -3,7 +3,11 @@ import json import shlex import subprocess import urllib.request - +from unittest import mock +from sanic.worker import GunicornWorker +from sanic.app import Sanic +import asyncio +import logging import pytest @@ -20,3 +24,112 @@ def test_gunicorn_worker(gunicorn_worker): with urllib.request.urlopen('http://localhost:1337/') as f: res = json.loads(f.read(100).decode()) assert res['test'] + + +class GunicornTestWorker(GunicornWorker): + + def __init__(self): + self.app = mock.Mock() + self.app.callable = Sanic("test_gunicorn_worker") + self.servers = {} + self.exit_code = 0 + self.cfg = mock.Mock() + self.notify = mock.Mock() + + +@pytest.fixture +def worker(): + return GunicornTestWorker() + + +def test_worker_init_process(worker): + with mock.patch('sanic.worker.asyncio') as mock_asyncio: + try: + worker.init_process() + except TypeError: + pass + + assert mock_asyncio.get_event_loop.return_value.close.called + assert mock_asyncio.new_event_loop.called + assert mock_asyncio.set_event_loop.called + + +def test_worker_init_signals(worker): + worker.loop = mock.Mock() + worker.init_signals() + assert worker.loop.add_signal_handler.called + + +def test_handle_abort(worker): + with mock.patch('sanic.worker.sys') as mock_sys: + worker.handle_abort(object(), object()) + assert not worker.alive + assert worker.exit_code == 1 + mock_sys.exit.assert_called_with(1) + + +def test_handle_quit(worker): + worker.handle_quit(object(), object()) + assert not worker.alive + assert worker.exit_code == 0 + + +def test_run_max_requests_exceeded(worker): + loop = asyncio.new_event_loop() + worker.ppid = 1 + worker.alive = True + sock = mock.Mock() + sock.cfg_addr = ('localhost', 8080) + worker.sockets = [sock] + worker.wsgi = mock.Mock() + worker.connections = set() + worker.log = mock.Mock() + worker.loop = loop + worker.servers = { + "server1": {"requests_count": 14}, + "server2": {"requests_count": 15}, + } + worker.max_requests = 10 + worker._run = mock.Mock(wraps=asyncio.coroutine(lambda *a, **kw: None)) + + # exceeding request count + _runner = asyncio.ensure_future(worker._check_alive(), loop=loop) + loop.run_until_complete(_runner) + + assert worker.alive == False + worker.notify.assert_called_with() + worker.log.info.assert_called_with("Max requests exceeded, shutting down: %s", + worker) + +def test_worker_close(worker): + loop = asyncio.new_event_loop() + asyncio.sleep = mock.Mock(wraps=asyncio.coroutine(lambda *a, **kw: None)) + worker.ppid = 1 + worker.pid = 2 + worker.cfg.graceful_timeout = 1.0 + worker.signal = mock.Mock() + worker.signal.stopped = False + worker.wsgi = mock.Mock() + conn = mock.Mock() + conn.websocket = mock.Mock() + conn.websocket.close_connection = mock.Mock( + wraps=asyncio.coroutine(lambda *a, **kw: None) + ) + worker.connections = set([conn]) + worker.log = mock.Mock() + worker.loop = loop + server = mock.Mock() + server.close = mock.Mock(wraps=lambda *a, **kw: None) + server.wait_closed = mock.Mock(wraps=asyncio.coroutine(lambda *a, **kw: None)) + worker.servers = { + server: {"requests_count": 14}, + } + worker.max_requests = 10 + + # close worker + _close = asyncio.ensure_future(worker.close(), loop=loop) + loop.run_until_complete(_close) + + assert worker.signal.stopped == True + conn.websocket.close_connection.assert_called_with(force=True) + assert len(worker.servers) == 0