diff --git a/.appveyor.yml b/.appveyor.yml index e983faa0..2d994cf5 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -17,11 +17,11 @@ environment: PYTHON_VERSION: "3.8.x" PYTHON_ARCH: "64" - - TOXENV: py39-no-ext - PYTHON: "C:\\Python39-x64\\python" - PYTHONPATH: "C:\\Python39-x64" - PYTHON_VERSION: "3.9.x" - PYTHON_ARCH: "64" + # - TOXENV: py39-no-ext + # PYTHON: "C:\\Python39-x64\\python" + # PYTHONPATH: "C:\\Python39-x64" + # PYTHON_VERSION: "3.9.x" + # PYTHON_ARCH: "64" init: SET "PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" diff --git a/docs/sanic/getting_started.rst b/docs/sanic/getting_started.rst index 97613360..a781e43a 100644 --- a/docs/sanic/getting_started.rst +++ b/docs/sanic/getting_started.rst @@ -60,3 +60,26 @@ Open the address `http://0.0.0.0:8000 `_ in your web browse the message *Hello world!*. You now have a working Sanic server! + +5. Application registry +----------------------- + +When you instantiate a Sanic instance, that can be retrieved at a later time from the Sanic app registry. This can be useful, for example, if you need to access your Sanic instance from a location where it is not otherwise accessible. + +.. code-block:: python + + # ./path/to/server.py + from sanic import Sanic + + app = Sanic("my_awesome_server") + + # ./path/to/somewhere_else.py + from sanic import Sanic + + app = Sanic.get_app("my_awesome_server") + +If you call ``Sanic.get_app("non-existing")`` on an app that does not exist, it will raise ``SanicException`` by default. You can, instead, force the method to return a new instance of ``Sanic`` with that name: + +.. code-block:: python + + app = Sanic.get_app("my_awesome_server", force_create=True) diff --git a/docs/sanic/routing.rst b/docs/sanic/routing.rst index 391a5225..9b110d68 100644 --- a/docs/sanic/routing.rst +++ b/docs/sanic/routing.rst @@ -133,7 +133,7 @@ which allows the handler function to work with any of the HTTP methods in the li async def get_handler(request): return text('GET request - {}'.format(request.args)) -There is also an optional `host` argument (which can be a list or a string). This restricts a route to the host or hosts provided. If there is a also a route with no host, it will be the default. +There is also an optional `host` argument (which can be a list or a string). This restricts a route to the host or hosts provided. If there is also a route with no host, it will be the default. .. code-block:: python diff --git a/docs/sanic/testing.rst b/docs/sanic/testing.rst index 0cf3ff40..b65bf4a2 100644 --- a/docs/sanic/testing.rst +++ b/docs/sanic/testing.rst @@ -58,10 +58,6 @@ More information about the available arguments to `httpx` can be found [in the documentation for `httpx `_. -Additionally, Sanic has an asynchronous testing client. The difference is that the async client will not stand up an -instance of your application, but will instead reach inside it using ASGI. All listeners and middleware are still -executed. - .. code-block:: python @pytest.mark.asyncio async def test_index_returns_200(): diff --git a/sanic/app.py b/sanic/app.py index 15a0d18e..81cd66d1 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -2,12 +2,11 @@ import logging import logging.config import os import re -import warnings from asyncio import CancelledError, Protocol, ensure_future, get_event_loop from collections import defaultdict, deque from functools import partial -from inspect import getmodulename, isawaitable, signature, stack +from inspect import isawaitable, signature from socket import socket from ssl import Purpose, SSLContext, create_default_context from traceback import format_exc @@ -38,6 +37,9 @@ from sanic.websocket import ConnectionClosed, WebSocketProtocol class Sanic: + _app_registry: Dict[str, "Sanic"] = {} + test_mode = False + def __init__( self, name=None, @@ -52,15 +54,10 @@ class Sanic: # Get name from previous stack frame if name is None: - warnings.warn( - "Sanic(name=None) is deprecated and None value support " - "for `name` will be removed in the next release. " + raise SanicException( + "Sanic instance cannot be unnamed. " "Please use Sanic(name='your_application_name') instead.", - DeprecationWarning, - stacklevel=2, ) - frame_records = stack()[1] - name = getmodulename(frame_records[1]) # logging if configure_logging: @@ -90,7 +87,8 @@ class Sanic: self.named_response_middleware = {} # Register alternative method names self.go_fast = self.run - self.test_mode = False + + self.__class__.register_app(self) @property def loop(self): @@ -1401,9 +1399,36 @@ class Sanic: # -------------------------------------------------------------------- # # Configuration # -------------------------------------------------------------------- # + def update_config(self, config: Union[bytes, str, dict, Any]): """Update app.config. Please refer to config.py::Config.update_config for documentation.""" self.config.update_config(config) + + # -------------------------------------------------------------------- # + # Class methods + # -------------------------------------------------------------------- # + + @classmethod + def register_app(cls, app: "Sanic") -> None: + """Register a Sanic instance""" + if not isinstance(app, cls): + raise SanicException("Registered app must be an instance of Sanic") + + name = app.name + if name in cls._app_registry and not cls.test_mode: + raise SanicException(f'Sanic app name "{name}" already in use.') + + cls._app_registry[name] = app + + @classmethod + def get_app(cls, name: str, *, force_create: bool = False) -> "Sanic": + """Retrieve an instantiated Sanic instance""" + try: + return cls._app_registry[name] + except KeyError: + if force_create: + return cls(name) + raise SanicException(f'Sanic app name "{name}" not found.') diff --git a/sanic/static.py b/sanic/static.py index c9c29fba..0a75d9dc 100644 --- a/sanic/static.py +++ b/sanic/static.py @@ -13,6 +13,7 @@ from sanic.exceptions import ( InvalidUsage, ) from sanic.handlers import ContentRangeHandler +from sanic.log import error_logger from sanic.response import HTTPResponse, file, file_stream @@ -40,6 +41,10 @@ async def _static_request_handler( # match filenames which got encoded (filenames with spaces etc) file_path = path.abspath(unquote(file_path)) if not file_path.startswith(path.abspath(unquote(root_path))): + error_logger.exception( + f"File not found: path={file_or_directory}, " + f"relative_url={file_uri}" + ) raise FileNotFound( "File not found", path=file_or_directory, relative_url=file_uri ) @@ -94,6 +99,10 @@ async def _static_request_handler( except ContentRangeError: raise except Exception: + error_logger.exception( + f"File not found: path={file_or_directory}, " + f"relative_url={file_uri}" + ) raise FileNotFound( "File not found", path=file_or_directory, relative_url=file_uri ) diff --git a/setup.py b/setup.py index a0e4bf1e..fc19e20a 100644 --- a/setup.py +++ b/setup.py @@ -5,6 +5,7 @@ import codecs import os import re import sys + from distutils.util import strtobool from setuptools import setup @@ -24,6 +25,7 @@ class PyTest(TestCommand): def run_tests(self): import shlex + import pytest errno = pytest.main(shlex.split(self.pytest_args)) @@ -38,7 +40,9 @@ def open_local(paths, mode="r", encoding="utf8"): with open_local(["sanic", "__version__.py"], encoding="latin1") as fp: try: - version = re.findall(r"^__version__ = \"([^']+)\"\r?$", fp.read(), re.M)[0] + version = re.findall( + r"^__version__ = \"([^']+)\"\r?$", fp.read(), re.M + )[0] except IndexError: raise RuntimeError("Unable to determine version.") @@ -72,7 +76,9 @@ setup_kwargs = { "entry_points": {"console_scripts": ["sanic = sanic.__main__:main"]}, } -env_dependency = '; sys_platform != "win32" ' 'and implementation_name == "cpython"' +env_dependency = ( + '; sys_platform != "win32" ' 'and implementation_name == "cpython"' +) ujson = "ujson>=1.35" + env_dependency uvloop = "uvloop>=0.5.3" + env_dependency @@ -89,9 +95,9 @@ requirements = [ tests_require = [ "pytest==5.2.1", "multidict>=5.0,<6.0", - "gunicorn", + "gunicorn==20.0.4", "pytest-cov", - "httpcore==0.3.0", + "httpcore==0.11.*", "beautifulsoup4", uvloop, ujson, diff --git a/tests/conftest.py b/tests/conftest.py index 45963863..3d57ac73 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,7 @@ from sanic.router import RouteExists, Router random.seed("Pack my box with five dozen liquor jugs.") +Sanic.test_mode = True if sys.platform in ["win32", "cygwin"]: collect_ignore = ["test_worker.py"] diff --git a/tests/test_app.py b/tests/test_app.py index ad85c3c2..7527b1c2 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -258,7 +258,7 @@ def test_handle_request_with_nested_sanic_exception(app, monkeypatch, caplog): def test_app_name_required(): - with pytest.deprecated_call(): + with pytest.raises(SanicException): Sanic() @@ -274,14 +274,35 @@ def test_app_has_test_mode_sync(): assert response.status == 200 -# @pytest.mark.asyncio -# async def test_app_has_test_mode_async(): -# app = Sanic("test") +def test_app_registry(): + instance = Sanic("test") + assert Sanic._app_registry["test"] is instance -# @app.get("/") -# async def handler(request): -# assert request.app.test_mode -# return text("test") -# _, response = await app.asgi_client.get("/") -# assert response.status == 200 +def test_app_registry_wrong_type(): + with pytest.raises(SanicException): + Sanic.register_app(1) + + +def test_app_registry_name_reuse(): + Sanic("test") + Sanic.test_mode = False + with pytest.raises(SanicException): + Sanic("test") + Sanic.test_mode = True + + +def test_app_registry_retrieval(): + instance = Sanic("test") + assert Sanic.get_app("test") is instance + + +def test_get_app_does_not_exist(): + with pytest.raises(SanicException): + Sanic.get_app("does-not-exist") + + +def test_get_app_does_not_exist_force_create(): + assert isinstance( + Sanic.get_app("does-not-exist", force_create=True), Sanic + ) diff --git a/tox.ini b/tox.ini index 908f45c8..49998c98 100644 --- a/tox.ini +++ b/tox.ini @@ -7,18 +7,18 @@ setenv = {py36,py37,py38,py39,pyNightly}-no-ext: SANIC_NO_UJSON=1 {py36,py37,py38,py39,pyNightly}-no-ext: SANIC_NO_UVLOOP=1 deps = - coverage + coverage==5.3 pytest==5.2.1 pytest-cov pytest-sanic pytest-sugar pytest-benchmark pytest-dependency - httpcore==0.3.0 + httpcore==0.11.* httpx==0.15.4 - chardet<=2.3.0 + chardet==3.* beautifulsoup4 - gunicorn + gunicorn==20.0.4 uvicorn websockets>=8.1,<9.0 commands = @@ -76,7 +76,7 @@ deps = recommonmark>=0.5.0 docutils pygments - gunicorn + gunicorn==20.0.4 commands = make docs-test