django / channels

Developer-friendly asynchrony for Django
https://channels.readthedocs.io
BSD 3-Clause "New" or "Revised" License
6.02k stars 793 forks source link

Feature Request: Use a `Scope` class for consumers #1986

Closed christophertubbs closed 1 year ago

christophertubbs commented 1 year ago

self.scope for Consumers is generally a dict-like object, meaning that its contents aren't very predictable and tools like IDEs can't predict what they will contain. In addition or instead of using this dict-like object, a specialized class could be used.

A partial example would look like:

class ConcreteScope:
    """
    A typed object with clear attributes for everything expected in a 'scope' dictionary for websockets
    """
    def __init__(self, scope: dict):
        from django.contrib.sessions.backends.db import SessionStore
        from django.contrib.auth.models import User

        self.__scope = scope
        self.__type: str = scope.get("type")
        self.__path: str = scope.get("path")
        self.__raw_path: bytes = scope.get("raw_path")
        self.__headers: typing.List[typing.Tuple[bytes, bytes]] = scope.get("headers", list())
        self.__query_arguments: typing.Dict[str, typing.List[str]] = parse_qs(scope.get("query_string", ""))
        self.__client_host: str = scope.get("client")[0] if 'client' in scope and len('scope') > 0 else None
        self.__client_port: str = scope.get("client")[-1] if 'client' in scope and len('scope') > 1 else None
        self.__server_host: str = scope.get("server")[0] if 'server' in scope and len('scope') > 0 else None
        self.__server_port: str = scope.get("server")[-1] if 'server' in scope and len('scope') > 1 else None
        self.__asgi: typing.Dict[str, str] = scope.get("asgi", dict())
        self.__cookies: typing.Dict[str, str] = scope.get("cookies", dict())
        self.__session: typing.Optional[SessionStore] = scope.get("session")
        self.__user: typing.Optional[User] = scope.get("user")
        self.__path_remaining: str = scope.get("path_remaining")

        if 'url_route' in scope and 'args' in scope['url_route']:
            self.__arguments: typing.Tuple[str] = scope['url_route']['args'] or tuple()
        else:
            self.__arguments: typing.Tuple[str] = tuple()

        if 'url_route' in scope and 'kwargs' in scope['url_route']:
            self.__kwargs: typing.Dict[str, str] = scope['url_route']['kwargs'] or dict()
        else:
            self.__kwargs: typing.Dict[str, str] = dict()

    # Properties used to access the dunder variables and products thereof

    def get(self, key, default: typing.Any = None) -> typing.Any:
        """
        Call the underlying scope dictionary's `get` function

        Args:
            key: The key for the value to get
            default: A value to return if the key is not present

        Returns:
            The value if the key is present, `None` otherwise
        """
        return self.__scope.get(key, default)

    def __getitem__(self, key):
        return self.__scope[key]

The inclusion of the internal ConcreteScope.__scope variable will ensure that most (if not all) functionality from the original scope variable is still provided, making ConcreteScope a drop in replacement.

carltongibson commented 1 year ago

asgiref.typing provides types you can specify already e.g. HTTPScope

christophertubbs commented 1 year ago

👍

A quick search doesn't show any real mention of the asgiref Scope TypedDicts in the docs, so maybe there's room for me to help contribute to some of them. There's all sort of nifty stuff in there I was completely unaware of, so spreading that knowledge around could help new users.

Thanks for looking into this so fast.