Opened 2 hours ago

#36211 new Bug

Subclassing the "runserver" handler doesn't serve static files

Reported by: Ivan Voras Owned by:
Component: Core (Management commands) Version: 5.1
Severity: Normal Keywords: runserver, autoreload
Cc: Triage Stage: Unreviewed
Has patch: no Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description

Just for convenience of development, not for production, I'm trying to write a "runserver" lookalike command that also starts Celery, and also uses the autoloader to do it. So I've subclassed Django's runserver Command class and redefined the handler(). It took me ages to understand RUN_MAIN and process management, but here's the result:

import atexit
import errno
import logging
import os
import re
import socket
import subprocess
from time import sleep

from django.conf import settings
from django.core.management.base import CommandError
from django.core.servers.basehttp import run
from django.db import connections
from django.utils import autoreload
from django.utils.regex_helper import _lazy_re_compile
from django.core.management.commands.runserver import Command as RunserverCommand

log = logging.getLogger("glassior")

naiveip_re = _lazy_re_compile(
    r"""^(?:
(?P<addr>
    (?P<ipv4>\d{1,3}(?:\.\d{1,3}){3}) |         # IPv4 address
    (?P<ipv6>\[[a-fA-F0-9:]+\]) |               # IPv6 address
    (?P<fqdn>[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*) # FQDN
):)?(?P<port>\d+)$""",
    re.X,
)

celery_process = None

class Command(RunserverCommand):
    help = "Starts a lightweight web server for development and a Celery worker with autoreload."

    def handle(self, *args, **options):
        print('runservercelery: Starting both Django and a Celery worker with autoreload...', os.environ.get("RUN_MAIN", False))

        if not settings.DEBUG and not settings.ALLOWED_HOSTS:
            raise CommandError("You must set settings.ALLOWED_HOSTS if DEBUG is False.")

        self.use_ipv6 = options["use_ipv6"]
        if self.use_ipv6 and not socket.has_ipv6:
            raise CommandError("Your Python does not support IPv6.")
        self._raw_ipv6 = False
        if not options["addrport"]:
            self.addr = ""
            self.port = self.default_port
        else:
            m = re.match(naiveip_re, options["addrport"])
            if m is None:
                raise CommandError(
                    '"%s" is not a valid port number '
                    "or address:port pair." % options["addrport"]
                )
            self.addr, _ipv4, _ipv6, _fqdn, self.port = m.groups()
            if not self.port.isdigit():
                raise CommandError("%r is not a valid port number." % self.port)
            if self.addr:
                if _ipv6:
                    self.addr = self.addr[1:-1]
                    self.use_ipv6 = True
                    self._raw_ipv6 = True
                elif self.use_ipv6 and not _fqdn:
                    raise CommandError('"%s" is not a valid IPv6 address.' % self.addr)
        if not self.addr:
            self.addr = self.default_addr_ipv6 if self.use_ipv6 else self.default_addr
            self._raw_ipv6 = self.use_ipv6

        if options["use_reloader"]:
            autoreload.run_with_reloader(self.main_loop, *args, **options)
        else:
            self.main_loop(*args, **options)

    def start_celery(self):
        global celery_process
        if os.environ.get("RUN_MAIN", None) != 'true':
            return

        celery_process = subprocess.Popen(
            'celery -A glassiorapp worker -l info --without-gossip --without-mingle --without-heartbeat -c 1',
            shell=True,
            process_group=0,
        )
        log.info(f"Started celery worker (PID: {celery_process.pid})")


    def our_inner_run(self, *args, **options) -> int | None:
        """
        Taken from django.core.management.commands.runserver.Command.inner_run.
        Returns exit code (None = no error) instead of calling sys.exit().
        """
        # If an exception was silenced in ManagementUtility.execute in order
        # to be raised in the child process, raise it now.
        autoreload.raise_last_exception()

        threading = False # options["use_threading"]
        # 'shutdown_message' is a stealth option.
        shutdown_message = options.get("shutdown_message", "")

        if not options["skip_checks"]:
            self.stdout.write("Performing system checks...\n\n")
            self.check(display_num_errors=True)
        # Need to check migrations here, so can't use the
        # requires_migrations_check attribute.
        self.check_migrations()
        # Close all connections opened during migration checking.
        for conn in connections.all(initialized_only=True):
            conn.close()

        try:
            handler = self.get_handler(*args, **options)
            run(
                self.addr,
                int(self.port),
                handler,
                ipv6=self.use_ipv6,
                threading=threading,
                on_bind=self.on_bind,
                server_cls=self.server_cls,
            )
        except OSError as e:
            # Use helpful error messages instead of ugly tracebacks.
            ERRORS = {
                errno.EACCES: "You don't have permission to access that port.",
                errno.EADDRINUSE: "That port is already in use.",
                errno.EADDRNOTAVAIL: "That IP address can't be assigned to.",
            }
            try:
                error_text = ERRORS[e.errno]
            except KeyError:
                error_text = e
            self.stderr.write("Error: %s" % error_text)
            # Need to use an OS exit because sys.exit doesn't work in a thread
            return 1
        except KeyboardInterrupt:
            print("**** KeyboardInterrupt") # This is never reached.
            if shutdown_message:
                self.stdout.write(shutdown_message)
            return 0

    def main_loop(self, *args, **options):
        self.start_celery()
        exit_code = self.our_inner_run(*args, **options) # Django's code
        # So, apparently our_inner_run doesn't return. 
        print(f"***** our_inner_run exit_code={exit_code}")


@atexit.register
def stop_celery():
    global celery_process
    if celery_process:
        log.info(f"Stopping celery worker (PID: {celery_process.pid})")
        # It's a mess.
        os.system(f"kill -TERM -{celery_process.pid}")
        celery_process = None
        sleep(1)

This works, BUT it doesn't start the static file server. I don't see anything special in the original handler, or in the new one, that would cause this, so I'm just stumped. Switching between running the original runserver and this one, the original serves static files perfectly fine, and this one returns the "no route found" error:

Using the URLconf defined in glassiorapp.urls, Django tried these URL patterns, in this order:

admin/
g/
The current path, static/web/color_modes.js, didn’t match any of these.

You’re seeing this error because you have DEBUG = True in your Django settings file. Change that to False, and Django will display a standard 404 page.

The actual app is being run and responds to the URL endpoints.

Is there something magical about the default runserver?

Change History (0)

Note: See TracTickets for help on using tickets.
Back to Top