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..caa211a64214 --- /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 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. +_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