diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index c478a961..92d93aa7 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -5,12 +5,7 @@ on: - main tags: - "!*" # Do not execute on tags - paths: - - sanic/* - - tests/* pull_request: - paths: - - "!*.MD" types: [opened, synchronize, reopened, ready_for_review] jobs: test: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5f09fd51..d8a76c6c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,12 +1,12 @@ .. note:: - From v21.9, CHANGELOG files are maintained in ``./docs/sanic/releases`` + CHANGELOG files are maintained in ``./docs/sanic/releases``. To view the full CHANGELOG, please visit https://sanic.readthedocs.io/en/stable/sanic/changelog.html. + Version 21.6.1 -------------- -Bugfixes -******** +**Bugfixes** * `#2178 `_ Update sanic-routing to allow for better splitting of complex URI templates @@ -20,8 +20,7 @@ Bugfixes Version 21.6.0 -------------- -Features -******** +**Features** * `#2094 `_ Add ``response.eof()`` method for closing a stream in a handler @@ -68,8 +67,7 @@ Features * `#2170 `_ Additional methods for attaching ``HTTPMethodView`` -Bugfixes -******** +**Bugfixes** * `#2091 `_ Fix ``UserWarning`` in ASGI mode for missing ``__slots__`` @@ -85,8 +83,7 @@ Bugfixes Fix issue where Blueprint exception handlers do not consistently route to proper handler -Deprecations and Removals -************************* +**Deprecations and Removals** * `#2156 `_ Remove config value ``REQUEST_BUFFER_QUEUE_SIZE`` @@ -95,14 +92,12 @@ Deprecations and Removals * `#2172 `_ Deprecate StreamingHTTPResponse -Developer infrastructure -************************ +**Developer infrastructure** * `#2149 `_ Remove Travis CI in favor of GitHub Actions -Improved Documentation -********************** +**Improved Documentation** * `#2164 `_ Fix typo in documentation @@ -112,8 +107,7 @@ Improved Documentation Version 21.3.2 -------------- -Bugfixes -******** +**Bugfixes** * `#2081 `_ Disable response timeout on websocket connections @@ -124,8 +118,7 @@ Bugfixes Version 21.3.1 -------------- -Bugfixes -******** +**Bugfixes** * `#2076 `_ Static files inside subfolders are not accessible (404) @@ -135,8 +128,7 @@ Version 21.3.0 `Release Notes `_ -Features -******** +**Features** * `#1876 `_ @@ -189,8 +181,7 @@ Features `#2063 `_ App and connection level context objects -Bugfixes and issues resolved -**************************** +**Bugfixes** * Resolve `#1420 `_ ``url_for`` where ``strict_slashes`` are on for a path ending in ``/`` @@ -220,8 +211,7 @@ Bugfixes and issues resolved `#2001 `_ Raise ValueError when cookie max-age is not an integer -Deprecations and Removals -************************* +**Deprecations and Removals** * `#2007 `_ @@ -240,8 +230,7 @@ Deprecations and Removals * ``Request.endpoint`` deprecated in favor of ``Request.name`` * handler type name prefixes removed (static, websocket, etc) -Developer infrastructure -************************ +**Developer infrastructure** * `#1995 `_ @@ -259,8 +248,7 @@ Developer infrastructure `#2049 `_ Updated setup.py to use ``find_packages`` -Improved Documentation -********************** +**Improved Documentation** * `#1218 `_ @@ -282,8 +270,7 @@ Improved Documentation `#2052 `_ Fix some examples and docs -Miscellaneous -************* +**Miscellaneous** * ``Request.route`` property * Better websocket subprotocols support @@ -329,8 +316,7 @@ Miscellaneous Version 20.12.3 --------------- -Bugfixes -******** +**Bugfixes** * `#2021 `_ @@ -339,8 +325,7 @@ Bugfixes Version 20.12.2 --------------- -Dependencies -************ +**Dependencies** * `#2026 `_ @@ -353,8 +338,7 @@ Dependencies Version 19.12.5 --------------- -Dependencies -************ +**Dependencies** * `#2025 `_ @@ -367,8 +351,7 @@ Dependencies Version 20.12.0 --------------- -Features -******** +**Features** * `#1993 `_ @@ -377,8 +360,7 @@ Features Version 20.12.0 --------------- -Features -******** +**Features** * `#1945 `_ @@ -416,22 +398,19 @@ Features `#1979 `_ Add app registry and Sanic class level app retrieval -Bugfixes -******** +**Bugfixes** * `#1965 `_ Fix Chunked Transport-Encoding in ASGI streaming response -Deprecations and Removals -************************* +**Deprecations and Removals** * `#1981 `_ Cleanup and remove deprecated code -Developer infrastructure -************************ +**Developer infrastructure** * `#1956 `_ @@ -445,8 +424,7 @@ Developer infrastructure `#1986 `_ Update tox requirements -Improved Documentation -********************** +**Improved Documentation** * `#1951 `_ @@ -464,8 +442,7 @@ Improved Documentation Version 20.9.1 --------------- -Bugfixes -******** +**Bugfixes** * `#1954 `_ @@ -478,8 +455,7 @@ Bugfixes Version 19.12.3 --------------- -Bugfixes -******** +**Bugfixes** * `#1959 `_ @@ -490,8 +466,7 @@ Version 20.9.0 --------------- -Features -******** +**Features** * `#1887 `_ @@ -518,22 +493,19 @@ Features `#1937 `_ Added auto, text, and json fallback error handlers (in v21.3, the default will change form html to auto) -Bugfixes -******** +**Bugfixes** * `#1897 `_ Resolves exception from unread bytes in stream -Deprecations and Removals -************************* +**Deprecations and Removals** * `#1903 `_ config.from_envar, config.from_pyfile, and config.from_object are deprecated and set to be removed in v21.3 -Developer infrastructure -************************ +**Developer infrastructure** * `#1890 `_, @@ -548,8 +520,7 @@ Developer infrastructure `#1924 `_ Adding --strict-markers for pytest -Improved Documentation -********************** +**Improved Documentation** * `#1922 `_ @@ -559,8 +530,7 @@ Improved Documentation Version 20.6.3 --------------- -Bugfixes -******** +**Bugfixes** * `#1884 `_ @@ -570,8 +540,7 @@ Bugfixes Version 20.6.2 --------------- -Features -******** +**Features** * `#1641 `_ @@ -581,8 +550,7 @@ Features Version 20.6.1 --------------- -Features -******** +**Features** * `#1760 `_ @@ -596,8 +564,7 @@ Features `#1880 `_ Add handler names for websockets for url_for usage -Bugfixes -******** +**Bugfixes** * `#1776 `_ @@ -619,15 +586,13 @@ Bugfixes `#1853 `_ Fix pickle error when attempting to pickle an application which contains websocket routes -Deprecations and Removals -************************* +**Deprecations and Removals** * `#1739 `_ Deprecate body_bytes to merge into body -Developer infrastructure -************************ +**Developer infrastructure** * `#1852 `_ @@ -642,8 +607,7 @@ Developer infrastructure Wrap run()'s "protocol" type annotation in Optional[] -Improved Documentation -********************** +**Improved Documentation** * `#1846 `_ @@ -663,8 +627,7 @@ Version 20.6.0 Version 20.3.0 --------------- -Features -******** +**Features** * `#1762 `_ @@ -695,8 +658,7 @@ Features `#1820 `_ Do not set content-type and content-length headers in exceptions -Bugfixes -******** +**Bugfixes** * `#1748 `_ @@ -714,8 +676,7 @@ Bugfixes `#1808 `_ Fix Ctrl+C and tests on Windows -Deprecations and Removals -************************* +**Deprecations and Removals** * `#1800 `_ @@ -733,8 +694,7 @@ Deprecations and Removals `#1818 `_ Complete deprecation of ``app.remove_route`` and ``request.raw_args`` -Dependencies -************ +**Dependencies** * `#1794 `_ @@ -744,15 +704,13 @@ Dependencies `#1806 `_ Import ``ASGIDispatch`` from top-level ``httpx`` (from third-party deprecation) -Developer infrastructure -************************ +**Developer infrastructure** * `#1833 `_ Resolve broken documentation builds -Improved Documentation -********************** +**Improved Documentation** * `#1755 `_ @@ -794,8 +752,7 @@ Improved Documentation Version 19.12.0 --------------- -Bugfixes -******** +**Bugfixes** - Fix blueprint middleware application @@ -814,8 +771,7 @@ Bugfixes due to an `AttributeError`. This fix makes the availability of `SERVER_NAME` on our `app.config` an optional behavior. (`#1707 `__) -Improved Documentation -********************** +**Improved Documentation** - Move docs from MD to RST @@ -829,8 +785,7 @@ Improved Documentation Version 19.6.3 -------------- -Features -******** +**Features** - Enable Towncrier Support @@ -838,8 +793,7 @@ Features of generating and managing change logs as part of each of pull requests. (`#1631 `__) -Improved Documentation -********************** +**Improved Documentation** - Documentation infrastructure changes @@ -852,8 +806,7 @@ Improved Documentation Version 19.6.2 -------------- -Features -******** +**Features** * `#1562 `_ @@ -869,8 +822,7 @@ Features Add Configure support from object string -Bugfixes -******** +**Bugfixes** * `#1587 `_ @@ -888,8 +840,7 @@ Bugfixes `#1594 `_ Strict Slashes behavior fix -Deprecations and Removals -************************* +**Deprecations and Removals** * `#1544 `_ @@ -913,8 +864,7 @@ Deprecations and Removals Version 19.3 ------------ -Features -******** +**Features** * `#1497 `_ @@ -982,8 +932,7 @@ Features This is a breaking change. -Bugfixes -******** +**Bugfixes** * @@ -1019,8 +968,7 @@ Bugfixes This allows the access log to be disabled for example when running via gunicorn. -Developer infrastructure -************************ +**Developer infrastructure** * `#1529 `_ Update project PyPI credentials * `#1515 `_ fix linter issue causing travis build failures (fix #1514) @@ -1028,8 +976,7 @@ Developer infrastructure * `#1478 `_ Upgrade setuptools version and use native docutils in doc build * `#1464 `_ Upgrade pytest, and fix caplog unit tests -Improved Documentation -********************** +**Improved Documentation** * `#1516 `_ Fix typo at the exception documentation * `#1510 `_ fix typo in Asyncio example @@ -1096,15 +1043,13 @@ Version 18.12 Version 0.8 ----------- -0.8.3 -***** +**0.8.3** * Changes: * Ownership changed to org 'sanic-org' -0.8.0 -***** +**0.8.0** * Changes: @@ -1184,19 +1129,16 @@ Version 0.1 ----------- -0.1.7 -***** +**0.1.7** * Reversed static url and directory arguments to meet spec -0.1.6 -***** +**0.1.6** * Static files * Lazy Cookie Loading -0.1.5 -***** +**0.1.5** * Cookies * Blueprint listeners and ordering @@ -1204,23 +1146,19 @@ Version 0.1 * Fix: Incomplete file reads on medium+ sized post requests * Breaking: after_start and before_stop now pass sanic as their first argument -0.1.4 -***** +**0.1.4** * Multiprocessing -0.1.3 -***** +**0.1.3** * Blueprint support * Faster Response processing -0.1.1 - 0.1.2 -************* +**0.1.1 - 0.1.2** * Struggling to update pypi via CI -0.1.0 -***** +**0.1.0** * Released to public diff --git a/README.rst b/README.rst index 2a11ba8d..6b6d0408 100644 --- a/README.rst +++ b/README.rst @@ -11,7 +11,7 @@ Sanic | Build fast. Run fast. :stub-columns: 1 * - Build - - | |Py39Test| |Py38Test| |Py37Test| + - | |Py310Test| |Py39Test| |Py38Test| |Py37Test| * - Docs - | |UserGuide| |Documentation| * - Package @@ -27,6 +27,8 @@ Sanic | Build fast. Run fast. :target: https://community.sanicframework.org/ .. |Discord| image:: https://img.shields.io/discord/812221182594121728?logo=discord :target: https://discord.gg/FARQzAEMAA +.. |Py310Test| image:: https://github.com/sanic-org/sanic/actions/workflows/pr-python310.yml/badge.svg?branch=main + :target: https://github.com/sanic-org/sanic/actions/workflows/pr-python310.yml .. |Py39Test| image:: https://github.com/sanic-org/sanic/actions/workflows/pr-python39.yml/badge.svg?branch=main :target: https://github.com/sanic-org/sanic/actions/workflows/pr-python39.yml .. |Py38Test| image:: https://github.com/sanic-org/sanic/actions/workflows/pr-python38.yml/badge.svg?branch=main @@ -75,7 +77,11 @@ The goal of the project is to provide a simple way to get up and running a highl Sponsor ------- -Check out `open collective `_ to learn more about helping to fund Sanic. +Check out `open collective `_ to learn more about helping to fund Sanic. + +Thanks to `Linode `_ for their contribution towards the development and community of Sanic. + +|Linode| Installation ------------ @@ -160,3 +166,8 @@ Contribution ------------ We are always happy to have new contributions. We have `marked issues good for anyone looking to get started `_, and welcome `questions on the forums `_. Please take a look at our `Contribution guidelines `_. + +.. |Linode| image:: https://www.linode.com/wp-content/uploads/2021/01/Linode-Logo-Black.svg + :alt: Linode + :target: https://www.linode.com + :width: 200px diff --git a/docs/sanic/api/core.rst b/docs/sanic/api/core.rst index 1891c843..1ed9070a 100644 --- a/docs/sanic/api/core.rst +++ b/docs/sanic/api/core.rst @@ -38,10 +38,3 @@ sanic.views .. automodule:: sanic.views :members: :show-inheritance: - -sanic.websocket ---------------- - -.. automodule:: sanic.websocket - :members: - :show-inheritance: diff --git a/docs/sanic/changelog.rst b/docs/sanic/changelog.rst index 516b8587..3bcb03d2 100644 --- a/docs/sanic/changelog.rst +++ b/docs/sanic/changelog.rst @@ -1,6 +1,6 @@ 📜 Changelog ============ -.. mdinclude:: ./releases/21.9.md - +.. mdinclude:: ./releases/21/21.12.md +.. mdinclude:: ./releases/21/21.9.md .. include:: ../../CHANGELOG.rst diff --git a/docs/sanic/releases/21/21.12.md b/docs/sanic/releases/21/21.12.md new file mode 100644 index 00000000..6c4dc419 --- /dev/null +++ b/docs/sanic/releases/21/21.12.md @@ -0,0 +1,58 @@ +## Version 21.12.0 + +### Features +- [#2260](https://github.com/sanic-org/sanic/pull/2260) Allow early Blueprint registrations to still apply later added objects +- [#2262](https://github.com/sanic-org/sanic/pull/2262) Noisy exceptions - force logging of all exceptions +- [#2264](https://github.com/sanic-org/sanic/pull/2264) Optional `uvloop` by configuration +- [#2270](https://github.com/sanic-org/sanic/pull/2270) Vhost support using multiple TLS certificates +- [#2277](https://github.com/sanic-org/sanic/pull/2277) Change signal routing for increased consistency + - *BREAKING CHANGE*: If you were manually routing signals there is a breaking change. The signal router's `get` is no longer 100% determinative. There is now an additional step to loop thru the returned signals for proper matching on the requirements. If signals are being dispatched using `app.dispatch` or `bp.dispatch`, there is no change. +- [#2290](https://github.com/sanic-org/sanic/pull/2290) Add contextual exceptions +- [#2291](https://github.com/sanic-org/sanic/pull/2291) Increase join concat performance +- [#2295](https://github.com/sanic-org/sanic/pull/2295), [#2316](https://github.com/sanic-org/sanic/pull/2316), [#2331](https://github.com/sanic-org/sanic/pull/2331) Restructure of CLI and application state with new displays and more command parity with `app.run` +- [#2302](https://github.com/sanic-org/sanic/pull/2302) Add route context at definition time +- [#2304](https://github.com/sanic-org/sanic/pull/2304) Named tasks and new API for managing background tasks +- [#2307](https://github.com/sanic-org/sanic/pull/2307) On app auto-reload, provide insight of changed files +- [#2308](https://github.com/sanic-org/sanic/pull/2308) Auto extend application with [Sanic Extensions](https://sanicframework.org/en/plugins/sanic-ext/getting-started.html) if it is installed, and provide first class support for accessing the extensions +- [#2309](https://github.com/sanic-org/sanic/pull/2309) Builtin signals changed to `Enum` +- [#2313](https://github.com/sanic-org/sanic/pull/2313) Support additional config implementation use case +- [#2321](https://github.com/sanic-org/sanic/pull/2321) Refactor environment variable hydration logic +- [#2327](https://github.com/sanic-org/sanic/pull/2327) Prevent sending multiple or mixed responses on a single request +- [#2330](https://github.com/sanic-org/sanic/pull/2330) Custom type casting on environment variables +- [#2332](https://github.com/sanic-org/sanic/pull/2332) Make all deprecation notices consistent +- [#2335](https://github.com/sanic-org/sanic/pull/2335) Allow underscore to start instance names + +### Bugfixes +- [#2273](https://github.com/sanic-org/sanic/pull/2273) Replace assignation by typing for `websocket_handshake` +- [#2285](https://github.com/sanic-org/sanic/pull/2285) Fix IPv6 display in startup logs +- [#2299](https://github.com/sanic-org/sanic/pull/2299) Dispatch `http.lifecyle.response` from exception handler + +### Deprecations and Removals +- [#2306](https://github.com/sanic-org/sanic/pull/2306) Removal of deprecated items + - `Sanic` and `Blueprint` may no longer have arbitrary properties attached to them + - `Sanic` and `Blueprint` forced to have compliant names + - alphanumeric + `_` + `-` + - must start with letter or `_` + - `load_env` keyword argument of `Sanic` + - `sanic.exceptions.abort` + - `sanic.views.CompositionView` + - `sanic.response.StreamingHTTPResponse` + - *NOTE:* the `stream()` response method (where you pass a callable streaming function) has been deprecated and will be removed in v22.6. You should upgrade all streaming responses to the new style: https://sanicframework.org/en/guide/advanced/streaming.html#response-streaming +- [#2320](https://github.com/sanic-org/sanic/pull/2320) Remove app instance from Config for error handler setting + +### Developer infrastructure +- [#2251](https://github.com/sanic-org/sanic/pull/2251) Change dev install command +- [#2286](https://github.com/sanic-org/sanic/pull/2286) Change codeclimate complexity threshold from 5 to 10 +- [#2287](https://github.com/sanic-org/sanic/pull/2287) Update host test function names so they are not overwritten +- [#2292](https://github.com/sanic-org/sanic/pull/2292) Fail CI on error +- [#2311](https://github.com/sanic-org/sanic/pull/2311), [#2324](https://github.com/sanic-org/sanic/pull/2324) Do not run tests for draft PRs +- [#2336](https://github.com/sanic-org/sanic/pull/2336) Remove paths from coverage checks +- [#2338](https://github.com/sanic-org/sanic/pull/2338) Cleanup ports on tests + +### Improved Documentation +- [#2269](https://github.com/sanic-org/sanic/pull/2269), [#2329](https://github.com/sanic-org/sanic/pull/2329), [#2333](https://github.com/sanic-org/sanic/pull/2333) Cleanup typos and fix language + +### Miscellaneous +- [#2257](https://github.com/sanic-org/sanic/pull/2257), [#2294](https://github.com/sanic-org/sanic/pull/2294), [#2341](https://github.com/sanic-org/sanic/pull/2341) Add Python 3.10 support +- [#2279](https://github.com/sanic-org/sanic/pull/2279), [#2317](https://github.com/sanic-org/sanic/pull/2317), [#2322](https://github.com/sanic-org/sanic/pull/2322) Add/correct missing type annotations +- [#2305](https://github.com/sanic-org/sanic/pull/2305) Fix examples to use modern implementations diff --git a/docs/sanic/releases/21.9.md b/docs/sanic/releases/21/21.9.md similarity index 87% rename from docs/sanic/releases/21.9.md rename to docs/sanic/releases/21/21.9.md index 8900340d..f95c65c1 100644 --- a/docs/sanic/releases/21.9.md +++ b/docs/sanic/releases/21/21.9.md @@ -1,4 +1,14 @@ -## Version 21.9 +## Version 21.9.3 +*Rerelease of v21.9.2 with some cleanup* + +## Version 21.9.2 +- [#2268](https://github.com/sanic-org/sanic/pull/2268) Make HTTP connections start in IDLE stage, avoiding delays and error messages +- [#2310](https://github.com/sanic-org/sanic/pull/2310) More consistent config setting with post-FALLBACK_ERROR_FORMAT apply + +## Version 21.9.1 +- [#2259](https://github.com/sanic-org/sanic/pull/2259) Allow non-conforming ErrorHandlers + +## Version 21.9.0 ### Features - [#2158](https://github.com/sanic-org/sanic/pull/2158), [#2248](https://github.com/sanic-org/sanic/pull/2248) Complete overhaul of I/O to websockets diff --git a/examples/add_task_sanic.py b/examples/add_task_sanic.py index ece26433..2487b85e 100644 --- a/examples/add_task_sanic.py +++ b/examples/add_task_sanic.py @@ -5,7 +5,7 @@ import asyncio from sanic import Sanic -app = Sanic(__name__) +app = Sanic("Example") async def notify_server_started_after_five_seconds(): diff --git a/examples/amending_request_object.py b/examples/amending_request_object.py index 366dd67d..2188b680 100644 --- a/examples/amending_request_object.py +++ b/examples/amending_request_object.py @@ -4,7 +4,7 @@ from sanic import Sanic from sanic.response import text -app = Sanic(__name__) +app = Sanic("Example") @app.middleware("request") diff --git a/examples/authorized_sanic.py b/examples/authorized_sanic.py index 33e54a4b..40f0f5a4 100644 --- a/examples/authorized_sanic.py +++ b/examples/authorized_sanic.py @@ -6,7 +6,7 @@ from sanic import Sanic from sanic.response import json -app = Sanic(__name__) +app = Sanic("Example") def check_request_for_authorization_status(request): diff --git a/examples/blueprint_middlware_execution_order.py b/examples/blueprint_middlware_execution_order.py index e179c36d..b858061a 100644 --- a/examples/blueprint_middlware_execution_order.py +++ b/examples/blueprint_middlware_execution_order.py @@ -8,9 +8,9 @@ are added. And blueprint response middleware are executed in _reverse_ order. On a valid request, it should print "1 2 3 6 5 4" to terminal """ -app = Sanic(__name__) +app = Sanic("Example") -bp = Blueprint("bp_" + __name__) +bp = Blueprint("bp_example") @bp.on_request diff --git a/examples/blueprints.py b/examples/blueprints.py index 62340a0d..4109f485 100644 --- a/examples/blueprints.py +++ b/examples/blueprints.py @@ -2,10 +2,10 @@ from sanic import Blueprint, Sanic from sanic.response import file, json -app = Sanic(__name__) -blueprint = Blueprint("name", url_prefix="/my_blueprint") -blueprint2 = Blueprint("name2", url_prefix="/my_blueprint2") -blueprint3 = Blueprint("name3", url_prefix="/my_blueprint3") +app = Sanic("Example") +blueprint = Blueprint("bp_example", url_prefix="/my_blueprint") +blueprint2 = Blueprint("bp_example2", url_prefix="/my_blueprint2") +blueprint3 = Blueprint("bp_example3", url_prefix="/my_blueprint3") @blueprint.route("/foo") diff --git a/examples/delayed_response.py b/examples/delayed_response.py index 5923d10a..3fab6812 100644 --- a/examples/delayed_response.py +++ b/examples/delayed_response.py @@ -3,7 +3,7 @@ from asyncio import sleep from sanic import Sanic, response -app = Sanic(__name__, strict_slashes=True) +app = Sanic("DelayedResponseApp", strict_slashes=True) @app.get("/") diff --git a/examples/exception_monitoring.py b/examples/exception_monitoring.py index 3d853d32..eb52ed9a 100644 --- a/examples/exception_monitoring.py +++ b/examples/exception_monitoring.py @@ -41,7 +41,7 @@ from sanic import Sanic handler = CustomHandler() -app = Sanic(__name__, error_handler=handler) +app = Sanic("Example", error_handler=handler) @app.route("/") diff --git a/examples/hello_world.py b/examples/hello_world.py index 948090c4..9e45b856 100644 --- a/examples/hello_world.py +++ b/examples/hello_world.py @@ -1,7 +1,7 @@ -from sanic import Sanic -from sanic import response +from sanic import Sanic, response -app = Sanic(__name__) + +app = Sanic("Example") @app.route("/") @@ -9,5 +9,5 @@ async def test(request): return response.json({"test": True}) -if __name__ == '__main__': +if __name__ == "__main__": app.run(host="0.0.0.0", port=8000) diff --git a/examples/limit_concurrency.py b/examples/limit_concurrency.py index 429a312b..37015817 100644 --- a/examples/limit_concurrency.py +++ b/examples/limit_concurrency.py @@ -6,7 +6,7 @@ from sanic import Sanic from sanic.response import json -app = Sanic(__name__) +app = Sanic("Example") sem = None diff --git a/examples/log_request_id.py b/examples/log_request_id.py index c0d2d6f9..87825640 100644 --- a/examples/log_request_id.py +++ b/examples/log_request_id.py @@ -44,7 +44,7 @@ LOG_SETTINGS = { } -app = Sanic(__name__, log_config=LOG_SETTINGS) +app = Sanic("Example", log_config=LOG_SETTINGS) @app.on_request diff --git a/examples/logdna_example.py b/examples/logdna_example.py index 01236d98..9e9303b7 100644 --- a/examples/logdna_example.py +++ b/examples/logdna_example.py @@ -43,7 +43,7 @@ logdna = logging.getLogger(__name__) logdna.setLevel(logging.INFO) logdna.addHandler(logdna_handler) -app = Sanic(__name__) +app = Sanic("Example") @app.middleware diff --git a/examples/modify_header_example.py b/examples/modify_header_example.py index f13e5f00..6455cb85 100644 --- a/examples/modify_header_example.py +++ b/examples/modify_header_example.py @@ -2,27 +2,29 @@ Modify header or status in response """ -from sanic import Sanic -from sanic import response - -app = Sanic(__name__) +from sanic import Sanic, response -@app.route('/') +app = Sanic("Example") + + +@app.route("/") def handle_request(request): return response.json( - {'message': 'Hello world!'}, - headers={'X-Served-By': 'sanic'}, - status=200 + {"message": "Hello world!"}, + headers={"X-Served-By": "sanic"}, + status=200, ) -@app.route('/unauthorized') +@app.route("/unauthorized") def handle_request(request): return response.json( - {'message': 'You are not authorized'}, - headers={'X-Served-By': 'sanic'}, - status=404 + {"message": "You are not authorized"}, + headers={"X-Served-By": "sanic"}, + status=404, ) -app.run(host="0.0.0.0", port=8000, debug=True) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/examples/pytest_xdist.py b/examples/pytest_xdist.py index 7c201903..7adeb93a 100644 --- a/examples/pytest_xdist.py +++ b/examples/pytest_xdist.py @@ -32,7 +32,7 @@ def test_port(worker_id): @pytest.fixture(scope="session") def app(): - app = Sanic() + app = Sanic("Example") @app.route("/") async def index(request): diff --git a/examples/raygun_example.py b/examples/raygun_example.py index fcb4a5eb..2270ea9d 100644 --- a/examples/raygun_example.py +++ b/examples/raygun_example.py @@ -8,7 +8,6 @@ from sanic.handlers import ErrorHandler class RaygunExceptionReporter(ErrorHandler): - def __init__(self, raygun_api_key=None): super().__init__() if raygun_api_key is None: @@ -22,16 +21,13 @@ class RaygunExceptionReporter(ErrorHandler): raygun_error_reporter = RaygunExceptionReporter() -app = Sanic(__name__, error_handler=raygun_error_reporter) +app = Sanic("Example", error_handler=raygun_error_reporter) @app.route("/raise") async def test(request): - raise SanicException('You Broke It!') + raise SanicException("You Broke It!") -if __name__ == '__main__': - app.run( - host="0.0.0.0", - port=getenv("PORT", 8080) - ) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=getenv("PORT", 8080)) diff --git a/examples/redirect_example.py b/examples/redirect_example.py index f73ad178..74fb10de 100644 --- a/examples/redirect_example.py +++ b/examples/redirect_example.py @@ -1,18 +1,18 @@ -from sanic import Sanic -from sanic import response +from sanic import Sanic, response -app = Sanic(__name__) - -@app.route('/') +app = Sanic("Example") + + +@app.route("/") def handle_request(request): - return response.redirect('/redirect') + return response.redirect("/redirect") -@app.route('/redirect') +@app.route("/redirect") async def test(request): return response.json({"Redirected": True}) -if __name__ == '__main__': - app.run(host="0.0.0.0", port=8000) \ No newline at end of file +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8000) diff --git a/examples/request_stream/server.py b/examples/request_stream/server.py index d3d35aef..bffc7451 100644 --- a/examples/request_stream/server.py +++ b/examples/request_stream/server.py @@ -1,65 +1,63 @@ from sanic import Sanic -from sanic.views import CompositionView -from sanic.views import HTTPMethodView -from sanic.views import stream as stream_decorator from sanic.blueprints import Blueprint from sanic.response import stream, text +from sanic.views import HTTPMethodView +from sanic.views import stream as stream_decorator -bp = Blueprint('blueprint_request_stream') -app = Sanic('request_stream') + +bp = Blueprint("bp_example") +app = Sanic("Example") class SimpleView(HTTPMethodView): - @stream_decorator async def post(self, request): - result = '' + result = "" while True: body = await request.stream.get() if body is None: break - result += body.decode('utf-8') + result += body.decode("utf-8") return text(result) -@app.post('/stream', stream=True) +@app.post("/stream", stream=True) async def handler(request): async def streaming(response): while True: body = await request.stream.get() if body is None: break - body = body.decode('utf-8').replace('1', 'A') + body = body.decode("utf-8").replace("1", "A") await response.write(body) + return stream(streaming) -@bp.put('/bp_stream', stream=True) +@bp.put("/bp_stream", stream=True) async def bp_handler(request): - result = '' + result = "" while True: body = await request.stream.get() if body is None: break - result += body.decode('utf-8').replace('1', 'A') + result += body.decode("utf-8").replace("1", "A") return text(result) async def post_handler(request): - result = '' + result = "" while True: body = await request.stream.get() if body is None: break - result += body.decode('utf-8') + result += body.decode("utf-8") return text(result) + app.blueprint(bp) -app.add_route(SimpleView.as_view(), '/method_view') -view = CompositionView() -view.add(['POST'], post_handler, stream=True) -app.add_route(view, '/composition_view') +app.add_route(SimpleView.as_view(), "/method_view") -if __name__ == '__main__': - app.run(host='0.0.0.0', port=8000) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8000) diff --git a/examples/request_timeout.py b/examples/request_timeout.py index 0c2c489c..1c7885ee 100644 --- a/examples/request_timeout.py +++ b/examples/request_timeout.py @@ -1,21 +1,23 @@ import asyncio -from sanic import Sanic -from sanic import response + +from sanic import Sanic, response from sanic.config import Config from sanic.exceptions import RequestTimeout + Config.REQUEST_TIMEOUT = 1 -app = Sanic(__name__) +app = Sanic("Example") -@app.route('/') +@app.route("/") async def test(request): await asyncio.sleep(3) - return response.text('Hello, world!') + return response.text("Hello, world!") @app.exception(RequestTimeout) def timeout(request, exception): - return response.text('RequestTimeout from error_handler.', 408) + return response.text("RequestTimeout from error_handler.", 408) -app.run(host='0.0.0.0', port=8000) + +app.run(host="0.0.0.0", port=8000) diff --git a/examples/rollbar_example.py b/examples/rollbar_example.py index cf37244f..53d53c5a 100644 --- a/examples/rollbar_example.py +++ b/examples/rollbar_example.py @@ -1,21 +1,22 @@ +from os import getenv + import rollbar -from sanic.handlers import ErrorHandler from sanic import Sanic from sanic.exceptions import SanicException -from os import getenv +from sanic.handlers import ErrorHandler + rollbar.init(getenv("ROLLBAR_API_KEY")) class RollbarExceptionHandler(ErrorHandler): - def default(self, request, exception): rollbar.report_message(str(exception)) return super().default(request, exception) -app = Sanic(__name__, error_handler=RollbarExceptionHandler()) +app = Sanic("Example", error_handler=RollbarExceptionHandler()) @app.route("/raise") @@ -24,7 +25,4 @@ def create_error(request): if __name__ == "__main__": - app.run( - host="0.0.0.0", - port=getenv("PORT", 8080) - ) + app.run(host="0.0.0.0", port=getenv("PORT", 8080)) diff --git a/examples/run_asgi.py b/examples/run_asgi.py index c29c5fbb..bc559789 100644 --- a/examples/run_asgi.py +++ b/examples/run_asgi.py @@ -11,7 +11,7 @@ from pathlib import Path from sanic import Sanic, response -app = Sanic(__name__) +app = Sanic("Example") @app.route("/text") diff --git a/examples/run_async.py b/examples/run_async.py index a30417d7..84c8a6be 100644 --- a/examples/run_async.py +++ b/examples/run_async.py @@ -1,13 +1,11 @@ import asyncio -from signal import SIGINT, signal - import uvloop from sanic import Sanic, response -app = Sanic(__name__) +app = Sanic("Example") @app.route("/") @@ -15,17 +13,18 @@ async def test(request): return response.json({"answer": "42"}) -asyncio.set_event_loop(uvloop.new_event_loop()) -server = app.create_server( - host="0.0.0.0", port=8000, return_asyncio_server=True -) -loop = asyncio.get_event_loop() -task = asyncio.ensure_future(server) -server = loop.run_until_complete(task) -loop.run_until_complete(server.startup()) -signal(SIGINT, lambda s, f: loop.stop()) +async def main(): + server = await app.create_server( + port=8000, host="0.0.0.0", return_asyncio_server=True + ) -try: - loop.run_forever() -finally: - loop.stop() + if server is None: + return + + await server.startup() + await server.serve_forever() + + +if __name__ == "__main__": + asyncio.set_event_loop(uvloop.new_event_loop()) + asyncio.run(main()) diff --git a/examples/run_async_advanced.py b/examples/run_async_advanced.py index 7ea30dd7..dc8d71c6 100644 --- a/examples/run_async_advanced.py +++ b/examples/run_async_advanced.py @@ -8,7 +8,7 @@ from sanic import Sanic, response from sanic.server import AsyncioServer -app = Sanic(__name__) +app = Sanic("Example") @app.before_server_start diff --git a/examples/sentry_example.py b/examples/sentry_example.py index 1ea75a0f..1dfc1ba4 100644 --- a/examples/sentry_example.py +++ b/examples/sentry_example.py @@ -6,20 +6,19 @@ from sentry_sdk.integrations.sanic import SanicIntegration from sanic import Sanic from sanic.response import json + sentry_init( dsn=getenv("SENTRY_DSN"), integrations=[SanicIntegration()], ) -app = Sanic(__name__) +app = Sanic("Example") # noinspection PyUnusedLocal @app.route("/working") async def working_path(request): - return json({ - "response": "Working API Response" - }) + return json({"response": "Working API Response"}) # noinspection PyUnusedLocal @@ -28,8 +27,5 @@ async def raise_error(request): raise Exception("Testing Sentry Integration") -if __name__ == '__main__': - app.run( - host="0.0.0.0", - port=getenv("PORT", 8080) - ) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=getenv("PORT", 8080)) diff --git a/examples/static_assets.py b/examples/static_assets.py index 23f324ef..347b489c 100644 --- a/examples/static_assets.py +++ b/examples/static_assets.py @@ -1,6 +1,6 @@ from sanic import Sanic -app = Sanic(__name__) +app = Sanic("Example") app.static("/", "./static") diff --git a/examples/teapot.py b/examples/teapot.py index 897f7836..5d742efc 100644 --- a/examples/teapot.py +++ b/examples/teapot.py @@ -1,13 +1,14 @@ from sanic import Sanic from sanic import response as res -app = Sanic(__name__) + +app = Sanic("Example") @app.route("/") async def test(req): - return res.text("I\'m a teapot", status=418) + return res.text("I'm a teapot", status=418) -if __name__ == '__main__': +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 8e4a8e09..f1377060 100644 --- a/examples/try_everything.py +++ b/examples/try_everything.py @@ -5,7 +5,7 @@ from sanic.exceptions import ServerError from sanic.log import logger as log -app = Sanic(__name__) +app = Sanic("Example") @app.route("/") diff --git a/examples/unix_socket.py b/examples/unix_socket.py index a64b205d..0963a625 100644 --- a/examples/unix_socket.py +++ b/examples/unix_socket.py @@ -4,7 +4,7 @@ import socket from sanic import Sanic, response -app = Sanic(__name__) +app = Sanic("Example") @app.route("/test") diff --git a/examples/url_for_example.py b/examples/url_for_example.py index f0d3614b..c3d936fa 100644 --- a/examples/url_for_example.py +++ b/examples/url_for_example.py @@ -1,7 +1,7 @@ from sanic import Sanic, response -app = Sanic(__name__) +app = Sanic("Example") @app.route("/") diff --git a/examples/vhosts.py b/examples/vhosts.py index 57d42e9d..335a3be8 100644 --- a/examples/vhosts.py +++ b/examples/vhosts.py @@ -8,7 +8,7 @@ from sanic.blueprints import Blueprint # curl -H "Host: bp.example.com" localhost:8000/question # curl -H "Host: bp.example.com" localhost:8000/answer -app = Sanic(__name__) +app = Sanic("Example") bp = Blueprint("bp", host="bp.example.com") diff --git a/examples/websocket.py b/examples/websocket.py index 7bcd2cd1..ce5a105c 100644 --- a/examples/websocket.py +++ b/examples/websocket.py @@ -2,7 +2,7 @@ from sanic import Sanic from sanic.response import redirect -app = Sanic(__name__) +app = Sanic("Example") app.static("index.html", "websocket.html") diff --git a/pyproject.toml b/pyproject.toml index 9787c3bd..01a47231 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,3 @@ [build-system] -requires = ["setuptools", "wheel"] +requires = ["setuptools<60.0", "wheel"] build-backend = "setuptools.build_meta" diff --git a/readthedocs.yml b/readthedocs.yml index 1c5d4722..87320098 100644 --- a/readthedocs.yml +++ b/readthedocs.yml @@ -6,4 +6,4 @@ python: path: . extra_requirements: - docs - system_packages: true \ No newline at end of file + system_packages: true diff --git a/sanic/__version__.py b/sanic/__version__.py index 02ed01d4..4f86417d 100644 --- a/sanic/__version__.py +++ b/sanic/__version__.py @@ -1 +1 @@ -__version__ = "21.12.0dev" +__version__ = "21.12.0" diff --git a/sanic/app.py b/sanic/app.py index a0eca9c2..cf80912d 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import logging import logging.config import os @@ -11,6 +12,7 @@ from asyncio import ( AbstractEventLoop, CancelledError, Protocol, + Task, ensure_future, get_event_loop, wait_for, @@ -26,6 +28,7 @@ from ssl import SSLContext from traceback import format_exc from types import SimpleNamespace from typing import ( + TYPE_CHECKING, Any, AnyStr, Awaitable, @@ -40,10 +43,11 @@ from typing import ( Set, Tuple, Type, + TypeVar, Union, ) from urllib.parse import urlencode, urlunparse -from warnings import filterwarnings, warn +from warnings import filterwarnings from sanic_routing.exceptions import ( # type: ignore FinalizationError, @@ -52,11 +56,12 @@ from sanic_routing.exceptions import ( # type: ignore from sanic_routing.route import Route # type: ignore from sanic import reloader_helpers +from sanic.application.ext import setup_ext from sanic.application.logo import get_logo from sanic.application.motd import MOTD from sanic.application.state import ApplicationState, Mode from sanic.asgi import ASGIApp -from sanic.base import BaseSanic +from sanic.base.root import BaseSanic from sanic.blueprint_group import BlueprintGroup from sanic.blueprints import Blueprint from sanic.compat import OS_IS_WINDOWS, enable_windows_color_support @@ -68,9 +73,16 @@ from sanic.exceptions import ( URLBuildError, ) from sanic.handlers import ErrorHandler +from sanic.helpers import _default from sanic.http import Stage from sanic.http.constants import HTTP -from sanic.log import LOGGING_CONFIG_DEFAULTS, Colors, error_logger, logger +from sanic.log import ( + LOGGING_CONFIG_DEFAULTS, + Colors, + deprecation, + error_logger, + logger, +) from sanic.mixins.listeners import ListenerEvent from sanic.models.futures import ( FutureException, @@ -84,11 +96,11 @@ from sanic.models.futures import ( from sanic.models.handler_types import ListenerType, MiddlewareType from sanic.models.handler_types import Sanic as SanicVar from sanic.request import Request -from sanic.response import BaseHTTPResponse, HTTPResponse +from sanic.response import BaseHTTPResponse, HTTPResponse, ResponseStream from sanic.router import Router from sanic.server import AsyncioServer, HttpProtocol from sanic.server import Signal as ServerSignal -from sanic.server import serve, serve_multiple, serve_single +from sanic.server import serve, serve_multiple, serve_single, try_use_uvloop from sanic.server.protocols.websocket_protocol import WebSocketProtocol from sanic.server.websockets.impl import ConnectionClosed from sanic.signals import Signal, SignalRouter @@ -96,11 +108,21 @@ from sanic.tls import process_to_context from sanic.touchup import TouchUp, TouchUpMeta +if TYPE_CHECKING: # no cov + try: + from sanic_ext import Extend # type: ignore + from sanic_ext.extensions.base import Extension # type: ignore + except ImportError: + Extend = TypeVar("Extend") # type: ignore + + if OS_IS_WINDOWS: enable_windows_color_support() filterwarnings("once", category=DeprecationWarning) +SANIC_PACKAGES = ("sanic-routing", "sanic-testing", "sanic-ext") + class Sanic(BaseSanic, metaclass=TouchUpMeta): """ @@ -113,12 +135,12 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): "_run_response_middleware", "_run_request_middleware", ) - __fake_slots__ = ( - "_app_registry", + __slots__ = ( "_asgi_app", "_asgi_client", "_blueprint_order", "_delayed_tasks", + "_ext", "_future_exceptions", "_future_listeners", "_future_middleware", @@ -127,20 +149,15 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): "_future_signals", "_future_statics", "_state", + "_task_registry", "_test_client", "_test_manager", - "asgi", - "auto_reload", - "auto_reload", "blueprints", "config", "configure_logging", "ctx", - "debug", "error_handler", "go_fast", - "is_running", - "is_stopping", "listeners", "name", "named_request_middleware", @@ -152,12 +169,12 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): "signal_router", "sock", "strict_slashes", - "test_mode", "websocket_enabled", "websocket_tasks", ) _app_registry: Dict[str, "Sanic"] = {} + _uvloop_setting = None # TODO: Remove in v22.6 test_mode = False def __init__( @@ -168,7 +185,6 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): router: Optional[Router] = None, signal_router: Optional[SignalRouter] = None, error_handler: Optional[ErrorHandler] = None, - load_env: Union[bool, str] = True, env_prefix: Optional[str] = SANIC_PREFIX, request_class: Optional[Type[Request]] = None, strict_slashes: bool = False, @@ -184,25 +200,27 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): dict_config = log_config or LOGGING_CONFIG_DEFAULTS logging.config.dictConfig(dict_config) # type: ignore - if config and (load_env is not True or env_prefix != SANIC_PREFIX): + if config and env_prefix != SANIC_PREFIX: raise SanicException( "When instantiating Sanic with config, you cannot also pass " - "load_env or env_prefix" + "env_prefix" ) + # First setup config + self.config: Config = config or Config(env_prefix=env_prefix) + + # Then we can do the rest self._asgi_client: Any = None - self._test_client: Any = None - self._test_manager: Any = None self._blueprint_order: List[Blueprint] = [] self._delayed_tasks: List[str] = [] self._future_registry: FutureRegistry = FutureRegistry() self._state: ApplicationState = ApplicationState(app=self) + self._task_registry: Dict[str, Task] = {} + self._test_client: Any = None + self._test_manager: Any = None + self.asgi = False + self.auto_reload = False self.blueprints: Dict[str, Blueprint] = {} - self.config: Config = config or Config( - load_env=load_env, - env_prefix=env_prefix, - app=self, - ) self.configure_logging: bool = configure_logging self.ctx: Any = ctx or SimpleNamespace() self.debug = False @@ -224,6 +242,12 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): self.go_fast = self.run if register is not None: + deprecation( + "The register argument is deprecated and will stop working " + "in v22.6. After v22.6 all apps will be added to the Sanic " + "app registry.", + 22.6, + ) self.config.REGISTER = register if self.config.REGISTER: self.__class__.register_app(self) @@ -254,32 +278,6 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): # Registration # -------------------------------------------------------------------- # - def add_task( - self, - task: Union[Future[Any], Coroutine[Any, Any, Any], Awaitable[Any]], - ) -> None: - """ - Schedule a task to run later, after the loop has started. - Different from asyncio.ensure_future in that it does not - also return a future, and the actual ensure_future call - is delayed until before server start. - - `See user guide re: background tasks - `__ - - :param task: future, couroutine or awaitable - """ - try: - loop = self.loop # Will raise SanicError if loop is not started - self._loop_add_task(task, self, loop) - except SanicException: - task_name = f"sanic.delayed_task.{hash(task)}" - if not self._delayed_tasks: - self.after_server_start(partial(self.dispatch_delayed_tasks)) - - self.signal(task_name)(partial(self.run_delayed_task, task=task)) - self._delayed_tasks.append(task_name) - def register_listener( self, listener: ListenerType[SanicVar], event: str ) -> ListenerType[SanicVar]: @@ -404,12 +402,16 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): websocket_handler.is_websocket = True # type: ignore params["handler"] = websocket_handler + ctx = params.pop("route_context") + routes = self.router.add(**params) if isinstance(routes, Route): routes = [routes] + for r in routes: r.ctx.websocket = websocket r.ctx.static = params.get("static", False) + r.ctx.__dict__.update(ctx) return routes @@ -755,7 +757,7 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): exception, request.name if request else None ) if handler: - warn( + deprecation( "An error occurred while handling the request after at " "least some part of the response was sent to the client. " "Therefore, the response from your custom exception " @@ -770,7 +772,7 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): "For further information, please see the docs: " "https://sanicframework.org/en/guide/advanced/" "signals.html", - DeprecationWarning, + 22.6, ) try: response = self.error_handler.response(request, exception) @@ -823,6 +825,9 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): else: if request.stream: response = request.stream.response + + # Marked for cleanup and DRY with handle_request/handle_exception + # when ResponseStream is no longer supporder if isinstance(response, BaseHTTPResponse): await self.dispatch( "http.lifecycle.response", @@ -833,6 +838,17 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): }, ) await response.send(end_stream=True) + elif isinstance(response, ResponseStream): + resp = await response(request) + await self.dispatch( + "http.lifecycle.response", + inline=True, + context={ + "request": request, + "response": resp, + }, + ) + await response.eof() else: raise ServerError( f"Invalid response type {response!r} (need HTTPResponse)" @@ -936,7 +952,8 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): elif not hasattr(handler, "is_websocket"): response = request.stream.response # type: ignore - # Make sure that response is finished / run StreamingHTTP callback + # Marked for cleanup and DRY with handle_request/handle_exception + # when ResponseStream is no longer supporder if isinstance(response, BaseHTTPResponse): await self.dispatch( "http.lifecycle.response", @@ -947,6 +964,17 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): }, ) await response.send(end_stream=True) + elif isinstance(response, ResponseStream): + resp = await response(request) + await self.dispatch( + "http.lifecycle.response", + inline=True, + context={ + "request": request, + "response": resp, + }, + ) + await response.eof() else: if not hasattr(handler, "is_websocket"): raise ServerError( @@ -1162,6 +1190,11 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): version=version, ) + if self.config.USE_UVLOOP is True or ( + self.config.USE_UVLOOP is _default and not OS_IS_WINDOWS + ): + try_use_uvloop() + try: self.is_running = True self.is_stopping = False @@ -1189,6 +1222,7 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): This kills the Sanic """ if not self.is_stopping: + self.shutdown_tasks(timeout=0) self.is_stopping = True get_event_loop().stop() @@ -1258,12 +1292,13 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): WebSocketProtocol if self.websocket_enabled else HttpProtocol ) - # if access_log is passed explicitly change config.ACCESS_LOG - if access_log is not None: - self.config.ACCESS_LOG = access_log - - if noisy_exceptions is not None: - self.config.NOISY_EXCEPTIONS = noisy_exceptions + # Set explicitly passed configuration values + for attribute, value in { + "ACCESS_LOG": access_log, + "NOISY_EXCEPTIONS": noisy_exceptions, + }.items(): + if value is not None: + setattr(self.config, attribute, value) server_settings = self._helper( host=host, @@ -1278,6 +1313,14 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): run_async=return_asyncio_server, ) + if self.config.USE_UVLOOP is not _default: + error_logger.warning( + "You are trying to change the uvloop configuration, but " + "this is only effective when using the run(...) method. " + "When using the create_server(...) method Sanic will use " + "the already existing loop." + ) + main_start = server_settings.pop("main_start", None) main_stop = server_settings.pop("main_stop", None) if main_start or main_stop: @@ -1399,27 +1442,15 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): if isinstance(version, int): version = HTTP(version) + ssl = process_to_context(ssl) self.debug = debug self.state.host = host self.state.port = port self.state.workers = workers - - # Serve - serve_location = "" - proto = "http" - if ssl is not None: - proto = "https" - if unix: - serve_location = f"{unix} {proto}://..." - elif sock: - serve_location = f"{sock.getsockname()} {proto}://..." - elif host and port: - # colon(:) is legal for a host only in an ipv6 address - display_host = f"[{host}]" if ":" in host else host - serve_location = f"{proto}://{display_host}:{port}" - - ssl = process_to_context(ssl) + self.state.ssl = ssl + self.state.unix = unix + self.state.sock = sock server_settings = { "protocol": protocol, @@ -1436,7 +1467,7 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): "version": version, } - self.motd(serve_location) + self.motd(self.serve_location) if sys.stdout.isatty() and not self.state.is_debug: error_logger.warning( @@ -1462,12 +1493,55 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): return server_settings + @property + def serve_location(self) -> str: + serve_location = "" + proto = "http" + if self.state.ssl is not None: + proto = "https" + if self.state.unix: + serve_location = f"{self.state.unix} {proto}://..." + elif self.state.sock: + serve_location = f"{self.state.sock.getsockname()} {proto}://..." + elif self.state.host and self.state.port: + # colon(:) is legal for a host only in an ipv6 address + display_host = ( + f"[{self.state.host}]" + if ":" in self.state.host + else self.state.host + ) + serve_location = f"{proto}://{display_host}:{self.state.port}" + + return serve_location + def _build_endpoint_name(self, *parts): parts = [self.name, *parts] return ".".join(parts) @classmethod - def _prep_task(cls, task, app, loop): + def _cancel_websocket_tasks(cls, app, loop): + for task in app.websocket_tasks: + task.cancel() + + @staticmethod + async def _listener( + app: Sanic, loop: AbstractEventLoop, listener: ListenerType + ): + maybe_coro = listener(app, loop) + if maybe_coro and isawaitable(maybe_coro): + await maybe_coro + + # -------------------------------------------------------------------- # + # Task management + # -------------------------------------------------------------------- # + + @classmethod + def _prep_task( + cls, + task, + app, + loop, + ): if callable(task): try: task = task(app) @@ -1477,14 +1551,22 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): return task @classmethod - def _loop_add_task(cls, task, app, loop): + def _loop_add_task( + cls, + task, + app, + loop, + *, + name: Optional[str] = None, + register: bool = True, + ) -> Task: prepped = cls._prep_task(task, app, loop) - loop.create_task(prepped) + task = loop.create_task(prepped, name=name) - @classmethod - def _cancel_websocket_tasks(cls, app, loop): - for task in app.websocket_tasks: - task.cancel() + if name and register: + app._task_registry[name] = task + + return task @staticmethod async def dispatch_delayed_tasks(app, loop): @@ -1497,13 +1579,132 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): prepped = app._prep_task(task, app, loop) await prepped - @staticmethod - async def _listener( - app: Sanic, loop: AbstractEventLoop, listener: ListenerType + def add_task( + self, + task: Union[Future[Any], Coroutine[Any, Any, Any], Awaitable[Any]], + *, + name: Optional[str] = None, + register: bool = True, + ) -> Optional[Task]: + """ + Schedule a task to run later, after the loop has started. + Different from asyncio.ensure_future in that it does not + also return a future, and the actual ensure_future call + is delayed until before server start. + + `See user guide re: background tasks + `__ + + :param task: future, couroutine or awaitable + """ + if name and sys.version_info == (3, 7): + name = None + error_logger.warning( + "Cannot set a name for a task when using Python 3.7. Your " + "task will be created without a name." + ) + try: + loop = self.loop # Will raise SanicError if loop is not started + return self._loop_add_task( + task, self, loop, name=name, register=register + ) + except SanicException: + task_name = f"sanic.delayed_task.{hash(task)}" + if not self._delayed_tasks: + self.after_server_start(partial(self.dispatch_delayed_tasks)) + + if name: + raise RuntimeError( + "Cannot name task outside of a running application" + ) + + self.signal(task_name)(partial(self.run_delayed_task, task=task)) + self._delayed_tasks.append(task_name) + return None + + def get_task( + self, name: str, *, raise_exception: bool = True + ) -> Optional[Task]: + if sys.version_info == (3, 7): + raise RuntimeError( + "This feature is only supported on using Python 3.8+." + ) + try: + return self._task_registry[name] + except KeyError: + if raise_exception: + raise SanicException( + f'Registered task named "{name}" not found.' + ) + return None + + async def cancel_task( + self, + name: str, + msg: Optional[str] = None, + *, + raise_exception: bool = True, + ) -> None: + if sys.version_info == (3, 7): + raise RuntimeError( + "This feature is only supported on using Python 3.8+." + ) + task = self.get_task(name, raise_exception=raise_exception) + if task and not task.cancelled(): + args: Tuple[str, ...] = () + if msg: + if sys.version_info >= (3, 9): + args = (msg,) + else: + raise RuntimeError( + "Cancelling a task with a message is only supported " + "on Python 3.9+." + ) + task.cancel(*args) + try: + await task + except CancelledError: + ... + + def purge_tasks(self): + if sys.version_info == (3, 7): + raise RuntimeError( + "This feature is only supported on using Python 3.8+." + ) + for task in self.tasks: + if task.done() or task.cancelled(): + name = task.get_name() + self._task_registry[name] = None + + self._task_registry = { + k: v for k, v in self._task_registry.items() if v is not None + } + + def shutdown_tasks( + self, timeout: Optional[float] = None, increment: float = 0.1 ): - maybe_coro = listener(app, loop) - if maybe_coro and isawaitable(maybe_coro): - await maybe_coro + if sys.version_info == (3, 7): + raise RuntimeError( + "This feature is only supported on using Python 3.8+." + ) + for task in self.tasks: + task.cancel() + + if timeout is None: + timeout = self.config.GRACEFUL_SHUTDOWN_TIMEOUT + + while len(self._task_registry) and timeout: + self.loop.run_until_complete(asyncio.sleep(increment)) + self.purge_tasks() + timeout -= increment + + @property + def tasks(self): + if sys.version_info == (3, 7): + raise RuntimeError( + "This feature is only supported on using Python 3.8+." + ) + return iter(self._task_registry.values()) # -------------------------------------------------------------------- # # ASGI @@ -1621,11 +1822,8 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): display["auto-reload"] = reload_display packages = [] - for package_name, module_name in { - "sanic-routing": "sanic_routing", - "sanic-testing": "sanic_testing", - "sanic-ext": "sanic_ext", - }.items(): + for package_name in SANIC_PACKAGES: + module_name = package_name.replace("-", "_") try: module = import_module(module_name) packages.append(f"{package_name}=={module.__version__}") @@ -1645,6 +1843,41 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): ) MOTD.output(logo, serve_location, display, extra) + @property + def ext(self) -> Extend: + if not hasattr(self, "_ext"): + setup_ext(self, fail=True) + + if not hasattr(self, "_ext"): + raise RuntimeError( + "Sanic Extensions is not installed. You can add it to your " + "environment using:\n$ pip install sanic[ext]\nor\n$ pip " + "install sanic-ext" + ) + return self._ext # type: ignore + + def extend( + self, + *, + extensions: Optional[List[Type[Extension]]] = None, + built_in_extensions: bool = True, + config: Optional[Union[Config, Dict[str, Any]]] = None, + **kwargs, + ) -> Extend: + if hasattr(self, "_ext"): + raise RuntimeError( + "Cannot extend Sanic after Sanic Extensions has been setup." + ) + setup_ext( + self, + extensions=extensions, + built_in_extensions=built_in_extensions, + config=config, + fail=True, + **kwargs, + ) + return self.ext + # -------------------------------------------------------------------- # # Class methods # -------------------------------------------------------------------- # @@ -1706,13 +1939,35 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta): async def _startup(self): self._future_registry.clear() + + # Startup Sanic Extensions + if not hasattr(self, "_ext"): + setup_ext(self) + if hasattr(self, "_ext"): + self.ext._display() + + # Setup routers self.signalize() self.finalize() - ErrorHandler.finalize( - self.error_handler, fallback=self.config.FALLBACK_ERROR_FORMAT - ) + + # TODO: Replace in v22.6 to check against apps in app registry + if ( + self.__class__._uvloop_setting is not None + and self.__class__._uvloop_setting != self.config.USE_UVLOOP + ): + error_logger.warning( + "It looks like you're running several apps with different " + "uvloop settings. This is not supported and may lead to " + "unintended behaviour." + ) + self.__class__._uvloop_setting = self.config.USE_UVLOOP + + # Startup time optimizations + ErrorHandler.finalize(self.error_handler, config=self.config) TouchUp.run(self) + self.state.is_started = True + async def _server_event( self, concern: str, diff --git a/sanic/application/ext.py b/sanic/application/ext.py new file mode 100644 index 00000000..deb7c5d4 --- /dev/null +++ b/sanic/application/ext.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from contextlib import suppress +from importlib import import_module +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: # no cov + from sanic import Sanic + + try: + from sanic_ext import Extend # type: ignore + except ImportError: + ... + + +def setup_ext(app: Sanic, *, fail: bool = False, **kwargs): + if not app.config.AUTO_EXTEND: + return + + sanic_ext = None + with suppress(ModuleNotFoundError): + sanic_ext = import_module("sanic_ext") + + if not sanic_ext: + if fail: + raise RuntimeError( + "Sanic Extensions is not installed. You can add it to your " + "environment using:\n$ pip install sanic[ext]\nor\n$ pip " + "install sanic-ext" + ) + + return + + if not getattr(app, "_ext", None): + Ext: Extend = getattr(sanic_ext, "Extend") + app._ext = Ext(app, **kwargs) + + return app.ext diff --git a/sanic/application/motd.py b/sanic/application/motd.py index 32825b12..4de046a5 100644 --- a/sanic/application/motd.py +++ b/sanic/application/motd.py @@ -41,9 +41,6 @@ class MOTD(ABC): class MOTDBasic(MOTD): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - def display(self): if self.logo: logger.debug(self.logo) diff --git a/sanic/application/state.py b/sanic/application/state.py index eb180708..0345dad5 100644 --- a/sanic/application/state.py +++ b/sanic/application/state.py @@ -5,7 +5,9 @@ import logging from dataclasses import dataclass, field from enum import Enum, auto from pathlib import Path -from typing import TYPE_CHECKING, Any, Set, Union +from socket import socket +from ssl import SSLContext +from typing import TYPE_CHECKING, Any, Optional, Set, Union from sanic.log import logger @@ -37,11 +39,15 @@ class ApplicationState: coffee: bool = field(default=False) fast: bool = field(default=False) host: str = field(default="") - mode: Mode = field(default=Mode.PRODUCTION) port: int = field(default=0) + ssl: Optional[SSLContext] = field(default=None) + sock: Optional[socket] = field(default=None) + unix: Optional[str] = field(default=None) + mode: Mode = field(default=Mode.PRODUCTION) reload_dirs: Set[Path] = field(default_factory=set) server: Server = field(default=Server.SANIC) is_running: bool = field(default=False) + is_started: bool = field(default=False) is_stopping: bool = field(default=False) verbosity: int = field(default=0) workers: int = field(default=0) diff --git a/sanic/asgi.py b/sanic/asgi.py index 00b181dc..5ef15a91 100644 --- a/sanic/asgi.py +++ b/sanic/asgi.py @@ -7,6 +7,7 @@ import sanic.app # noqa from sanic.compat import Header from sanic.exceptions import ServerError +from sanic.helpers import _default from sanic.http import Stage from sanic.models.asgi import ASGIReceive, ASGIScope, ASGISend, MockTransport from sanic.request import Request @@ -53,6 +54,13 @@ class Lifespan: await self.asgi_app.sanic_app._server_event("init", "before") await self.asgi_app.sanic_app._server_event("init", "after") + if self.asgi_app.sanic_app.config.USE_UVLOOP is not _default: + warnings.warn( + "You have set the USE_UVLOOP configuration option, but Sanic " + "cannot control the event loop when running in ASGI mode." + "This option will be ignored." + ) + async def shutdown(self) -> None: """ Gather the listeners to fire on server stop. diff --git a/sanic/base/__init__.py b/sanic/base/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sanic/base/meta.py b/sanic/base/meta.py new file mode 100644 index 00000000..2c6870c2 --- /dev/null +++ b/sanic/base/meta.py @@ -0,0 +1,6 @@ +class SanicMeta(type): + @classmethod + def __prepare__(metaclass, name, bases, **kwds): + cls = super().__prepare__(metaclass, name, bases, **kwds) + cls["__slots__"] = () + return cls diff --git a/sanic/base.py b/sanic/base/root.py similarity index 57% rename from sanic/base.py rename to sanic/base/root.py index 1489f545..7fe76988 100644 --- a/sanic/base.py +++ b/sanic/base/root.py @@ -1,8 +1,8 @@ import re -from typing import Any, Tuple -from warnings import warn +from typing import Any +from sanic.base.meta import SanicMeta from sanic.exceptions import SanicException from sanic.mixins.exceptions import ExceptionMixin from sanic.mixins.listeners import ListenerMixin @@ -11,7 +11,7 @@ from sanic.mixins.routes import RouteMixin from sanic.mixins.signals import SignalMixin -VALID_NAME = re.compile(r"^[a-zA-Z][a-zA-Z0-9_\-]*$") +VALID_NAME = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_\-]*$") class BaseSanic( @@ -20,8 +20,9 @@ class BaseSanic( ListenerMixin, ExceptionMixin, SignalMixin, + metaclass=SanicMeta, ): - __fake_slots__: Tuple[str, ...] + __slots__ = ("name",) def __init__(self, name: str = None, *args: Any, **kwargs: Any) -> None: class_name = self.__class__.__name__ @@ -33,11 +34,10 @@ class BaseSanic( ) if not VALID_NAME.match(name): - warn( - f"{class_name} instance named '{name}' uses a format that is" - f"deprecated. Starting in version 21.12, {class_name} objects " - "must be named only using alphanumeric characters, _, or -.", - DeprecationWarning, + raise SanicException( + f"{class_name} instance named '{name}' uses an invalid " + "format. Names must begin with a character and may only " + "contain alphanumeric characters, _, or -." ) self.name = name @@ -52,15 +52,12 @@ class BaseSanic( return f'{self.__class__.__name__}(name="{self.name}")' def __setattr__(self, name: str, value: Any) -> None: - # This is a temporary compat layer so we can raise a warning until - # setting attributes on the app instance can be removed and deprecated - # with a proper implementation of __slots__ - if name not in self.__fake_slots__: - warn( + try: + super().__setattr__(name, value) + except AttributeError as e: + raise AttributeError( f"Setting variables on {self.__class__.__name__} instances is " - "deprecated and will be removed in version 21.12. You should " - f"change your {self.__class__.__name__} instance to use " + "not allowed. You should change your " + f"{self.__class__.__name__} instance to use " f"instance.ctx.{name} instead.", - DeprecationWarning, - ) - super().__setattr__(name, value) + ) from e diff --git a/sanic/blueprint_group.py b/sanic/blueprint_group.py index a9b51410..b16d8c58 100644 --- a/sanic/blueprint_group.py +++ b/sanic/blueprint_group.py @@ -5,7 +5,7 @@ from functools import partial from typing import TYPE_CHECKING, List, Optional, Union -if TYPE_CHECKING: +if TYPE_CHECKING: # no cov from sanic.blueprints import Blueprint diff --git a/sanic/blueprints.py b/sanic/blueprints.py index 6a6c2e82..df4501dd 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -24,7 +24,7 @@ from typing import ( from sanic_routing.exceptions import NotFound # type: ignore from sanic_routing.route import Route # type: ignore -from sanic.base import BaseSanic +from sanic.base.root import BaseSanic from sanic.blueprint_group import BlueprintGroup from sanic.exceptions import SanicException from sanic.helpers import Default, _default @@ -36,8 +36,8 @@ from sanic.models.handler_types import ( ) -if TYPE_CHECKING: - from sanic import Sanic # noqa +if TYPE_CHECKING: # no cov + from sanic import Sanic def lazy(func, as_decorator=True): @@ -85,7 +85,7 @@ class Blueprint(BaseSanic): trailing */* """ - __fake_slots__ = ( + __slots__ = ( "_apps", "_future_routes", "_future_statics", @@ -98,7 +98,6 @@ class Blueprint(BaseSanic): "host", "listeners", "middlewares", - "name", "routes", "statics", "strict_slashes", @@ -348,6 +347,7 @@ class Blueprint(BaseSanic): future.static, version_prefix, error_format, + future.route_context, ) if (self, apply_route) in app._future_registry: @@ -400,8 +400,9 @@ class Blueprint(BaseSanic): for future in self._future_signals: if (self, future) in app._future_registry: continue - future.condition.update({"blueprint": self.name}) - app._apply_signal(future) + future.condition.update({"__blueprint__": self.name}) + # Force exclusive to be False + app._apply_signal(tuple((*future[:-1], False))) self.routes += [route for route in routes if isinstance(route, Route)] self.websocket_routes += [ @@ -426,7 +427,7 @@ class Blueprint(BaseSanic): async def dispatch(self, *args, **kwargs): condition = kwargs.pop("condition", {}) - condition.update({"blueprint": self.name}) + condition.update({"__blueprint__": self.name}) kwargs["condition"] = condition await asyncio.gather( *[app.dispatch(*args, **kwargs) for app in self.apps] diff --git a/sanic/compat.py b/sanic/compat.py index 87278267..e28e64a1 100644 --- a/sanic/compat.py +++ b/sanic/compat.py @@ -8,6 +8,14 @@ from multidict import CIMultiDict # type: ignore OS_IS_WINDOWS = os.name == "nt" +UVLOOP_INSTALLED = False + +try: + import uvloop # type: ignore # noqa + + UVLOOP_INSTALLED = True +except ImportError: + pass def enable_windows_color_support(): diff --git a/sanic/config.py b/sanic/config.py index 261f608a..30c8627f 100644 --- a/sanic/config.py +++ b/sanic/config.py @@ -1,28 +1,26 @@ from __future__ import annotations -from inspect import isclass +from inspect import getmembers, isclass, isdatadescriptor from os import environ from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, Optional, Union -from warnings import warn +from typing import Any, Callable, Dict, Optional, Sequence, Union -from sanic.errorpages import check_error_format +from sanic.errorpages import DEFAULT_FORMAT, check_error_format +from sanic.helpers import Default, _default from sanic.http import Http +from sanic.log import deprecation, error_logger from sanic.utils import load_module_from_file_location, str_to_bool -if TYPE_CHECKING: # no cov - from sanic import Sanic - - SANIC_PREFIX = "SANIC_" DEFAULT_CONFIG = { + "_FALLBACK_ERROR_FORMAT": _default, "ACCESS_LOG": True, + "AUTO_EXTEND": True, "AUTO_RELOAD": False, "EVENT_AUTOREGISTER": False, - "FALLBACK_ERROR_FORMAT": "auto", "FORWARDED_FOR_HEADER": "X-Forwarded-For", "FORWARDED_SECRET": None, "GRACEFUL_SHUTDOWN_TIMEOUT": 15.0, # 15 sec @@ -40,17 +38,31 @@ DEFAULT_CONFIG = { "REQUEST_MAX_SIZE": 100000000, # 100 megabytes "REQUEST_TIMEOUT": 60, # 60 seconds "RESPONSE_TIMEOUT": 60, # 60 seconds + "USE_UVLOOP": _default, "WEBSOCKET_MAX_SIZE": 2 ** 20, # 1 megabyte "WEBSOCKET_PING_INTERVAL": 20, "WEBSOCKET_PING_TIMEOUT": 20, } +# These values will be removed from the Config object in v22.6 and moved +# to the application state +DEPRECATED_CONFIG = ("SERVER_RUNNING", "RELOADER_PROCESS", "RELOADED_FILES") -class Config(dict): + +class DescriptorMeta(type): + def __init__(cls, *_): + cls.__setters__ = {name for name, _ in getmembers(cls, cls._is_setter)} + + @staticmethod + def _is_setter(member: object): + return isdatadescriptor(member) and hasattr(member, "setter") + + +class Config(dict, metaclass=DescriptorMeta): ACCESS_LOG: bool + AUTO_EXTEND: bool AUTO_RELOAD: bool EVENT_AUTOREGISTER: bool - FALLBACK_ERROR_FORMAT: str FORWARDED_FOR_HEADER: str FORWARDED_SECRET: Optional[str] GRACEFUL_SHUTDOWN_TIMEOUT: float @@ -69,6 +81,7 @@ class Config(dict): REQUEST_TIMEOUT: int RESPONSE_TIMEOUT: int SERVER_NAME: str + USE_UVLOOP: Union[Default, bool] WEBSOCKET_MAX_SIZE: int WEBSOCKET_PING_INTERVAL: int WEBSOCKET_PING_TIMEOUT: int @@ -76,33 +89,27 @@ class Config(dict): def __init__( self, defaults: Dict[str, Union[str, bool, int, float, None]] = None, - load_env: Optional[Union[bool, str]] = True, env_prefix: Optional[str] = SANIC_PREFIX, keep_alive: Optional[bool] = None, *, - app: Optional[Sanic] = None, + converters: Optional[Sequence[Callable[[str], Any]]] = None, ): defaults = defaults or {} super().__init__({**DEFAULT_CONFIG, **defaults}) - self._app = app + self._converters = [str, str_to_bool, float, int] self._LOGO = "" + if converters: + for converter in converters: + self.register_type(converter) + if keep_alive is not None: self.KEEP_ALIVE = keep_alive if env_prefix != SANIC_PREFIX: if env_prefix: self.load_environment_vars(env_prefix) - elif load_env is not True: - if load_env: - self.load_environment_vars(prefix=load_env) - warn( - "Use of load_env is deprecated and will be removed in " - "21.12. Modify the configuration prefix by passing " - "env_prefix instead.", - DeprecationWarning, - ) else: self.load_environment_vars(SANIC_PREFIX) @@ -117,6 +124,13 @@ class Config(dict): raise AttributeError(f"Config has no '{ke.args[0]}'") def __setattr__(self, attr, value) -> None: + if attr in self.__class__.__setters__: + try: + super().__setattr__(attr, value) + except AttributeError: + ... + else: + return None self.update({attr: value}) def __setitem__(self, attr, value) -> None: @@ -136,32 +150,37 @@ class Config(dict): "REQUEST_MAX_SIZE", ): self._configure_header_size() - elif attr == "FALLBACK_ERROR_FORMAT": - self._check_error_format() - if self.app and value != self.app.error_handler.fallback: - if self.app.error_handler.fallback != "auto": - warn( - "Overriding non-default ErrorHandler fallback " - "value. Changing from " - f"{self.app.error_handler.fallback} to {value}." - ) - self.app.error_handler.fallback = value elif attr == "LOGO": self._LOGO = value - warn( + deprecation( "Setting the config.LOGO is deprecated and will no longer " "be supported starting in v22.6.", - DeprecationWarning, + 22.6, ) - @property - def app(self): - return self._app - @property def LOGO(self): return self._LOGO + @property + def FALLBACK_ERROR_FORMAT(self) -> str: + if self._FALLBACK_ERROR_FORMAT is _default: + return DEFAULT_FORMAT + return self._FALLBACK_ERROR_FORMAT + + @FALLBACK_ERROR_FORMAT.setter + def FALLBACK_ERROR_FORMAT(self, value): + self._check_error_format(value) + if ( + self._FALLBACK_ERROR_FORMAT is not _default + and value != self._FALLBACK_ERROR_FORMAT + ): + error_logger.warning( + "Setting config.FALLBACK_ERROR_FORMAT on an already " + "configured value may have unintended consequences." + ) + self._FALLBACK_ERROR_FORMAT = value + def _configure_header_size(self): Http.set_header_max_size( self.REQUEST_MAX_HEADER_SIZE, @@ -169,8 +188,8 @@ class Config(dict): self.REQUEST_MAX_SIZE, ) - def _check_error_format(self): - check_error_format(self.FALLBACK_ERROR_FORMAT) + def _check_error_format(self, format: Optional[str] = None): + check_error_format(format or self.FALLBACK_ERROR_FORMAT) def load_environment_vars(self, prefix=SANIC_PREFIX): """ @@ -184,20 +203,45 @@ class Config(dict): - ``float`` - ``bool`` - Anything else will be imported as a ``str``. + Anything else will be imported as a ``str``. If you would like to add + additional types to this list, you can use + :meth:`sanic.config.Config.register_type`. Just make sure that they + are registered before you instantiate your application. + + .. code-block:: python + + class Foo: + def __init__(self, name) -> None: + self.name = name + + + config = Config(converters=[Foo]) + app = Sanic(__name__, config=config) + + `See user guide re: config + `__ """ + lower_case_var_found = False for key, value in environ.items(): if not key.startswith(prefix): continue + if not key.isupper(): + lower_case_var_found = True _, config_key = key.split(prefix, 1) - for converter in (int, float, str_to_bool, str): + for converter in reversed(self._converters): try: self[config_key] = converter(value) break except ValueError: pass + if lower_case_var_found: + deprecation( + "Lowercase environment variables will not be " + "loaded into Sanic config beginning in v22.9.", + 22.9, + ) def update_config(self, config: Union[bytes, str, dict, Any]): """ @@ -267,3 +311,17 @@ class Config(dict): self.update(config) load = update_config + + def register_type(self, converter: Callable[[str], Any]) -> None: + """ + Allows for adding custom function to cast from a string value to any + other type. The function should raise ValueError if it is not the + correct type. + """ + if converter in self._converters: + error_logger.warning( + f"Configuration value converter '{converter.__name__}' has " + "already been registered" + ) + return + self._converters.append(converter) diff --git a/sanic/errorpages.py b/sanic/errorpages.py index 66ff6c95..06495b0a 100644 --- a/sanic/errorpages.py +++ b/sanic/errorpages.py @@ -34,6 +34,7 @@ except ImportError: # noqa from json import dumps +DEFAULT_FORMAT = "auto" FALLBACK_TEXT = ( "The server encountered an internal error and " "cannot complete your request." diff --git a/sanic/exceptions.py b/sanic/exceptions.py index 6459f15a..d3bd2613 100644 --- a/sanic/exceptions.py +++ b/sanic/exceptions.py @@ -244,25 +244,3 @@ class InvalidSignal(SanicException): class WebsocketClosed(SanicException): quiet = True message = "Client has closed the websocket connection" - - -def abort(status_code: int, message: Optional[Union[str, bytes]] = None): - """ - Raise an exception based on SanicException. Returns the HTTP response - message appropriate for the given status code, unless provided. - - STATUS_CODES from sanic.helpers for the given status code. - - :param status_code: The HTTP status code to return. - :param message: The HTTP response body. Defaults to the messages in - """ - import warnings - - warnings.warn( - "sanic.exceptions.abort has been marked as deprecated, and will be " - "removed in release 21.12.\n To migrate your code, simply replace " - "abort(status_code, msg) with raise SanicException(msg, status_code), " - "or even better, raise an appropriate SanicException subclass." - ) - - raise SanicException(message=message, status_code=status_code) diff --git a/sanic/handlers.py b/sanic/handlers.py index 8c543c6d..44ff77a7 100644 --- a/sanic/handlers.py +++ b/sanic/handlers.py @@ -1,14 +1,23 @@ -from inspect import signature -from typing import Dict, List, Optional, Tuple, Type -from warnings import warn +from __future__ import annotations -from sanic.errorpages import BaseRenderer, HTMLRenderer, exception_response +from inspect import signature +from typing import Dict, List, Optional, Tuple, Type, Union + +from sanic.config import Config +from sanic.errorpages import ( + DEFAULT_FORMAT, + BaseRenderer, + HTMLRenderer, + exception_response, +) from sanic.exceptions import ( ContentRangeError, HeaderNotFound, InvalidRangeType, + SanicException, ) -from sanic.log import error_logger +from sanic.helpers import Default, _default +from sanic.log import deprecation, error_logger from sanic.models.handler_types import RouteHandler from sanic.response import text @@ -28,24 +37,91 @@ class ErrorHandler: # Beginning in v22.3, the base renderer will be TextRenderer def __init__( - self, fallback: str = "auto", base: Type[BaseRenderer] = HTMLRenderer + self, + fallback: Union[str, Default] = _default, + base: Type[BaseRenderer] = HTMLRenderer, ): self.handlers: List[Tuple[Type[BaseException], RouteHandler]] = [] self.cached_handlers: Dict[ Tuple[Type[BaseException], Optional[str]], Optional[RouteHandler] ] = {} self.debug = False - self.fallback = fallback + self._fallback = fallback self.base = base + if fallback is not _default: + self._warn_fallback_deprecation() + + @property + def fallback(self): + # This is for backwards compat and can be removed in v22.6 + if self._fallback is _default: + return DEFAULT_FORMAT + return self._fallback + + @fallback.setter + def fallback(self, value: str): + self._warn_fallback_deprecation() + if not isinstance(value, str): + raise SanicException( + f"Cannot set error handler fallback to: value={value}" + ) + self._fallback = value + + @staticmethod + def _warn_fallback_deprecation(): + deprecation( + "Setting the ErrorHandler fallback value directly is " + "deprecated and no longer supported. This feature will " + "be removed in v22.6. Instead, use " + "app.config.FALLBACK_ERROR_FORMAT.", + 22.6, + ) + @classmethod - def finalize(cls, error_handler, fallback: Optional[str] = None): - if ( - fallback - and fallback != "auto" - and error_handler.fallback == "auto" - ): - error_handler.fallback = fallback + def _get_fallback_value(cls, error_handler: ErrorHandler, config: Config): + if error_handler._fallback is not _default: + if config._FALLBACK_ERROR_FORMAT is _default: + return error_handler.fallback + + error_logger.warning( + "Conflicting error fallback values were found in the " + "error handler and in the app.config while handling an " + "exception. Using the value from app.config." + ) + return config.FALLBACK_ERROR_FORMAT + + @classmethod + def finalize( + cls, + error_handler: ErrorHandler, + fallback: Optional[str] = None, + config: Optional[Config] = None, + ): + if fallback: + deprecation( + "Setting the ErrorHandler fallback value via finalize() " + "is deprecated and no longer supported. This feature will " + "be removed in v22.6. Instead, use " + "app.config.FALLBACK_ERROR_FORMAT.", + 22.6, + ) + + if config is None: + deprecation( + "Starting in v22.3, config will be a required argument " + "for ErrorHandler.finalize().", + 22.3, + ) + + if fallback and fallback != DEFAULT_FORMAT: + if error_handler._fallback is not _default: + error_logger.warning( + f"Setting the fallback value to {fallback}. This changes " + "the current non-default value " + f"'{error_handler._fallback}'." + ) + error_handler._fallback = fallback if not isinstance(error_handler, cls): error_logger.warning( @@ -54,7 +130,7 @@ class ErrorHandler: sig = signature(error_handler.lookup) if len(sig.parameters) == 1: - warn( + deprecation( "You are using a deprecated error handler. The lookup " "method should accept two positional parameters: " "(exception, route_name: Optional[str]). " @@ -62,9 +138,10 @@ class ErrorHandler: "specific exceptions will not work properly. Beginning " "in v22.3, the legacy style lookup method will not " "work at all.", - DeprecationWarning, + 22.3, ) - error_handler._lookup = error_handler._legacy_lookup + legacy_lookup = error_handler._legacy_lookup + error_handler._lookup = legacy_lookup # type: ignore def _full_lookup(self, exception, route_name: Optional[str] = None): return self.lookup(exception, route_name) @@ -188,12 +265,13 @@ class ErrorHandler: :return: """ self.log(request, exception) + fallback = ErrorHandler._get_fallback_value(self, request.app.config) return exception_response( request, exception, debug=self.debug, base=self.base, - fallback=self.fallback, + fallback=fallback, ) @staticmethod diff --git a/sanic/http/http1.py b/sanic/http/http1.py index c00929be..96232fb5 100644 --- a/sanic/http/http1.py +++ b/sanic/http/http1.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Optional -if TYPE_CHECKING: +if TYPE_CHECKING: # no cov from sanic.request import Request from sanic.response import BaseHTTPResponse diff --git a/sanic/log.py b/sanic/log.py index 99c8b732..000b3ed8 100644 --- a/sanic/log.py +++ b/sanic/log.py @@ -3,6 +3,7 @@ import sys from enum import Enum from typing import Any, Dict +from warnings import warn LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict( @@ -78,3 +79,11 @@ access_logger = logging.getLogger("sanic.access") """ Logger used by Sanic for access logging """ + + +def deprecation(message: str, version: float): + version_info = f"[DEPRECATION v{version}] " + if sys.stdout.isatty(): + version_info = f"{Colors.RED}{version_info}" + message = f"{Colors.YELLOW}{message}{Colors.END}" + warn(version_info + message, DeprecationWarning) diff --git a/sanic/mixins/exceptions.py b/sanic/mixins/exceptions.py index aed1ae5f..fc5b82ef 100644 --- a/sanic/mixins/exceptions.py +++ b/sanic/mixins/exceptions.py @@ -1,9 +1,10 @@ from typing import Set +from sanic.base.meta import SanicMeta from sanic.models.futures import FutureException -class ExceptionMixin: +class ExceptionMixin(metaclass=SanicMeta): def __init__(self, *args, **kwargs) -> None: self._future_exceptions: Set[FutureException] = set() diff --git a/sanic/mixins/listeners.py b/sanic/mixins/listeners.py index 39c969b8..cc8375f0 100644 --- a/sanic/mixins/listeners.py +++ b/sanic/mixins/listeners.py @@ -2,6 +2,7 @@ from enum import Enum, auto from functools import partial from typing import List, Optional, Union +from sanic.base.meta import SanicMeta from sanic.models.futures import FutureListener from sanic.models.handler_types import ListenerType, Sanic @@ -18,7 +19,7 @@ class ListenerEvent(str, Enum): MAIN_PROCESS_STOP = auto() -class ListenerMixin: +class ListenerMixin(metaclass=SanicMeta): def __init__(self, *args, **kwargs) -> None: self._future_listeners: List[FutureListener] = [] diff --git a/sanic/mixins/middleware.py b/sanic/mixins/middleware.py index bd006062..1049aacf 100644 --- a/sanic/mixins/middleware.py +++ b/sanic/mixins/middleware.py @@ -1,10 +1,11 @@ from functools import partial from typing import List +from sanic.base.meta import SanicMeta from sanic.models.futures import FutureMiddleware -class MiddlewareMixin: +class MiddlewareMixin(metaclass=SanicMeta): def __init__(self, *args, **kwargs) -> None: self._future_middleware: List[FutureMiddleware] = [] diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py index 01911e66..9e2cf96f 100644 --- a/sanic/mixins/routes.py +++ b/sanic/mixins/routes.py @@ -1,4 +1,5 @@ from ast import NodeVisitor, Return, parse +from contextlib import suppress from functools import partial, wraps from inspect import getsource, signature from mimetypes import guess_type @@ -12,6 +13,7 @@ from urllib.parse import unquote from sanic_routing.route import Route # type: ignore +from sanic.base.meta import SanicMeta from sanic.compat import stat_async from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE, HTTP_METHODS from sanic.errorpages import RESPONSE_MAPPING @@ -22,19 +24,27 @@ from sanic.exceptions import ( InvalidUsage, ) from sanic.handlers import ContentRangeHandler -from sanic.log import error_logger +from sanic.log import deprecation, error_logger from sanic.models.futures import FutureRoute, FutureStatic from sanic.models.handler_types import RouteHandler from sanic.response import HTTPResponse, file, file_stream -from sanic.views import CompositionView +from sanic.types import HashableDict RouteWrapper = Callable[ [RouteHandler], Union[RouteHandler, Tuple[Route, RouteHandler]] ] +RESTRICTED_ROUTE_CONTEXT = ( + "ignore_body", + "stream", + "hosts", + "static", + "error_format", + "websocket", +) -class RouteMixin: +class RouteMixin(metaclass=SanicMeta): name: str def __init__(self, *args, **kwargs) -> None: @@ -65,10 +75,20 @@ class RouteMixin: static: bool = False, version_prefix: str = "/v", error_format: Optional[str] = None, + **ctx_kwargs: Any, ) -> RouteWrapper: """ Decorate a function to be registered as a route + + **Example using context kwargs** + + .. code-block:: python + + @app.route(..., ctx_foo="foobar") + async def route_handler(request: Request): + assert request.route.ctx.foo == "foobar" + :param uri: path of the URL :param methods: list or tuple of methods allowed :param host: the host, if required @@ -80,6 +100,8 @@ class RouteMixin: body (eg. GET requests) :param version_prefix: URL path that should be before the version value; default: ``/v`` + :param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix + will be appended to the route context (``route.ctx``) :return: tuple of routes, decorated function """ @@ -94,6 +116,8 @@ class RouteMixin: if not methods and not websocket: methods = frozenset({"GET"}) + route_context = self._build_route_context(ctx_kwargs) + def decorator(handler): nonlocal uri nonlocal methods @@ -152,6 +176,7 @@ class RouteMixin: static, version_prefix, error_format, + route_context, ) self._future_routes.add(route) @@ -196,6 +221,7 @@ class RouteMixin: stream: bool = False, version_prefix: str = "/v", error_format: Optional[str] = None, + **ctx_kwargs, ) -> RouteHandler: """A helper method to register class instance or functions as a handler to the application url @@ -212,6 +238,8 @@ class RouteMixin: :param stream: boolean specifying if the handler is a stream handler :param version_prefix: URL path that should be before the version value; default: ``/v`` + :param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix + will be appended to the route context (``route.ctx``) :return: function or class instance """ # Handle HTTPMethodView differently @@ -226,14 +254,6 @@ class RouteMixin: if hasattr(_handler, "is_stream"): stream = True - # handle composition view differently - if isinstance(handler, CompositionView): - methods = handler.handlers.keys() - for _handler in handler.handlers.values(): - if hasattr(_handler, "is_stream"): - stream = True - break - if strict_slashes is None: strict_slashes = self.strict_slashes @@ -247,6 +267,7 @@ class RouteMixin: name=name, version_prefix=version_prefix, error_format=error_format, + **ctx_kwargs, )(handler) return handler @@ -261,6 +282,7 @@ class RouteMixin: ignore_body: bool = True, version_prefix: str = "/v", error_format: Optional[str] = None, + **ctx_kwargs, ) -> RouteWrapper: """ Add an API URL under the **GET** *HTTP* method @@ -273,6 +295,8 @@ class RouteMixin: :param name: Unique name that can be used to identify the Route :param version_prefix: URL path that should be before the version value; default: ``/v`` + :param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix + will be appended to the route context (``route.ctx``) :return: Object decorated with :func:`route` method """ return self.route( @@ -285,6 +309,7 @@ class RouteMixin: ignore_body=ignore_body, version_prefix=version_prefix, error_format=error_format, + **ctx_kwargs, ) def post( @@ -297,6 +322,7 @@ class RouteMixin: name: Optional[str] = None, version_prefix: str = "/v", error_format: Optional[str] = None, + **ctx_kwargs, ) -> RouteWrapper: """ Add an API URL under the **POST** *HTTP* method @@ -309,6 +335,8 @@ class RouteMixin: :param name: Unique name that can be used to identify the Route :param version_prefix: URL path that should be before the version value; default: ``/v`` + :param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix + will be appended to the route context (``route.ctx``) :return: Object decorated with :func:`route` method """ return self.route( @@ -321,6 +349,7 @@ class RouteMixin: name=name, version_prefix=version_prefix, error_format=error_format, + **ctx_kwargs, ) def put( @@ -333,6 +362,7 @@ class RouteMixin: name: Optional[str] = None, version_prefix: str = "/v", error_format: Optional[str] = None, + **ctx_kwargs, ) -> RouteWrapper: """ Add an API URL under the **PUT** *HTTP* method @@ -345,6 +375,8 @@ class RouteMixin: :param name: Unique name that can be used to identify the Route :param version_prefix: URL path that should be before the version value; default: ``/v`` + :param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix + will be appended to the route context (``route.ctx``) :return: Object decorated with :func:`route` method """ return self.route( @@ -357,6 +389,7 @@ class RouteMixin: name=name, version_prefix=version_prefix, error_format=error_format, + **ctx_kwargs, ) def head( @@ -369,6 +402,7 @@ class RouteMixin: ignore_body: bool = True, version_prefix: str = "/v", error_format: Optional[str] = None, + **ctx_kwargs, ) -> RouteWrapper: """ Add an API URL under the **HEAD** *HTTP* method @@ -389,6 +423,8 @@ class RouteMixin: :type ignore_body: bool, optional :param version_prefix: URL path that should be before the version value; default: ``/v`` + :param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix + will be appended to the route context (``route.ctx``) :return: Object decorated with :func:`route` method """ return self.route( @@ -401,6 +437,7 @@ class RouteMixin: ignore_body=ignore_body, version_prefix=version_prefix, error_format=error_format, + **ctx_kwargs, ) def options( @@ -413,6 +450,7 @@ class RouteMixin: ignore_body: bool = True, version_prefix: str = "/v", error_format: Optional[str] = None, + **ctx_kwargs, ) -> RouteWrapper: """ Add an API URL under the **OPTIONS** *HTTP* method @@ -433,6 +471,8 @@ class RouteMixin: :type ignore_body: bool, optional :param version_prefix: URL path that should be before the version value; default: ``/v`` + :param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix + will be appended to the route context (``route.ctx``) :return: Object decorated with :func:`route` method """ return self.route( @@ -445,6 +485,7 @@ class RouteMixin: ignore_body=ignore_body, version_prefix=version_prefix, error_format=error_format, + **ctx_kwargs, ) def patch( @@ -457,6 +498,7 @@ class RouteMixin: name: Optional[str] = None, version_prefix: str = "/v", error_format: Optional[str] = None, + **ctx_kwargs, ) -> RouteWrapper: """ Add an API URL under the **PATCH** *HTTP* method @@ -479,6 +521,8 @@ class RouteMixin: :type ignore_body: bool, optional :param version_prefix: URL path that should be before the version value; default: ``/v`` + :param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix + will be appended to the route context (``route.ctx``) :return: Object decorated with :func:`route` method """ return self.route( @@ -491,6 +535,7 @@ class RouteMixin: name=name, version_prefix=version_prefix, error_format=error_format, + **ctx_kwargs, ) def delete( @@ -503,6 +548,7 @@ class RouteMixin: ignore_body: bool = True, version_prefix: str = "/v", error_format: Optional[str] = None, + **ctx_kwargs, ) -> RouteWrapper: """ Add an API URL under the **DELETE** *HTTP* method @@ -515,6 +561,8 @@ class RouteMixin: :param name: Unique name that can be used to identify the Route :param version_prefix: URL path that should be before the version value; default: ``/v`` + :param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix + will be appended to the route context (``route.ctx``) :return: Object decorated with :func:`route` method """ return self.route( @@ -527,6 +575,7 @@ class RouteMixin: ignore_body=ignore_body, version_prefix=version_prefix, error_format=error_format, + **ctx_kwargs, ) def websocket( @@ -540,6 +589,7 @@ class RouteMixin: apply: bool = True, version_prefix: str = "/v", error_format: Optional[str] = None, + **ctx_kwargs, ): """ Decorate a function to be registered as a websocket route @@ -553,6 +603,8 @@ class RouteMixin: be used with :func:`url_for` :param version_prefix: URL path that should be before the version value; default: ``/v`` + :param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix + will be appended to the route context (``route.ctx``) :return: tuple of routes, decorated function """ return self.route( @@ -567,6 +619,7 @@ class RouteMixin: websocket=True, version_prefix=version_prefix, error_format=error_format, + **ctx_kwargs, ) def add_websocket_route( @@ -580,6 +633,7 @@ class RouteMixin: name: Optional[str] = None, version_prefix: str = "/v", error_format: Optional[str] = None, + **ctx_kwargs, ): """ A helper method to register a function as a websocket route. @@ -598,6 +652,8 @@ class RouteMixin: be used with :func:`url_for` :param version_prefix: URL path that should be before the version value; default: ``/v`` + :param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix + will be appended to the route context (``route.ctx``) :return: Objected decorated by :func:`websocket` """ return self.websocket( @@ -609,6 +665,7 @@ class RouteMixin: name=name, version_prefix=version_prefix, error_format=error_format, + **ctx_kwargs, )(handler) def static( @@ -918,19 +975,16 @@ class RouteMixin: return route - def _determine_error_format(self, handler) -> Optional[str]: - if not isinstance(handler, CompositionView): - try: - src = dedent(getsource(handler)) - tree = parse(src) - http_response_types = self._get_response_types(tree) + def _determine_error_format(self, handler) -> str: + with suppress(OSError, TypeError): + src = dedent(getsource(handler)) + tree = parse(src) + http_response_types = self._get_response_types(tree) - if len(http_response_types) == 1: - return next(iter(http_response_types)) - except (OSError, TypeError): - ... + if len(http_response_types) == 1: + return next(iter(http_response_types)) - return None + return "" def _get_response_types(self, node): types = set() @@ -939,7 +993,18 @@ class RouteMixin: def visit_Return(self, node: Return) -> Any: nonlocal types - try: + with suppress(AttributeError): + if node.value.func.id == "stream": # type: ignore + deprecation( + "The sanic.response.stream method has been " + "deprecated and will be removed in v22.6. Please " + "upgrade your application to use the new style " + "streaming pattern. See " + "https://sanicframework.org/en/guide/advanced/" + "streaming.html#response-streaming for more " + "information.", + 22.6, + ) checks = [node.value.func.id] # type: ignore if node.value.keywords: # type: ignore checks += [ @@ -951,9 +1016,32 @@ class RouteMixin: for check in checks: if check in RESPONSE_MAPPING: types.add(RESPONSE_MAPPING[check]) - except AttributeError: - ... HttpResponseVisitor().visit(node) return types + + def _build_route_context(self, raw): + ctx_kwargs = { + key.replace("ctx_", ""): raw.pop(key) + for key in {**raw}.keys() + if key.startswith("ctx_") + } + restricted = [ + key for key in ctx_kwargs.keys() if key in RESTRICTED_ROUTE_CONTEXT + ] + if restricted: + restricted_arguments = ", ".join(restricted) + raise AttributeError( + "Cannot use restricted route context: " + f"{restricted_arguments}. This limitation is only in place " + "until v22.3 when the restricted names will no longer be in" + "conflict. See https://github.com/sanic-org/sanic/issues/2303 " + "for more information." + ) + if raw: + unexpected_arguments = ", ".join(raw.keys()) + raise TypeError( + f"Unexpected keyword arguments: {unexpected_arguments}" + ) + return HashableDict(ctx_kwargs) diff --git a/sanic/mixins/signals.py b/sanic/mixins/signals.py index 57b01b46..601c4b18 100644 --- a/sanic/mixins/signals.py +++ b/sanic/mixins/signals.py @@ -1,17 +1,14 @@ from enum import Enum from typing import Any, Callable, Dict, Optional, Set, Union +from sanic.base.meta import SanicMeta from sanic.models.futures import FutureSignal from sanic.models.handler_types import SignalHandler from sanic.signals import Signal +from sanic.types import HashableDict -class HashableDict(dict): - def __hash__(self): - return hash(tuple(sorted(self.items()))) - - -class SignalMixin: +class SignalMixin(metaclass=SanicMeta): def __init__(self, *args, **kwargs) -> None: self._future_signals: Set[FutureSignal] = set() @@ -24,6 +21,7 @@ class SignalMixin: *, apply: bool = True, condition: Dict[str, Any] = None, + exclusive: bool = True, ) -> Callable[[SignalHandler], SignalHandler]: """ For creating a signal handler, used similar to a route handler: @@ -36,17 +34,22 @@ class SignalMixin: :param event: Representation of the event in ``one.two.three`` form :type event: str - :param apply: For lazy evaluation, defaults to True + :param apply: For lazy evaluation, defaults to ``True`` :type apply: bool, optional :param condition: For use with the ``condition`` argument in dispatch - filtering, defaults to None + filtering, defaults to ``None`` + :param exclusive: When ``True``, the signal can only be dispatched + when the condition has been met. When ``False``, the signal can + be dispatched either with or without it. *THIS IS INAPPLICABLE TO + BLUEPRINT SIGNALS. THEY ARE ALWAYS NON-EXCLUSIVE*, defaults + to ``True`` :type condition: Dict[str, Any], optional """ event_value = str(event.value) if isinstance(event, Enum) else event def decorator(handler: SignalHandler): future_signal = FutureSignal( - handler, event_value, HashableDict(condition or {}) + handler, event_value, HashableDict(condition or {}), exclusive ) self._future_signals.add(future_signal) @@ -62,6 +65,7 @@ class SignalMixin: handler: Optional[Callable[..., Any]], event: str, condition: Dict[str, Any] = None, + exclusive: bool = True, ): if not handler: @@ -69,7 +73,9 @@ class SignalMixin: ... handler = noop - self.signal(event=event, condition=condition)(handler) + self.signal(event=event, condition=condition, exclusive=exclusive)( + handler + ) return handler def event(self, event: str): diff --git a/sanic/models/futures.py b/sanic/models/futures.py index 21f9c674..e97a54b0 100644 --- a/sanic/models/futures.py +++ b/sanic/models/futures.py @@ -7,6 +7,7 @@ from sanic.models.handler_types import ( MiddlewareType, SignalHandler, ) +from sanic.types import HashableDict class FutureRoute(NamedTuple): @@ -25,6 +26,7 @@ class FutureRoute(NamedTuple): static: bool version_prefix: str error_format: Optional[str] + route_context: HashableDict class FutureListener(NamedTuple): @@ -60,6 +62,7 @@ class FutureSignal(NamedTuple): handler: SignalHandler event: str condition: Optional[Dict[str, str]] + exclusive: bool class FutureRegistry(set): diff --git a/sanic/request.py b/sanic/request.py index ddec6e82..97ab9982 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -15,7 +15,7 @@ from typing import ( from sanic_routing.route import Route # type: ignore -if TYPE_CHECKING: +if TYPE_CHECKING: # no cov from sanic.server import ConnInfo from sanic.app import Sanic diff --git a/sanic/response.py b/sanic/response.py index 357668e6..8525d381 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from functools import partial from mimetypes import guess_type from os import path @@ -12,10 +14,10 @@ from typing import ( Iterator, Optional, Tuple, + TypeVar, Union, ) from urllib.parse import quote_plus -from warnings import warn from sanic.compat import Header, open_async from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE @@ -28,6 +30,10 @@ from sanic.models.protocol_types import HTMLProtocol, Range if TYPE_CHECKING: from sanic.asgi import ASGIApp + from sanic.request import Request +else: + Request = TypeVar("Request") + try: from ujson import dumps as json_dumps @@ -136,95 +142,6 @@ class BaseHTTPResponse: await self.stream.send(data, end_stream=end_stream) -StreamingFunction = Callable[[BaseHTTPResponse], Coroutine[Any, Any, None]] - - -class StreamingHTTPResponse(BaseHTTPResponse): - """ - Old style streaming response where you pass a streaming function: - - .. code-block:: python - - async def sample_streaming_fn(response): - await response.write("foo") - await asyncio.sleep(1) - await response.write("bar") - await asyncio.sleep(1) - - @app.post("/") - async def test(request): - return stream(sample_streaming_fn) - - .. warning:: - - **Deprecated** and set for removal in v21.12. You can now achieve the - same functionality without a callback. - - .. code-block:: python - - @app.post("/") - async def test(request): - response = await request.respond() - await response.send("foo", False) - await asyncio.sleep(1) - await response.send("bar", False) - await asyncio.sleep(1) - await response.send("", True) - return response - - """ - - __slots__ = ( - "streaming_fn", - "status", - "content_type", - "headers", - "_cookies", - ) - - def __init__( - self, - streaming_fn: StreamingFunction, - status: int = 200, - headers: Optional[Union[Header, Dict[str, str]]] = None, - content_type: str = "text/plain; charset=utf-8", - ignore_deprecation_notice: bool = False, - ): - if not ignore_deprecation_notice: - warn( - "Use of the StreamingHTTPResponse is deprecated in v21.6, and " - "will be removed in v21.12. Please upgrade your streaming " - "response implementation. You can learn more here: " - "https://sanicframework.org/en/guide/advanced/streaming.html" - "#response-streaming. If you use the builtin stream() or " - "file_stream() methods, this upgrade will be be done for you." - ) - - super().__init__() - - self.content_type = content_type - self.streaming_fn = streaming_fn - self.status = status - self.headers = Header(headers or {}) - self._cookies = None - - async def write(self, data): - """Writes a chunk of data to the streaming response. - - :param data: str or bytes-ish data to be written. - """ - await super().send(self._encode_body(data)) - - async def send(self, *args, **kwargs): - if self.streaming_fn is not None: - await self.streaming_fn(self) - self.streaming_fn = None - await super().send(*args, **kwargs) - - async def eof(self): - raise NotImplementedError - - class HTTPResponse(BaseHTTPResponse): """ HTTP response to be sent back to the client. @@ -419,6 +336,109 @@ async def file( ) +def redirect( + to: str, + headers: Optional[Dict[str, str]] = None, + status: int = 302, + content_type: str = "text/html; charset=utf-8", +) -> HTTPResponse: + """ + Abort execution and cause a 302 redirect (by default) by setting a + Location header. + + :param to: path or fully qualified URL to redirect to + :param headers: optional dict of headers to include in the new request + :param status: status code (int) of the new request, defaults to 302 + :param content_type: the content type (string) of the response + """ + headers = headers or {} + + # URL Quote the URL before redirecting + safe_to = quote_plus(to, safe=":/%#?&=@[]!$&'()*+,;") + + # According to RFC 7231, a relative URI is now permitted. + headers["Location"] = safe_to + + return HTTPResponse( + status=status, headers=headers, content_type=content_type + ) + + +class ResponseStream: + """ + ResponseStream is a compat layer to bridge the gap after the deprecation + of StreamingHTTPResponse. In v22.6 it will be removed when: + - stream is removed + - file_stream is moved to new style streaming + - file and file_stream are combined into a single API + """ + + __slots__ = ( + "_cookies", + "content_type", + "headers", + "request", + "response", + "status", + "streaming_fn", + ) + + def __init__( + self, + streaming_fn: Callable[ + [Union[BaseHTTPResponse, ResponseStream]], + Coroutine[Any, Any, None], + ], + status: int = 200, + headers: Optional[Union[Header, Dict[str, str]]] = None, + content_type: Optional[str] = None, + ): + self.streaming_fn = streaming_fn + self.status = status + self.headers = headers or Header() + self.content_type = content_type + self.request: Optional[Request] = None + self._cookies: Optional[CookieJar] = None + + async def write(self, message: str): + await self.response.send(message) + + async def stream(self) -> HTTPResponse: + if not self.request: + raise ServerError("Attempted response to unknown request") + self.response = await self.request.respond( + headers=self.headers, + status=self.status, + content_type=self.content_type, + ) + await self.streaming_fn(self) + return self.response + + async def eof(self) -> None: + await self.response.eof() + + @property + def cookies(self) -> CookieJar: + if self._cookies is None: + self._cookies = CookieJar(self.headers) + return self._cookies + + @property + def processed_headers(self): + return self.response.processed_headers + + @property + def body(self): + return self.response.body + + def __call__(self, request: Request) -> ResponseStream: + self.request = request + return self + + def __await__(self): + return self.stream().__await__() + + async def file_stream( location: Union[str, PurePath], status: int = 200, @@ -427,7 +447,7 @@ async def file_stream( headers: Optional[Dict[str, str]] = None, filename: Optional[str] = None, _range: Optional[Range] = None, -) -> StreamingHTTPResponse: +) -> ResponseStream: """Return a streaming response object with file data. :param location: Location of file on system. @@ -435,7 +455,6 @@ async def file_stream( :param mime_type: Specific mime_type. :param headers: Custom Headers. :param filename: Override filename. - :param chunked: Deprecated :param _range: """ headers = headers or {} @@ -471,23 +490,24 @@ async def file_stream( break await response.write(content) - return StreamingHTTPResponse( + return ResponseStream( streaming_fn=_streaming_fn, status=status, headers=headers, content_type=mime_type, - ignore_deprecation_notice=True, ) def stream( - streaming_fn: StreamingFunction, + streaming_fn: Callable[ + [Union[BaseHTTPResponse, ResponseStream]], Coroutine[Any, Any, None] + ], status: int = 200, headers: Optional[Dict[str, str]] = None, content_type: str = "text/plain; charset=utf-8", -): - """Accepts an coroutine `streaming_fn` which can be used to - write chunks to a streaming response. Returns a `StreamingHTTPResponse`. +) -> ResponseStream: + """Accepts a coroutine `streaming_fn` which can be used to + write chunks to a streaming response. Returns a `ResponseStream`. Example usage:: @@ -501,42 +521,13 @@ def stream( :param streaming_fn: A coroutine accepts a response and writes content to that response. - :param mime_type: Specific mime_type. + :param status: HTTP status. + :param content_type: Specific content_type. :param headers: Custom Headers. - :param chunked: Deprecated """ - return StreamingHTTPResponse( + return ResponseStream( streaming_fn, headers=headers, content_type=content_type, status=status, - ignore_deprecation_notice=True, - ) - - -def redirect( - to: str, - headers: Optional[Dict[str, str]] = None, - status: int = 302, - content_type: str = "text/html; charset=utf-8", -) -> HTTPResponse: - """ - Abort execution and cause a 302 redirect (by default) by setting a - Location header. - - :param to: path or fully qualified URL to redirect to - :param headers: optional dict of headers to include in the new request - :param status: status code (int) of the new request, defaults to 302 - :param content_type: the content type (string) of the response - """ - headers = headers or {} - - # URL Quote the URL before redirecting - safe_to = quote_plus(to, safe=":/%#?&=@[]!$&'()*+,;") - - # According to RFC 7231, a relative URI is now permitted. - headers["Location"] = safe_to - - return HTTPResponse( - status=status, headers=headers, content_type=content_type ) diff --git a/sanic/server/__init__.py b/sanic/server/__init__.py index 8e26dcd0..116bd05c 100644 --- a/sanic/server/__init__.py +++ b/sanic/server/__init__.py @@ -1,20 +1,10 @@ -import asyncio - from sanic.models.server_types import ConnInfo, Signal from sanic.server.async_server import AsyncioServer +from sanic.server.loop import try_use_uvloop from sanic.server.protocols.http_protocol import HttpProtocol from sanic.server.runners import serve, serve_multiple, serve_single -try: - import uvloop # type: ignore - - if not isinstance(asyncio.get_event_loop_policy(), uvloop.EventLoopPolicy): - asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) -except ImportError: - pass - - __all__ = ( "AsyncioServer", "ConnInfo", @@ -23,4 +13,5 @@ __all__ = ( "serve", "serve_multiple", "serve_single", + "try_use_uvloop", ) diff --git a/sanic/server/async_server.py b/sanic/server/async_server.py index 33b8b4c0..c13af464 100644 --- a/sanic/server/async_server.py +++ b/sanic/server/async_server.py @@ -2,7 +2,14 @@ from __future__ import annotations import asyncio +from typing import TYPE_CHECKING + from sanic.exceptions import SanicException +from sanic.log import deprecation + + +if TYPE_CHECKING: + from sanic import Sanic class AsyncioServer: @@ -11,11 +18,11 @@ class AsyncioServer: a user who needs to manage the server lifecycle manually. """ - __slots__ = ("app", "connections", "loop", "serve_coro", "server", "init") + __slots__ = ("app", "connections", "loop", "serve_coro", "server") def __init__( self, - app, + app: Sanic, loop, serve_coro, connections, @@ -27,13 +34,20 @@ class AsyncioServer: self.loop = loop self.serve_coro = serve_coro self.server = None - self.init = False + + @property + def init(self): + deprecation( + "AsyncioServer.init has been deprecated and will be removed " + "in v22.6. Use Sanic.state.is_started instead.", + 22.6, + ) + return self.app.state.is_started def startup(self): """ Trigger "before_server_start" events """ - self.init = True return self.app._startup() def before_start(self): @@ -77,30 +91,33 @@ class AsyncioServer: return task def start_serving(self): - if self.server: - try: - return self.server.start_serving() - except AttributeError: - raise NotImplementedError( - "server.start_serving not available in this version " - "of asyncio or uvloop." - ) + return self._serve(self.server.start_serving) def serve_forever(self): + return self._serve(self.server.serve_forever) + + def _serve(self, serve_func): if self.server: + if not self.app.state.is_started: + raise SanicException( + "Cannot run Sanic server without first running " + "await server.startup()" + ) + try: - return self.server.serve_forever() + return serve_func() except AttributeError: + name = serve_func.__name__ raise NotImplementedError( - "server.serve_forever not available in this version " + f"server.{name} not available in this version " "of asyncio or uvloop." ) def _server_event(self, concern: str, action: str): - if not self.init: + if not self.app.state.is_started: raise SanicException( "Cannot dispatch server event without " - "first running server.startup()" + "first running await server.startup()" ) return self.app._server_event(concern, action, loop=self.loop) diff --git a/sanic/server/loop.py b/sanic/server/loop.py new file mode 100644 index 00000000..5613f709 --- /dev/null +++ b/sanic/server/loop.py @@ -0,0 +1,49 @@ +import asyncio + +from distutils.util import strtobool +from os import getenv + +from sanic.compat import OS_IS_WINDOWS +from sanic.log import error_logger + + +def try_use_uvloop() -> None: + """ + Use uvloop instead of the default asyncio loop. + """ + if OS_IS_WINDOWS: + error_logger.warning( + "You are trying to use uvloop, but uvloop is not compatible " + "with your system. You can disable uvloop completely by setting " + "the 'USE_UVLOOP' configuration value to false, or simply not " + "defining it and letting Sanic handle it for you. Sanic will now " + "continue to run using the default event loop." + ) + return + + try: + import uvloop # type: ignore + except ImportError: + error_logger.warning( + "You are trying to use uvloop, but uvloop is not " + "installed in your system. In order to use uvloop " + "you must first install it. Otherwise, you can disable " + "uvloop completely by setting the 'USE_UVLOOP' " + "configuration value to false. Sanic will now continue " + "to run with the default event loop." + ) + return + + uvloop_install_removed = strtobool(getenv("SANIC_NO_UVLOOP", "no")) + if uvloop_install_removed: + error_logger.info( + "You are requesting to run Sanic using uvloop, but the " + "install-time 'SANIC_NO_UVLOOP' environment variable (used to " + "opt-out of installing uvloop with Sanic) is set to true. If " + "you want to prevent Sanic from overriding the event loop policy " + "during runtime, set the 'USE_UVLOOP' configuration value to " + "false." + ) + + if not isinstance(asyncio.get_event_loop_policy(), uvloop.EventLoopPolicy): + asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) diff --git a/sanic/server/protocols/websocket_protocol.py b/sanic/server/protocols/websocket_protocol.py index ffc0e8a4..ad6f8f95 100644 --- a/sanic/server/protocols/websocket_protocol.py +++ b/sanic/server/protocols/websocket_protocol.py @@ -1,12 +1,11 @@ from typing import TYPE_CHECKING, Optional, Sequence, cast -from warnings import warn from websockets.connection import CLOSED, CLOSING, OPEN from websockets.server import ServerConnection from websockets.typing import Subprotocol from sanic.exceptions import ServerError -from sanic.log import error_logger +from sanic.log import deprecation, error_logger from sanic.server import HttpProtocol from ..websockets.impl import WebsocketImplProtocol @@ -17,6 +16,14 @@ if TYPE_CHECKING: class WebSocketProtocol(HttpProtocol): + __slots__ = ( + "websocket", + "websocket_timeout", + "websocket_max_size", + "websocket_ping_interval", + "websocket_ping_timeout", + ) + def __init__( self, *args, @@ -35,24 +42,24 @@ class WebSocketProtocol(HttpProtocol): self.websocket_max_size = websocket_max_size if websocket_max_queue is not None and websocket_max_queue > 0: # TODO: Reminder remove this warning in v22.3 - warn( + deprecation( "Websocket no longer uses queueing, so websocket_max_queue" " is no longer required.", - DeprecationWarning, + 22.3, ) if websocket_read_limit is not None and websocket_read_limit > 0: # TODO: Reminder remove this warning in v22.3 - warn( + deprecation( "Websocket no longer uses read buffers, so " "websocket_read_limit is not required.", - DeprecationWarning, + 22.3, ) if websocket_write_limit is not None and websocket_write_limit > 0: # TODO: Reminder remove this warning in v22.3 - warn( + deprecation( "Websocket no longer uses write buffers, so " "websocket_write_limit is not required.", - DeprecationWarning, + 22.3, ) self.websocket_ping_interval = websocket_ping_interval self.websocket_ping_timeout = websocket_ping_timeout diff --git a/sanic/server/runners.py b/sanic/server/runners.py index c5fbd2f2..aed22ffe 100644 --- a/sanic/server/runners.py +++ b/sanic/server/runners.py @@ -1,5 +1,7 @@ from __future__ import annotations +import sys + from ssl import SSLContext from typing import TYPE_CHECKING, Dict, Optional, Type, Union @@ -22,6 +24,7 @@ from signal import signal as signal_func from aioquic.asyncio import serve as quic_serve +from sanic.application.ext import setup_ext from sanic.compat import OS_IS_WINDOWS, ctrlc_workaround_for_windows from sanic.http.http3 import get_config, get_ticket_store from sanic.log import error_logger, logger @@ -122,6 +125,7 @@ def serve( **asyncio_server_kwargs, ) + setup_ext(app) if run_async: return AsyncioServer( app=app, @@ -182,6 +186,9 @@ def serve( loop.run_until_complete(asyncio.sleep(0.1)) start_shutdown = start_shutdown + 0.1 + if sys.version_info > (3, 7): + app.shutdown_tasks(graceful - start_shutdown) + # Force close non-idle connection after waiting for # graceful_shutdown_timeout for conn in connections: diff --git a/sanic/signals.py b/sanic/signals.py index 7bb510fa..f4061b69 100644 --- a/sanic/signals.py +++ b/sanic/signals.py @@ -4,7 +4,7 @@ import asyncio from enum import Enum from inspect import isawaitable -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union, cast from sanic_routing import BaseRouter, Route, RouteGroup # type: ignore from sanic_routing.exceptions import NotFound # type: ignore @@ -142,12 +142,21 @@ class SignalRouter(BaseRouter): if context: params.update(context) + signals = group.routes if not reverse: - handlers = handlers[::-1] + signals = signals[::-1] try: - for handler in handlers: - if condition is None or condition == handler.__requirements__: - maybe_coroutine = handler(**params) + for signal in signals: + params.pop("__trigger__", None) + if ( + (condition is None and signal.ctx.exclusive is False) + or ( + condition is None + and not signal.handler.__requirements__ + ) + or (condition == signal.handler.__requirements__) + ) and (signal.ctx.trigger or event == signal.ctx.definition): + maybe_coroutine = signal.handler(**params) if isawaitable(maybe_coroutine): retval = await maybe_coroutine if retval: @@ -190,23 +199,36 @@ class SignalRouter(BaseRouter): handler: SignalHandler, event: str, condition: Optional[Dict[str, Any]] = None, + exclusive: bool = True, ) -> Signal: + event_definition = event parts = self._build_event_parts(event) if parts[2].startswith("<"): name = ".".join([*parts[:-1], "*"]) + trigger = self._clean_trigger(parts[2]) else: name = event + trigger = "" + + if not trigger: + event = ".".join([*parts[:2], "<__trigger__>"]) handler.__requirements__ = condition # type: ignore + handler.__trigger__ = trigger # type: ignore - return super().add( + signal = super().add( event, handler, - requirements=condition, name=name, append=True, ) # type: ignore + signal.ctx.exclusive = exclusive + signal.ctx.trigger = trigger + signal.ctx.definition = event_definition + + return cast(Signal, signal) + def finalize(self, do_compile: bool = True, do_optimize: bool = False): self.add(_blank, "sanic.__signal__.__init__") @@ -238,3 +260,9 @@ class SignalRouter(BaseRouter): "Cannot declare reserved signal event: %s" % event ) return parts + + def _clean_trigger(self, trigger: str) -> str: + trigger = trigger[1:-1] + if ":" in trigger: + trigger, _ = trigger.split(":") + return trigger diff --git a/sanic/touchup/meta.py b/sanic/touchup/meta.py index 9f60af38..682600d6 100644 --- a/sanic/touchup/meta.py +++ b/sanic/touchup/meta.py @@ -1,9 +1,10 @@ +from sanic.base.meta import SanicMeta from sanic.exceptions import SanicException from .service import TouchUp -class TouchUpMeta(type): +class TouchUpMeta(SanicMeta): def __new__(cls, name, bases, attrs, **kwargs): gen_class = super().__new__(cls, name, bases, attrs, **kwargs) diff --git a/sanic/types/__init__.py b/sanic/types/__init__.py new file mode 100644 index 00000000..043fffb4 --- /dev/null +++ b/sanic/types/__init__.py @@ -0,0 +1,4 @@ +from .hashable_dict import HashableDict + + +__all__ = ("HashableDict",) diff --git a/sanic/types/hashable_dict.py b/sanic/types/hashable_dict.py new file mode 100644 index 00000000..e756a321 --- /dev/null +++ b/sanic/types/hashable_dict.py @@ -0,0 +1,3 @@ +class HashableDict(dict): + def __hash__(self): + return hash(tuple(sorted(self.items()))) diff --git a/sanic/views.py b/sanic/views.py index c983bef7..23cd110d 100644 --- a/sanic/views.py +++ b/sanic/views.py @@ -9,14 +9,11 @@ from typing import ( Optional, Union, ) -from warnings import warn -from sanic.constants import HTTP_METHODS -from sanic.exceptions import InvalidUsage from sanic.models.handler_types import RouteHandler -if TYPE_CHECKING: +if TYPE_CHECKING: # no cov from sanic import Sanic from sanic.blueprints import Blueprint @@ -84,6 +81,8 @@ class HTTPMethodView: def dispatch_request(self, request, *args, **kwargs): handler = getattr(self, request.method.lower(), None) + if not handler and request.method == "HEAD": + handler = self.get return handler(request, *args, **kwargs) @classmethod @@ -136,48 +135,3 @@ class HTTPMethodView: def stream(func): func.is_stream = True return func - - -class CompositionView: - """Simple method-function mapped view for the sanic. - You can add handler functions to methods (get, post, put, patch, delete) - for every HTTP method you want to support. - - For example: - - .. code-block:: python - - view = CompositionView() - view.add(['GET'], lambda request: text('I am get method')) - view.add(['POST', 'PUT'], lambda request: text('I am post/put method')) - - If someone tries to use a non-implemented method, there will be a - 405 response. - """ - - def __init__(self): - self.handlers = {} - self.name = self.__class__.__name__ - warn( - "CompositionView has been deprecated and will be removed in " - "v21.12. Please update your view to HTTPMethodView.", - DeprecationWarning, - ) - - def __name__(self): - return self.name - - def add(self, methods, handler, stream=False): - if stream: - handler.is_stream = stream - for method in methods: - if method not in HTTP_METHODS: - raise InvalidUsage(f"{method} is not a valid HTTP method.") - - if method in self.handlers: - raise InvalidUsage(f"Method {method} is already registered.") - self.handlers[method] = handler - - def __call__(self, request, *args, **kwargs): - handler = self.handlers[request.method.upper()] - return handler(request, *args, **kwargs) diff --git a/sanic/worker.py b/sanic/worker.py index a3bc29b8..befe8d78 100644 --- a/sanic/worker.py +++ b/sanic/worker.py @@ -7,22 +7,19 @@ import traceback from gunicorn.workers import base # type: ignore +from sanic.compat import UVLOOP_INSTALLED from sanic.log import logger -from sanic.server import HttpProtocol, Signal, serve +from sanic.server import HttpProtocol, Signal, serve, try_use_uvloop from sanic.server.protocols.websocket_protocol import WebSocketProtocol try: import ssl # type: ignore -except ImportError: +except ImportError: # no cov ssl = None # type: ignore -try: - import uvloop # type: ignore - - asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) -except ImportError: - pass +if UVLOOP_INSTALLED: # no cov + try_use_uvloop() class GunicornWorker(base.Worker): diff --git a/setup.py b/setup.py index 36de0c4f..ea6f285a 100644 --- a/setup.py +++ b/setup.py @@ -147,6 +147,7 @@ extras_require = { "dev": dev_require, "docs": docs_require, "all": all_require, + "ext": ["sanic-ext"], } setup_kwargs["install_requires"] = requirements diff --git a/tests/conftest.py b/tests/conftest.py index 292914cd..22decde5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,8 +6,10 @@ import string import sys import uuid +from contextlib import suppress from logging import LogRecord -from typing import Callable, List, Tuple +from typing import List, Tuple +from unittest.mock import MagicMock import pytest @@ -184,3 +186,21 @@ def message_in_records(): return error_captured return msg_in_log + + +@pytest.fixture +def ext_instance(): + ext_instance = MagicMock() + ext_instance.injection = MagicMock() + return ext_instance + + +@pytest.fixture(autouse=True) # type: ignore +def sanic_ext(ext_instance): # noqa + sanic_ext = MagicMock(__version__="1.2.3") + sanic_ext.Extend = MagicMock() + sanic_ext.Extend.return_value = ext_instance + sys.modules["sanic_ext"] = sanic_ext + yield sanic_ext + with suppress(KeyError): + del sys.modules["sanic_ext"] diff --git a/tests/fake/server.py b/tests/fake/server.py index 43f6d27f..1220c23e 100644 --- a/tests/fake/server.py +++ b/tests/fake/server.py @@ -1,5 +1,4 @@ import json -import logging from sanic import Sanic, text from sanic.log import LOGGING_CONFIG_DEFAULTS, logger @@ -9,7 +8,7 @@ LOGGING_CONFIG = {**LOGGING_CONFIG_DEFAULTS} LOGGING_CONFIG["formatters"]["generic"]["format"] = "%(message)s" LOGGING_CONFIG["loggers"]["sanic.root"]["level"] = "DEBUG" -app = Sanic(__name__, log_config=LOGGING_CONFIG) +app = Sanic("FakeServer", log_config=LOGGING_CONFIG) @app.get("/") diff --git a/tests/test_app.py b/tests/test_app.py index 75a5b65f..467aaee4 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2,17 +2,20 @@ import asyncio import logging import re -from email import message +from collections import Counter from inspect import isawaitable from os import environ from unittest.mock import Mock, patch -import py import pytest +import sanic + from sanic import Sanic +from sanic.compat import OS_IS_WINDOWS from sanic.config import Config from sanic.exceptions import SanicException +from sanic.helpers import _default from sanic.response import text @@ -21,15 +24,6 @@ def clear_app_registry(): Sanic._app_registry = {} -def uvloop_installed(): - try: - import uvloop # noqa - - return True - except ImportError: - return False - - def test_app_loop_running(app): @app.get("/test") async def handler(request): @@ -41,41 +35,39 @@ def test_app_loop_running(app): def test_create_asyncio_server(app): - if not uvloop_installed(): - loop = asyncio.get_event_loop() - asyncio_srv_coro = app.create_server(return_asyncio_server=True) - assert isawaitable(asyncio_srv_coro) - srv = loop.run_until_complete(asyncio_srv_coro) - assert srv.is_serving() is True + loop = asyncio.get_event_loop() + asyncio_srv_coro = app.create_server(return_asyncio_server=True) + assert isawaitable(asyncio_srv_coro) + srv = loop.run_until_complete(asyncio_srv_coro) + assert srv.is_serving() is True def test_asyncio_server_no_start_serving(app): - if not uvloop_installed(): - loop = asyncio.get_event_loop() - asyncio_srv_coro = app.create_server( - port=43123, - return_asyncio_server=True, - asyncio_server_kwargs=dict(start_serving=False), - ) - srv = loop.run_until_complete(asyncio_srv_coro) - assert srv.is_serving() is False + loop = asyncio.get_event_loop() + asyncio_srv_coro = app.create_server( + port=43123, + return_asyncio_server=True, + asyncio_server_kwargs=dict(start_serving=False), + ) + srv = loop.run_until_complete(asyncio_srv_coro) + assert srv.is_serving() is False def test_asyncio_server_start_serving(app): - if not uvloop_installed(): - loop = asyncio.get_event_loop() - asyncio_srv_coro = app.create_server( - port=43124, - return_asyncio_server=True, - asyncio_server_kwargs=dict(start_serving=False), - ) - srv = loop.run_until_complete(asyncio_srv_coro) - assert srv.is_serving() is False - loop.run_until_complete(srv.start_serving()) - assert srv.is_serving() is True - wait_close = srv.close() - loop.run_until_complete(wait_close) - # Looks like we can't easily test `serve_forever()` + loop = asyncio.get_event_loop() + asyncio_srv_coro = app.create_server( + port=43124, + return_asyncio_server=True, + asyncio_server_kwargs=dict(start_serving=False), + ) + srv = loop.run_until_complete(asyncio_srv_coro) + assert srv.is_serving() is False + loop.run_until_complete(srv.startup()) + loop.run_until_complete(srv.start_serving()) + assert srv.is_serving() is True + wait_close = srv.close() + loop.run_until_complete(wait_close) + # Looks like we can't easily test `serve_forever()` def test_create_server_main(app, caplog): @@ -92,6 +84,21 @@ def test_create_server_main(app, caplog): ) in caplog.record_tuples +def test_create_server_no_startup(app): + loop = asyncio.get_event_loop() + asyncio_srv_coro = app.create_server( + port=43124, + return_asyncio_server=True, + asyncio_server_kwargs=dict(start_serving=False), + ) + srv = loop.run_until_complete(asyncio_srv_coro) + message = ( + "Cannot run Sanic server without first running await server.startup()" + ) + with pytest.raises(SanicException, match=message): + loop.run_until_complete(srv.start_serving()) + + def test_create_server_main_convenience(app, caplog): app.main_process_start(lambda *_: ...) loop = asyncio.get_event_loop() @@ -106,6 +113,19 @@ def test_create_server_main_convenience(app, caplog): ) in caplog.record_tuples +def test_create_server_init(app, caplog): + loop = asyncio.get_event_loop() + asyncio_srv_coro = app.create_server(return_asyncio_server=True) + server = loop.run_until_complete(asyncio_srv_coro) + + message = ( + "AsyncioServer.init has been deprecated and will be removed in v22.6. " + "Use Sanic.state.is_started instead." + ) + with pytest.warns(DeprecationWarning, match=message): + server.init + + def test_app_loop_not_running(app): with pytest.raises(SanicException) as excinfo: app.loop @@ -373,6 +393,22 @@ def test_app_no_registry(): Sanic.get_app("no-register") +def test_app_no_registry_deprecation_message(): + with pytest.warns(DeprecationWarning) as records: + Sanic("no-register", register=False) + Sanic("yes-register", register=True) + + message = ( + "[DEPRECATION v22.6] The register argument is deprecated and will " + "stop working in v22.6. After v22.6 all apps will be added to the " + "Sanic app registry." + ) + + assert len(records) == 2 + for record in records: + assert record.message.args[0] == message + + def test_app_no_registry_env(): environ["SANIC_REGISTER"] = "False" Sanic("no-register") @@ -384,15 +420,12 @@ def test_app_no_registry_env(): def test_app_set_attribute_warning(app): - with pytest.warns(DeprecationWarning) as record: - app.foo = 1 - - assert len(record) == 1 - assert record[0].message.args[0] == ( - "Setting variables on Sanic instances is deprecated " - "and will be removed in version 21.12. You should change your " - "Sanic instance to use instance.ctx.foo instead." + message = ( + "Setting variables on Sanic instances is not allowed. You should " + "change your Sanic instance to use instance.ctx.foo instead." ) + with pytest.raises(AttributeError, match=message): + app.foo = 1 def test_app_set_context(app): @@ -414,15 +447,7 @@ def test_bad_custom_config(): SanicException, match=( "When instantiating Sanic with config, you cannot also pass " - "load_env or env_prefix" - ), - ): - Sanic("test", config=1, load_env=1) - with pytest.raises( - SanicException, - match=( - "When instantiating Sanic with config, you cannot also pass " - "load_env or env_prefix" + "env_prefix" ), ): Sanic("test", config=1, env_prefix=1) @@ -448,6 +473,98 @@ def test_custom_context(): assert app.ctx == ctx +def test_uvloop_config(app, monkeypatch): + @app.get("/test") + def handler(request): + return text("ok") + + try_use_uvloop = Mock() + monkeypatch.setattr(sanic.app, "try_use_uvloop", try_use_uvloop) + + # Default config + app.test_client.get("/test") + if OS_IS_WINDOWS: + try_use_uvloop.assert_not_called() + else: + try_use_uvloop.assert_called_once() + + try_use_uvloop.reset_mock() + app.config["USE_UVLOOP"] = False + app.test_client.get("/test") + try_use_uvloop.assert_not_called() + + try_use_uvloop.reset_mock() + app.config["USE_UVLOOP"] = True + app.test_client.get("/test") + try_use_uvloop.assert_called_once() + + +def test_uvloop_cannot_never_called_with_create_server(caplog, monkeypatch): + apps = (Sanic("default-uvloop"), Sanic("no-uvloop"), Sanic("yes-uvloop")) + + apps[1].config.USE_UVLOOP = False + apps[2].config.USE_UVLOOP = True + + try_use_uvloop = Mock() + monkeypatch.setattr(sanic.app, "try_use_uvloop", try_use_uvloop) + + loop = asyncio.get_event_loop() + + with caplog.at_level(logging.WARNING): + for app in apps: + srv_coro = app.create_server( + return_asyncio_server=True, + asyncio_server_kwargs=dict(start_serving=False), + ) + loop.run_until_complete(srv_coro) + + try_use_uvloop.assert_not_called() # Check it didn't try to change policy + + message = ( + "You are trying to change the uvloop configuration, but " + "this is only effective when using the run(...) method. " + "When using the create_server(...) method Sanic will use " + "the already existing loop." + ) + + counter = Counter([(r[1], r[2]) for r in caplog.record_tuples]) + modified = sum(1 for app in apps if app.config.USE_UVLOOP is not _default) + + assert counter[(logging.WARNING, message)] == modified + + +def test_multiple_uvloop_configs_display_warning(caplog): + Sanic._uvloop_setting = None # Reset the setting (changed in prev tests) + + default_uvloop = Sanic("default-uvloop") + no_uvloop = Sanic("no-uvloop") + yes_uvloop = Sanic("yes-uvloop") + + no_uvloop.config.USE_UVLOOP = False + yes_uvloop.config.USE_UVLOOP = True + + loop = asyncio.get_event_loop() + + with caplog.at_level(logging.WARNING): + for app in (default_uvloop, no_uvloop, yes_uvloop): + srv_coro = app.create_server( + return_asyncio_server=True, + asyncio_server_kwargs=dict(start_serving=False), + ) + srv = loop.run_until_complete(srv_coro) + loop.run_until_complete(srv.startup()) + + message = ( + "It looks like you're running several apps with different " + "uvloop settings. This is not supported and may lead to " + "unintended behaviour." + ) + + counter = Counter([(r[1], r[2]) for r in caplog.record_tuples]) + + assert counter[(logging.WARNING, message)] == 2 + + def test_cannot_run_fast_and_workers(app): message = "You cannot use both fast=True and workers=X" with pytest.raises(RuntimeError, match=message): diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 3d464a4f..d00a70bd 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -145,6 +145,37 @@ def test_listeners_triggered_async(app): assert after_server_stop +def test_non_default_uvloop_config_raises_warning(app): + app.config.USE_UVLOOP = True + + class CustomServer(uvicorn.Server): + def install_signal_handlers(self): + pass + + config = uvicorn.Config(app=app, loop="asyncio", limit_max_requests=0) + server = CustomServer(config=config) + + with pytest.warns(UserWarning) as records: + server.run() + + all_tasks = asyncio.all_tasks(asyncio.get_event_loop()) + for task in all_tasks: + task.cancel() + + msg = "" + for record in records: + _msg = str(record.message) + if _msg.startswith("You have set the USE_UVLOOP configuration"): + msg = _msg + break + + assert msg == ( + "You have set the USE_UVLOOP configuration option, but Sanic " + "cannot control the event loop when running in ASGI mode." + "This option will be ignored." + ) + + @pytest.mark.asyncio async def test_mockprotocol_events(protocol): assert protocol._not_paused.is_set() diff --git a/tests/test_base.py b/tests/test_base.py index 11d275a1..32307309 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,6 +1,7 @@ import pytest from sanic import Blueprint, Sanic +from sanic.exceptions import SanicException @pytest.fixture @@ -79,24 +80,18 @@ def test_names_okay(name): ) def test_names_not_okay(name): app_message = ( - f"Sanic instance named '{name}' uses a format that isdeprecated. " - "Starting in version 21.12, Sanic objects must be named only using " - "alphanumeric characters, _, or -." + f"Sanic instance named '{name}' uses an invalid format. Names must " + "begin with a character and may only contain alphanumeric " + "characters, _, or -." ) bp_message = ( - f"Blueprint instance named '{name}' uses a format that isdeprecated. " - "Starting in version 21.12, Blueprint objects must be named only using " - "alphanumeric characters, _, or -." + f"Blueprint instance named '{name}' uses an invalid format. Names " + "must begin with a character and may only contain alphanumeric " + "characters, _, or -." ) - with pytest.warns(DeprecationWarning) as app_e: - app = Sanic(name) + with pytest.raises(SanicException, match=app_message): + Sanic(name) - with pytest.warns(DeprecationWarning) as bp_e: - bp = Blueprint(name) - - assert app.name == name - assert bp.name == name - - assert app_e[0].message.args[0] == app_message - assert bp_e[0].message.args[0] == bp_message + with pytest.raises(SanicException, match=bp_message): + Blueprint(name) diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 3aa4487a..9c326ded 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -15,7 +15,6 @@ from sanic.exceptions import ( ) from sanic.request import Request from sanic.response import json, text -from sanic.views import CompositionView # ------------------------------------------------------------ # @@ -833,7 +832,7 @@ def test_static_blueprint_name(static_file_directory, file_name): @pytest.mark.parametrize("file_name", ["test.file"]) def test_static_blueprintp_mw(app: Sanic, static_file_directory, file_name): - current_file = inspect.getfile(inspect.currentframe()) + current_file = inspect.getfile(inspect.currentframe()) # type: ignore with open(current_file, "rb") as file: file.read() @@ -862,31 +861,6 @@ def test_static_blueprintp_mw(app: Sanic, static_file_directory, file_name): assert triggered is True -def test_route_handler_add(app: Sanic): - view = CompositionView() - - async def get_handler(request): - return json({"response": "OK"}) - - view.add(["GET"], get_handler, stream=False) - - async def default_handler(request): - return text("OK") - - bp = Blueprint(name="handler", url_prefix="/handler") - bp.add_route(default_handler, uri="/default/", strict_slashes=True) - - bp.add_route(view, uri="/view", name="test") - - app.blueprint(bp) - - _, response = app.test_client.get("/handler/default/") - assert response.text == "OK" - - _, response = app.test_client.get("/handler/view") - assert response.json["response"] == "OK" - - def test_websocket_route(app: Sanic): event = asyncio.Event() @@ -1079,15 +1053,12 @@ def test_blueprint_registered_multiple_apps(): def test_bp_set_attribute_warning(): bp = Blueprint("bp") - with pytest.warns(DeprecationWarning) as record: - bp.foo = 1 - - assert len(record) == 1 - assert record[0].message.args[0] == ( - "Setting variables on Blueprint instances is deprecated " - "and will be removed in version 21.12. You should change your " - "Blueprint instance to use instance.ctx.foo instead." + message = ( + "Setting variables on Blueprint instances is not allowed. You should " + "change your Blueprint instance to use instance.ctx.foo instead." ) + with pytest.raises(AttributeError, match=message): + bp.foo = 1 def test_early_registration(app): diff --git a/tests/test_cli.py b/tests/test_cli.py index 86daa36f..254c91d4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -32,6 +32,12 @@ def starting_line(lines): return 0 +def read_app_info(lines): + for line in lines: + if line.startswith(b"{") and line.endswith(b"}"): + return json.loads(line) + + @pytest.mark.parametrize( "appname", ( @@ -199,9 +205,7 @@ def test_debug(cmd): command = ["sanic", "fake.server.app", cmd] out, err, exitcode = capture(command) lines = out.split(b"\n") - - app_info = lines[starting_line(lines) + 9] - info = json.loads(app_info) + info = read_app_info(lines) assert info["debug"] is True assert info["auto_reload"] is True @@ -212,9 +216,7 @@ def test_auto_reload(cmd): command = ["sanic", "fake.server.app", cmd] out, err, exitcode = capture(command) lines = out.split(b"\n") - - app_info = lines[starting_line(lines) + 9] - info = json.loads(app_info) + info = read_app_info(lines) assert info["debug"] is False assert info["auto_reload"] is True @@ -227,9 +229,7 @@ def test_access_logs(cmd, expected): command = ["sanic", "fake.server.app", cmd] out, err, exitcode = capture(command) lines = out.split(b"\n") - - app_info = lines[starting_line(lines) + 8] - info = json.loads(app_info) + info = read_app_info(lines) assert info["access_log"] is expected @@ -254,8 +254,6 @@ def test_noisy_exceptions(cmd, expected): command = ["sanic", "fake.server.app", cmd] out, err, exitcode = capture(command) lines = out.split(b"\n") - - app_info = lines[starting_line(lines) + 8] - info = json.loads(app_info) + info = read_app_info(lines) assert info["noisy_exceptions"] is expected diff --git a/tests/test_config.py b/tests/test_config.py index 67324f1e..9237b55c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,3 +1,5 @@ +import logging + from contextlib import contextmanager from os import environ from pathlib import Path @@ -7,6 +9,8 @@ from unittest.mock import Mock import pytest +from pytest import MonkeyPatch + from sanic import Sanic from sanic.config import DEFAULT_CONFIG, Config from sanic.exceptions import PyFileError @@ -32,21 +36,26 @@ class ConfigTest: return self.not_for_config -def test_load_from_object(app): +class UltimateAnswer: + def __init__(self, answer): + self.answer = int(answer) + + +def test_load_from_object(app: Sanic): app.config.load(ConfigTest) assert "CONFIG_VALUE" in app.config assert app.config.CONFIG_VALUE == "should be used" assert "not_for_config" not in app.config -def test_load_from_object_string(app): +def test_load_from_object_string(app: Sanic): app.config.load("test_config.ConfigTest") assert "CONFIG_VALUE" in app.config assert app.config.CONFIG_VALUE == "should be used" assert "not_for_config" not in app.config -def test_load_from_instance(app): +def test_load_from_instance(app: Sanic): app.config.load(ConfigTest()) assert "CONFIG_VALUE" in app.config assert app.config.CONFIG_VALUE == "should be used" @@ -55,7 +64,7 @@ def test_load_from_instance(app): assert "another_not_for_config" not in app.config -def test_load_from_object_string_exception(app): +def test_load_from_object_string_exception(app: Sanic): with pytest.raises(ImportError): app.config.load("test_config.Config.test") @@ -74,26 +83,6 @@ def test_auto_bool_env_prefix(): del environ["SANIC_TEST_ANSWER"] -def test_dont_load_env(): - environ["SANIC_TEST_ANSWER"] = "42" - app = Sanic(name=__name__, load_env=False) - assert getattr(app.config, "TEST_ANSWER", None) is None - del environ["SANIC_TEST_ANSWER"] - - -@pytest.mark.parametrize("load_env", [None, False, "", "MYAPP_"]) -def test_load_env_deprecation(load_env): - with pytest.warns(DeprecationWarning, match=r"21\.12"): - _ = Sanic(name=__name__, load_env=load_env) - - -def test_load_env_prefix(): - environ["MYAPP_TEST_ANSWER"] = "42" - app = Sanic(name=__name__, load_env="MYAPP_") - assert app.config.TEST_ANSWER == 42 - del environ["MYAPP_TEST_ANSWER"] - - @pytest.mark.parametrize("env_prefix", [None, ""]) def test_empty_load_env_prefix(env_prefix): environ["SANIC_TEST_ANSWER"] = "42" @@ -102,20 +91,6 @@ def test_empty_load_env_prefix(env_prefix): del environ["SANIC_TEST_ANSWER"] -def test_load_env_prefix_float_values(): - environ["MYAPP_TEST_ROI"] = "2.3" - app = Sanic(name=__name__, load_env="MYAPP_") - assert app.config.TEST_ROI == 2.3 - del environ["MYAPP_TEST_ROI"] - - -def test_load_env_prefix_string_value(): - environ["MYAPP_TEST_TOKEN"] = "somerandomtesttoken" - app = Sanic(name=__name__, load_env="MYAPP_") - assert app.config.TEST_TOKEN == "somerandomtesttoken" - del environ["MYAPP_TEST_TOKEN"] - - def test_env_prefix(): environ["MYAPP_TEST_ANSWER"] = "42" app = Sanic(name=__name__, env_prefix="MYAPP_") @@ -137,7 +112,45 @@ def test_env_prefix_string_value(): del environ["MYAPP_TEST_TOKEN"] -def test_load_from_file(app): +def test_env_w_custom_converter(): + environ["SANIC_TEST_ANSWER"] = "42" + + config = Config(converters=[UltimateAnswer]) + app = Sanic(name=__name__, config=config) + assert isinstance(app.config.TEST_ANSWER, UltimateAnswer) + assert app.config.TEST_ANSWER.answer == 42 + del environ["SANIC_TEST_ANSWER"] + + +def test_env_lowercase(): + with pytest.warns(None) as record: + environ["SANIC_test_answer"] = "42" + app = Sanic(name=__name__) + assert app.config.test_answer == 42 + assert str(record[0].message) == ( + "[DEPRECATION v22.9] Lowercase environment variables will not be " + "loaded into Sanic config beginning in v22.9." + ) + del environ["SANIC_test_answer"] + + +def test_add_converter_multiple_times(caplog): + def converter(): + ... + + message = ( + "Configuration value converter 'converter' has already been registered" + ) + config = Config() + config.register_type(converter) + with caplog.at_level(logging.WARNING): + config.register_type(converter) + + assert ("sanic.error", logging.WARNING, message) in caplog.record_tuples + assert len(config._converters) == 5 + + +def test_load_from_file(app: Sanic): config = dedent( """ VALUE = 'some value' @@ -156,12 +169,12 @@ def test_load_from_file(app): assert "condition" not in app.config -def test_load_from_missing_file(app): +def test_load_from_missing_file(app: Sanic): with pytest.raises(IOError): app.config.load("non-existent file") -def test_load_from_envvar(app): +def test_load_from_envvar(app: Sanic): config = "VALUE = 'some value'" with temp_path() as config_path: config_path.write_text(config) @@ -171,7 +184,7 @@ def test_load_from_envvar(app): assert app.config.VALUE == "some value" -def test_load_from_missing_envvar(app): +def test_load_from_missing_envvar(app: Sanic): with pytest.raises(IOError) as e: app.config.load("non-existent variable") assert str(e.value) == ( @@ -181,7 +194,7 @@ def test_load_from_missing_envvar(app): ) -def test_load_config_from_file_invalid_syntax(app): +def test_load_config_from_file_invalid_syntax(app: Sanic): config = "VALUE = some value" with temp_path() as config_path: config_path.write_text(config) @@ -190,7 +203,7 @@ def test_load_config_from_file_invalid_syntax(app): app.config.load(config_path) -def test_overwrite_exisiting_config(app): +def test_overwrite_exisiting_config(app: Sanic): app.config.DEFAULT = 1 class Config: @@ -200,7 +213,7 @@ def test_overwrite_exisiting_config(app): assert app.config.DEFAULT == 2 -def test_overwrite_exisiting_config_ignore_lowercase(app): +def test_overwrite_exisiting_config_ignore_lowercase(app: Sanic): app.config.default = 1 class Config: @@ -210,7 +223,7 @@ def test_overwrite_exisiting_config_ignore_lowercase(app): assert app.config.default == 1 -def test_missing_config(app): +def test_missing_config(app: Sanic): with pytest.raises(AttributeError, match="Config has no 'NON_EXISTENT'"): _ = app.config.NON_EXISTENT @@ -278,7 +291,7 @@ def test_config_custom_defaults_with_env(): del environ[key] -def test_config_access_log_passing_in_run(app): +def test_config_access_log_passing_in_run(app: Sanic): assert app.config.ACCESS_LOG is True @app.listener("after_server_start") @@ -293,7 +306,7 @@ def test_config_access_log_passing_in_run(app): @pytest.mark.asyncio -async def test_config_access_log_passing_in_create_server(app): +async def test_config_access_log_passing_in_create_server(app: Sanic): assert app.config.ACCESS_LOG is True @app.listener("after_server_start") @@ -342,18 +355,18 @@ _test_setting_as_module = str( ], ids=["from_dict", "from_class", "from_file"], ) -def test_update(app, conf_object): +def test_update(app: Sanic, conf_object): app.update_config(conf_object) assert app.config["TEST_SETTING_VALUE"] == 1 -def test_update_from_lowercase_key(app): +def test_update_from_lowercase_key(app: Sanic): d = {"test_setting_value": 1} app.update_config(d) assert "test_setting_value" not in app.config -def test_deprecation_notice_when_setting_logo(app): +def test_deprecation_notice_when_setting_logo(app: Sanic): message = ( "Setting the config.LOGO is deprecated and will no longer be " "supported starting in v22.6." @@ -362,7 +375,7 @@ def test_deprecation_notice_when_setting_logo(app): app.config.LOGO = "My Custom Logo" -def test_config_set_methods(app, monkeypatch): +def test_config_set_methods(app: Sanic, monkeypatch: MonkeyPatch): post_set = Mock() monkeypatch.setattr(Config, "_post_set", post_set) diff --git a/tests/test_create_task.py b/tests/test_create_task.py index 99f724b5..c98666a9 100644 --- a/tests/test_create_task.py +++ b/tests/test_create_task.py @@ -1,7 +1,11 @@ import asyncio +import sys from threading import Event +import pytest + +from sanic.exceptions import SanicException from sanic.response import text @@ -48,3 +52,41 @@ def test_create_task_with_app_arg(app): _, response = app.test_client.get("/") assert response.text == "test_create_task_with_app_arg" + + +@pytest.mark.skipif(sys.version_info < (3, 8), reason="Not supported in 3.7") +def test_create_named_task(app): + async def dummy(): + ... + + @app.before_server_start + async def setup(app, _): + app.add_task(dummy, name="dummy_task") + + @app.after_server_start + async def stop(app, _): + task = app.get_task("dummy_task") + + assert app._task_registry + assert isinstance(task, asyncio.Task) + + assert task.get_name() == "dummy_task" + + app.stop() + + app.run() + + +@pytest.mark.skipif(sys.version_info < (3, 8), reason="Not supported in 3.7") +def test_create_named_task_fails_outside_app(app): + async def dummy(): + ... + + message = "Cannot name task outside of a running application" + with pytest.raises(RuntimeError, match=message): + app.add_task(dummy, name="dummy_task") + assert not app._task_registry + + message = 'Registered task named "dummy_task" not found.' + with pytest.raises(SanicException, match=message): + app.get_task("dummy_task") diff --git a/tests/test_deprecation.py b/tests/test_deprecation.py new file mode 100644 index 00000000..3bf5bd36 --- /dev/null +++ b/tests/test_deprecation.py @@ -0,0 +1,9 @@ +import pytest + +from sanic.log import deprecation + + +def test_deprecation(): + message = r"\[DEPRECATION v9\.9\] hello" + with pytest.warns(DeprecationWarning, match=message): + deprecation("hello", 9.9) diff --git a/tests/test_errorpages.py b/tests/test_errorpages.py index 1843f6a7..f8e425b0 100644 --- a/tests/test_errorpages.py +++ b/tests/test_errorpages.py @@ -280,40 +280,20 @@ def test_allow_fallback_error_format_set_main_process_start(app): async def start(app, _): app.config.FALLBACK_ERROR_FORMAT = "text" - request, response = app.test_client.get("/error") - assert request.app.error_handler.fallback == "text" + _, response = app.test_client.get("/error") assert response.status == 500 assert response.content_type == "text/plain; charset=utf-8" -def test_setting_fallback_to_non_default_raise_warning(app): - app.error_handler = ErrorHandler(fallback="text") +def test_setting_fallback_on_config_changes_as_expected(app): + app.error_handler = ErrorHandler() - assert app.error_handler.fallback == "text" - - with pytest.warns( - UserWarning, - match=( - "Overriding non-default ErrorHandler fallback value. " - "Changing from text to auto." - ), - ): - app.config.FALLBACK_ERROR_FORMAT = "auto" - - assert app.error_handler.fallback == "auto" + _, response = app.test_client.get("/error") + assert response.content_type == "text/html; charset=utf-8" app.config.FALLBACK_ERROR_FORMAT = "text" - - with pytest.warns( - UserWarning, - match=( - "Overriding non-default ErrorHandler fallback value. " - "Changing from text to json." - ), - ): - app.config.FALLBACK_ERROR_FORMAT = "json" - - assert app.error_handler.fallback == "json" + _, response = app.test_client.get("/error") + assert response.content_type == "text/plain; charset=utf-8" def test_allow_fallback_error_format_in_config_injection(): @@ -327,7 +307,6 @@ def test_allow_fallback_error_format_in_config_injection(): raise Exception("something went wrong") request, response = app.test_client.get("/error") - assert request.app.error_handler.fallback == "text" assert response.status == 500 assert response.content_type == "text/plain; charset=utf-8" @@ -339,6 +318,23 @@ def test_allow_fallback_error_format_in_config_replacement(app): app.config = MyConfig() request, response = app.test_client.get("/error") - assert request.app.error_handler.fallback == "text" assert response.status == 500 assert response.content_type == "text/plain; charset=utf-8" + + +def test_config_fallback_before_and_after_startup(app): + app.config.FALLBACK_ERROR_FORMAT = "json" + + @app.main_process_start + async def start(app, _): + app.config.FALLBACK_ERROR_FORMAT = "text" + + _, response = app.test_client.get("/error") + assert response.status == 500 + assert response.content_type == "text/plain; charset=utf-8" + + +def test_config_fallback_bad_value(app): + message = "Unknown format: fake" + with pytest.raises(SanicException, match=message): + app.config.FALLBACK_ERROR_FORMAT = "fake" diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index eea97935..3ec2959d 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -13,7 +13,6 @@ from sanic.exceptions import ( SanicException, ServerError, Unauthorized, - abort, ) from sanic.response import text @@ -88,10 +87,6 @@ def exception_app(): def handler_500_error(request): raise SanicException(status_code=500) - @app.route("/old_abort") - def handler_old_abort_error(request): - abort(500) - @app.route("/abort/message") def handler_abort_message(request): raise SanicException(message="Custom Message", status_code=500) @@ -239,11 +234,6 @@ def test_sanic_exception(exception_app): assert response.status == 500 assert "Custom Message" in response.text - with warnings.catch_warnings(record=True) as w: - request, response = exception_app.test_client.get("/old_abort") - assert response.status == 500 - assert len(w) == 1 and "deprecated" in w[0].message.args[0] - def test_custom_exception_default_message(exception_app): class TeaError(SanicException): @@ -262,7 +252,7 @@ def test_custom_exception_default_message(exception_app): def test_exception_in_ws_logged(caplog): - app = Sanic(__file__) + app = Sanic(__name__) @app.websocket("/feed") async def feed(request, ws): @@ -271,9 +261,13 @@ def test_exception_in_ws_logged(caplog): with caplog.at_level(logging.INFO): app.test_client.websocket("/feed") - error_logs = [r for r in caplog.record_tuples if r[0] == "sanic.error"] - assert error_logs[1][1] == logging.ERROR - assert "Exception occurred while handling uri:" in error_logs[1][2] + for record in caplog.record_tuples: + if record[2].startswith("Exception occurred"): + break + + assert record[0] == "sanic.error" + assert record[1] == logging.ERROR + assert "Exception occurred while handling uri:" in record[2] @pytest.mark.parametrize("debug", (True, False)) diff --git a/tests/test_exceptions_handler.py b/tests/test_exceptions_handler.py index a0de6737..8be3d28e 100644 --- a/tests/test_exceptions_handler.py +++ b/tests/test_exceptions_handler.py @@ -226,11 +226,12 @@ def test_single_arg_exception_handler_notice( exception_handler_app.error_handler = CustomErrorHandler() message = ( - "You are using a deprecated error handler. The lookup method should " - "accept two positional parameters: (exception, route_name: " - "Optional[str]). Until you upgrade your ErrorHandler.lookup, " - "Blueprint specific exceptions will not work properly. Beginning in " - "v22.3, the legacy style lookup method will not work at all." + "[DEPRECATION v22.3] You are using a deprecated error handler. The " + "lookup method should accept two positional parameters: (exception, " + "route_name: Optional[str]). Until you upgrade your " + "ErrorHandler.lookup, Blueprint specific exceptions will not work " + "properly. Beginning in v22.3, the legacy style lookup method will " + "not work at all." ) with pytest.warns(DeprecationWarning) as record: _, response = exception_handler_app.test_client.get("/1") diff --git a/tests/test_ext_integration.py b/tests/test_ext_integration.py new file mode 100644 index 00000000..ec311a02 --- /dev/null +++ b/tests/test_ext_integration.py @@ -0,0 +1,84 @@ +import sys + +from unittest.mock import MagicMock + +import pytest + +from sanic import Sanic + + +try: + import sanic_ext + + SANIC_EXT_IN_ENV = True +except ImportError: + SANIC_EXT_IN_ENV = False + + +@pytest.fixture +def stoppable_app(app): + @app.before_server_start + async def stop(*_): + app.stop() + + return app + + +def test_ext_is_loaded(stoppable_app: Sanic, sanic_ext): + stoppable_app.run() + sanic_ext.Extend.assert_called_once_with(stoppable_app) + + +def test_ext_is_not_loaded(stoppable_app: Sanic, sanic_ext): + stoppable_app.config.AUTO_EXTEND = False + stoppable_app.run() + sanic_ext.Extend.assert_not_called() + + +def test_extend_with_args(stoppable_app: Sanic, sanic_ext): + stoppable_app.extend(built_in_extensions=False) + stoppable_app.run() + sanic_ext.Extend.assert_called_once_with( + stoppable_app, built_in_extensions=False, config=None, extensions=None + ) + + +def test_access_object_sets_up_extension(app: Sanic, sanic_ext): + app.ext + sanic_ext.Extend.assert_called_once_with(app) + + +def test_extend_cannot_be_called_multiple_times(app: Sanic, sanic_ext): + app.extend() + + message = "Cannot extend Sanic after Sanic Extensions has been setup." + with pytest.raises(RuntimeError, match=message): + app.extend() + sanic_ext.Extend.assert_called_once_with( + app, extensions=None, built_in_extensions=True, config=None + ) + + +@pytest.mark.skipif( + SANIC_EXT_IN_ENV, + reason="Running tests with sanic_ext already in the environment", +) +def test_fail_if_not_loaded(app: Sanic): + del sys.modules["sanic_ext"] + with pytest.raises( + RuntimeError, match="Sanic Extensions is not installed.*" + ): + app.extend(built_in_extensions=False) + + +def test_can_access_app_ext_while_running(app: Sanic, sanic_ext, ext_instance): + class IceCream: + flavor: str + + @app.before_server_start + async def injections(*_): + app.ext.injection(IceCream) + app.stop() + + app.run() + ext_instance.injection.assert_called_with(IceCream) diff --git a/tests/test_graceful_shutdown.py b/tests/test_graceful_shutdown.py index 1733ffd1..54ba92d8 100644 --- a/tests/test_graceful_shutdown.py +++ b/tests/test_graceful_shutdown.py @@ -2,7 +2,6 @@ import asyncio import logging import time -from collections import Counter from multiprocessing import Process import httpx @@ -36,11 +35,14 @@ def test_no_exceptions_when_cancel_pending_request(app, caplog): p.kill() - counter = Counter([r[1] for r in caplog.record_tuples]) - - assert counter[logging.INFO] == 11 - assert logging.ERROR not in counter - assert ( - caplog.record_tuples[9][2] - == "Request: GET http://127.0.0.1:8000/ stopped. Transport is closed." - ) + info = 0 + for record in caplog.record_tuples: + assert record[1] != logging.ERROR + if record[1] == logging.INFO: + info += 1 + if record[2].startswith("Request:"): + assert record[2] == ( + "Request: GET http://127.0.0.1:8000/ stopped. " + "Transport is closed." + ) + assert info == 11 diff --git a/tests/test_json_encoding.py b/tests/test_json_encoding.py index ab1858b9..da04ebdb 100644 --- a/tests/test_json_encoding.py +++ b/tests/test_json_encoding.py @@ -45,7 +45,7 @@ def default_back_to_ujson(): def test_change_encoder(): - Sanic("...", dumps=sdumps) + Sanic("Test", dumps=sdumps) assert BaseHTTPResponse._dumps == sdumps @@ -53,7 +53,7 @@ def test_change_encoder_to_some_custom(): def my_custom_encoder(): return "foo" - Sanic("...", dumps=my_custom_encoder) + Sanic("Test", dumps=my_custom_encoder) assert BaseHTTPResponse._dumps == my_custom_encoder @@ -68,7 +68,7 @@ def test_json_response_ujson(payload): ): json(payload, dumps=sdumps) - Sanic("...", dumps=sdumps) + Sanic("Test", dumps=sdumps) with pytest.raises( TypeError, match="Object of type Foo is not JSON serializable" ): @@ -87,6 +87,6 @@ def test_json_response_json(): response = json(too_big_for_ujson, dumps=sdumps) assert sys.getsizeof(response.body) == 54 - Sanic("...", dumps=sdumps) + Sanic("Test", dumps=sdumps) response = json(too_big_for_ujson) assert sys.getsizeof(response.body) == 54 diff --git a/tests/test_keep_alive_timeout.py b/tests/test_keep_alive_timeout.py index e30761ed..71c2a694 100644 --- a/tests/test_keep_alive_timeout.py +++ b/tests/test_keep_alive_timeout.py @@ -2,6 +2,7 @@ import asyncio import platform from asyncio import sleep as aio_sleep +from itertools import count from os import environ import pytest @@ -15,7 +16,12 @@ from sanic.response import text CONFIG_FOR_TESTS = {"KEEP_ALIVE_TIMEOUT": 2, "KEEP_ALIVE": True} -PORT = 42101 # test_keep_alive_timeout_reuse doesn't work with random port +PORT = 42001 # test_keep_alive_timeout_reuse doesn't work with random port +port_counter = count() + + +def get_port(): + return next(port_counter) + PORT keep_alive_timeout_app_reuse = Sanic("test_ka_timeout_reuse") @@ -63,9 +69,10 @@ 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.""" + port = get_port() loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - client = ReusableClient(keep_alive_timeout_app_reuse, loop=loop, port=PORT) + client = ReusableClient(keep_alive_timeout_app_reuse, loop=loop, port=port) with client: headers = {"Connection": "keep-alive"} request, response = client.get("/1", headers=headers) @@ -90,10 +97,11 @@ def test_keep_alive_timeout_reuse(): 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.""" + port = get_port() loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) client = ReusableClient( - keep_alive_app_client_timeout, loop=loop, port=PORT + keep_alive_app_client_timeout, loop=loop, port=port ) with client: headers = {"Connection": "keep-alive"} @@ -117,10 +125,11 @@ def test_keep_alive_server_timeout(): 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.""" + port = get_port() loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) client = ReusableClient( - keep_alive_app_server_timeout, loop=loop, port=PORT + keep_alive_app_server_timeout, loop=loop, port=port ) with client: headers = {"Connection": "keep-alive"} @@ -141,9 +150,10 @@ def test_keep_alive_server_timeout(): reason="Not testable with current client", ) def test_keep_alive_connection_context(): + port = get_port() loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - client = ReusableClient(keep_alive_app_context, loop=loop, port=PORT) + client = ReusableClient(keep_alive_app_context, loop=loop, port=port) with client: headers = {"Connection": "keep-alive"} request1, _ = client.post("/ctx", headers=headers) diff --git a/tests/test_reloader.py b/tests/test_reloader.py index 1cc84f97..d78b49a9 100644 --- a/tests/test_reloader.py +++ b/tests/test_reloader.py @@ -107,7 +107,7 @@ argv = dict( "-m", "sanic", "--port", - "42104", + "42204", "--debug", "reloader.app", ], @@ -117,11 +117,12 @@ argv = dict( @pytest.mark.parametrize( "runargs, mode", [ - (dict(port=42102, auto_reload=True), "script"), - (dict(port=42103, debug=True), "module"), + (dict(port=42202, auto_reload=True), "script"), + (dict(port=42203, debug=True), "module"), ({}, "sanic"), ], ) +@pytest.mark.xfail async def test_reloader_live(runargs, mode): with TemporaryDirectory() as tmpdir: filename = os.path.join(tmpdir, "reloader.py") @@ -149,11 +150,12 @@ async def test_reloader_live(runargs, mode): @pytest.mark.parametrize( "runargs, mode", [ - (dict(port=42102, auto_reload=True), "script"), - (dict(port=42103, debug=True), "module"), + (dict(port=42302, auto_reload=True), "script"), + (dict(port=42303, debug=True), "module"), ({}, "sanic"), ], ) +@pytest.mark.xfail async def test_reloader_live_with_dir(runargs, mode): with TemporaryDirectory() as tmpdir: filename = os.path.join(tmpdir, "reloader.py") diff --git a/tests/test_request_cancel.py b/tests/test_request_cancel.py index b5d90882..8b5de18d 100644 --- a/tests/test_request_cancel.py +++ b/tests/test_request_cancel.py @@ -8,13 +8,13 @@ from sanic.response import stream, text @pytest.mark.asyncio async def test_request_cancel_when_connection_lost(app): - app.still_serving_cancelled_request = False + app.ctx.still_serving_cancelled_request = False @app.get("/") async def handler(request): await asyncio.sleep(1.0) # at this point client is already disconnected - app.still_serving_cancelled_request = True + app.ctx.still_serving_cancelled_request = True return text("OK") # schedule client call @@ -32,12 +32,12 @@ async def test_request_cancel_when_connection_lost(app): # Wait for server and check if it's still serving the cancelled request await asyncio.sleep(1.0) - assert app.still_serving_cancelled_request is False + assert app.ctx.still_serving_cancelled_request is False @pytest.mark.asyncio async def test_stream_request_cancel_when_conn_lost(app): - app.still_serving_cancelled_request = False + app.ctx.still_serving_cancelled_request = False @app.post("/post/", stream=True) async def post(request, id): @@ -52,7 +52,7 @@ async def test_stream_request_cancel_when_conn_lost(app): await asyncio.sleep(1.0) # at this point client is already disconnected - app.still_serving_cancelled_request = True + app.ctx.still_serving_cancelled_request = True return stream(streaming) @@ -71,4 +71,4 @@ async def test_stream_request_cancel_when_conn_lost(app): # Wait for server and check if it's still serving the cancelled request await asyncio.sleep(1.0) - assert app.still_serving_cancelled_request is False + assert app.ctx.still_serving_cancelled_request is False diff --git a/tests/test_request_data.py b/tests/test_request_data.py index a1b78e95..51f2b230 100644 --- a/tests/test_request_data.py +++ b/tests/test_request_data.py @@ -68,11 +68,11 @@ def test_app_injection(app): @app.listener("after_server_start") async def inject_data(app, loop): - app.injected = expected + app.ctx.injected = expected @app.get("/") async def handler(request): - return json({"injected": request.app.injected}) + return json({"injected": request.app.ctx.injected}) request, response = app.test_client.get("/") diff --git a/tests/test_request_stream.py b/tests/test_request_stream.py index dd0b3530..37cbc04b 100644 --- a/tests/test_request_stream.py +++ b/tests/test_request_stream.py @@ -8,7 +8,7 @@ import pytest from sanic import Sanic from sanic.blueprints import Blueprint from sanic.response import json, text -from sanic.views import CompositionView, HTTPMethodView +from sanic.views import HTTPMethodView from sanic.views import stream as stream_decorator @@ -423,33 +423,6 @@ def test_request_stream_blueprint(app): assert response.text == data -def test_request_stream_composition_view(app): - def get_handler(request): - return text("OK") - - async def post_handler(request): - result = "" - while True: - body = await request.stream.read() - if body is None: - break - result += body.decode("utf-8") - return text(result) - - view = CompositionView() - view.add(["GET"], get_handler) - view.add(["POST"], post_handler, stream=True) - app.add_route(view, "/composition_view") - - request, response = app.test_client.get("/composition_view") - assert response.status == 200 - assert response.text == "OK" - - request, response = app.test_client.post("/composition_view", data=data) - assert response.status == 200 - assert response.text == data - - def test_request_stream(app): """test for complex application""" bp = Blueprint("test_blueprint_request_stream") @@ -510,14 +483,8 @@ def test_request_stream(app): app.add_route(SimpleView.as_view(), "/method_view") - view = CompositionView() - view.add(["GET"], get_handler) - view.add(["POST"], post_handler, stream=True) - app.blueprint(bp) - app.add_route(view, "/composition_view") - request, response = app.test_client.get("/method_view") assert response.status == 200 assert response.text == "OK" @@ -526,14 +493,6 @@ def test_request_stream(app): assert response.status == 200 assert response.text == data - request, response = app.test_client.get("/composition_view") - assert response.status == 200 - assert response.text == "OK" - - request, response = app.test_client.post("/composition_view", data=data) - assert response.status == 200 - assert response.text == data - request, response = app.test_client.get("/get") assert response.status == 200 assert response.text == "OK" diff --git a/tests/test_response.py b/tests/test_response.py index 8d301abf..526f0c73 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -15,6 +15,8 @@ from aiofiles import os as async_os from pytest import LogCaptureFixture from sanic import Request, Sanic +from sanic.compat import Header +from sanic.cookies import CookieJar from sanic.response import ( HTTPResponse, empty, @@ -277,7 +279,7 @@ def test_non_chunked_streaming_returns_correct_content( assert response.text == "foo,bar" -def test_stream_response_with_cookies(app): +def test_stream_response_with_cookies_legacy(app): @app.route("/") async def test(request: Request): response = stream(sample_streaming_fn, content_type="text/csv") @@ -289,6 +291,25 @@ def test_stream_response_with_cookies(app): assert response.cookies["test"] == "pass" +def test_stream_response_with_cookies(app): + @app.route("/") + async def test(request: Request): + headers = Header() + cookies = CookieJar(headers) + cookies["test"] = "modified" + cookies["test"] = "pass" + response = await request.respond( + content_type="text/csv", headers=headers + ) + + await response.send("foo,") + await asyncio.sleep(0.001) + await response.send("bar") + + request, response = app.test_client.get("/") + assert response.cookies["test"] == "pass" + + def test_stream_response_without_cookies(app): @app.route("/") async def test(request: Request): @@ -561,37 +582,37 @@ def test_multiple_responses( message_in_records: Callable[[List[LogRecord], str], bool], ): @app.route("/1") - async def handler(request: Request): + async def handler1(request: Request): response = await request.respond() await response.send("foo") response = await request.respond() @app.route("/2") - async def handler(request: Request): + async def handler2(request: Request): response = await request.respond() response = await request.respond() await response.send("foo") @app.get("/3") - async def handler(request: Request): + async def handler3(request: Request): response = await request.respond() await response.send("foo,") response = await request.respond() await response.send("bar") @app.get("/4") - async def handler(request: Request): + async def handler4(request: Request): response = await request.respond(headers={"one": "one"}) return json({"foo": "bar"}, headers={"one": "two"}) @app.get("/5") - async def handler(request: Request): + async def handler5(request: Request): response = await request.respond(headers={"one": "one"}) await response.send("foo") return json({"foo": "bar"}, headers={"one": "two"}) @app.get("/6") - async def handler(request: Request): + async def handler6(request: Request): response = await request.respond(headers={"one": "one"}) await response.send("foo, ") json_response = json({"foo": "bar"}, headers={"one": "two"}) diff --git a/tests/test_routes.py b/tests/test_routes.py index 520ab5be..3a7674c5 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -16,7 +16,7 @@ from sanic import Blueprint, Sanic from sanic.constants import HTTP_METHODS from sanic.exceptions import NotFound, SanicException from sanic.request import Request -from sanic.response import json, text +from sanic.response import empty, json, text @pytest.mark.parametrize( @@ -1230,3 +1230,41 @@ def test_routes_with_and_without_slash_definitions(app): _, response = app.test_client.post(f"/{term}/") assert response.status == 200 assert response.text == f"{term}_with" + + +def test_added_route_ctx_kwargs(app): + @app.route("/", ctx_foo="foo", ctx_bar=99) + async def handler(request: Request): + return empty() + + request, _ = app.test_client.get("/") + + assert request.route.ctx.foo == "foo" + assert request.route.ctx.bar == 99 + + +def test_added_bad_route_kwargs(app): + message = "Unexpected keyword arguments: foo, bar" + with pytest.raises(TypeError, match=message): + + @app.route("/", foo="foo", bar=99) + async def handler(request: Request): + ... + + +@pytest.mark.asyncio +async def test_added_callable_route_ctx_kwargs(app): + def foo(*args, **kwargs): + return "foo" + + async def bar(*args, **kwargs): + return 99 + + @app.route("/", ctx_foo=foo, ctx_bar=bar) + async def handler(request: Request): + return empty() + + request, _ = await app.asgi_client.get("/") + + assert request.route.ctx.foo() == "foo" + assert await request.route.ctx.bar() == 99 diff --git a/tests/test_server_events.py b/tests/test_server_events.py index 7ce1859c..058a7cf6 100644 --- a/tests/test_server_events.py +++ b/tests/test_server_events.py @@ -101,7 +101,7 @@ async def test_trigger_before_events_create_server(app): @app.listener("before_server_start") async def init_db(app, loop): - app.db = MySanicDb() + app.ctx.db = MySanicDb() srv = await app.create_server( debug=True, return_asyncio_server=True, port=PORT @@ -109,8 +109,8 @@ async def test_trigger_before_events_create_server(app): await srv.startup() await srv.before_start() - assert hasattr(app, "db") - assert isinstance(app.db, MySanicDb) + assert hasattr(app.ctx, "db") + assert isinstance(app.ctx.db, MySanicDb) @pytest.mark.asyncio @@ -122,9 +122,9 @@ async def test_trigger_before_events_create_server_missing_event(app): @app.listener async def init_db(app, loop): - app.db = MySanicDb() + app.ctx.db = MySanicDb() - assert not hasattr(app, "db") + assert not hasattr(app.ctx, "db") def test_create_server_trigger_events(app): diff --git a/tests/test_server_loop.py b/tests/test_server_loop.py new file mode 100644 index 00000000..fbd5cc2b --- /dev/null +++ b/tests/test_server_loop.py @@ -0,0 +1,116 @@ +import logging + +from unittest.mock import Mock, patch + +import pytest + +from sanic.server import loop +from sanic.compat import OS_IS_WINDOWS, UVLOOP_INSTALLED + + +@pytest.mark.skipif( + not OS_IS_WINDOWS, + reason="Not testable with current client", +) +def test_raises_warning_if_os_is_windows(caplog): + with caplog.at_level(logging.WARNING): + loop.try_use_uvloop() + + for record in caplog.records: + if record.message.startswith("You are trying to use"): + break + + assert record.message == ( + "You are trying to use uvloop, but uvloop is not compatible " + "with your system. You can disable uvloop completely by setting " + "the 'USE_UVLOOP' configuration value to false, or simply not " + "defining it and letting Sanic handle it for you. Sanic will now " + "continue to run using the default event loop." + ) + + +@pytest.mark.skipif( + OS_IS_WINDOWS or UVLOOP_INSTALLED, + reason="Not testable with current client", +) +def test_raises_warning_if_uvloop_not_installed(caplog): + with caplog.at_level(logging.WARNING): + loop.try_use_uvloop() + + for record in caplog.records: + if record.message.startswith("You are trying to use"): + break + + assert record.message == ( + "You are trying to use uvloop, but uvloop is not " + "installed in your system. In order to use uvloop " + "you must first install it. Otherwise, you can disable " + "uvloop completely by setting the 'USE_UVLOOP' " + "configuration value to false. Sanic will now continue " + "to run with the default event loop." + ) + + +@pytest.mark.skipif( + OS_IS_WINDOWS or not UVLOOP_INSTALLED, + reason="Not testable with current client", +) +def test_logs_when_install_and_runtime_config_mismatch(caplog, monkeypatch): + getenv = Mock(return_value="no") + monkeypatch.setattr(loop, "getenv", getenv) + + with caplog.at_level(logging.INFO): + loop.try_use_uvloop() + + getenv.assert_called_once_with("SANIC_NO_UVLOOP", "no") + assert caplog.record_tuples == [] + + getenv = Mock(return_value="yes") + monkeypatch.setattr(loop, "getenv", getenv) + with caplog.at_level(logging.INFO): + loop.try_use_uvloop() + + getenv.assert_called_once_with("SANIC_NO_UVLOOP", "no") + for record in caplog.records: + if record.message.startswith("You are requesting to run"): + break + + assert record.message == ( + "You are requesting to run Sanic using uvloop, but the " + "install-time 'SANIC_NO_UVLOOP' environment variable (used to " + "opt-out of installing uvloop with Sanic) is set to true. If " + "you want to prevent Sanic from overriding the event loop policy " + "during runtime, set the 'USE_UVLOOP' configuration value to " + "false." + ) + + +@pytest.mark.skipif( + OS_IS_WINDOWS or not UVLOOP_INSTALLED, + reason="Not testable with current client", +) +def test_sets_loop_policy_only_when_not_already_set(monkeypatch): + import uvloop # type: ignore + + # Existing policy is not uvloop.EventLoopPolicy + get_event_loop_policy = Mock(return_value=None) + monkeypatch.setattr( + loop.asyncio, "get_event_loop_policy", get_event_loop_policy + ) + + with patch("asyncio.set_event_loop_policy") as set_event_loop_policy: + loop.try_use_uvloop() + set_event_loop_policy.assert_called_once() + args, _ = set_event_loop_policy.call_args + policy = args[0] + assert isinstance(policy, uvloop.EventLoopPolicy) + + # Existing policy is uvloop.EventLoopPolicy + get_event_loop_policy = Mock(return_value=policy) + monkeypatch.setattr( + loop.asyncio, "get_event_loop_policy", get_event_loop_policy + ) + + with patch("asyncio.set_event_loop_policy") as set_event_loop_policy: + loop.try_use_uvloop() + set_event_loop_policy.assert_not_called() diff --git a/tests/test_signals.py b/tests/test_signals.py index 51aea3c8..98354309 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -145,6 +145,23 @@ async def test_dispatch_signal_triggers_with_requirements(app): assert counter == 1 +@pytest.mark.asyncio +async def test_dispatch_signal_triggers_with_requirements_exclusive(app): + counter = 0 + + @app.signal("foo.bar.baz", condition={"one": "two"}, exclusive=False) + def sync_signal(*_): + nonlocal counter + counter += 1 + + app.signal_router.finalize() + + await app.dispatch("foo.bar.baz") + assert counter == 1 + await app.dispatch("foo.bar.baz", condition={"one": "two"}) + assert counter == 2 + + @pytest.mark.asyncio async def test_dispatch_signal_triggers_with_context(app): counter = 0 @@ -204,6 +221,24 @@ async def test_dispatch_signal_triggers_on_bp(app): assert bp_counter == 2 +@pytest.mark.asyncio +async def test_dispatch_signal_triggers_on_bp_alone(app): + bp = Blueprint("bp") + + bp_counter = 0 + + @bp.signal("foo.bar.baz") + def bp_signal(): + nonlocal bp_counter + bp_counter += 1 + + app.blueprint(bp) + app.signal_router.finalize() + await app.dispatch("foo.bar.baz") + await bp.dispatch("foo.bar.baz") + assert bp_counter == 2 + + @pytest.mark.asyncio async def test_dispatch_signal_triggers_event(app): app_counter = 0 diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 00000000..a1b98a81 --- /dev/null +++ b/tests/test_tasks.py @@ -0,0 +1,94 @@ +import asyncio +import sys + +from asyncio.tasks import Task +from unittest.mock import Mock, call + +import pytest + +from sanic.app import Sanic +from sanic.response import empty + + +pytestmark = pytest.mark.asyncio + + +async def dummy(n=0): + for _ in range(n): + await asyncio.sleep(1) + return True + + +@pytest.fixture(autouse=True) +def mark_app_running(app): + app.is_running = True + + +@pytest.mark.skipif(sys.version_info < (3, 8), reason="Not supported in 3.7") +async def test_add_task_returns_task(app: Sanic): + task = app.add_task(dummy()) + + assert isinstance(task, Task) + assert len(app._task_registry) == 0 + + +@pytest.mark.skipif(sys.version_info < (3, 8), reason="Not supported in 3.7") +async def test_add_task_with_name(app: Sanic): + task = app.add_task(dummy(), name="dummy") + + assert isinstance(task, Task) + assert len(app._task_registry) == 1 + assert task is app.get_task("dummy") + + for task in app.tasks: + assert task in app._task_registry.values() + + +@pytest.mark.skipif(sys.version_info < (3, 8), reason="Not supported in 3.7") +async def test_cancel_task(app: Sanic): + task = app.add_task(dummy(3), name="dummy") + + assert task + assert not task.done() + assert not task.cancelled() + + await asyncio.sleep(0.1) + + assert not task.done() + assert not task.cancelled() + + await app.cancel_task("dummy") + + assert task.cancelled() + + +@pytest.mark.skipif(sys.version_info < (3, 8), reason="Not supported in 3.7") +async def test_purge_tasks(app: Sanic): + app.add_task(dummy(3), name="dummy") + + await app.cancel_task("dummy") + + assert len(app._task_registry) == 1 + + app.purge_tasks() + + assert len(app._task_registry) == 0 + + +@pytest.mark.skipif(sys.version_info < (3, 8), reason="Not supported in 3.7") +def test_shutdown_tasks_on_app_stop(): + class TestSanic(Sanic): + shutdown_tasks = Mock() + + app = TestSanic("Test") + + @app.route("/") + async def handler(_): + return empty() + + app.test_client.get("/") + + app.shutdown_tasks.call_args == [ + call(timeout=0), + call(15.0), + ] diff --git a/tests/test_unix_socket.py b/tests/test_unix_socket.py index b985e284..c26ebe06 100644 --- a/tests/test_unix_socket.py +++ b/tests/test_unix_socket.py @@ -5,6 +5,8 @@ import platform import subprocess import sys +from string import ascii_lowercase + import httpcore import httpx import pytest @@ -16,6 +18,9 @@ from sanic.response import text pytestmark = pytest.mark.skipif(os.name != "posix", reason="UNIX only") SOCKPATH = "/tmp/sanictest.sock" SOCKPATH2 = "/tmp/sanictest2.sock" +httpx_version = tuple( + map(int, httpx.__version__.strip(ascii_lowercase).split(".")) +) @pytest.fixture(autouse=True) @@ -141,7 +146,10 @@ def test_unix_connection(): @app.listener("after_server_start") async def client(app, loop): - transport = httpcore.AsyncConnectionPool(uds=SOCKPATH) + if httpx_version >= (0, 20): + transport = httpx.AsyncHTTPTransport(uds=SOCKPATH) + else: + transport = httpcore.AsyncConnectionPool(uds=SOCKPATH) try: async with httpx.AsyncClient(transport=transport) as client: r = await client.get("http://myhost.invalid/") @@ -186,7 +194,10 @@ async def test_zero_downtime(): from time import monotonic as current_time async def client(): - transport = httpcore.AsyncConnectionPool(uds=SOCKPATH) + if httpx_version >= (0, 20): + transport = httpx.AsyncHTTPTransport(uds=SOCKPATH) + else: + transport = httpcore.AsyncConnectionPool(uds=SOCKPATH) for _ in range(40): async with httpx.AsyncClient(transport=transport) as client: r = await client.get("http://localhost/sleep/0.1") @@ -211,7 +222,10 @@ async def test_zero_downtime(): processes = [spawn()] while not os.path.exists(SOCKPATH): if processes[0].poll() is not None: - raise Exception("Worker did not start properly") + raise Exception( + "Worker did not start properly. " + f"stderr: {processes[0].stderr.read()}" + ) await asyncio.sleep(0.0001) ino = os.stat(SOCKPATH).st_ino task = asyncio.get_event_loop().create_task(client()) diff --git a/tests/test_versioning.py b/tests/test_versioning.py index dbada37f..396629b3 100644 --- a/tests/test_versioning.py +++ b/tests/test_versioning.py @@ -19,7 +19,7 @@ def test_route(app, handler): def test_bp(app, handler): - bp = Blueprint(__file__, version=1) + bp = Blueprint(__name__, version=1) bp.route("/")(handler) app.blueprint(bp) @@ -28,7 +28,7 @@ def test_bp(app, handler): def test_bp_use_route(app, handler): - bp = Blueprint(__file__, version=1) + bp = Blueprint(__name__, version=1) bp.route("/", version=1.1)(handler) app.blueprint(bp) @@ -37,7 +37,7 @@ def test_bp_use_route(app, handler): def test_bp_group(app, handler): - bp = Blueprint(__file__) + bp = Blueprint(__name__) bp.route("/")(handler) group = Blueprint.group(bp, version=1) app.blueprint(group) @@ -47,7 +47,7 @@ def test_bp_group(app, handler): def test_bp_group_use_bp(app, handler): - bp = Blueprint(__file__, version=1.1) + bp = Blueprint(__name__, version=1.1) bp.route("/")(handler) group = Blueprint.group(bp, version=1) app.blueprint(group) @@ -57,7 +57,7 @@ def test_bp_group_use_bp(app, handler): def test_bp_group_use_registration(app, handler): - bp = Blueprint(__file__, version=1.1) + bp = Blueprint(__name__, version=1.1) bp.route("/")(handler) group = Blueprint.group(bp, version=1) app.blueprint(group, version=1.2) @@ -67,7 +67,7 @@ def test_bp_group_use_registration(app, handler): def test_bp_group_use_route(app, handler): - bp = Blueprint(__file__, version=1.1) + bp = Blueprint(__name__, version=1.1) bp.route("/", version=1.3)(handler) group = Blueprint.group(bp, version=1) app.blueprint(group, version=1.2) @@ -84,7 +84,7 @@ def test_version_prefix_route(app, handler): def test_version_prefix_bp(app, handler): - bp = Blueprint(__file__, version=1, version_prefix="/api/v") + bp = Blueprint(__name__, version=1, version_prefix="/api/v") bp.route("/")(handler) app.blueprint(bp) @@ -93,7 +93,7 @@ def test_version_prefix_bp(app, handler): def test_version_prefix_bp_use_route(app, handler): - bp = Blueprint(__file__, version=1, version_prefix="/ignore/v") + bp = Blueprint(__name__, version=1, version_prefix="/ignore/v") bp.route("/", version=1.1, version_prefix="/api/v")(handler) app.blueprint(bp) @@ -102,7 +102,7 @@ def test_version_prefix_bp_use_route(app, handler): def test_version_prefix_bp_group(app, handler): - bp = Blueprint(__file__) + bp = Blueprint(__name__) bp.route("/")(handler) group = Blueprint.group(bp, version=1, version_prefix="/api/v") app.blueprint(group) @@ -112,7 +112,7 @@ def test_version_prefix_bp_group(app, handler): def test_version_prefix_bp_group_use_bp(app, handler): - bp = Blueprint(__file__, version=1.1, version_prefix="/api/v") + bp = Blueprint(__name__, version=1.1, version_prefix="/api/v") bp.route("/")(handler) group = Blueprint.group(bp, version=1, version_prefix="/ignore/v") app.blueprint(group) @@ -122,7 +122,7 @@ def test_version_prefix_bp_group_use_bp(app, handler): def test_version_prefix_bp_group_use_registration(app, handler): - bp = Blueprint(__file__, version=1.1, version_prefix="/alsoignore/v") + bp = Blueprint(__name__, version=1.1, version_prefix="/alsoignore/v") bp.route("/")(handler) group = Blueprint.group(bp, version=1, version_prefix="/ignore/v") app.blueprint(group, version=1.2, version_prefix="/api/v") @@ -132,7 +132,7 @@ def test_version_prefix_bp_group_use_registration(app, handler): def test_version_prefix_bp_group_use_route(app, handler): - bp = Blueprint(__file__, version=1.1, version_prefix="/alsoignore/v") + bp = Blueprint(__name__, version=1.1, version_prefix="/alsoignore/v") bp.route("/", version=1.3, version_prefix="/api/v")(handler) group = Blueprint.group(bp, version=1, version_prefix="/ignore/v") app.blueprint(group, version=1.2, version_prefix="/stillignoring/v") diff --git a/tests/test_views.py b/tests/test_views.py index 23cbd9ce..dd9eac7b 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -5,7 +5,7 @@ from sanic.constants import HTTP_METHODS from sanic.exceptions import InvalidUsage from sanic.request import Request from sanic.response import HTTPResponse, text -from sanic.views import CompositionView, HTTPMethodView +from sanic.views import HTTPMethodView @pytest.mark.parametrize("method", HTTP_METHODS) @@ -225,81 +225,3 @@ def test_with_decorator(app): request, response = app.test_client.get("/") assert response.text == "I am get method" assert results[0] == 1 - - -def test_composition_view_rejects_incorrect_methods(): - def foo(request): - return text("Foo") - - view = CompositionView() - - with pytest.raises(InvalidUsage) as e: - view.add(["GET", "FOO"], foo) - - assert str(e.value) == "FOO is not a valid HTTP method." - - -def test_composition_view_rejects_duplicate_methods(): - def foo(request): - return text("Foo") - - view = CompositionView() - - with pytest.raises(InvalidUsage) as e: - view.add(["GET", "POST", "GET"], foo) - - assert str(e.value) == "Method GET is already registered." - - -@pytest.mark.parametrize("method", HTTP_METHODS) -def test_composition_view_runs_methods_as_expected(app, method): - view = CompositionView() - - def first(request): - return text("first method") - - view.add(["GET", "POST", "PUT"], first) - view.add(["DELETE", "PATCH"], lambda x: text("second method")) - - app.add_route(view, "/") - - if method in ["GET", "POST", "PUT"]: - request, response = getattr(app.test_client, method.lower())("/") - assert response.status == 200 - assert response.text == "first method" - - response = view(request) - assert response.body.decode() == "first method" - - if method in ["DELETE", "PATCH"]: - request, response = getattr(app.test_client, method.lower())("/") - assert response.text == "second method" - - response = view(request) - assert response.body.decode() == "second method" - - -@pytest.mark.parametrize("method", HTTP_METHODS) -def test_composition_view_rejects_invalid_methods(app, method): - view = CompositionView() - view.add(["GET", "POST", "PUT"], lambda x: text("first method")) - - app.add_route(view, "/") - - if method in ["GET", "POST", "PUT"]: - request, response = getattr(app.test_client, method.lower())("/") - assert response.status == 200 - assert response.text == "first method" - - if method in ["DELETE", "PATCH"]: - request, response = getattr(app.test_client, method.lower())("/") - assert response.status == 405 - - -def test_composition_view_deprecation(): - message = ( - "CompositionView has been deprecated and will be removed in v21.12. " - "Please update your view to HTTPMethodView." - ) - with pytest.warns(DeprecationWarning, match=message): - CompositionView()