From 50015bab8233b17a23e53523391f77222933db83 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Thu, 7 Aug 2025 10:15:13 +0200 Subject: [PATCH 1/8] Add stubs for WebTest --- stubs/WebTest/@tests/stubtest_allowlist.txt | 20 +++ stubs/WebTest/METADATA.toml | 3 + stubs/WebTest/webtest/__init__.pyi | 14 ++ stubs/WebTest/webtest/app.pyi | 187 ++++++++++++++++++++ stubs/WebTest/webtest/debugapp.pyi | 21 +++ stubs/WebTest/webtest/forms.pyi | 175 ++++++++++++++++++ stubs/WebTest/webtest/http.pyi | 30 ++++ stubs/WebTest/webtest/response.pyi | 89 ++++++++++ 8 files changed, 539 insertions(+) create mode 100644 stubs/WebTest/@tests/stubtest_allowlist.txt create mode 100644 stubs/WebTest/METADATA.toml create mode 100644 stubs/WebTest/webtest/__init__.pyi create mode 100644 stubs/WebTest/webtest/app.pyi create mode 100644 stubs/WebTest/webtest/debugapp.pyi create mode 100644 stubs/WebTest/webtest/forms.pyi create mode 100644 stubs/WebTest/webtest/http.pyi create mode 100644 stubs/WebTest/webtest/response.pyi diff --git a/stubs/WebTest/@tests/stubtest_allowlist.txt b/stubs/WebTest/@tests/stubtest_allowlist.txt new file mode 100644 index 000000000000..f353712fea66 --- /dev/null +++ b/stubs/WebTest/@tests/stubtest_allowlist.txt @@ -0,0 +1,20 @@ +# error: failed to find stub +# ========================== +# These modules have been migrated to external packages +# and emit an `ImportError` if people try to use the +# functions/classes defined within +webtest.ext +webtest.sel +# Compatibility/utility modules for internal use that didn't +# seem worth including in the stubs +webtest.compat +webtest.lint +webtest.utils + +# error: variable differs from runtime type +# ========================================= +# Even though this can be `None`, it never should be during +# normal use of WebTest, so it seems more pragmatic to treat +# it as always non-`None` +webtest.response.TestResponse.request +webtest.TestResponse.request diff --git a/stubs/WebTest/METADATA.toml b/stubs/WebTest/METADATA.toml new file mode 100644 index 000000000000..c46ca9e6dcb6 --- /dev/null +++ b/stubs/WebTest/METADATA.toml @@ -0,0 +1,3 @@ +version = "3.0.*" +upstream_repository = "https://github.com/Pylons/webtest" +requires = ["types-beautifulsoup4", "types-waitress", "types-WebOb"] diff --git a/stubs/WebTest/webtest/__init__.pyi b/stubs/WebTest/webtest/__init__.pyi new file mode 100644 index 000000000000..bad803e222dd --- /dev/null +++ b/stubs/WebTest/webtest/__init__.pyi @@ -0,0 +1,14 @@ +from webtest.app import AppError as AppError, TestApp as TestApp, TestRequest as TestRequest +from webtest.forms import ( + Checkbox as Checkbox, + Field as Field, + Form as Form, + Hidden as Hidden, + Radio as Radio, + Select as Select, + Submit as Submit, + Text as Text, + Textarea as Textarea, + Upload as Upload, +) +from webtest.response import TestResponse as TestResponse diff --git a/stubs/WebTest/webtest/app.pyi b/stubs/WebTest/webtest/app.pyi new file mode 100644 index 000000000000..8c5f58afe643 --- /dev/null +++ b/stubs/WebTest/webtest/app.pyi @@ -0,0 +1,187 @@ +import json +from _typeshed.wsgi import WSGIApplication +from collections.abc import Mapping, Sequence +from http.cookiejar import CookieJar, DefaultCookiePolicy +from typing import Any, Generic, Literal, TypeVar +from typing_extensions import TypeAlias + +from webob.request import BaseRequest +from webtest.response import TestResponse + +_Files: TypeAlias = Sequence[tuple[str, str] | tuple[str, str, bytes]] +_AppT = TypeVar("_AppT", bound=WSGIApplication, default=WSGIApplication) + +__all__ = ["TestApp", "TestRequest"] + +class AppError(Exception): + def __init__(self, message: str, *args: object) -> None: ... + +class CookiePolicy(DefaultCookiePolicy): ... + +class TestRequest(BaseRequest): + ResponseClass: type[TestResponse] + __test__: Literal[False] + +class TestApp(Generic[_AppT]): + RequestClass: type[TestRequest] + app: _AppT + lint: bool + relative_to: str | None + extra_environ: dict[str, Any] + use_unicode: bool + cookiejar: CookieJar + JSONEncoder: json.JSONEncoder + __test__: Literal[False] + def __init__( + self, + app: _AppT, + extra_environ: dict[str, Any] | None = None, + relative_to: str | None = None, + use_unicode: bool = True, + cookiejar: CookieJar | None = None, + parser_features: Sequence[str] | str | None = None, + json_encoder: json.JSONEncoder | None = None, + lint: bool = True, + ) -> None: ... + def get_authorization(self) -> tuple[str, str | tuple[str, str]]: ... + def set_authorization(self, value: tuple[str, str | tuple[str, str]]) -> None: ... + @property + def authorization(self) -> tuple[str, str | tuple[str, str]]: ... + @authorization.setter + def authorization(self, value: tuple[str, str | tuple[str, str]]) -> None: ... + @property + def cookies(self) -> dict[str, str | None]: ... + def set_cookie(self, name: str, value: str | None) -> None: ... + def reset(self) -> None: ... + def set_parser_features(self, parser_features: Sequence[str] | str) -> None: ... + def get( + self, + url: str, + params: Mapping[str, str] | str | None = None, + headers: Mapping[str, str] | None = None, + extra_environ: Mapping[str, Any] | None = None, + status: int | str | None = None, + expect_errors: bool = False, + xhr: bool = False, + ) -> TestResponse: ... + def post( + self, + url: str, + params: Mapping[str, str] | str = "", + headers: Mapping[str, str] | None = None, + extra_environ: Mapping[str, Any] | None = None, + status: int | str | None = None, + upload_files: _Files | None = None, + expect_errors: bool = False, + content_type: str | None = None, + xhr: bool = False, + ) -> TestResponse: ... + def put( + self, + url: str, + params: Mapping[str, str] | str = "", + headers: Mapping[str, str] | None = None, + extra_environ: Mapping[str, Any] | None = None, + status: int | str | None = None, + upload_files: _Files | None = None, + expect_errors: bool = False, + content_type: str | None = None, + xhr: bool = False, + ) -> TestResponse: ... + def patch( + self, + url: str, + params: Mapping[str, str] | str = "", + headers: Mapping[str, str] | None = None, + extra_environ: Mapping[str, Any] | None = None, + status: int | str | None = None, + upload_files: _Files | None = None, + expect_errors: bool = False, + content_type: str | None = None, + xhr: bool = False, + ) -> TestResponse: ... + def delete( + self, + url: str, + params: Mapping[str, str] | str = "", + headers: Mapping[str, str] | None = None, + extra_environ: Mapping[str, Any] | None = None, + status: int | str | None = None, + expect_errors: bool = False, + content_type: str | None = None, + xhr: bool = False, + ) -> TestResponse: ... + def options( + self, + url: str, + headers: Mapping[str, str] | None = None, + extra_environ: Mapping[str, Any] | None = None, + status: int | str | None = None, + expect_errors: bool = False, + xhr: bool = False, + ) -> TestResponse: ... + def head( + self, + url: str, + params: Mapping[str, str] | str | None = None, + headers: Mapping[str, str] | None = None, + extra_environ: Mapping[str, Any] | None = None, + status: int | str | None = None, + expect_errors: bool = False, + xhr: bool = False, + ) -> TestResponse: ... + def post_json( + self, + url: str, + params: Any = ..., + *, + headers: Mapping[str, str] | None = None, + extra_environ: Mapping[str, Any] | None = None, + status: int | str | None = None, + expect_errors: bool = False, + content_type: str | None = None, + xhr: bool = False, + ) -> TestResponse: ... + def put_json( + self, + url: str, + params: Any = ..., + *, + headers: Mapping[str, str] | None = None, + extra_environ: Mapping[str, Any] | None = None, + status: int | str | None = None, + expect_errors: bool = False, + content_type: str | None = None, + xhr: bool = False, + ) -> TestResponse: ... + def patch_json( + self, + url: str, + params: Any = ..., + *, + headers: Mapping[str, str] | None = None, + extra_environ: Mapping[str, Any] | None = None, + status: int | str | None = None, + expect_errors: bool = False, + content_type: str | None = None, + xhr: bool = False, + ) -> TestResponse: ... + def delete_json( + self, + url: str, + params: Any = ..., + *, + headers: Mapping[str, str] | None = None, + extra_environ: Mapping[str, Any] | None = None, + status: int | str | None = None, + expect_errors: bool = False, + content_type: str | None = None, + xhr: bool = False, + ) -> TestResponse: ... + def encode_multipart(self, params: Sequence[tuple[str, str]], files: _Files) -> tuple[str, bytes]: ... + def request( + self, url_or_req: str | TestRequest, status: int | str | None = None, expect_errors: bool = False, **req_params: Any + ) -> TestResponse: ... + def do_request( + self, req: TestRequest, status: int | str | None = None, expect_errors: bool | None = None + ) -> TestResponse: ... diff --git a/stubs/WebTest/webtest/debugapp.pyi b/stubs/WebTest/webtest/debugapp.pyi new file mode 100644 index 000000000000..f887184dcd70 --- /dev/null +++ b/stubs/WebTest/webtest/debugapp.pyi @@ -0,0 +1,21 @@ +from _typeshed import StrOrBytesPath +from _typeshed.wsgi import StartResponse, WSGIEnvironment +from collections.abc import Iterable +from typing import TypedDict +from typing_extensions import Unpack + +class _DebugAppParams(TypedDict, total=False): + form: StrOrBytesPath | bytes | None + show_form: bool + +__all__ = ["DebugApp", "make_debug_app"] + +class DebugApp: + form: bytes | None + show_form: bool + def __init__(self, form: StrOrBytesPath | bytes | None = None, show_form: bool = False) -> None: ... + def __call__(self, environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]: ... + +debug_app: DebugApp + +def make_debug_app(global_conf: object, **local_conf: Unpack[_DebugAppParams]) -> DebugApp: ... diff --git a/stubs/WebTest/webtest/forms.pyi b/stubs/WebTest/webtest/forms.pyi new file mode 100644 index 000000000000..ab913d4f394d --- /dev/null +++ b/stubs/WebTest/webtest/forms.pyi @@ -0,0 +1,175 @@ +from collections.abc import Generator, Sequence +from typing import Any, TypedDict, TypeVar, overload +from typing_extensions import TypeAlias + +from bs4 import BeautifulSoup +from webtest.response import TestResponse + +_T = TypeVar("_T") + +class _Classes(TypedDict): + submit: type[Submit] + button: type[Submit] + image: type[Submit] + multiple_select: type[MultipleSelect] + select: type[Select] + hidden: type[Hidden] + file: type[File] + text: type[Text] + search: type[Text] + email: type[Email] + password: type[Text] + checkbox: type[Checkbox] + textarea: type[Textarea] + radio: type[Radio] + +# NOTE: It seems unergonmic having to put isinstance checks everywhere +# in your test code where you're accessing a form field, so we +# return `Any` for now. What we would really like to use here is +# `AnyOf`, but that doesn't exist yet. +_AnyField: TypeAlias = Any + +class NoValue: ... + +class Upload: + filename: str + content: bytes | None + content_type: str | None + def __init__(self, filename: str, content: bytes | None = None, content_type: str | None = None) -> None: ... + def __iter__(self) -> Generator[str | bytes]: ... + +class Field: + classes: _Classes + form: Form + tag: str + name: str + pos: int + id: str + attrs: dict[str, str] + def __init__( + self, form: Form, tag: str, name: str, pos: int, value: str | None = None, id: str | None = None, **attrs: str + ) -> None: ... + def value__get(self) -> str: ... + def value__set(self, value: str | None) -> None: ... + @property + def value(self) -> str: ... + @value.setter + def value(self, value: str | None) -> None: ... + def force_value(self, value: str | None) -> None: ... + +class Select(Field): + options: list[tuple[str, bool, str]] + optionPositions: list[int] + selectedIndex: int | None + # NOTE: Even though it's safe to pass any object into text, I don't + # think that follows the spirit of this argument and is more + # likely a consequence of reusing the same utility function + # in order to handle bytes for Py2 compat. + @overload + def select(self, value: None, text: str | bytes) -> None: ... + @overload + def select(self, value: None = None, *, text: str | bytes) -> None: ... + @overload + def select(self, value: object, text: None = None) -> None: ... + def value__get(self) -> str: ... + def value__set(self, value: object | None) -> None: ... + @property + def value(self) -> str: ... + @value.setter + def value(self, value: object | None) -> None: ... + +class MultipleSelect(Field): + options: list[tuple[str, bool, str]] + selectedIndices: list[int] + @overload + def select_multiple(self, value: None, texts: Sequence[str | bytes]) -> None: ... + @overload + def select_multiple(self, value: None = None, *, texts: Sequence[str | bytes]) -> None: ... + @overload + def select_multiple(self, value: Sequence[object], texts: None = None) -> None: ... + def value__get(self) -> list[str] | None: ... # type: ignore[override] + def value__set(self, values: Sequence[object] | None) -> None: ... + @property # type: ignore[override] + def value(self) -> list[str] | None: ... + @value.setter + def value(self, value: Sequence[object] | None) -> None: ... + # NOTE: Since unlike setting the value normally this doesn't perform + # any kind of type conversion, we're better off only allowing + # what `value__get` is supposed to be able to return. + def force_value(self, values: list[str] | None) -> None: ... # type: ignore[override] + +class Radio(Select): ... + +class Checkbox(Field): + def value__get(self) -> str | None: ... # type: ignore[override] + def value__set(self, value: object) -> None: ... + @property # type: ignore[override] + def value(self) -> str | None: ... + @value.setter + def value(self, value: object) -> None: ... + def checked__get(self) -> bool: ... + def checked__set(self, value: object) -> None: ... + @property + def checked(self) -> bool: ... + @checked.setter + def checked(self, value: object) -> None: ... + +class Text(Field): ... +class Email(Field): ... +class File(Field): ... +class Textarea(Text): ... +class Hidden(Text): ... + +class Submit(Field): + def value__get(self) -> None: ... # type: ignore[override] + @property # type: ignore[misc] + def value(self) -> None: ... # type: ignore[override] + def value_if_submitted(self) -> str: ... + +class Form: + FieldClass: type[Field] + response: TestResponse + text: str + html: BeautifulSoup + action: str + method: str + id: str | None + enctype: str + field_order: list[tuple[str, Field]] + fields: dict[str, list[Field]] + def __init__(self, response: TestResponse, text: str, parser_features: Sequence[str] | str = "html.parser") -> None: ... + # NOTE: Technically it is only safe to pass `str | None` for most fields + # but this method is not really usable if we don't lift this + # restriction, we just have to assume people know what they + # are doing + def __setitem__(self, name: str, value: Any | None) -> None: ... + def set(self, name: str, value: Any | None, index: int | None = None) -> None: ... + def __getitem__(self, name: str) -> _AnyField: ... + @overload + def get(self, name: str, index: int | None = None) -> _AnyField: ... + @overload + def get(self, name: str, index: int | None, default: _T) -> _AnyField | _T: ... + @overload + def get(self, name: str, index: int | None = None, *, default: _T) -> _AnyField | _T: ... + @overload + def select(self, name: str, value: None, text: str | bytes, index: int | None = None) -> None: ... + @overload + def select(self, name: str, value: None = None, *, text: str | bytes, index: int | None = None) -> None: ... + @overload + def select(self, name: str, value: object, text: None = None, index: int | None = None) -> None: ... + @overload + def select_multiple(self, name: str, value: None, texts: Sequence[str | bytes], index: int | None = None) -> None: ... + @overload + def select_multiple( + self, name: str, value: None = None, *, texts: Sequence[str | bytes], index: int | None = None + ) -> None: ... + @overload + def select_multiple(self, name: str, value: Sequence[object], texts: None = None, index: int | None = None) -> None: ... + def submit( + self, name: str | None = None, index: int | None = None, value: str | None = None, **args: Any + ) -> TestResponse: ... + def lint(self) -> None: ... + def upload_fields(self) -> list[tuple[str, str] | tuple[str, str, bytes]]: ... + def submit_fields( + self, name: str | None = None, index: int | None = None, submit_value: str | None = None + ) -> list[tuple[str, str]]: ... diff --git a/stubs/WebTest/webtest/http.pyi b/stubs/WebTest/webtest/http.pyi new file mode 100644 index 000000000000..d3987ea48d58 --- /dev/null +++ b/stubs/WebTest/webtest/http.pyi @@ -0,0 +1,30 @@ +from _typeshed import Incomplete +from _typeshed.wsgi import StartResponse, WSGIApplication, WSGIEnvironment +from collections.abc import Iterable +from threading import Thread +from typing import Literal +from typing_extensions import Self, TypeAlias + +from waitress.server import TcpWSGIServer + +# NOTE: We may never really be able to complete this, since `create` +# invokes `cls.__init__` which is exempt from LSP violations +# unless we get something like `KwArgsOf[cls.__init__]`. +_WSGIServerParams: TypeAlias = Incomplete + +def get_free_port() -> tuple[str, int]: ... +def check_server(host: str, port: int, path_info: str = "/", timeout: float = 3, retries: int = 30) -> int: ... + +class StopableWSGIServer(TcpWSGIServer): + was_shutdown: bool + runner: Thread + test_app: WSGIApplication + application_url: str + def wrapper(self, environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]: ... + def run(self) -> None: ... + def shutdown(self, debug: bool = False) -> Literal[True]: ... + # NOTE: This has the same keyword arguments as cls.__init__, which + # we can't express + @classmethod + def create(cls, application: WSGIApplication, **kwargs: _WSGIServerParams) -> Self: ... + def wait(self, retries: int = 30) -> bool: ... diff --git a/stubs/WebTest/webtest/response.pyi b/stubs/WebTest/webtest/response.pyi new file mode 100644 index 000000000000..7cfb6b64cabb --- /dev/null +++ b/stubs/WebTest/webtest/response.pyi @@ -0,0 +1,89 @@ +import re +from _typeshed.wsgi import WSGIApplication +from collections.abc import Callable, Mapping, Sequence +from typing import Any, Literal, TypedDict, overload +from typing_extensions import TypeAlias, Unpack +from xml.etree import ElementTree + +from bs4 import BeautifulSoup +from webob import Response +from webtest.app import TestApp, TestRequest, _Files +from webtest.forms import Form + +_Pattern: TypeAlias = str | bytes | re.Pattern[str] | Callable[[str], bool] +# NOTE: These are optional dependencies, so we don't want to depend on them +# in the stubs either. Also there are no stubs for pyquery anyways. +_PyQuery: TypeAlias = Any +_PyQueryParams: TypeAlias = Any +_LxmlElement: TypeAlias = Any + +class _GetParams(TypedDict, total=False): + params: Mapping[str, str] | str + headers: Mapping[str, str] + extra_environ: Mapping[str, Any] + status: int | str | None + expect_errors: bool + xhr: bool + +class _PostParams(_GetParams, total=False): + upload_files: _Files + content_type: str + +class TestResponse(Response): + # NOTE: The way WebTest creates reponses the request is always set + # we could've used `MaybeNone`, but it seems more pragmatic + # to just assume that this is always set. + request: TestRequest # type: ignore[assignment] + app: WSGIApplication + test_app: TestApp + parser_features: str | Sequence[str] + __test__: Literal[False] + @property + def forms(self) -> dict[str | int, Form]: ... + @property + def form(self) -> Form: ... + @property + def testbody(self) -> str: ... + def follow(self, **kw: Unpack[_GetParams]) -> TestResponse: ... + def maybe_follow(self, **kw: Unpack[_GetParams]) -> TestResponse: ... + def click( + self, + description: _Pattern | None = None, + linkid: _Pattern | None = None, + href: _Pattern | None = None, + index: int | None = None, + verbose: bool = False, + extra_environ: dict[str, Any] | None = None, + ) -> TestResponse: ... + def clickbutton( + self, + description: _Pattern | None = None, + buttonid: _Pattern | None = None, + href: _Pattern | None = None, + onclick: str | None = None, + index: int | None = None, + verbose: bool = False, + ) -> TestResponse: ... + @overload + def goto(self, href: str, method: Literal["get"] = "get", **args: Unpack[_GetParams]) -> TestResponse: ... + @overload + def goto(self, href: str, method: Literal["post"], **args: Unpack[_PostParams]) -> TestResponse: ... + @property + def normal_body(self) -> bytes: ... + @property + def unicode_normal_body(self) -> str: ... + def __contains__(self, s: str) -> bool: ... + def mustcontain(self, *strings: str, no: Sequence[str] | str = ...) -> None: ... + @property + def html(self) -> BeautifulSoup: ... + @property + def xml(self) -> ElementTree.Element: ... + @property + def lxml(self) -> _LxmlElement: ... + @property + def json(self) -> Any: ... + @property + def pyquery(self) -> _PyQuery: ... + def PyQuery(self, **kwargs: _PyQueryParams) -> _PyQuery: ... + def showbrowser(self) -> None: ... + def __str__(self) -> str: ... # type: ignore[override] # noqa: Y029 From 09683861b672eaae72421a6439da52e99dba4fb9 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Thu, 7 Aug 2025 11:46:06 +0200 Subject: [PATCH 2/8] Fix typo in comment [skip ci] --- stubs/WebTest/webtest/forms.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stubs/WebTest/webtest/forms.pyi b/stubs/WebTest/webtest/forms.pyi index ab913d4f394d..caa211a64214 100644 --- a/stubs/WebTest/webtest/forms.pyi +++ b/stubs/WebTest/webtest/forms.pyi @@ -23,7 +23,7 @@ class _Classes(TypedDict): textarea: type[Textarea] radio: type[Radio] -# NOTE: It seems unergonmic having to put isinstance checks everywhere +# NOTE: It seems unergonomic having to put isinstance checks everywhere # in your test code where you're accessing a form field, so we # return `Any` for now. What we would really like to use here is # `AnyOf`, but that doesn't exist yet. From 3e12bf3cab86a801377b314cc29e30c155ec5055 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Wed, 3 Sep 2025 14:21:22 +0200 Subject: [PATCH 3/8] Improves some types in `app.pyi` based on review --- stubs/WebTest/webtest/app.pyi | 84 +++++++++++++++++++++-------------- 1 file changed, 51 insertions(+), 33 deletions(-) diff --git a/stubs/WebTest/webtest/app.pyi b/stubs/WebTest/webtest/app.pyi index 8c5f58afe643..cd95c49dff35 100644 --- a/stubs/WebTest/webtest/app.pyi +++ b/stubs/WebTest/webtest/app.pyi @@ -1,13 +1,27 @@ import json -from _typeshed.wsgi import WSGIApplication -from collections.abc import Mapping, Sequence +from _typeshed import SupportsItems, SupportsKeysAndGetItem +from _typeshed.wsgi import WSGIApplication, WSGIEnvironment +from collections.abc import Iterable, Sequence from http.cookiejar import CookieJar, DefaultCookiePolicy from typing import Any, Generic, Literal, TypeVar from typing_extensions import TypeAlias from webob.request import BaseRequest +from webtest.forms import File, Upload from webtest.response import TestResponse +# NOTE: While it is possible to pass different kinds of values depending on +# the exact configuration of the request, it seems more robust to +# restrict them to the types that are supported by all code paths. +# I don't expect anyone to try to pass different kinds of values +# in a non-JSON request. +_ParamValue: TypeAlias = File | Upload | int | bytes | str +_Params: TypeAlias = SupportsItems[str | bytes, _ParamValue] | Sequence[tuple[str | bytes, _ParamValue]] +# NOTE: Using `Collection` rather than `Iterable` would probably be slightly +# safer since WebTest will check this parameter for truthyness. But since +# objects are truthy by default, this should only lead to issues in truly +# exotic cases. +_ExtraEnviron: TypeAlias = SupportsKeysAndGetItem[str, Any] | Iterable[tuple[str, Any]] _Files: TypeAlias = Sequence[tuple[str, str] | tuple[str, str, bytes]] _AppT = TypeVar("_AppT", bound=WSGIApplication, default=WSGIApplication) @@ -27,7 +41,7 @@ class TestApp(Generic[_AppT]): app: _AppT lint: bool relative_to: str | None - extra_environ: dict[str, Any] + extra_environ: WSGIEnvironment use_unicode: bool cookiejar: CookieJar JSONEncoder: json.JSONEncoder @@ -35,7 +49,11 @@ class TestApp(Generic[_AppT]): def __init__( self, app: _AppT, - extra_environ: dict[str, Any] | None = None, + # NOTE: this extra_environ is different from the others and needs to + # support __delitem__, it seems easiest to just treat this like + # a regular WSGIEnvironment. The docs also say that this should + # be a dictionary. + extra_environ: WSGIEnvironment | None = None, relative_to: str | None = None, use_unicode: bool = True, cookiejar: CookieJar | None = None, @@ -57,9 +75,9 @@ class TestApp(Generic[_AppT]): def get( self, url: str, - params: Mapping[str, str] | str | None = None, - headers: Mapping[str, str] | None = None, - extra_environ: Mapping[str, Any] | None = None, + params: _Params | str | None = None, + headers: dict[str, str] | None = None, + extra_environ: _ExtraEnviron | None = None, status: int | str | None = None, expect_errors: bool = False, xhr: bool = False, @@ -67,9 +85,9 @@ class TestApp(Generic[_AppT]): def post( self, url: str, - params: Mapping[str, str] | str = "", - headers: Mapping[str, str] | None = None, - extra_environ: Mapping[str, Any] | None = None, + params: _Params | str = "", + headers: dict[str, str] | None = None, + extra_environ: _ExtraEnviron | None = None, status: int | str | None = None, upload_files: _Files | None = None, expect_errors: bool = False, @@ -79,9 +97,9 @@ class TestApp(Generic[_AppT]): def put( self, url: str, - params: Mapping[str, str] | str = "", - headers: Mapping[str, str] | None = None, - extra_environ: Mapping[str, Any] | None = None, + params: _Params | str = "", + headers: dict[str, str] | None = None, + extra_environ: _ExtraEnviron | None = None, status: int | str | None = None, upload_files: _Files | None = None, expect_errors: bool = False, @@ -91,9 +109,9 @@ class TestApp(Generic[_AppT]): def patch( self, url: str, - params: Mapping[str, str] | str = "", - headers: Mapping[str, str] | None = None, - extra_environ: Mapping[str, Any] | None = None, + params: _Params | str = "", + headers: dict[str, str] | None = None, + extra_environ: _ExtraEnviron | None = None, status: int | str | None = None, upload_files: _Files | None = None, expect_errors: bool = False, @@ -103,9 +121,9 @@ class TestApp(Generic[_AppT]): def delete( self, url: str, - params: Mapping[str, str] | str = "", - headers: Mapping[str, str] | None = None, - extra_environ: Mapping[str, Any] | None = None, + params: _Params | str = "", + headers: dict[str, str] | None = None, + extra_environ: _ExtraEnviron | None = None, status: int | str | None = None, expect_errors: bool = False, content_type: str | None = None, @@ -114,8 +132,8 @@ class TestApp(Generic[_AppT]): def options( self, url: str, - headers: Mapping[str, str] | None = None, - extra_environ: Mapping[str, Any] | None = None, + headers: dict[str, str] | None = None, + extra_environ: _ExtraEnviron | None = None, status: int | str | None = None, expect_errors: bool = False, xhr: bool = False, @@ -123,9 +141,9 @@ class TestApp(Generic[_AppT]): def head( self, url: str, - params: Mapping[str, str] | str | None = None, - headers: Mapping[str, str] | None = None, - extra_environ: Mapping[str, Any] | None = None, + params: _Params | str | None = None, + headers: dict[str, str] | None = None, + extra_environ: _ExtraEnviron | None = None, status: int | str | None = None, expect_errors: bool = False, xhr: bool = False, @@ -135,8 +153,8 @@ class TestApp(Generic[_AppT]): url: str, params: Any = ..., *, - headers: Mapping[str, str] | None = None, - extra_environ: Mapping[str, Any] | None = None, + headers: dict[str, str] | None = None, + extra_environ: _ExtraEnviron | None = None, status: int | str | None = None, expect_errors: bool = False, content_type: str | None = None, @@ -147,8 +165,8 @@ class TestApp(Generic[_AppT]): url: str, params: Any = ..., *, - headers: Mapping[str, str] | None = None, - extra_environ: Mapping[str, Any] | None = None, + headers: dict[str, str] | None = None, + extra_environ: _ExtraEnviron | None = None, status: int | str | None = None, expect_errors: bool = False, content_type: str | None = None, @@ -159,8 +177,8 @@ class TestApp(Generic[_AppT]): url: str, params: Any = ..., *, - headers: Mapping[str, str] | None = None, - extra_environ: Mapping[str, Any] | None = None, + headers: dict[str, str] | None = None, + extra_environ: _ExtraEnviron | None = None, status: int | str | None = None, expect_errors: bool = False, content_type: str | None = None, @@ -171,14 +189,14 @@ class TestApp(Generic[_AppT]): url: str, params: Any = ..., *, - headers: Mapping[str, str] | None = None, - extra_environ: Mapping[str, Any] | None = None, + headers: dict[str, str] | None = None, + extra_environ: _ExtraEnviron | None = None, status: int | str | None = None, expect_errors: bool = False, content_type: str | None = None, xhr: bool = False, ) -> TestResponse: ... - def encode_multipart(self, params: Sequence[tuple[str, str]], files: _Files) -> tuple[str, bytes]: ... + def encode_multipart(self, params: Iterable[tuple[str | bytes, _ParamValue]], files: _Files) -> tuple[str, bytes]: ... def request( self, url_or_req: str | TestRequest, status: int | str | None = None, expect_errors: bool = False, **req_params: Any ) -> TestResponse: ... From 2818de24e3e76092adf92a877a9008519b2f4f85 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Wed, 3 Sep 2025 14:25:33 +0200 Subject: [PATCH 4/8] Switches to `beautifulsoup4` from `types-beautifulsoup4` in requirements --- stubs/WebTest/METADATA.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stubs/WebTest/METADATA.toml b/stubs/WebTest/METADATA.toml index c46ca9e6dcb6..8426f872a6ee 100644 --- a/stubs/WebTest/METADATA.toml +++ b/stubs/WebTest/METADATA.toml @@ -1,3 +1,3 @@ version = "3.0.*" upstream_repository = "https://github.com/Pylons/webtest" -requires = ["types-beautifulsoup4", "types-waitress", "types-WebOb"] +requires = ["beautifulsoup4", "types-waitress", "types-WebOb"] From 50526994d0da23d57d830751225d94c93f30693f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 06:21:05 +0000 Subject: [PATCH 5/8] [pre-commit.ci] auto fixes from pre-commit.com hooks --- stubs/WebTest/webtest/app.pyi | 5 +++-- stubs/WebTest/webtest/forms.pyi | 19 +++++++++++++++++-- stubs/WebTest/webtest/http.pyi | 4 ++-- stubs/WebTest/webtest/response.pyi | 6 ++++-- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/stubs/WebTest/webtest/app.pyi b/stubs/WebTest/webtest/app.pyi index cd95c49dff35..0d43cc9ff633 100644 --- a/stubs/WebTest/webtest/app.pyi +++ b/stubs/WebTest/webtest/app.pyi @@ -3,8 +3,7 @@ from _typeshed import SupportsItems, SupportsKeysAndGetItem from _typeshed.wsgi import WSGIApplication, WSGIEnvironment from collections.abc import Iterable, Sequence from http.cookiejar import CookieJar, DefaultCookiePolicy -from typing import Any, Generic, Literal, TypeVar -from typing_extensions import TypeAlias +from typing import Any, Generic, Literal, TypeAlias, TypeVar from webob.request import BaseRequest from webtest.forms import File, Upload @@ -63,10 +62,12 @@ class TestApp(Generic[_AppT]): ) -> None: ... def get_authorization(self) -> tuple[str, str | tuple[str, str]]: ... def set_authorization(self, value: tuple[str, str | tuple[str, str]]) -> None: ... + @property def authorization(self) -> tuple[str, str | tuple[str, str]]: ... @authorization.setter def authorization(self, value: tuple[str, str | tuple[str, str]]) -> None: ... + @property def cookies(self) -> dict[str, str | None]: ... def set_cookie(self, name: str, value: str | None) -> None: ... diff --git a/stubs/WebTest/webtest/forms.pyi b/stubs/WebTest/webtest/forms.pyi index caa211a64214..5a87dbfd0a9b 100644 --- a/stubs/WebTest/webtest/forms.pyi +++ b/stubs/WebTest/webtest/forms.pyi @@ -1,6 +1,5 @@ from collections.abc import Generator, Sequence -from typing import Any, TypedDict, TypeVar, overload -from typing_extensions import TypeAlias +from typing import Any, TypeAlias, TypedDict, TypeVar, overload from bs4 import BeautifulSoup from webtest.response import TestResponse @@ -51,16 +50,19 @@ class Field: ) -> None: ... def value__get(self) -> str: ... def value__set(self, value: str | None) -> None: ... + @property def value(self) -> str: ... @value.setter def value(self, value: str | None) -> None: ... + def force_value(self, value: str | None) -> None: ... class Select(Field): options: list[tuple[str, bool, str]] optionPositions: list[int] selectedIndex: int | None + # NOTE: Even though it's safe to pass any object into text, I don't # think that follows the spirit of this argument and is more # likely a consequence of reusing the same utility function @@ -71,8 +73,10 @@ class Select(Field): def select(self, value: None = None, *, text: str | bytes) -> None: ... @overload def select(self, value: object, text: None = None) -> None: ... + def value__get(self) -> str: ... def value__set(self, value: object | None) -> None: ... + @property def value(self) -> str: ... @value.setter @@ -81,18 +85,22 @@ class Select(Field): class MultipleSelect(Field): options: list[tuple[str, bool, str]] selectedIndices: list[int] + @overload def select_multiple(self, value: None, texts: Sequence[str | bytes]) -> None: ... @overload def select_multiple(self, value: None = None, *, texts: Sequence[str | bytes]) -> None: ... @overload def select_multiple(self, value: Sequence[object], texts: None = None) -> None: ... + def value__get(self) -> list[str] | None: ... # type: ignore[override] def value__set(self, values: Sequence[object] | None) -> None: ... + @property # type: ignore[override] def value(self) -> list[str] | None: ... @value.setter def value(self, value: Sequence[object] | None) -> None: ... + # NOTE: Since unlike setting the value normally this doesn't perform # any kind of type conversion, we're better off only allowing # what `value__get` is supposed to be able to return. @@ -103,12 +111,15 @@ class Radio(Select): ... class Checkbox(Field): def value__get(self) -> str | None: ... # type: ignore[override] def value__set(self, value: object) -> None: ... + @property # type: ignore[override] def value(self) -> str | None: ... @value.setter def value(self, value: object) -> None: ... + def checked__get(self) -> bool: ... def checked__set(self, value: object) -> None: ... + @property def checked(self) -> bool: ... @checked.setter @@ -145,18 +156,21 @@ class Form: def __setitem__(self, name: str, value: Any | None) -> None: ... def set(self, name: str, value: Any | None, index: int | None = None) -> None: ... def __getitem__(self, name: str) -> _AnyField: ... + @overload def get(self, name: str, index: int | None = None) -> _AnyField: ... @overload def get(self, name: str, index: int | None, default: _T) -> _AnyField | _T: ... @overload def get(self, name: str, index: int | None = None, *, default: _T) -> _AnyField | _T: ... + @overload def select(self, name: str, value: None, text: str | bytes, index: int | None = None) -> None: ... @overload def select(self, name: str, value: None = None, *, text: str | bytes, index: int | None = None) -> None: ... @overload def select(self, name: str, value: object, text: None = None, index: int | None = None) -> None: ... + @overload def select_multiple(self, name: str, value: None, texts: Sequence[str | bytes], index: int | None = None) -> None: ... @overload @@ -165,6 +179,7 @@ class Form: ) -> None: ... @overload def select_multiple(self, name: str, value: Sequence[object], texts: None = None, index: int | None = None) -> None: ... + def submit( self, name: str | None = None, index: int | None = None, value: str | None = None, **args: Any ) -> TestResponse: ... diff --git a/stubs/WebTest/webtest/http.pyi b/stubs/WebTest/webtest/http.pyi index d3987ea48d58..8c0e3314db38 100644 --- a/stubs/WebTest/webtest/http.pyi +++ b/stubs/WebTest/webtest/http.pyi @@ -2,8 +2,8 @@ from _typeshed import Incomplete from _typeshed.wsgi import StartResponse, WSGIApplication, WSGIEnvironment from collections.abc import Iterable from threading import Thread -from typing import Literal -from typing_extensions import Self, TypeAlias +from typing import Literal, TypeAlias +from typing_extensions import Self from waitress.server import TcpWSGIServer diff --git a/stubs/WebTest/webtest/response.pyi b/stubs/WebTest/webtest/response.pyi index 7cfb6b64cabb..a39a925c332a 100644 --- a/stubs/WebTest/webtest/response.pyi +++ b/stubs/WebTest/webtest/response.pyi @@ -1,8 +1,8 @@ import re from _typeshed.wsgi import WSGIApplication from collections.abc import Callable, Mapping, Sequence -from typing import Any, Literal, TypedDict, overload -from typing_extensions import TypeAlias, Unpack +from typing import Any, Literal, TypeAlias, TypedDict, overload +from typing_extensions import Unpack from xml.etree import ElementTree from bs4 import BeautifulSoup @@ -64,10 +64,12 @@ class TestResponse(Response): index: int | None = None, verbose: bool = False, ) -> TestResponse: ... + @overload def goto(self, href: str, method: Literal["get"] = "get", **args: Unpack[_GetParams]) -> TestResponse: ... @overload def goto(self, href: str, method: Literal["post"], **args: Unpack[_PostParams]) -> TestResponse: ... + @property def normal_body(self) -> bytes: ... @property From 75c51c660b045aa4e67f622714416cd94f022092 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Tue, 9 Jun 2026 08:21:53 +0200 Subject: [PATCH 6/8] Updates `METADATA.toml` --- stubs/WebTest/METADATA.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stubs/WebTest/METADATA.toml b/stubs/WebTest/METADATA.toml index 8426f872a6ee..0c917c5cbc93 100644 --- a/stubs/WebTest/METADATA.toml +++ b/stubs/WebTest/METADATA.toml @@ -1,3 +1,3 @@ version = "3.0.*" -upstream_repository = "https://github.com/Pylons/webtest" -requires = ["beautifulsoup4", "types-waitress", "types-WebOb"] +upstream-repository = "https://github.com/Pylons/webtest" +dependencies = ["beautifulsoup4", "types-waitress", "types-WebOb"] From 3c9538aa0ddac5fa3729483b8dc506f04373a131 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Tue, 9 Jun 2026 08:27:21 +0200 Subject: [PATCH 7/8] Addresses stubtest complaints --- stubs/WebTest/@tests/stubtest_allowlist.txt | 1 - stubs/WebTest/webtest/debugapp.pyi | 3 ++- stubs/WebTest/webtest/forms.pyi | 3 ++- stubs/WebTest/webtest/response.pyi | 4 +++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/stubs/WebTest/@tests/stubtest_allowlist.txt b/stubs/WebTest/@tests/stubtest_allowlist.txt index f353712fea66..57f2ad0779b7 100644 --- a/stubs/WebTest/@tests/stubtest_allowlist.txt +++ b/stubs/WebTest/@tests/stubtest_allowlist.txt @@ -17,4 +17,3 @@ webtest.utils # normal use of WebTest, so it seems more pragmatic to treat # it as always non-`None` webtest.response.TestResponse.request -webtest.TestResponse.request diff --git a/stubs/WebTest/webtest/debugapp.pyi b/stubs/WebTest/webtest/debugapp.pyi index f887184dcd70..ea633f9c8346 100644 --- a/stubs/WebTest/webtest/debugapp.pyi +++ b/stubs/WebTest/webtest/debugapp.pyi @@ -1,9 +1,10 @@ from _typeshed import StrOrBytesPath from _typeshed.wsgi import StartResponse, WSGIEnvironment from collections.abc import Iterable -from typing import TypedDict +from typing import TypedDict, type_check_only from typing_extensions import Unpack +@type_check_only class _DebugAppParams(TypedDict, total=False): form: StrOrBytesPath | bytes | None show_form: bool diff --git a/stubs/WebTest/webtest/forms.pyi b/stubs/WebTest/webtest/forms.pyi index 5a87dbfd0a9b..b50e556425af 100644 --- a/stubs/WebTest/webtest/forms.pyi +++ b/stubs/WebTest/webtest/forms.pyi @@ -1,11 +1,12 @@ from collections.abc import Generator, Sequence -from typing import Any, TypeAlias, TypedDict, TypeVar, overload +from typing import Any, TypeAlias, TypedDict, TypeVar, overload, type_check_only from bs4 import BeautifulSoup from webtest.response import TestResponse _T = TypeVar("_T") +@type_check_only class _Classes(TypedDict): submit: type[Submit] button: type[Submit] diff --git a/stubs/WebTest/webtest/response.pyi b/stubs/WebTest/webtest/response.pyi index a39a925c332a..2821f616e16c 100644 --- a/stubs/WebTest/webtest/response.pyi +++ b/stubs/WebTest/webtest/response.pyi @@ -1,7 +1,7 @@ import re from _typeshed.wsgi import WSGIApplication from collections.abc import Callable, Mapping, Sequence -from typing import Any, Literal, TypeAlias, TypedDict, overload +from typing import Any, Literal, TypeAlias, TypedDict, overload, type_check_only from typing_extensions import Unpack from xml.etree import ElementTree @@ -17,6 +17,7 @@ _PyQuery: TypeAlias = Any _PyQueryParams: TypeAlias = Any _LxmlElement: TypeAlias = Any +@type_check_only class _GetParams(TypedDict, total=False): params: Mapping[str, str] | str headers: Mapping[str, str] @@ -25,6 +26,7 @@ class _GetParams(TypedDict, total=False): expect_errors: bool xhr: bool +@type_check_only class _PostParams(_GetParams, total=False): upload_files: _Files content_type: str From 25262d10052acfc4e4ce8bd7fca4329ecae7e180 Mon Sep 17 00:00:00 2001 From: David Salvisberg Date: Wed, 10 Jun 2026 08:06:17 +0200 Subject: [PATCH 8/8] Relaxes type constraint on multi-select fields --- stubs/WebTest/webtest/forms.pyi | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/stubs/WebTest/webtest/forms.pyi b/stubs/WebTest/webtest/forms.pyi index b50e556425af..695b943d7fb1 100644 --- a/stubs/WebTest/webtest/forms.pyi +++ b/stubs/WebTest/webtest/forms.pyi @@ -1,4 +1,4 @@ -from collections.abc import Generator, Sequence +from collections.abc import Collection, Generator, Iterable, Sequence from typing import Any, TypeAlias, TypedDict, TypeVar, overload, type_check_only from bs4 import BeautifulSoup @@ -88,19 +88,19 @@ class MultipleSelect(Field): selectedIndices: list[int] @overload - def select_multiple(self, value: None, texts: Sequence[str | bytes]) -> None: ... + def select_multiple(self, value: None, texts: Iterable[str | bytes]) -> None: ... @overload - def select_multiple(self, value: None = None, *, texts: Sequence[str | bytes]) -> None: ... + def select_multiple(self, value: None = None, *, texts: Iterable[str | bytes]) -> None: ... @overload - def select_multiple(self, value: Sequence[object], texts: None = None) -> None: ... + def select_multiple(self, value: Collection[object], texts: None = None) -> None: ... def value__get(self) -> list[str] | None: ... # type: ignore[override] - def value__set(self, values: Sequence[object] | None) -> None: ... + def value__set(self, values: Collection[object] | None) -> None: ... @property # type: ignore[override] def value(self) -> list[str] | None: ... @value.setter - def value(self, value: Sequence[object] | None) -> None: ... + def value(self, value: Collection[object] | None) -> None: ... # NOTE: Since unlike setting the value normally this doesn't perform # any kind of type conversion, we're better off only allowing @@ -173,13 +173,13 @@ class Form: def select(self, name: str, value: object, text: None = None, index: int | None = None) -> None: ... @overload - def select_multiple(self, name: str, value: None, texts: Sequence[str | bytes], index: int | None = None) -> None: ... + def select_multiple(self, name: str, value: None, texts: Iterable[str | bytes], index: int | None = None) -> None: ... @overload def select_multiple( - self, name: str, value: None = None, *, texts: Sequence[str | bytes], index: int | None = None + self, name: str, value: None = None, *, texts: Iterable[str | bytes], index: int | None = None ) -> None: ... @overload - def select_multiple(self, name: str, value: Sequence[object], texts: None = None, index: int | None = None) -> None: ... + def select_multiple(self, name: str, value: Iterable[object], texts: None = None, index: int | None = None) -> None: ... def submit( self, name: str | None = None, index: int | None = None, value: str | None = None, **args: Any