diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e5903e0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml deleted file mode 100644 index d939432..0000000 --- a/.github/workflows/pre-commit.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: pre-commit - -on: - push: - branches: - - main - pull_request: - -jobs: - pre-commit: - runs-on: ubuntu-20.04 - - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - - uses: actions/setup-python@v2 - with: - python-version: 3.9 - - - uses: pre-commit/action@v2.0.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ea93fa7..8d4d1c4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,33 +3,37 @@ name: Tests on: push: branches: - - master + - main pull_request: jobs: tests: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }}-latest strategy: fail-fast: false matrix: + os: + - ubuntu + - windows python-version: - - 3.6 - - 3.7 - - 3.8 - - 3.9 + - "3.7" + - "3.8" + - "3.9" + - "3.10" + - "3.11" steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip setuptools wheel - python -m pip install --upgrade tox tox-py + python -m pip install --upgrade tox - name: Run tox targets for ${{ matrix.python-version }} - run: tox --py current + run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d .) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5de3ae5..3f61b80 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,20 +1,20 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.11.0 + rev: v3.3.1 hooks: - id: pyupgrade args: [--py36-plus] - repo: https://github.com/psf/black - rev: 20.8b1 + rev: 23.3.0 hooks: - id: black language_version: python3 - repo: https://github.com/pycqa/isort - rev: 5.8.0 + rev: 5.12.0 hooks: - id: isort - repo: https://github.com/PyCQA/flake8 - rev: 3.9.0 + rev: 6.0.0 hooks: - id: flake8 additional_dependencies: diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 8463751..996b763 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,54 @@ +4.0.0 (2022-10-07) +------------------ + +Major versioning targeting use with Channels 4.0 and beyond. Except where +noted should remain usable with Channels v3 projects, but updating Channels to the latest version is recommended. + +* Added a ``runserver`` command to run an ASGI Django development server. + + Added ``"daphne"`` to the ``INSTALLED_APPS`` setting, before + ``"django.contrib.staticfiles"`` to enable: + + INSTALLED_APPS = [ + "daphne", + ... + ] + + This replaces the Channels implementation of ``runserver``, which is removed + in Channels 4.0. + +* Made the ``DaphneProcess`` tests helper class compatible with the ``spawn`` + process start method, which is used on macOS and Windows. + + Note that requires Channels v4 if using with ``ChannelsLiveServerTestCase``. + +* Dropped support for Python 3.6. + +* Updated dependencies to the latest versions. + + Previously a range of Twisted versions have been supported. Recent Twisted + releases (22.2, 22.4) have issued security fixes, so those are now the + minimum supported version. Given the stability of Twisted, supporting a + range of versions does not represent a good use of maintainer time. Going + forward the latest Twisted version will be required. + +* Set ``daphne`` as default ``Server`` header. + + This can be configured with the ``--server-name`` CLI argument. + + Added the new ``--no-server-name`` CLI argument to disable the ``Server`` + header, which is equivalent to ``--server-name=` (an empty name). + +* Added ``--log-fmt`` CLI argument. + +* Added support for ``ASGI_THREADS`` environment variable, setting the maximum + number of workers used by a ``SyncToAsync`` thread-pool executor. + + Set e.g. ``ASGI_THREADS=4 daphne ...`` when running to limit the number of + workers. + +* Removed deprecated ``--ws_protocols`` CLI option. + 3.0.2 (2021-04-07) ------------------ diff --git a/README.rst b/README.rst index 5b08d59..4a3f49a 100644 --- a/README.rst +++ b/README.rst @@ -54,7 +54,7 @@ Daphne supports terminating HTTP/2 connections natively. You'll need to do a couple of things to get it working, though. First, you need to make sure you install the Twisted ``http2`` and ``tls`` extras:: - pip install -U 'Twisted[tls,http2]' + pip install -U "Twisted[tls,http2]" Next, because all current browsers only support HTTP/2 when using TLS, you will need to start Daphne with TLS turned on, which can be done using the Twisted endpoint syntax:: @@ -122,7 +122,7 @@ The compression implementation is provided by Python Support -------------- -Daphne requires Python 3.6 or later. +Daphne requires Python 3.7 or later. Contributing diff --git a/daphne/__init__.py b/daphne/__init__.py index 530cbd0..ae556a6 100755 --- a/daphne/__init__.py +++ b/daphne/__init__.py @@ -1,6 +1,6 @@ import sys -__version__ = "3.0.2" +__version__ = "4.0.0" # Windows on Python 3.8+ uses ProactorEventLoop, which is not compatible with diff --git a/daphne/apps.py b/daphne/apps.py new file mode 100644 index 0000000..de104db --- /dev/null +++ b/daphne/apps.py @@ -0,0 +1,16 @@ +# Import the server here to ensure the reactor is installed very early on in case other +# packages import twisted.internet.reactor (e.g. raven does this). +from django.apps import AppConfig +from django.core import checks + +import daphne.server # noqa: F401 + +from .checks import check_daphne_installed + + +class DaphneConfig(AppConfig): + name = "daphne" + verbose_name = "Daphne" + + def ready(self): + checks.register(check_daphne_installed, checks.Tags.staticfiles) diff --git a/daphne/checks.py b/daphne/checks.py new file mode 100644 index 0000000..058ad7e --- /dev/null +++ b/daphne/checks.py @@ -0,0 +1,21 @@ +# Django system check to ensure daphne app is listed in INSTALLED_APPS before django.contrib.staticfiles. +from django.core.checks import Error, register + + +@register() +def check_daphne_installed(app_configs, **kwargs): + from django.apps import apps + from django.contrib.staticfiles.apps import StaticFilesConfig + + from daphne.apps import DaphneConfig + + for app in apps.get_app_configs(): + if isinstance(app, DaphneConfig): + return [] + if isinstance(app, StaticFilesConfig): + return [ + Error( + "Daphne must be listed before django.contrib.staticfiles in INSTALLED_APPS.", + id="daphne.E001", + ) + ] diff --git a/daphne/cli.py b/daphne/cli.py index 923b9d3..7c0c3c9 100755 --- a/daphne/cli.py +++ b/daphne/cli.py @@ -90,6 +90,11 @@ class CommandLineInterface: help="Where to write the access log (- for stdout, the default for verbosity=1)", default=None, ) + self.parser.add_argument( + "--log-fmt", + help="Log format to use", + default="%(asctime)-15s %(levelname)-8s %(message)s", + ) self.parser.add_argument( "--ping-interval", type=int, @@ -108,13 +113,6 @@ class CommandLineInterface: help="The number of seconds an ASGI application has to exit after client disconnect before it is killed", default=10, ) - self.parser.add_argument( - "--ws-protocol", - nargs="*", - dest="ws_protocols", - help="The WebSocket protocols you wish to support", - default=None, - ) self.parser.add_argument( "--root-path", dest="root_path", @@ -157,7 +155,10 @@ class CommandLineInterface: "--server-name", dest="server_name", help="specify which value should be passed to response header Server attribute", - default="Daphne", + default="daphne", + ) + self.parser.add_argument( + "--no-server-name", dest="server_name", action="store_const", const="" ) self.server = None @@ -215,7 +216,7 @@ class CommandLineInterface: 2: logging.DEBUG, 3: logging.DEBUG, # Also turns on asyncio debug }[args.verbosity], - format="%(asctime)-15s %(levelname)-8s %(message)s", + format=args.log_fmt, ) # If verbosity is 1 or greater, or they told us explicitly, set up access log access_log_stream = None @@ -272,7 +273,6 @@ class CommandLineInterface: action_logger=AccessLogGenerator(access_log_stream) if access_log_stream else None, - ws_protocols=args.ws_protocols, root_path=args.root_path, verbosity=args.verbosity, proxy_forwarded_address_header=self._get_forwarded_host(args=args), diff --git a/daphne/http_protocol.py b/daphne/http_protocol.py index 7df7bae..f0657fd 100755 --- a/daphne/http_protocol.py +++ b/daphne/http_protocol.py @@ -50,6 +50,8 @@ class WebRequest(http.Request): ) # Shorten it a bit, bytes wise def __init__(self, *args, **kwargs): + self.client_addr = None + self.server_addr = None try: http.Request.__init__(self, *args, **kwargs) # Easy server link @@ -77,9 +79,6 @@ class WebRequest(http.Request): # requires unicode string. self.client_addr = [str(self.client.host), self.client.port] self.server_addr = [str(self.host.host), self.host.port] - else: - self.client_addr = None - self.server_addr = None self.client_scheme = "https" if self.isSecure() else "http" @@ -250,8 +249,8 @@ class WebRequest(http.Request): # Write headers for header, value in message.get("headers", {}): self.responseHeaders.addRawHeader(header, value) - if self.server.server_name and self.server.server_name.lower() != "daphne": - self.setHeader(b"server", self.server.server_name.encode("utf-8")) + if self.server.server_name and not self.responseHeaders.hasHeader("server"): + self.setHeader(b"server", self.server.server_name.encode()) logger.debug( "HTTP %s response started for %s", message["status"], self.client_addr ) diff --git a/daphne/management/__init__.py b/daphne/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/daphne/management/commands/__init__.py b/daphne/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/daphne/management/commands/runserver.py b/daphne/management/commands/runserver.py new file mode 100644 index 0000000..b2fd2ee --- /dev/null +++ b/daphne/management/commands/runserver.py @@ -0,0 +1,191 @@ +import datetime +import importlib +import logging +import sys + +from django.apps import apps +from django.conf import settings +from django.contrib.staticfiles.handlers import ASGIStaticFilesHandler +from django.core.exceptions import ImproperlyConfigured +from django.core.management import CommandError +from django.core.management.commands.runserver import Command as RunserverCommand + +from daphne import __version__ +from daphne.endpoints import build_endpoint_description_strings +from daphne.server import Server + +logger = logging.getLogger("django.channels.server") + + +def get_default_application(): + """ + Gets the default application, set in the ASGI_APPLICATION setting. + """ + try: + path, name = settings.ASGI_APPLICATION.rsplit(".", 1) + except (ValueError, AttributeError): + raise ImproperlyConfigured("Cannot find ASGI_APPLICATION setting.") + try: + module = importlib.import_module(path) + except ImportError: + raise ImproperlyConfigured("Cannot import ASGI_APPLICATION module %r" % path) + try: + value = getattr(module, name) + except AttributeError: + raise ImproperlyConfigured( + f"Cannot find {name!r} in ASGI_APPLICATION module {path}" + ) + return value + + +class Command(RunserverCommand): + protocol = "http" + server_cls = Server + + def add_arguments(self, parser): + super().add_arguments(parser) + parser.add_argument( + "--noasgi", + action="store_false", + dest="use_asgi", + default=True, + help="Run the old WSGI-based runserver rather than the ASGI-based one", + ) + parser.add_argument( + "--http_timeout", + action="store", + dest="http_timeout", + type=int, + default=None, + help=( + "Specify the daphne http_timeout interval in seconds " + "(default: no timeout)" + ), + ) + parser.add_argument( + "--websocket_handshake_timeout", + action="store", + dest="websocket_handshake_timeout", + type=int, + default=5, + help=( + "Specify the daphne websocket_handshake_timeout interval in " + "seconds (default: 5)" + ), + ) + + def handle(self, *args, **options): + self.http_timeout = options.get("http_timeout", None) + self.websocket_handshake_timeout = options.get("websocket_handshake_timeout", 5) + # Check Channels is installed right + if options["use_asgi"] and not hasattr(settings, "ASGI_APPLICATION"): + raise CommandError( + "You have not set ASGI_APPLICATION, which is needed to run the server." + ) + # Dispatch upward + super().handle(*args, **options) + + def inner_run(self, *args, **options): + # Maybe they want the wsgi one? + if not options.get("use_asgi", True): + if hasattr(RunserverCommand, "server_cls"): + self.server_cls = RunserverCommand.server_cls + return RunserverCommand.inner_run(self, *args, **options) + # Run checks + self.stdout.write("Performing system checks...\n\n") + self.check(display_num_errors=True) + self.check_migrations() + # Print helpful text + quit_command = "CTRL-BREAK" if sys.platform == "win32" else "CONTROL-C" + now = datetime.datetime.now().strftime("%B %d, %Y - %X") + self.stdout.write(now) + self.stdout.write( + ( + "Django version %(version)s, using settings %(settings)r\n" + "Starting ASGI/Daphne version %(daphne_version)s development server" + " at %(protocol)s://%(addr)s:%(port)s/\n" + "Quit the server with %(quit_command)s.\n" + ) + % { + "version": self.get_version(), + "daphne_version": __version__, + "settings": settings.SETTINGS_MODULE, + "protocol": self.protocol, + "addr": "[%s]" % self.addr if self._raw_ipv6 else self.addr, + "port": self.port, + "quit_command": quit_command, + } + ) + + # Launch server in 'main' thread. Signals are disabled as it's still + # actually a subthread under the autoreloader. + logger.debug("Daphne running, listening on %s:%s", self.addr, self.port) + + # build the endpoint description string from host/port options + endpoints = build_endpoint_description_strings(host=self.addr, port=self.port) + try: + self.server_cls( + application=self.get_application(options), + endpoints=endpoints, + signal_handlers=not options["use_reloader"], + action_logger=self.log_action, + http_timeout=self.http_timeout, + root_path=getattr(settings, "FORCE_SCRIPT_NAME", "") or "", + websocket_handshake_timeout=self.websocket_handshake_timeout, + ).run() + logger.debug("Daphne exited") + except KeyboardInterrupt: + shutdown_message = options.get("shutdown_message", "") + if shutdown_message: + self.stdout.write(shutdown_message) + return + + def get_application(self, options): + """ + Returns the static files serving application wrapping the default application, + if static files should be served. Otherwise just returns the default + handler. + """ + staticfiles_installed = apps.is_installed("django.contrib.staticfiles") + use_static_handler = options.get("use_static_handler", staticfiles_installed) + insecure_serving = options.get("insecure_serving", False) + if use_static_handler and (settings.DEBUG or insecure_serving): + return ASGIStaticFilesHandler(get_default_application()) + else: + return get_default_application() + + def log_action(self, protocol, action, details): + """ + Logs various different kinds of requests to the console. + """ + # HTTP requests + if protocol == "http" and action == "complete": + msg = "HTTP %(method)s %(path)s %(status)s [%(time_taken).2f, %(client)s]" + + # Utilize terminal colors, if available + if 200 <= details["status"] < 300: + # Put 2XX first, since it should be the common case + logger.info(self.style.HTTP_SUCCESS(msg), details) + elif 100 <= details["status"] < 200: + logger.info(self.style.HTTP_INFO(msg), details) + elif details["status"] == 304: + logger.info(self.style.HTTP_NOT_MODIFIED(msg), details) + elif 300 <= details["status"] < 400: + logger.info(self.style.HTTP_REDIRECT(msg), details) + elif details["status"] == 404: + logger.warning(self.style.HTTP_NOT_FOUND(msg), details) + elif 400 <= details["status"] < 500: + logger.warning(self.style.HTTP_BAD_REQUEST(msg), details) + else: + # Any 5XX, or any other response + logger.error(self.style.HTTP_SERVER_ERROR(msg), details) + + # Websocket requests + elif protocol == "websocket" and action == "connected": + logger.info("WebSocket CONNECT %(path)s [%(client)s]", details) + elif protocol == "websocket" and action == "disconnected": + logger.info("WebSocket DISCONNECT %(path)s [%(client)s]", details) + elif protocol == "websocket" and action == "connecting": + logger.info("WebSocket HANDSHAKING %(path)s [%(client)s]", details) + elif protocol == "websocket" and action == "rejected": + logger.info("WebSocket REJECT %(path)s [%(client)s]", details) diff --git a/daphne/server.py b/daphne/server.py index 0ea1164..8ca2214 100755 --- a/daphne/server.py +++ b/daphne/server.py @@ -1,10 +1,18 @@ # This has to be done first as Twisted is import-order-sensitive with reactors import asyncio # isort:skip +import os # isort:skip import sys # isort:skip import warnings # isort:skip +from concurrent.futures import ThreadPoolExecutor # isort:skip from twisted.internet import asyncioreactor # isort:skip + twisted_loop = asyncio.new_event_loop() +if "ASGI_THREADS" in os.environ: + twisted_loop.set_default_executor( + ThreadPoolExecutor(max_workers=int(os.environ["ASGI_THREADS"])) + ) + current_reactor = sys.modules.get("twisted.internet.reactor", None) if current_reactor is not None: if not isinstance(current_reactor, asyncioreactor.AsyncioSelectorReactor): @@ -13,6 +21,7 @@ if current_reactor is not None: + "you can fix this warning by importing daphne.server early in your codebase or " + "finding the package that imports Twisted and importing it later on.", UserWarning, + stacklevel=2, ) del sys.modules["twisted.internet.reactor"] asyncioreactor.install(twisted_loop) @@ -62,9 +71,7 @@ class Server: websocket_handshake_timeout=5, application_close_timeout=10, ready_callable=None, - server_name="Daphne", - # Deprecated and does not work, remove in version 2.2 - ws_protocols=None, + server_name="daphne", ): self.application = application self.endpoints = endpoints or [] diff --git a/daphne/testing.py b/daphne/testing.py index e2c7200..785edf9 100644 --- a/daphne/testing.py +++ b/daphne/testing.py @@ -26,6 +26,9 @@ class BaseDaphneTestingInstance: self.request_buffer_size = request_buffer_size self.application = application + def get_application(self): + return self.application + def __enter__(self): # Option Daphne features kwargs = {} @@ -41,7 +44,7 @@ class BaseDaphneTestingInstance: # Start up process self.process = DaphneProcess( host=self.host, - application=self.application, + get_application=self.get_application, kwargs=kwargs, setup=self.process_setup, teardown=self.process_teardown, @@ -123,13 +126,13 @@ class DaphneProcess(multiprocessing.Process): port it ends up listening on back to the parent process. """ - def __init__(self, host, application, kwargs=None, setup=None, teardown=None): + def __init__(self, host, get_application, kwargs=None, setup=None, teardown=None): super().__init__() self.host = host - self.application = application + self.get_application = get_application self.kwargs = kwargs or {} - self.setup = setup or (lambda: None) - self.teardown = teardown or (lambda: None) + self.setup = setup + self.teardown = teardown self.port = multiprocessing.Value("i") self.ready = multiprocessing.Event() self.errors = multiprocessing.Queue() @@ -146,11 +149,13 @@ class DaphneProcess(multiprocessing.Process): from .endpoints import build_endpoint_description_strings from .server import Server + application = self.get_application() + try: # Create the server class endpoints = build_endpoint_description_strings(host=self.host, port=0) self.server = Server( - application=self.application, + application=application, endpoints=endpoints, signal_handlers=False, **self.kwargs @@ -158,11 +163,13 @@ class DaphneProcess(multiprocessing.Process): # Set up a poller to look for the port reactor.callLater(0.1, self.resolve_port) # Run with setup/teardown - self.setup() + if self.setup is not None: + self.setup() try: self.server.run() finally: - self.teardown() + if self.teardown is not None: + self.teardown() except BaseException as e: # Put the error on our queue so the parent gets it self.errors.put((e, traceback.format_exc())) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fed528d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg index ddb3d5d..cdde703 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,53 @@ +[metadata] +name = daphne +version = attr: daphne.__version__ +url = https://github.com/django/daphne +author = Django Software Foundation +author_email = foundation@djangoproject.com +description = Django ASGI (HTTP/WebSocket) server +long_description = file: README.rst +long_description_content_type = text/x-rst +license = BSD +classifiers = + Development Status :: 4 - Beta + Environment :: Web Environment + Intended Audience :: Developers + License :: OSI Approved :: BSD License + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Topic :: Internet :: WWW/HTTP + +[options] +package_dir = + twisted=daphne/twisted +packages = find: +include_package_data = True +install_requires = + asgiref>=3.5.2,<4 + autobahn>=22.4.2 + twisted[tls]>=22.4 +python_requires = >=3.7 +setup_requires = + pytest-runner +zip_safe = False + +[options.entry_points] +console_scripts = + daphne = daphne.cli:CommandLineInterface.entrypoint + +[options.extras_require] +tests = + django + hypothesis + pytest + pytest-asyncio + [flake8] exclude = venv/*,tox/*,docs/*,testproject/*,js_client/*,.eggs/* extend-ignore = E123, E128, E266, E402, W503, E731, W601 @@ -8,3 +58,4 @@ profile = black [tool:pytest] testpaths = tests +asyncio_mode = strict diff --git a/setup.py b/setup.py deleted file mode 100755 index af3b3b9..0000000 --- a/setup.py +++ /dev/null @@ -1,48 +0,0 @@ -import os - -from setuptools import find_packages, setup - -from daphne import __version__ - -# We use the README as the long_description -readme_path = os.path.join(os.path.dirname(__file__), "README.rst") -with open(readme_path) as fp: - long_description = fp.read() - -setup( - name="daphne", - version=__version__, - url="https://github.com/django/daphne", - author="Django Software Foundation", - author_email="foundation@djangoproject.com", - description="Django ASGI (HTTP/WebSocket) server", - long_description=long_description, - license="BSD", - zip_safe=False, - package_dir={"twisted": "daphne/twisted"}, - packages=find_packages() + ["twisted.plugins"], - include_package_data=True, - install_requires=["twisted[tls]>=18.7", "autobahn>=0.18", "asgiref>=3.2.10,<4"], - python_requires=">=3.6", - setup_requires=["pytest-runner"], - extras_require={ - "tests": ["hypothesis==4.23", "pytest~=3.10", "pytest-asyncio~=0.8"] - }, - entry_points={ - "console_scripts": ["daphne = daphne.cli:CommandLineInterface.entrypoint"] - }, - classifiers=[ - "Development Status :: 4 - Beta", - "Environment :: Web Environment", - "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Topic :: Internet :: WWW/HTTP", - ], -) diff --git a/tests/test_checks.py b/tests/test_checks.py new file mode 100644 index 0000000..48b23bd --- /dev/null +++ b/tests/test_checks.py @@ -0,0 +1,21 @@ +import django +from django.conf import settings +from django.test.utils import override_settings + +from daphne.checks import check_daphne_installed + + +def test_check_daphne_installed(): + """ + Test check error is raised if daphne is not listed before staticfiles, and vice versa. + """ + settings.configure( + INSTALLED_APPS=["daphne.apps.DaphneConfig", "django.contrib.staticfiles"] + ) + django.setup() + errors = check_daphne_installed(None) + assert len(errors) == 0 + with override_settings(INSTALLED_APPS=["django.contrib.staticfiles", "daphne"]): + errors = check_daphne_installed(None) + assert len(errors) == 1 + assert errors[0].id == "daphne.E001" diff --git a/tests/test_cli.py b/tests/test_cli.py index 17335ed..8368488 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ import logging +import os from argparse import ArgumentError -from unittest import TestCase +from unittest import TestCase, skipUnless from daphne.cli import CommandLineInterface from daphne.endpoints import build_endpoint_description_strings as build @@ -240,3 +241,27 @@ class TestCLIInterface(TestCase): exc.exception.message, "--proxy-headers has to be passed for this parameter.", ) + + def test_custom_servername(self): + """ + Passing `--server-name` will set the default server header + from 'daphne' to the passed one. + """ + self.assertCLI([], {"server_name": "daphne"}) + self.assertCLI(["--server-name", ""], {"server_name": ""}) + self.assertCLI(["--server-name", "python"], {"server_name": "python"}) + + def test_no_servername(self): + """ + Passing `--no-server-name` will set server name to '' (empty string) + """ + self.assertCLI(["--no-server-name"], {"server_name": ""}) + + +@skipUnless(os.getenv("ASGI_THREADS"), "ASGI_THREADS environment variable not set.") +class TestASGIThreads(TestCase): + def test_default_executor(self): + from daphne.server import twisted_loop + + executor = twisted_loop._default_executor + self.assertEqual(executor._max_workers, int(os.getenv("ASGI_THREADS"))) diff --git a/tests/test_http_protocol.py b/tests/test_http_protocol.py new file mode 100644 index 0000000..024479d --- /dev/null +++ b/tests/test_http_protocol.py @@ -0,0 +1,49 @@ +import unittest + +from daphne.http_protocol import WebRequest + + +class MockServer: + """ + Mock server object for testing. + """ + + def protocol_connected(self, *args, **kwargs): + pass + + +class MockFactory: + """ + Mock factory object for testing. + """ + + def __init__(self): + self.server = MockServer() + + +class MockChannel: + """ + Mock channel object for testing. + """ + + def __init__(self): + self.factory = MockFactory() + self.transport = None + + def getPeer(self, *args, **kwargs): + return "peer" + + def getHost(self, *args, **kwargs): + return "host" + + +class TestHTTPProtocol(unittest.TestCase): + """ + Tests the HTTP protocol classes. + """ + + def test_web_request_initialisation(self): + channel = MockChannel() + request = WebRequest(channel) + self.assertIsNone(request.client_addr) + self.assertIsNone(request.server_addr) diff --git a/tests/test_http_response.py b/tests/test_http_response.py index 0f42df2..22f6480 100644 --- a/tests/test_http_response.py +++ b/tests/test_http_response.py @@ -13,10 +13,11 @@ class TestHTTPResponse(DaphneTestCase): Lowercases and sorts headers, and strips transfer-encoding ones. """ return sorted( - [ + [(b"server", b"daphne")] + + [ (name.lower(), value.strip()) for name, value in headers - if name.lower() != b"transfer-encoding" + if name.lower() not in (b"server", b"transfer-encoding") ] ) diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 93a03b0..9cbb9a1 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -342,7 +342,7 @@ async def cancelling_application(scope, receive, send): from twisted.internet import reactor # Stop the server after a short delay so that the teardown is run. - reactor.callLater(2, lambda: reactor.stop()) + reactor.callLater(2, reactor.stop) await send({"type": "websocket.accept"}) raise asyncio.CancelledError() diff --git a/tox.ini b/tox.ini index c507a8b..305bf14 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,8 @@ [tox] envlist = - py{36,37,38,39}-twisted{187,latest} + py{37,38,39,310,311} [testenv] -usedevelop = true extras = tests commands = pytest -v {posargs} -deps = - twisted187: twisted==18.7.0 - twistedlatest: twisted>=20.3.0