From 33d3a99bef8ef86d87e9f71300d2b7e1462bd6f1 Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Tue, 6 May 2025 11:41:04 +0700 Subject: [PATCH 01/12] Add mypy plugin support to stubtest configuration --- CONTRIBUTING.md | 6 ++++++ lib/ts_utils/metadata.py | 15 ++++++++++++++- lib/ts_utils/mypy.py | 15 +++++++++++++-- tests/README.md | 17 +++++++++++++++++ tests/check_typeshed_structure.py | 5 ++++- tests/stubtest_third_party.py | 2 +- 6 files changed, 55 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e6645ede68bc..34e341d0272e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -229,6 +229,12 @@ This has the following keys: If not specified, stubtest is run only on `linux`. Only add extra OSes to the test if there are platform-specific branches in a stubs package. +* `mypy_plugins` (default: `[]`): A list of Python modules to use as mypy plugins +when running stubtest. For example: `mypy_plugins = ["mypy_django_plugin.main"]` +* `mypy_plugins_config` (default: `{}`): A dictionary mapping plugin names to their +configuration dictionaries for use by mypy plugins. For example: +`mypy_plugins_config = {"django-stubs" = {"django_settings_module" = "django_settings"}}` + `*_dependencies` are usually packages needed to `pip install` the implementation distribution. diff --git a/lib/ts_utils/metadata.py b/lib/ts_utils/metadata.py index ec30f9301425..e8e0f73d5f33 100644 --- a/lib/ts_utils/metadata.py +++ b/lib/ts_utils/metadata.py @@ -11,7 +11,7 @@ from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path -from typing import Annotated, Final, NamedTuple, final +from typing import Annotated, Final, NamedTuple, final, Any from typing_extensions import TypeGuard import tomli @@ -41,6 +41,9 @@ def _is_list_of_strings(obj: object) -> TypeGuard[list[str]]: return isinstance(obj, list) and all(isinstance(item, str) for item in obj) +def _is_nested_dict(obj: object) -> TypeGuard[dict[str, dict[str, Any]]]: + return isinstance(obj, dict) and all(isinstance(item, dict) for item in obj.values()) + @functools.cache def _get_oldest_supported_python() -> str: @@ -71,6 +74,8 @@ class StubtestSettings: ignore_missing_stub: bool platforms: list[str] stubtest_requirements: list[str] + mypy_plugins: list[str] + mypy_plugins_config: dict[str, dict[str, Any]] def system_requirements_for_platform(self, platform: str) -> list[str]: assert platform in _STUBTEST_PLATFORM_MAPPING, f"Unrecognised platform {platform!r}" @@ -93,6 +98,8 @@ def read_stubtest_settings(distribution: str) -> StubtestSettings: ignore_missing_stub: object = data.get("ignore_missing_stub", False) specified_platforms: object = data.get("platforms", ["linux"]) stubtest_requirements: object = data.get("stubtest_requirements", []) + mypy_plugins: object = data.get("mypy_plugins", []) + mypy_plugins_config: object = data.get("mypy_plugins_config", {}) assert type(skip) is bool assert type(ignore_missing_stub) is bool @@ -104,6 +111,8 @@ def read_stubtest_settings(distribution: str) -> StubtestSettings: assert _is_list_of_strings(choco_dependencies) assert _is_list_of_strings(extras) assert _is_list_of_strings(stubtest_requirements) + assert _is_list_of_strings(mypy_plugins) + assert _is_nested_dict(mypy_plugins_config) unrecognised_platforms = set(specified_platforms) - _STUBTEST_PLATFORM_MAPPING.keys() assert not unrecognised_platforms, f"Unrecognised platforms specified for {distribution!r}: {unrecognised_platforms}" @@ -124,6 +133,8 @@ def read_stubtest_settings(distribution: str) -> StubtestSettings: ignore_missing_stub=ignore_missing_stub, platforms=specified_platforms, stubtest_requirements=stubtest_requirements, + mypy_plugins=mypy_plugins, + mypy_plugins_config=mypy_plugins_config, ) @@ -179,6 +190,8 @@ def is_obsolete(self) -> bool: "ignore_missing_stub", "platforms", "stubtest_requirements", + "mypy_plugins", + "mypy_plugins_config", } } _DIST_NAME_RE: Final = re.compile(r"^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$", re.IGNORECASE) diff --git a/lib/ts_utils/mypy.py b/lib/ts_utils/mypy.py index 7fc050b155d1..dc058539cdb0 100644 --- a/lib/ts_utils/mypy.py +++ b/lib/ts_utils/mypy.py @@ -6,7 +6,7 @@ import tomli -from ts_utils.metadata import metadata_path +from ts_utils.metadata import metadata_path, StubtestSettings from ts_utils.utils import NamedTemporaryFile, TemporaryFileWrapper @@ -50,7 +50,7 @@ def validate_configuration(section_name: str, mypy_section: dict[str, Any]) -> M @contextmanager -def temporary_mypy_config_file(configurations: Iterable[MypyDistConf]) -> Generator[TemporaryFileWrapper[str]]: +def temporary_mypy_config_file(configurations: Iterable[MypyDistConf], stubtest_settings: StubtestSettings | None = None) -> Generator[TemporaryFileWrapper[str]]: temp = NamedTemporaryFile("w+") try: for dist_conf in configurations: @@ -58,6 +58,17 @@ def temporary_mypy_config_file(configurations: Iterable[MypyDistConf]) -> Genera for k, v in dist_conf.values.items(): temp.write(f"{k} = {v}\n") temp.write("[mypy]\n") + + if stubtest_settings: + if stubtest_settings.mypy_plugins: + temp.write(f"plugins = {'.'.join(stubtest_settings.mypy_plugins)}\n") + + if stubtest_settings.mypy_plugins_config: + for plugin_name, plugin_dict in stubtest_settings.mypy_plugins_config.items(): + temp.write(f"[mypy.plugins.{plugin_name}]\n") + for k, v in plugin_dict.items(): + temp.write(f"{k} = {v}\n") + temp.flush() yield temp finally: diff --git a/tests/README.md b/tests/README.md index a00b1733146c..9df4f9c7ac6b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -196,6 +196,23 @@ that stubtest reports to be missing should necessarily be added to the stub. For some implementation details, it is often better to add allowlist entries for missing objects rather than trying to match the runtime in every detail. +### Support for mypy plugins in stubtest + +For stubs that require mypy plugins to check correctly (such as Django), stubtest +supports configuring mypy plugins through the METADATA.toml file. This allows stubtest to +leverage type information provided by these plugins when validating stubs. + +To use this feature, add the following configuration to the `tool.stubtest` section in your METADATA.toml: + +```toml +mypy_plugins = ["mypy_django_plugin.main"] +mypy_plugins_config = { "django-stubs" = { "django_settings_module" = "django_settings" } } +``` + +For Django stubs specifically, you'll need to create a `django_settings.py` file in your test directory +that contains the Django settings required by the plugin. This file will be referenced by the plugin +configuration to properly validate Django-specific types during stubtest execution. + ## typecheck\_typeshed.py Run using diff --git a/tests/check_typeshed_structure.py b/tests/check_typeshed_structure.py index bcb02061e055..672dd01a34f3 100755 --- a/tests/check_typeshed_structure.py +++ b/tests/check_typeshed_structure.py @@ -72,7 +72,10 @@ def check_stubs() -> None: ), f"Directory name must be a valid distribution name: {dist}" assert not dist.name.startswith("types-"), f"Directory name not allowed to start with 'types-': {dist}" - allowed = {"METADATA.toml", "README", "README.md", "README.rst", TESTS_DIR} + extra_allowed_files = { + "django_settings.py", # This file contains Django settings used by the mypy_django_plugin during stubtest execution. + } + allowed = {"METADATA.toml", "README", "README.md", "README.rst", *extra_allowed_files, TESTS_DIR} assert_consistent_filetypes(dist, kind=".pyi", allowed=allowed) tests_dir = tests_path(dist.name) diff --git a/tests/stubtest_third_party.py b/tests/stubtest_third_party.py index 0530f6279628..1b853c1c408e 100755 --- a/tests/stubtest_third_party.py +++ b/tests/stubtest_third_party.py @@ -97,7 +97,7 @@ def run_stubtest( return False mypy_configuration = mypy_configuration_from_distribution(dist_name) - with temporary_mypy_config_file(mypy_configuration) as temp: + with temporary_mypy_config_file(mypy_configuration, stubtest_settings) as temp: ignore_missing_stub = ["--ignore-missing-stub"] if stubtest_settings.ignore_missing_stub else [] packages_to_check = [d.name for d in dist.iterdir() if d.is_dir() and d.name.isidentifier()] modules_to_check = [d.stem for d in dist.iterdir() if d.is_file() and d.suffix == ".pyi"] From 7a879ac3ed88fa79a7bc63e478245849d08d305b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 6 May 2025 04:43:40 +0000 Subject: [PATCH 02/12] [pre-commit.ci] auto fixes from pre-commit.com hooks --- lib/ts_utils/metadata.py | 3 ++- lib/ts_utils/mypy.py | 8 +++++--- tests/check_typeshed_structure.py | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/ts_utils/metadata.py b/lib/ts_utils/metadata.py index e8e0f73d5f33..fd21a5bbe4a3 100644 --- a/lib/ts_utils/metadata.py +++ b/lib/ts_utils/metadata.py @@ -11,7 +11,7 @@ from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path -from typing import Annotated, Final, NamedTuple, final, Any +from typing import Annotated, Any, Final, NamedTuple, final from typing_extensions import TypeGuard import tomli @@ -41,6 +41,7 @@ def _is_list_of_strings(obj: object) -> TypeGuard[list[str]]: return isinstance(obj, list) and all(isinstance(item, str) for item in obj) + def _is_nested_dict(obj: object) -> TypeGuard[dict[str, dict[str, Any]]]: return isinstance(obj, dict) and all(isinstance(item, dict) for item in obj.values()) diff --git a/lib/ts_utils/mypy.py b/lib/ts_utils/mypy.py index dc058539cdb0..39f4255ec011 100644 --- a/lib/ts_utils/mypy.py +++ b/lib/ts_utils/mypy.py @@ -6,7 +6,7 @@ import tomli -from ts_utils.metadata import metadata_path, StubtestSettings +from ts_utils.metadata import StubtestSettings, metadata_path from ts_utils.utils import NamedTemporaryFile, TemporaryFileWrapper @@ -50,7 +50,9 @@ def validate_configuration(section_name: str, mypy_section: dict[str, Any]) -> M @contextmanager -def temporary_mypy_config_file(configurations: Iterable[MypyDistConf], stubtest_settings: StubtestSettings | None = None) -> Generator[TemporaryFileWrapper[str]]: +def temporary_mypy_config_file( + configurations: Iterable[MypyDistConf], stubtest_settings: StubtestSettings | None = None +) -> Generator[TemporaryFileWrapper[str]]: temp = NamedTemporaryFile("w+") try: for dist_conf in configurations: @@ -62,7 +64,7 @@ def temporary_mypy_config_file(configurations: Iterable[MypyDistConf], stubtest_ if stubtest_settings: if stubtest_settings.mypy_plugins: temp.write(f"plugins = {'.'.join(stubtest_settings.mypy_plugins)}\n") - + if stubtest_settings.mypy_plugins_config: for plugin_name, plugin_dict in stubtest_settings.mypy_plugins_config.items(): temp.write(f"[mypy.plugins.{plugin_name}]\n") diff --git a/tests/check_typeshed_structure.py b/tests/check_typeshed_structure.py index 672dd01a34f3..1ae62295df94 100755 --- a/tests/check_typeshed_structure.py +++ b/tests/check_typeshed_structure.py @@ -73,7 +73,7 @@ def check_stubs() -> None: assert not dist.name.startswith("types-"), f"Directory name not allowed to start with 'types-': {dist}" extra_allowed_files = { - "django_settings.py", # This file contains Django settings used by the mypy_django_plugin during stubtest execution. + "django_settings.py" # This file contains Django settings used by the mypy_django_plugin during stubtest execution. } allowed = {"METADATA.toml", "README", "README.md", "README.rst", *extra_allowed_files, TESTS_DIR} assert_consistent_filetypes(dist, kind=".pyi", allowed=allowed) From 6d3dac130c8c7d901f6be871643afb2c7c8169b2 Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Tue, 6 May 2025 12:07:04 +0700 Subject: [PATCH 03/12] Remove allowing django_settings.py file, and suggest put django settings file to @tests dir instead --- CONTRIBUTING.md | 2 +- tests/README.md | 4 ++-- tests/check_typeshed_structure.py | 5 +---- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34e341d0272e..60ac72a9ad05 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -233,7 +233,7 @@ This has the following keys: when running stubtest. For example: `mypy_plugins = ["mypy_django_plugin.main"]` * `mypy_plugins_config` (default: `{}`): A dictionary mapping plugin names to their configuration dictionaries for use by mypy plugins. For example: -`mypy_plugins_config = {"django-stubs" = {"django_settings_module" = "django_settings"}}` +`mypy_plugins_config = {"django-stubs" = {"django_settings_module" = "@tests.jango_settings"}}` `*_dependencies` are usually packages needed to `pip install` the implementation diff --git a/tests/README.md b/tests/README.md index 9df4f9c7ac6b..e2fd81fb7362 100644 --- a/tests/README.md +++ b/tests/README.md @@ -206,10 +206,10 @@ To use this feature, add the following configuration to the `tool.stubtest` sect ```toml mypy_plugins = ["mypy_django_plugin.main"] -mypy_plugins_config = { "django-stubs" = { "django_settings_module" = "django_settings" } } +mypy_plugins_config = { "django-stubs" = { "django_settings_module" = "@tests.django_settings" } } ``` -For Django stubs specifically, you'll need to create a `django_settings.py` file in your test directory +For Django stubs specifically, you'll need to create a `django_settings.py` file in your `@tests` directory that contains the Django settings required by the plugin. This file will be referenced by the plugin configuration to properly validate Django-specific types during stubtest execution. diff --git a/tests/check_typeshed_structure.py b/tests/check_typeshed_structure.py index 1ae62295df94..bcb02061e055 100755 --- a/tests/check_typeshed_structure.py +++ b/tests/check_typeshed_structure.py @@ -72,10 +72,7 @@ def check_stubs() -> None: ), f"Directory name must be a valid distribution name: {dist}" assert not dist.name.startswith("types-"), f"Directory name not allowed to start with 'types-': {dist}" - extra_allowed_files = { - "django_settings.py" # This file contains Django settings used by the mypy_django_plugin during stubtest execution. - } - allowed = {"METADATA.toml", "README", "README.md", "README.rst", *extra_allowed_files, TESTS_DIR} + allowed = {"METADATA.toml", "README", "README.md", "README.rst", TESTS_DIR} assert_consistent_filetypes(dist, kind=".pyi", allowed=allowed) tests_dir = tests_path(dist.name) From ce0740fcfd3945589b643922e929df1bad934ab6 Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Tue, 6 May 2025 12:19:56 +0700 Subject: [PATCH 04/12] Allow django_settings.py file to put in @tests folder --- tests/check_typeshed_structure.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/check_typeshed_structure.py b/tests/check_typeshed_structure.py index bcb02061e055..2ab6b335fd1a 100755 --- a/tests/check_typeshed_structure.py +++ b/tests/check_typeshed_structure.py @@ -27,6 +27,10 @@ # consistent CI runs. linters = {"mypy", "pyright", "pytype", "ruff"} +ALLOWED_PY_FILES_IN_TESTS_DIR = { + "django_settings.py" # This file contains Django settings used by the mypy_django_plugin during stubtest execution. +} + def assert_consistent_filetypes( directory: Path, *, kind: str, allowed: set[str], allow_nonidentifier_filenames: bool = False @@ -81,7 +85,9 @@ def check_stubs() -> None: def check_tests_dir(tests_dir: Path) -> None: - py_files_present = any(file.suffix == ".py" for file in tests_dir.iterdir()) + py_files_present = any( + file.suffix == ".py" and file.name not in ALLOWED_PY_FILES_IN_TESTS_DIR for file in tests_dir.iterdir() + ) error_message = f"Test-case files must be in an `{TESTS_DIR}/{TEST_CASES_DIR}` directory, not in the `{TESTS_DIR}` directory" assert not py_files_present, error_message From 307524e85712f2913b67026100814d1ec1d17ae7 Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Tue, 6 May 2025 12:35:08 +0700 Subject: [PATCH 05/12] Correct doc typo --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 60ac72a9ad05..47c40eb5e175 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -233,7 +233,7 @@ This has the following keys: when running stubtest. For example: `mypy_plugins = ["mypy_django_plugin.main"]` * `mypy_plugins_config` (default: `{}`): A dictionary mapping plugin names to their configuration dictionaries for use by mypy plugins. For example: -`mypy_plugins_config = {"django-stubs" = {"django_settings_module" = "@tests.jango_settings"}}` +`mypy_plugins_config = {"django-stubs" = {"django_settings_module" = "@tests.django_settings"}}` `*_dependencies` are usually packages needed to `pip install` the implementation From bd18512086b077948e9335a483b2979498160da2 Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Tue, 6 May 2025 20:18:55 +0700 Subject: [PATCH 06/12] Test the nested dict keys also for TypeGuard _is_nested_dict --- lib/ts_utils/metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ts_utils/metadata.py b/lib/ts_utils/metadata.py index fd21a5bbe4a3..2cf093ffc4a4 100644 --- a/lib/ts_utils/metadata.py +++ b/lib/ts_utils/metadata.py @@ -43,7 +43,7 @@ def _is_list_of_strings(obj: object) -> TypeGuard[list[str]]: def _is_nested_dict(obj: object) -> TypeGuard[dict[str, dict[str, Any]]]: - return isinstance(obj, dict) and all(isinstance(item, dict) for item in obj.values()) + return isinstance(obj, dict) and all(isinstance(k, str) and isinstance(v, dict) for k, v in obj.items()) @functools.cache From c12cd7823673420b71df5bd0d93abf8d9127af81 Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Sun, 4 May 2025 00:11:54 +0700 Subject: [PATCH 07/12] Django channels stubs --- pyrightconfig.stricter.json | 1 + stubs/channels/METADATA.toml | 8 ++ stubs/channels/channels/__init__.pyi | 2 + stubs/channels/channels/apps.pyi | 6 ++ stubs/channels/channels/auth.pyi | 32 +++++++ stubs/channels/channels/consumer.pyi | 54 +++++++++++ stubs/channels/channels/db.pyi | 15 +++ stubs/channels/channels/exceptions.pyi | 8 ++ stubs/channels/channels/generic/__init__.pyi | 0 stubs/channels/channels/generic/http.pyi | 19 ++++ stubs/channels/channels/generic/websocket.pyi | 64 +++++++++++++ stubs/channels/channels/layers.pyi | 96 +++++++++++++++++++ .../channels/channels/management/__init__.pyi | 0 .../channels/management/commands/__init__.pyi | 0 .../management/commands/runworker.pyi | 20 ++++ stubs/channels/channels/middleware.pyi | 12 +++ stubs/channels/channels/routing.pyi | 31 ++++++ stubs/channels/channels/security/__init__.pyi | 0 .../channels/channels/security/websocket.pyi | 25 +++++ stubs/channels/channels/sessions.pyi | 53 ++++++++++ stubs/channels/channels/testing/__init__.pyi | 6 ++ .../channels/channels/testing/application.pyi | 12 +++ stubs/channels/channels/testing/http.pyi | 39 ++++++++ stubs/channels/channels/testing/live.pyi | 34 +++++++ stubs/channels/channels/testing/websocket.pyi | 50 ++++++++++ stubs/channels/channels/utils.pyi | 15 +++ stubs/channels/channels/worker.pyi | 13 +++ 27 files changed, 615 insertions(+) create mode 100644 stubs/channels/METADATA.toml create mode 100644 stubs/channels/channels/__init__.pyi create mode 100644 stubs/channels/channels/apps.pyi create mode 100644 stubs/channels/channels/auth.pyi create mode 100644 stubs/channels/channels/consumer.pyi create mode 100644 stubs/channels/channels/db.pyi create mode 100644 stubs/channels/channels/exceptions.pyi create mode 100644 stubs/channels/channels/generic/__init__.pyi create mode 100644 stubs/channels/channels/generic/http.pyi create mode 100644 stubs/channels/channels/generic/websocket.pyi create mode 100644 stubs/channels/channels/layers.pyi create mode 100644 stubs/channels/channels/management/__init__.pyi create mode 100644 stubs/channels/channels/management/commands/__init__.pyi create mode 100644 stubs/channels/channels/management/commands/runworker.pyi create mode 100644 stubs/channels/channels/middleware.pyi create mode 100644 stubs/channels/channels/routing.pyi create mode 100644 stubs/channels/channels/security/__init__.pyi create mode 100644 stubs/channels/channels/security/websocket.pyi create mode 100644 stubs/channels/channels/sessions.pyi create mode 100644 stubs/channels/channels/testing/__init__.pyi create mode 100644 stubs/channels/channels/testing/application.pyi create mode 100644 stubs/channels/channels/testing/http.pyi create mode 100644 stubs/channels/channels/testing/live.pyi create mode 100644 stubs/channels/channels/testing/websocket.pyi create mode 100644 stubs/channels/channels/utils.pyi create mode 100644 stubs/channels/channels/worker.pyi diff --git a/pyrightconfig.stricter.json b/pyrightconfig.stricter.json index 4242a2ba3280..c518c2ad2b02 100644 --- a/pyrightconfig.stricter.json +++ b/pyrightconfig.stricter.json @@ -33,6 +33,7 @@ "stubs/braintree", "stubs/caldav", "stubs/cffi", + "stubs/channels", "stubs/click-web", "stubs/corus", "stubs/dateparser", diff --git a/stubs/channels/METADATA.toml b/stubs/channels/METADATA.toml new file mode 100644 index 000000000000..7d980d0a436f --- /dev/null +++ b/stubs/channels/METADATA.toml @@ -0,0 +1,8 @@ +version = "4.*" +upstream_repository = "https://github.com/django/channels" +requires = ["django-stubs", "asgiref"] +requires_python = ">=3.10" + +[tool.stubtest] +skip = true # due to the need of django mypy plugin config, it should be skipped. +stubtest_requirements = ["daphne"] diff --git a/stubs/channels/channels/__init__.pyi b/stubs/channels/channels/__init__.pyi new file mode 100644 index 000000000000..ae22f453163e --- /dev/null +++ b/stubs/channels/channels/__init__.pyi @@ -0,0 +1,2 @@ +__version__: str +DEFAULT_CHANNEL_LAYER: str diff --git a/stubs/channels/channels/apps.pyi b/stubs/channels/channels/apps.pyi new file mode 100644 index 000000000000..9d479a2d49e5 --- /dev/null +++ b/stubs/channels/channels/apps.pyi @@ -0,0 +1,6 @@ +from django.apps import AppConfig +from django.utils.functional import _StrOrPromise + +class ChannelsConfig(AppConfig): + name: str = ... + verbose_name: _StrOrPromise = ... diff --git a/stubs/channels/channels/auth.pyi b/stubs/channels/channels/auth.pyi new file mode 100644 index 000000000000..5f3b7b5e20fe --- /dev/null +++ b/stubs/channels/channels/auth.pyi @@ -0,0 +1,32 @@ +from typing import Any + +from asgiref.typing import ASGIReceiveCallable, ASGISendCallable +from channels.middleware import BaseMiddleware +from django.contrib.auth.backends import BaseBackend +from django.contrib.auth.base_user import AbstractBaseUser +from django.contrib.auth.models import AnonymousUser +from django.utils.functional import LazyObject + +from .consumer import _ChannelScope, _LazySession +from .db import database_sync_to_async +from .utils import _ChannelApplication + +@database_sync_to_async +def get_user(scope: _ChannelScope) -> AbstractBaseUser | AnonymousUser: ... +@database_sync_to_async +def login(scope: _ChannelScope, user: AbstractBaseUser, backend: BaseBackend | None = ...) -> None: ... +@database_sync_to_async +def logout(scope: _ChannelScope) -> None: ... +def _get_user_session_key(session: _LazySession) -> Any: ... + +class UserLazyObject(AbstractBaseUser, LazyObject): + def _setup(self) -> None: ... + +class AuthMiddleware(BaseMiddleware): + def populate_scope(self, scope: _ChannelScope) -> None: ... + async def resolve_scope(self, scope: _ChannelScope) -> None: ... + async def __call__( + self, scope: _ChannelScope, receive: ASGIReceiveCallable, send: ASGISendCallable + ) -> _ChannelApplication: ... + +def AuthMiddlewareStack(inner: _ChannelApplication) -> _ChannelApplication: ... diff --git a/stubs/channels/channels/consumer.pyi b/stubs/channels/channels/consumer.pyi new file mode 100644 index 000000000000..bb7c8e332cee --- /dev/null +++ b/stubs/channels/channels/consumer.pyi @@ -0,0 +1,54 @@ +from collections.abc import Awaitable +from typing import Any, ClassVar, Protocol + +from asgiref.typing import ASGIReceiveCallable, ASGISendCallable, Scope, WebSocketScope +from channels.auth import UserLazyObject +from channels.db import database_sync_to_async +from django.contrib.sessions.backends.base import SessionBase +from django.utils.functional import LazyObject + +class _LazySession(SessionBase, LazyObject): # type: ignore[misc] + _wrapped: SessionBase + +# Base ASGI Scope definition +class _ChannelScope(WebSocketScope, total=False): + # Channel specific + channel: str + url_route: dict[str, Any] + path_remaining: str + + # Auth specific + cookies: dict[str, str] + session: _LazySession + user: UserLazyObject | None + +def get_handler_name(message: dict[str, Any]) -> str: ... + +class _ASGIApplicationProtocol(Protocol): + consumer_class: Any + consumer_initkwargs: dict[str, Any] + + def __call__(self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> Awaitable[None]: ... + +class AsyncConsumer: + _sync: ClassVar[bool] = ... + channel_layer_alias: ClassVar[str] = ... + + scope: _ChannelScope + channel_layer: Any + channel_name: str + channel_receive: ASGIReceiveCallable + base_send: ASGISendCallable + + async def __call__(self, scope: _ChannelScope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: ... + async def dispatch(self, message: dict[str, Any]) -> None: ... + async def send(self, message: dict[str, Any]) -> None: ... + @classmethod + def as_asgi(cls, **initkwargs: Any) -> _ASGIApplicationProtocol: ... + +class SyncConsumer(AsyncConsumer): + _sync: ClassVar[bool] = ... + + @database_sync_to_async + def dispatch(self, message: dict[str, Any]) -> None: ... # type: ignore[override] + def send(self, message: dict[str, Any]) -> None: ... # type: ignore[override] diff --git a/stubs/channels/channels/db.pyi b/stubs/channels/channels/db.pyi new file mode 100644 index 000000000000..98c68d1e21db --- /dev/null +++ b/stubs/channels/channels/db.pyi @@ -0,0 +1,15 @@ +from asyncio import BaseEventLoop +from collections.abc import Callable, Coroutine +from typing import Any, TypeVar +from typing_extensions import ParamSpec + +from asgiref.sync import SyncToAsync + +_P = ParamSpec("_P") +_R = TypeVar("_R") + +class DatabaseSyncToAsync(SyncToAsync[_P, _R]): + def thread_handler(self, loop: BaseEventLoop, *args: Any, **kwargs: Any) -> Any: ... + +def database_sync_to_async(func: Callable[_P, _R]) -> Callable[_P, Coroutine[Any, Any, _R]]: ... +async def aclose_old_connections() -> None: ... diff --git a/stubs/channels/channels/exceptions.pyi b/stubs/channels/channels/exceptions.pyi new file mode 100644 index 000000000000..eaba1dfaee14 --- /dev/null +++ b/stubs/channels/channels/exceptions.pyi @@ -0,0 +1,8 @@ +class RequestAborted(Exception): ... +class RequestTimeout(RequestAborted): ... +class InvalidChannelLayerError(ValueError): ... +class AcceptConnection(Exception): ... +class DenyConnection(Exception): ... +class ChannelFull(Exception): ... +class MessageTooLarge(Exception): ... +class StopConsumer(Exception): ... diff --git a/stubs/channels/channels/generic/__init__.pyi b/stubs/channels/channels/generic/__init__.pyi new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/stubs/channels/channels/generic/http.pyi b/stubs/channels/channels/generic/http.pyi new file mode 100644 index 000000000000..85d83a7fd208 --- /dev/null +++ b/stubs/channels/channels/generic/http.pyi @@ -0,0 +1,19 @@ +from collections.abc import Iterable +from typing import Any + +from asgiref.typing import HTTPDisconnectEvent, HTTPRequestEvent, HTTPScope +from channels.consumer import AsyncConsumer + +class AsyncHttpConsumer(AsyncConsumer): + body: list[bytes] + scope: HTTPScope # type: ignore[assignment] + + def __init__(self, *args: Any, **kwargs: Any) -> None: ... + async def send_headers(self, *, status: int = ..., headers: Iterable[tuple[bytes, bytes]] | None = ...) -> None: ... + async def send_body(self, body: bytes, *, more_body: bool = ...) -> None: ... + async def send_response(self, status: int, body: bytes, **kwargs: Any) -> None: ... + async def handle(self, body: bytes) -> None: ... + async def disconnect(self) -> None: ... + async def http_request(self, message: HTTPRequestEvent) -> None: ... + async def http_disconnect(self, message: HTTPDisconnectEvent) -> None: ... + async def send(self, message: dict[str, Any]) -> None: ... # type: ignore[override] diff --git a/stubs/channels/channels/generic/websocket.pyi b/stubs/channels/channels/generic/websocket.pyi new file mode 100644 index 000000000000..aa1a54443560 --- /dev/null +++ b/stubs/channels/channels/generic/websocket.pyi @@ -0,0 +1,64 @@ +from typing import Any + +from asgiref.typing import WebSocketConnectEvent, WebSocketDisconnectEvent, WebSocketReceiveEvent +from channels.consumer import AsyncConsumer, SyncConsumer, _ChannelScope + +class WebsocketConsumer(SyncConsumer): + groups: list[str] | None + scope: _ChannelScope + channel_name: str + channel_layer: Any + channel_receive: Any + base_send: Any + + def __init__(self, *args: Any, **kwargs: Any) -> None: ... + def websocket_connect(self, message: WebSocketConnectEvent) -> None: ... + def connect(self) -> None: ... + def accept(self, subprotocol: str | None = ..., headers: list[tuple[str, str]] | None = ...) -> None: ... + def websocket_receive(self, message: WebSocketReceiveEvent) -> None: ... + def receive(self, text_data: str | None = ..., bytes_data: bytes | None = ...) -> None: ... + def send( # type: ignore[override] + self, text_data: str | None = ..., bytes_data: bytes | None = ..., close: bool = ... + ) -> None: ... + def close(self, code: int | bool | None = ..., reason: str | None = ...) -> None: ... + def websocket_disconnect(self, message: WebSocketDisconnectEvent) -> None: ... + def disconnect(self, code: int) -> None: ... + +class JsonWebsocketConsumer(WebsocketConsumer): + def receive(self, text_data: str | None = ..., bytes_data: bytes | None = ..., **kwargs: Any) -> None: ... + def receive_json(self, content: Any, **kwargs: Any) -> None: ... + def send_json(self, content: Any, close: bool = ...) -> None: ... + @classmethod + def decode_json(cls, text_data: str) -> Any: ... + @classmethod + def encode_json(cls, content: Any) -> str: ... + +class AsyncWebsocketConsumer(AsyncConsumer): + groups: list[str] | None + scope: _ChannelScope + channel_name: str + channel_layer: Any + channel_receive: Any + base_send: Any + + def __init__(self, *args: Any, **kwargs: Any) -> None: ... + async def websocket_connect(self, message: WebSocketConnectEvent) -> None: ... + async def connect(self) -> None: ... + async def accept(self, subprotocol: str | None = ..., headers: list[tuple[str, str]] | None = ...) -> None: ... + async def websocket_receive(self, message: WebSocketReceiveEvent) -> None: ... + async def receive(self, text_data: str | None = ..., bytes_data: bytes | None = ...) -> None: ... + async def send( # type: ignore[override] + self, text_data: str | None = ..., bytes_data: bytes | None = ..., close: bool = ... + ) -> None: ... + async def close(self, code: int | bool | None = ..., reason: str | None = ...) -> None: ... + async def websocket_disconnect(self, message: WebSocketDisconnectEvent) -> None: ... + async def disconnect(self, code: int) -> None: ... + +class AsyncJsonWebsocketConsumer(AsyncWebsocketConsumer): + async def receive(self, text_data: str | None = ..., bytes_data: bytes | None = ..., **kwargs: Any) -> None: ... + async def receive_json(self, content: Any, **kwargs: Any) -> None: ... + async def send_json(self, content: Any, close: bool = ...) -> None: ... + @classmethod + async def decode_json(cls, text_data: str) -> Any: ... + @classmethod + async def encode_json(cls, content: Any) -> str: ... diff --git a/stubs/channels/channels/layers.pyi b/stubs/channels/channels/layers.pyi new file mode 100644 index 000000000000..46c90bbd6821 --- /dev/null +++ b/stubs/channels/channels/layers.pyi @@ -0,0 +1,96 @@ +import asyncio +from re import Pattern +from typing import Any, TypeAlias, overload +from typing_extensions import deprecated + +class ChannelLayerManager: + backends: dict[str, BaseChannelLayer] + + def __init__(self) -> None: ... + def _reset_backends(self, setting: str, **kwargs: Any) -> None: ... + @property + def configs(self) -> dict[str, Any]: ... + def make_backend(self, name: str) -> BaseChannelLayer: ... + def make_test_backend(self, name: str) -> Any: ... + def _make_backend(self, name: str, config: dict[str, Any]) -> BaseChannelLayer: ... + def __getitem__(self, key: str) -> BaseChannelLayer: ... + def __contains__(self, key: str) -> bool: ... + def set(self, key: str, layer: BaseChannelLayer) -> BaseChannelLayer | None: ... + +_ChannelCapacityPattern: TypeAlias = Pattern[str] | str +_ChannelCapacityDict: TypeAlias = dict[_ChannelCapacityPattern, int] +_CompiledChannelCapacity: TypeAlias = list[tuple[Pattern[str], int]] + +class BaseChannelLayer: + MAX_NAME_LENGTH: int = ... + expiry: int + capacity: int + channel_capacity: _ChannelCapacityDict + channel_name_regex: Pattern[str] + group_name_regex: Pattern[str] + invalid_name_error: str + + def __init__(self, expiry: int = ..., capacity: int = ..., channel_capacity: _ChannelCapacityDict | None = ...) -> None: ... + def compile_capacities(self, channel_capacity: _ChannelCapacityDict) -> _CompiledChannelCapacity: ... + def get_capacity(self, channel: str) -> int: ... + @overload + def match_type_and_length(self, name: str) -> bool: ... + @overload + def match_type_and_length(self, name: Any) -> bool: ... + @overload + def require_valid_channel_name(self, name: str, receive: bool = ...) -> bool: ... + @overload + def require_valid_channel_name(self, name: Any, receive: bool = ...) -> bool: ... + @overload + def require_valid_group_name(self, name: str) -> bool: ... + @overload + def require_valid_group_name(self, name: Any) -> bool: ... + @overload + def valid_channel_names(self, names: list[str], receive: bool = ...) -> bool: ... + @overload + def valid_channel_names(self, names: list[Any], receive: bool = ...) -> bool: ... + def non_local_name(self, name: str) -> str: ... + async def send(self, channel: str, message: dict[str, Any]) -> None: ... + async def receive(self, channel: str) -> dict[str, Any]: ... + async def new_channel(self) -> str: ... + async def flush(self) -> None: ... + async def group_add(self, group: str, channel: str) -> None: ... + async def group_discard(self, group: str, channel: str) -> None: ... + async def group_send(self, group: str, message: dict[str, Any]) -> None: ... + @deprecated("Use require_valid_channel_name instead.") + def valid_channel_name(self, channel_name: str, receive: bool = ...) -> bool: ... + @deprecated("Use require_valid_group_name instead.") + def valid_group_name(self, group_name: str) -> bool: ... + +_InMemoryQueueData: TypeAlias = tuple[float, dict[str, Any]] + +class InMemoryChannelLayer(BaseChannelLayer): + channels: dict[str, asyncio.Queue[_InMemoryQueueData]] + groups: dict[str, dict[str, float]] + group_expiry: int + + def __init__( + self, + expiry: int = ..., + group_expiry: int = ..., + capacity: int = ..., + channel_capacity: _ChannelCapacityDict | None = ..., + **kwargs: Any, + ) -> None: ... + + extensions: list[str] + + async def send(self, channel: str, message: dict[str, Any]) -> None: ... + async def receive(self, channel: str) -> dict[str, Any]: ... + async def new_channel(self, prefix: str = ...) -> str: ... + def _clean_expired(self) -> None: ... + async def flush(self) -> None: ... + async def close(self) -> None: ... + def _remove_from_groups(self, channel: str) -> None: ... + async def group_add(self, group: str, channel: str) -> None: ... + async def group_discard(self, group: str, channel: str) -> None: ... + async def group_send(self, group: str, message: dict[str, Any]) -> None: ... + +def get_channel_layer(alias: str = ...) -> BaseChannelLayer | None: ... + +channel_layers: ChannelLayerManager diff --git a/stubs/channels/channels/management/__init__.pyi b/stubs/channels/channels/management/__init__.pyi new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/stubs/channels/channels/management/commands/__init__.pyi b/stubs/channels/channels/management/commands/__init__.pyi new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/stubs/channels/channels/management/commands/runworker.pyi b/stubs/channels/channels/management/commands/runworker.pyi new file mode 100644 index 000000000000..1ea4a1e6d4f8 --- /dev/null +++ b/stubs/channels/channels/management/commands/runworker.pyi @@ -0,0 +1,20 @@ +import logging +from argparse import ArgumentParser +from typing import Any + +from channels.layers import BaseChannelLayer +from channels.worker import Worker +from django.core.management.base import BaseCommand + +logger: logging.Logger + +class Command(BaseCommand): + leave_locale_alone: bool = ... + worker_class: type[Worker] = ... + verbosity: int + channel_layer: BaseChannelLayer + + def add_arguments(self, parser: ArgumentParser) -> None: ... + def handle( + self, *args: Any, application_path: str | None = ..., channels: list[str] | None = ..., layer: str = ..., **options: Any + ) -> None: ... diff --git a/stubs/channels/channels/middleware.pyi b/stubs/channels/channels/middleware.pyi new file mode 100644 index 000000000000..339ae9218244 --- /dev/null +++ b/stubs/channels/channels/middleware.pyi @@ -0,0 +1,12 @@ +from asgiref.typing import ASGIReceiveCallable, ASGISendCallable + +from .consumer import _ChannelScope +from .utils import _ChannelApplication + +class BaseMiddleware: + inner: _ChannelApplication + + def __init__(self, inner: _ChannelApplication) -> None: ... + async def __call__( + self, scope: _ChannelScope, receive: ASGIReceiveCallable, send: ASGISendCallable + ) -> _ChannelApplication: ... diff --git a/stubs/channels/channels/routing.pyi b/stubs/channels/channels/routing.pyi new file mode 100644 index 000000000000..ac98a8e4d7fd --- /dev/null +++ b/stubs/channels/channels/routing.pyi @@ -0,0 +1,31 @@ +from typing import Any + +from asgiref.typing import ASGIReceiveCallable, ASGISendCallable +from django.urls.resolvers import URLPattern + +from .consumer import _ASGIApplicationProtocol, _ChannelScope +from .utils import _ChannelApplication + +def get_default_application() -> ProtocolTypeRouter: ... + +class ProtocolTypeRouter: + application_mapping: dict[str, _ChannelApplication] + + def __init__(self, application_mapping: dict[str, Any]) -> None: ... + async def __call__(self, scope: _ChannelScope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: ... + +class _ExtendedURLPattern(URLPattern): + callback: _ASGIApplicationProtocol | URLRouter + +class URLRouter: + _path_routing: bool = ... + routes: list[_ExtendedURLPattern | URLRouter] + + def __init__(self, routes: list[_ExtendedURLPattern | URLRouter]) -> None: ... + async def __call__(self, scope: _ChannelScope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: ... + +class ChannelNameRouter: + application_mapping: dict[str, _ChannelApplication] + + def __init__(self, application_mapping: dict[str, _ChannelApplication]) -> None: ... + async def __call__(self, scope: _ChannelScope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: ... diff --git a/stubs/channels/channels/security/__init__.pyi b/stubs/channels/channels/security/__init__.pyi new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/stubs/channels/channels/security/websocket.pyi b/stubs/channels/channels/security/websocket.pyi new file mode 100644 index 000000000000..aa76a1b4012e --- /dev/null +++ b/stubs/channels/channels/security/websocket.pyi @@ -0,0 +1,25 @@ +from collections.abc import Iterable +from re import Pattern +from typing import Any +from urllib.parse import ParseResult + +from asgiref.typing import ASGIReceiveCallable, ASGISendCallable +from channels.consumer import _ChannelScope +from channels.generic.websocket import AsyncWebsocketConsumer +from channels.utils import _ChannelApplication + +class OriginValidator: + application: _ChannelApplication + allowed_origins: Iterable[str | Pattern[str]] + + def __init__(self, application: _ChannelApplication, allowed_origins: Iterable[str | Pattern[str]]) -> None: ... + async def __call__(self, scope: _ChannelScope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> Any: ... + def valid_origin(self, parsed_origin: ParseResult | None) -> bool: ... + def validate_origin(self, parsed_origin: ParseResult | None) -> bool: ... + def match_allowed_origin(self, parsed_origin: ParseResult | None, pattern: str | Pattern[str]) -> bool: ... + def get_origin_port(self, origin: ParseResult | None) -> int | None: ... + +def AllowedHostsOriginValidator(application: _ChannelApplication) -> OriginValidator: ... + +class WebsocketDenier(AsyncWebsocketConsumer): + async def connect(self) -> None: ... diff --git a/stubs/channels/channels/sessions.pyi b/stubs/channels/channels/sessions.pyi new file mode 100644 index 000000000000..85b73adadf9f --- /dev/null +++ b/stubs/channels/channels/sessions.pyi @@ -0,0 +1,53 @@ +import datetime +from collections.abc import Awaitable +from typing import Any + +from asgiref.typing import ASGIReceiveCallable, ASGISendCallable +from channels.consumer import _ChannelScope +from channels.utils import _ChannelApplication + +class CookieMiddleware: + inner: _ChannelApplication + + def __init__(self, inner: _ChannelApplication) -> None: ... + async def __call__(self, scope: _ChannelScope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> Any: ... + @classmethod + def set_cookie( + cls, + message: dict[str, Any], + key: str, + value: str = "", + max_age: int | None = ..., + expires: str | datetime.datetime | None = ..., + path: str = ..., + domain: str | None = ..., + secure: bool = ..., + httponly: bool = ..., + samesite: str = ..., + ) -> None: ... + @classmethod + def delete_cookie(cls, message: dict[str, Any], key: str, path: str = ..., domain: str | None = ...) -> None: ... + +class InstanceSessionWrapper: + save_message_types: list[str] + cookie_response_message_types: list[str] + cookie_name: str + session_store: Any + scope: _ChannelScope + activated: bool + real_send: ASGISendCallable + + def __init__(self, scope: _ChannelScope, send: ASGISendCallable) -> None: ... + async def resolve_session(self) -> None: ... + async def send(self, message: dict[str, Any]) -> Awaitable[None]: ... + async def save_session(self) -> None: ... + +class SessionMiddleware: + inner: _ChannelApplication + + def __init__(self, inner: _ChannelApplication) -> None: ... + async def __call__( + self, scope: _ChannelScope, receive: ASGIReceiveCallable, send: ASGISendCallable + ) -> _ChannelApplication: ... + +def SessionMiddlewareStack(inner: _ChannelApplication) -> _ChannelApplication: ... diff --git a/stubs/channels/channels/testing/__init__.pyi b/stubs/channels/channels/testing/__init__.pyi new file mode 100644 index 000000000000..1cb75a0bf6dd --- /dev/null +++ b/stubs/channels/channels/testing/__init__.pyi @@ -0,0 +1,6 @@ +from .application import ApplicationCommunicator +from .http import HttpCommunicator +from .live import ChannelsLiveServerTestCase +from .websocket import WebsocketCommunicator + +__all__ = ["ApplicationCommunicator", "HttpCommunicator", "ChannelsLiveServerTestCase", "WebsocketCommunicator"] diff --git a/stubs/channels/channels/testing/application.pyi b/stubs/channels/channels/testing/application.pyi new file mode 100644 index 000000000000..313d10cc52d6 --- /dev/null +++ b/stubs/channels/channels/testing/application.pyi @@ -0,0 +1,12 @@ +from typing import Any + +from asgiref.testing import ApplicationCommunicator as BaseApplicationCommunicator + +def no_op() -> None: ... + +class ApplicationCommunicator(BaseApplicationCommunicator): + async def send_input(self, message: dict[str, Any]) -> None: ... + async def receive_output(self, timeout: float = ...) -> dict[str, Any]: ... + async def receive_nothing(self, timeout: float = ..., interval: float = ...) -> bool: ... + async def wait(self, timeout: float = ...) -> None: ... + def stop(self, exceptions: bool = ...) -> None: ... diff --git a/stubs/channels/channels/testing/http.pyi b/stubs/channels/channels/testing/http.pyi new file mode 100644 index 000000000000..3f243e77c47f --- /dev/null +++ b/stubs/channels/channels/testing/http.pyi @@ -0,0 +1,39 @@ +from collections.abc import Iterable +from typing import Literal, TypedDict + +from channels.testing.application import ApplicationCommunicator +from channels.utils import _ChannelApplication + +# HTTP test-specific response type +class _HTTPTestResponse(TypedDict, total=False): + status: int + headers: Iterable[tuple[bytes, bytes]] + body: bytes + +class _HTTPTestScope(TypedDict, total=False): + type: Literal["http"] + http_version: str + method: str + scheme: str + path: str + raw_path: bytes + query_string: bytes + root_path: str + headers: Iterable[tuple[bytes, bytes]] | None + client: tuple[str, int] | None + server: tuple[str, int | None] | None + +class HttpCommunicator(ApplicationCommunicator): + scope: _HTTPTestScope + body: bytes + sent_request: bool + + def __init__( + self, + application: _ChannelApplication, + method: str, + path: str, + body: bytes = ..., + headers: Iterable[tuple[bytes, bytes]] | None = ..., + ) -> None: ... + async def get_response(self, timeout: float = ...) -> _HTTPTestResponse: ... diff --git a/stubs/channels/channels/testing/live.pyi b/stubs/channels/channels/testing/live.pyi new file mode 100644 index 000000000000..c80e5cc3c721 --- /dev/null +++ b/stubs/channels/channels/testing/live.pyi @@ -0,0 +1,34 @@ +from collections.abc import Callable +from typing import Any, ClassVar, TypeAlias + +from channels.routing import ProtocolTypeRouter +from channels.utils import _ChannelApplication +from django.contrib.staticfiles.handlers import ASGIStaticFilesHandler +from django.db.backends.base.base import BaseDatabaseWrapper +from django.db.backends.sqlite3.base import DatabaseWrapper +from django.test.testcases import TransactionTestCase +from django.test.utils import modify_settings + +DaphneProcess: TypeAlias = Any + +_StaticWrapper: TypeAlias = Callable[[ProtocolTypeRouter], _ChannelApplication] + +def make_application(*, static_wrapper: _StaticWrapper | None) -> Any: ... + +class ChannelsLiveServerTestCase(TransactionTestCase): + host: ClassVar[str] = ... + ProtocolServerProcess: ClassVar[type[DaphneProcess]] = ... + static_wrapper: ClassVar[type[ASGIStaticFilesHandler]] = ... + serve_static: ClassVar[bool] = ... + + _port: int + _server_process: DaphneProcess + _live_server_modified_settings: modify_settings + + @property + def live_server_url(self) -> str: ... + @property + def live_server_ws_url(self) -> str: ... + def _pre_setup(self) -> None: ... + def _post_teardown(self) -> None: ... + def _is_in_memory_db(self, connection: BaseDatabaseWrapper | DatabaseWrapper) -> bool: ... diff --git a/stubs/channels/channels/testing/websocket.pyi b/stubs/channels/channels/testing/websocket.pyi new file mode 100644 index 000000000000..b9df1aa1b537 --- /dev/null +++ b/stubs/channels/channels/testing/websocket.pyi @@ -0,0 +1,50 @@ +from collections.abc import Iterable +from typing import Any, Literal, TypeAlias, TypedDict, overload +from typing_extensions import NotRequired + +from asgiref.typing import ASGIVersions +from channels.testing.application import ApplicationCommunicator +from channels.utils import _ChannelApplication + +class _WebsocketTestScope(TypedDict, total=False): + spec_version: int + type: Literal["websocket"] + asgi: ASGIVersions + http_version: str + scheme: str + path: str + raw_path: bytes + query_string: bytes + root_path: str + headers: Iterable[tuple[bytes, bytes]] | None + client: tuple[str, int] | None + server: tuple[str, int | None] | None + subprotocols: Iterable[str] | None + state: NotRequired[dict[str, Any]] + extensions: dict[str, dict[object, object]] | None + +_Connected: TypeAlias = bool +_CloseCodeOrAcceptSubProtocol: TypeAlias = int | str | None +_WebsocketConnectResponse: TypeAlias = tuple[_Connected, _CloseCodeOrAcceptSubProtocol] + +class WebsocketCommunicator(ApplicationCommunicator): + scope: _WebsocketTestScope + response_headers: list[tuple[bytes, bytes]] | None + + def __init__( + self, + application: _ChannelApplication, + path: str, + headers: Iterable[tuple[bytes, bytes]] | None = ..., + subprotocols: Iterable[str] | None = ..., + spec_version: int | None = ..., + ) -> None: ... + async def connect(self, timeout: float = ...) -> _WebsocketConnectResponse: ... + async def send_to(self, text_data: str | None = ..., bytes_data: bytes | None = ...) -> None: ... + @overload + async def send_json_to(self, data: dict[str, Any]) -> None: ... + @overload + async def send_json_to(self, data: Any) -> None: ... + async def receive_from(self, timeout: float = ...) -> str | bytes: ... + async def receive_json_from(self, timeout: float = ...) -> dict[str, Any]: ... + async def disconnect(self, code: int = ..., timeout: float = ...) -> None: ... diff --git a/stubs/channels/channels/utils.pyi b/stubs/channels/channels/utils.pyi new file mode 100644 index 000000000000..5567648f1f31 --- /dev/null +++ b/stubs/channels/channels/utils.pyi @@ -0,0 +1,15 @@ +from collections.abc import Awaitable, Callable +from typing import Any, Protocol, TypeAlias + +from asgiref.typing import ASGIApplication, ASGIReceiveCallable + +def name_that_thing(thing: Any) -> str: ... +async def await_many_dispatch( + consumer_callables: list[Callable[[], Awaitable[ASGIReceiveCallable]]], dispatch: Callable[[dict[str, Any]], Awaitable[None]] +) -> None: ... + +class _MiddlewareProtocol(Protocol): + def __init__(self, *args: Any, **kwargs: Any) -> None: ... + async def __call__(self, scope: Any, receive: Any, send: Any) -> Any: ... + +_ChannelApplication: TypeAlias = _MiddlewareProtocol | ASGIApplication # noqa: Y047 diff --git a/stubs/channels/channels/worker.pyi b/stubs/channels/channels/worker.pyi new file mode 100644 index 000000000000..08d979ac76a9 --- /dev/null +++ b/stubs/channels/channels/worker.pyi @@ -0,0 +1,13 @@ +from asgiref.server import StatelessServer +from channels.layers import BaseChannelLayer +from channels.utils import _ChannelApplication + +class Worker(StatelessServer): + channels: list[str] + channel_layer: BaseChannelLayer + + def __init__( + self, application: _ChannelApplication, channels: list[str], channel_layer: BaseChannelLayer, max_applications: int = ... + ) -> None: ... + async def handle(self) -> None: ... + async def listener(self, channel: str) -> None: ... From 86ca864408d63cafc5d36f2a161a43965fb87345 Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Mon, 5 May 2025 00:50:25 +0700 Subject: [PATCH 08/12] Fix alias type for python 3.9 --- stubs/channels/METADATA.toml | 1 - stubs/channels/channels/apps.pyi | 3 +-- stubs/channels/channels/layers.pyi | 4 ++-- stubs/channels/channels/testing/live.pyi | 3 ++- stubs/channels/channels/testing/websocket.pyi | 4 ++-- stubs/channels/channels/utils.pyi | 3 ++- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/stubs/channels/METADATA.toml b/stubs/channels/METADATA.toml index 7d980d0a436f..632d96ee0d46 100644 --- a/stubs/channels/METADATA.toml +++ b/stubs/channels/METADATA.toml @@ -1,7 +1,6 @@ version = "4.*" upstream_repository = "https://github.com/django/channels" requires = ["django-stubs", "asgiref"] -requires_python = ">=3.10" [tool.stubtest] skip = true # due to the need of django mypy plugin config, it should be skipped. diff --git a/stubs/channels/channels/apps.pyi b/stubs/channels/channels/apps.pyi index 9d479a2d49e5..72007d91886c 100644 --- a/stubs/channels/channels/apps.pyi +++ b/stubs/channels/channels/apps.pyi @@ -1,6 +1,5 @@ from django.apps import AppConfig -from django.utils.functional import _StrOrPromise class ChannelsConfig(AppConfig): name: str = ... - verbose_name: _StrOrPromise = ... + verbose_name: str = ... diff --git a/stubs/channels/channels/layers.pyi b/stubs/channels/channels/layers.pyi index 46c90bbd6821..022e03f38d96 100644 --- a/stubs/channels/channels/layers.pyi +++ b/stubs/channels/channels/layers.pyi @@ -1,7 +1,7 @@ import asyncio from re import Pattern -from typing import Any, TypeAlias, overload -from typing_extensions import deprecated +from typing import Any, overload +from typing_extensions import TypeAlias, deprecated class ChannelLayerManager: backends: dict[str, BaseChannelLayer] diff --git a/stubs/channels/channels/testing/live.pyi b/stubs/channels/channels/testing/live.pyi index c80e5cc3c721..68847948c353 100644 --- a/stubs/channels/channels/testing/live.pyi +++ b/stubs/channels/channels/testing/live.pyi @@ -1,5 +1,6 @@ from collections.abc import Callable -from typing import Any, ClassVar, TypeAlias +from typing import Any, ClassVar +from typing_extensions import TypeAlias from channels.routing import ProtocolTypeRouter from channels.utils import _ChannelApplication diff --git a/stubs/channels/channels/testing/websocket.pyi b/stubs/channels/channels/testing/websocket.pyi index b9df1aa1b537..5aab5799352e 100644 --- a/stubs/channels/channels/testing/websocket.pyi +++ b/stubs/channels/channels/testing/websocket.pyi @@ -1,6 +1,6 @@ from collections.abc import Iterable -from typing import Any, Literal, TypeAlias, TypedDict, overload -from typing_extensions import NotRequired +from typing import Any, Literal, TypedDict, overload +from typing_extensions import NotRequired, TypeAlias from asgiref.typing import ASGIVersions from channels.testing.application import ApplicationCommunicator diff --git a/stubs/channels/channels/utils.pyi b/stubs/channels/channels/utils.pyi index 5567648f1f31..58cbdc9500ea 100644 --- a/stubs/channels/channels/utils.pyi +++ b/stubs/channels/channels/utils.pyi @@ -1,5 +1,6 @@ from collections.abc import Awaitable, Callable -from typing import Any, Protocol, TypeAlias +from typing import Any, Protocol +from typing_extensions import TypeAlias from asgiref.typing import ASGIApplication, ASGIReceiveCallable From 6483518af892f606c695012026f1fc6ce623821f Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Mon, 5 May 2025 01:12:20 +0700 Subject: [PATCH 09/12] Correct some Any type channel layer --- stubs/channels/channels/consumer.pyi | 3 ++- stubs/channels/channels/generic/websocket.pyi | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/stubs/channels/channels/consumer.pyi b/stubs/channels/channels/consumer.pyi index bb7c8e332cee..3a1c57fc7451 100644 --- a/stubs/channels/channels/consumer.pyi +++ b/stubs/channels/channels/consumer.pyi @@ -4,6 +4,7 @@ from typing import Any, ClassVar, Protocol from asgiref.typing import ASGIReceiveCallable, ASGISendCallable, Scope, WebSocketScope from channels.auth import UserLazyObject from channels.db import database_sync_to_async +from channels.layers import BaseChannelLayer from django.contrib.sessions.backends.base import SessionBase from django.utils.functional import LazyObject @@ -35,7 +36,7 @@ class AsyncConsumer: channel_layer_alias: ClassVar[str] = ... scope: _ChannelScope - channel_layer: Any + channel_layer: BaseChannelLayer channel_name: str channel_receive: ASGIReceiveCallable base_send: ASGISendCallable diff --git a/stubs/channels/channels/generic/websocket.pyi b/stubs/channels/channels/generic/websocket.pyi index aa1a54443560..c380d0c14dc8 100644 --- a/stubs/channels/channels/generic/websocket.pyi +++ b/stubs/channels/channels/generic/websocket.pyi @@ -2,12 +2,13 @@ from typing import Any from asgiref.typing import WebSocketConnectEvent, WebSocketDisconnectEvent, WebSocketReceiveEvent from channels.consumer import AsyncConsumer, SyncConsumer, _ChannelScope +from channels.layers import BaseChannelLayer class WebsocketConsumer(SyncConsumer): groups: list[str] | None scope: _ChannelScope channel_name: str - channel_layer: Any + channel_layer: BaseChannelLayer channel_receive: Any base_send: Any @@ -37,7 +38,7 @@ class AsyncWebsocketConsumer(AsyncConsumer): groups: list[str] | None scope: _ChannelScope channel_name: str - channel_layer: Any + channel_layer: BaseChannelLayer channel_receive: Any base_send: Any From 04e61370b8291814e97b459b36da8999eb628784 Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Mon, 5 May 2025 12:29:30 +0700 Subject: [PATCH 10/12] Add type_check_only, TODO comment, and constrain django-stubs version --- stubs/channels/METADATA.toml | 4 ++-- stubs/channels/channels/routing.pyi | 3 ++- stubs/channels/channels/testing/http.pyi | 3 ++- stubs/channels/channels/testing/live.pyi | 2 +- stubs/channels/channels/testing/websocket.pyi | 3 ++- stubs/channels/channels/utils.pyi | 4 ++-- 6 files changed, 11 insertions(+), 8 deletions(-) diff --git a/stubs/channels/METADATA.toml b/stubs/channels/METADATA.toml index 632d96ee0d46..cf12e31aa2eb 100644 --- a/stubs/channels/METADATA.toml +++ b/stubs/channels/METADATA.toml @@ -1,7 +1,7 @@ version = "4.*" upstream_repository = "https://github.com/django/channels" -requires = ["django-stubs", "asgiref"] +requires = ["django-stubs>=4.2,<5.3", "asgiref"] [tool.stubtest] -skip = true # due to the need of django mypy plugin config, it should be skipped. +skip = true # TODO: enable stubtest once Django mypy plugin config is supported stubtest_requirements = ["daphne"] diff --git a/stubs/channels/channels/routing.pyi b/stubs/channels/channels/routing.pyi index ac98a8e4d7fd..94d025e24985 100644 --- a/stubs/channels/channels/routing.pyi +++ b/stubs/channels/channels/routing.pyi @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, type_check_only from asgiref.typing import ASGIReceiveCallable, ASGISendCallable from django.urls.resolvers import URLPattern @@ -14,6 +14,7 @@ class ProtocolTypeRouter: def __init__(self, application_mapping: dict[str, Any]) -> None: ... async def __call__(self, scope: _ChannelScope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: ... +@type_check_only class _ExtendedURLPattern(URLPattern): callback: _ASGIApplicationProtocol | URLRouter diff --git a/stubs/channels/channels/testing/http.pyi b/stubs/channels/channels/testing/http.pyi index 3f243e77c47f..51d52b194fc3 100644 --- a/stubs/channels/channels/testing/http.pyi +++ b/stubs/channels/channels/testing/http.pyi @@ -1,5 +1,5 @@ from collections.abc import Iterable -from typing import Literal, TypedDict +from typing import Literal, TypedDict, type_check_only from channels.testing.application import ApplicationCommunicator from channels.utils import _ChannelApplication @@ -10,6 +10,7 @@ class _HTTPTestResponse(TypedDict, total=False): headers: Iterable[tuple[bytes, bytes]] body: bytes +@type_check_only class _HTTPTestScope(TypedDict, total=False): type: Literal["http"] http_version: str diff --git a/stubs/channels/channels/testing/live.pyi b/stubs/channels/channels/testing/live.pyi index 68847948c353..16322bbe59f4 100644 --- a/stubs/channels/channels/testing/live.pyi +++ b/stubs/channels/channels/testing/live.pyi @@ -10,7 +10,7 @@ from django.db.backends.sqlite3.base import DatabaseWrapper from django.test.testcases import TransactionTestCase from django.test.utils import modify_settings -DaphneProcess: TypeAlias = Any +DaphneProcess: TypeAlias = Any # TODO: temporary hack for daphne.testing.DaphneProcess; remove once daphne provides types _StaticWrapper: TypeAlias = Callable[[ProtocolTypeRouter], _ChannelApplication] diff --git a/stubs/channels/channels/testing/websocket.pyi b/stubs/channels/channels/testing/websocket.pyi index 5aab5799352e..c181d57f8b66 100644 --- a/stubs/channels/channels/testing/websocket.pyi +++ b/stubs/channels/channels/testing/websocket.pyi @@ -1,11 +1,12 @@ from collections.abc import Iterable -from typing import Any, Literal, TypedDict, overload +from typing import Any, Literal, TypedDict, overload, type_check_only from typing_extensions import NotRequired, TypeAlias from asgiref.typing import ASGIVersions from channels.testing.application import ApplicationCommunicator from channels.utils import _ChannelApplication +@type_check_only class _WebsocketTestScope(TypedDict, total=False): spec_version: int type: Literal["websocket"] diff --git a/stubs/channels/channels/utils.pyi b/stubs/channels/channels/utils.pyi index 58cbdc9500ea..b92c892b04e3 100644 --- a/stubs/channels/channels/utils.pyi +++ b/stubs/channels/channels/utils.pyi @@ -1,5 +1,5 @@ from collections.abc import Awaitable, Callable -from typing import Any, Protocol +from typing import Any, Protocol, type_check_only from typing_extensions import TypeAlias from asgiref.typing import ASGIApplication, ASGIReceiveCallable @@ -8,7 +8,7 @@ def name_that_thing(thing: Any) -> str: ... async def await_many_dispatch( consumer_callables: list[Callable[[], Awaitable[ASGIReceiveCallable]]], dispatch: Callable[[dict[str, Any]], Awaitable[None]] ) -> None: ... - +@type_check_only class _MiddlewareProtocol(Protocol): def __init__(self, *args: Any, **kwargs: Any) -> None: ... async def __call__(self, scope: Any, receive: Any, send: Any) -> Any: ... From e334d67335b7374f464c26271064afc262772e0c Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Mon, 5 May 2025 12:43:50 +0700 Subject: [PATCH 11/12] Add type_check_only for channels consumer and testing http --- stubs/channels/channels/consumer.pyi | 6 ++++-- stubs/channels/channels/testing/http.pyi | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/stubs/channels/channels/consumer.pyi b/stubs/channels/channels/consumer.pyi index 3a1c57fc7451..d975b70735e1 100644 --- a/stubs/channels/channels/consumer.pyi +++ b/stubs/channels/channels/consumer.pyi @@ -1,5 +1,5 @@ from collections.abc import Awaitable -from typing import Any, ClassVar, Protocol +from typing import Any, ClassVar, Protocol, type_check_only from asgiref.typing import ASGIReceiveCallable, ASGISendCallable, Scope, WebSocketScope from channels.auth import UserLazyObject @@ -8,10 +8,12 @@ from channels.layers import BaseChannelLayer from django.contrib.sessions.backends.base import SessionBase from django.utils.functional import LazyObject +@type_check_only class _LazySession(SessionBase, LazyObject): # type: ignore[misc] _wrapped: SessionBase # Base ASGI Scope definition +@type_check_only class _ChannelScope(WebSocketScope, total=False): # Channel specific channel: str @@ -24,7 +26,7 @@ class _ChannelScope(WebSocketScope, total=False): user: UserLazyObject | None def get_handler_name(message: dict[str, Any]) -> str: ... - +@type_check_only class _ASGIApplicationProtocol(Protocol): consumer_class: Any consumer_initkwargs: dict[str, Any] diff --git a/stubs/channels/channels/testing/http.pyi b/stubs/channels/channels/testing/http.pyi index 51d52b194fc3..0e72190f3198 100644 --- a/stubs/channels/channels/testing/http.pyi +++ b/stubs/channels/channels/testing/http.pyi @@ -5,6 +5,7 @@ from channels.testing.application import ApplicationCommunicator from channels.utils import _ChannelApplication # HTTP test-specific response type +@type_check_only class _HTTPTestResponse(TypedDict, total=False): status: int headers: Iterable[tuple[bytes, bytes]] From 5911b0003bbae9f303a0d5c3c9148f621fffd7bc Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Tue, 6 May 2025 11:44:44 +0700 Subject: [PATCH 12/12] Add stubtest --- stubs/channels/@tests/django_settings.py | 12 ++++++++++++ stubs/channels/@tests/stubtest_allowlist.txt | 3 +++ stubs/channels/METADATA.toml | 3 ++- 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 stubs/channels/@tests/django_settings.py create mode 100644 stubs/channels/@tests/stubtest_allowlist.txt diff --git a/stubs/channels/@tests/django_settings.py b/stubs/channels/@tests/django_settings.py new file mode 100644 index 000000000000..2be16834be19 --- /dev/null +++ b/stubs/channels/@tests/django_settings.py @@ -0,0 +1,12 @@ +SECRET_KEY = "1" + +INSTALLED_APPS = ( + "django.contrib.contenttypes", + "django.contrib.sites", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.admin.apps.SimpleAdminConfig", + "django.contrib.staticfiles", + "django.contrib.auth", + "channels", +) diff --git a/stubs/channels/@tests/stubtest_allowlist.txt b/stubs/channels/@tests/stubtest_allowlist.txt new file mode 100644 index 000000000000..0891cbe07efa --- /dev/null +++ b/stubs/channels/@tests/stubtest_allowlist.txt @@ -0,0 +1,3 @@ +channels.auth.UserLazyObject +channels.auth.UserLazyObject.* +channels.db.database_sync_to_async diff --git a/stubs/channels/METADATA.toml b/stubs/channels/METADATA.toml index cf12e31aa2eb..9ed6485c6a8b 100644 --- a/stubs/channels/METADATA.toml +++ b/stubs/channels/METADATA.toml @@ -3,5 +3,6 @@ upstream_repository = "https://github.com/django/channels" requires = ["django-stubs>=4.2,<5.3", "asgiref"] [tool.stubtest] -skip = true # TODO: enable stubtest once Django mypy plugin config is supported +mypy_plugins = ['mypy_django_plugin.main'] +mypy_plugins_config = {"django-stubs" = {"django_settings_module" = "@tests.django_settings"}} stubtest_requirements = ["daphne"]