Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/typecheck.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Type Check

on: [push, pull_request]

permissions:
contents: read

jobs:
typecheck:
runs-on: ubuntu-24.04
timeout-minutes: 10

steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.10"

- name: Install dependencies
run: |
python -m pip install pip==26.0.1
python -m pip install -e . --group typecheck

- name: Run pyright
run: python -m pyright src/requests/
15 changes: 13 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,20 @@ Source = "https://github.com/psf/requests"
[project.optional-dependencies]
security = []
socks = ["PySocks>=1.5.6, !=1.5.7"]
use_chardet_on_py3 = ["chardet>=3.0.2,<8"]
use_chardet_on_py3 = ["chardet>=3.0.2,<7"]

[dependency-groups]
test = [
"pytest-httpbin==2.1.0",
"pytest-cov",
"pytest-mock",
"pytest-xdist",
"PySocks>=1.5.6, !=1.5.7",
"pytest>=3"
"pytest>=3",
]
typecheck = [
"pyright",
"typing_extensions",
]

[tool.setuptools]
Expand Down Expand Up @@ -100,3 +106,8 @@ addopts = "--doctest-modules"
doctest_optionflags = "NORMALIZE_WHITESPACE ELLIPSIS"
minversion = "6.2"
testpaths = ["tests"]


[tool.pyright]
include = ["src/requests"]
typeCheckingMode = "strict"
70 changes: 53 additions & 17 deletions src/requests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
:license: Apache 2.0, see LICENSE for more details.
"""

from __future__ import annotations

import warnings

import urllib3
Expand All @@ -50,21 +52,25 @@
charset_normalizer_version = None

try:
from chardet import __version__ as chardet_version
from chardet import __version__ as chardet_version # type: ignore[import-not-found]
except ImportError:
chardet_version = None


def check_compatibility(urllib3_version, chardet_version, charset_normalizer_version):
urllib3_version = urllib3_version.split(".")
assert urllib3_version != ["dev"] # Verify urllib3 isn't installed from git.
def check_compatibility(
urllib3_version: str,
chardet_version: str | None,
charset_normalizer_version: str | None,
) -> None:
urllib3_version_list = urllib3_version.split(".")
assert urllib3_version_list != ["dev"] # Verify urllib3 isn't installed from git.

# Sometimes, urllib3 only reports its version as 16.1.
if len(urllib3_version) == 2:
urllib3_version.append("0")
if len(urllib3_version_list) == 2:
urllib3_version_list.append("0")

# Check urllib3 for compatibility.
major, minor, patch = urllib3_version # noqa: F811
major, minor, patch = urllib3_version_list # noqa: F811
major, minor, patch = int(major), int(minor), int(patch)
# urllib3 >= 1.21.1
assert major >= 1
Expand All @@ -90,28 +96,28 @@ def check_compatibility(urllib3_version, chardet_version, charset_normalizer_ver
)


def _check_cryptography(cryptography_version):
def _check_cryptography(cryptography_version: str) -> None:
# cryptography < 1.3.4
try:
cryptography_version = list(map(int, cryptography_version.split(".")))
cryptography_version_list = list(map(int, cryptography_version.split(".")))
except ValueError:
return

if cryptography_version < [1, 3, 4]:
warning = (
f"Old version of cryptography ({cryptography_version}) may cause slowdown."
)
if cryptography_version_list < [1, 3, 4]:
warning = f"Old version of cryptography ({cryptography_version_list}) may cause slowdown."
warnings.warn(warning, RequestsDependencyWarning)


# Check imported dependencies for compatibility.
try:
check_compatibility(
urllib3.__version__, chardet_version, charset_normalizer_version
urllib3.__version__, # type: ignore[reportPrivateImportUsage]
chardet_version, # type: ignore[reportUnknownArgumentType]
charset_normalizer_version,
)
except (AssertionError, ValueError):
warnings.warn(
f"urllib3 ({urllib3.__version__}) or chardet "
f"urllib3 ({urllib3.__version__}) or chardet " # type: ignore[reportPrivateImportUsage]
f"({chardet_version})/charset_normalizer ({charset_normalizer_version}) "
"doesn't match a supported version!",
RequestsDependencyWarning,
Expand All @@ -132,9 +138,11 @@ def _check_cryptography(cryptography_version):
pyopenssl.inject_into_urllib3()

# Check cryptography version
from cryptography import __version__ as cryptography_version
from cryptography import ( # type: ignore[reportMissingImports]
__version__ as cryptography_version, # type: ignore[reportUnknownVariableType]
)

_check_cryptography(cryptography_version)
_check_cryptography(cryptography_version) # type: ignore[reportUnknownArgumentType]
except ImportError:
pass

Expand Down Expand Up @@ -177,6 +185,34 @@ def _check_cryptography(cryptography_version):
from .sessions import Session, session
from .status_codes import codes

__all__ = (
"ConnectionError",
"ConnectTimeout",
"HTTPError",
"JSONDecodeError",
"PreparedRequest",
"ReadTimeout",
"Request",
"RequestException",
"Response",
"Session",
"Timeout",
"TooManyRedirects",
"URLRequired",
"codes",
"delete",
"get",
"head",
"options",
"packages",
"patch",
"post",
"put",
"request",
"session",
"utils",
)

logging.getLogger(__name__).addHandler(NullHandler())

# FileModeWarnings go off per the default.
Expand Down
4 changes: 2 additions & 2 deletions src/requests/_internal_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
}


def to_native_string(string, encoding="ascii"):
def to_native_string(string: str | bytes, encoding: str = "ascii") -> str:
"""Given a string object, regardless of type, returns a representation of
that string in the native string type, encoding and decoding where
necessary. This assumes ASCII unless told otherwise.
Expand All @@ -36,7 +36,7 @@ def to_native_string(string, encoding="ascii"):
return out


def unicode_is_ascii(u_string):
def unicode_is_ascii(u_string: str) -> bool:
"""Determine if unicode string only contains ASCII characters.

:param str u_string: unicode string to check. Must be unicode
Expand Down
133 changes: 133 additions & 0 deletions src/requests/_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""
requests._types
~~~~~~~~~~~~~~~

This module contains type aliases used internally by the Requests library.
These types are not part of the public API and must not be relied upon
by external code.
"""

from __future__ import annotations

from collections.abc import Callable, Iterable, Mapping, MutableMapping
from typing import (
TYPE_CHECKING,
Any,
Protocol,
TypeVar,
runtime_checkable,
)

_T_co = TypeVar("_T_co", covariant=True)


@runtime_checkable
class SupportsRead(Protocol[_T_co]):
def read(self, length: int = ...) -> _T_co: ...


@runtime_checkable
class SupportsItems(Protocol):
def items(self) -> Iterable[tuple[Any, Any]]: ...


# These are needed at runtime for default_hooks() return type
HookType = Callable[["Response"], Any]
HooksInputType = Mapping[str, "Iterable[HookType] | HookType"]


def is_prepared(request: PreparedRequest) -> TypeIs[_ValidatedRequest]:
"""Verify a PreparedRequest has been fully prepared."""
if TYPE_CHECKING:
return request.url is not None and request.method is not None
# noop at runtime to avoid AssertionError
return True


if TYPE_CHECKING:
from typing import TypeAlias

from typing_extensions import TypeIs # move to typing when Python >= 3.13

from .auth import AuthBase
from .cookies import RequestsCookieJar
from .models import PreparedRequest, Response
from .structures import CaseInsensitiveDict

class _ValidatedRequest(PreparedRequest):
"""Subtype asserting a PreparedRequest has been fully prepared before calling.

The override suppression is required because mutable attribute types are
invariant (Liskov), but we only narrow after preparation is complete. This
is the explicit contract for Requests but Python's typing doesn't have a
better way to represent the requirement.
"""

url: str # type: ignore[reportIncompatibleVariableOverride]
method: str # type: ignore[reportIncompatibleVariableOverride]

# Type aliases for core API concepts (ordered by request() signature)
UriType: TypeAlias = str | bytes

_ParamsMappingKeyType: TypeAlias = str | bytes | int | float
_ParamsMappingValueType: TypeAlias = (
str | bytes | int | float | Iterable[str | bytes | int | float] | None
)
ParamsType: TypeAlias = (
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be lovely if these type hints were available through a "public" interface, since I've often written code that wraps a requests call, where I would want to use the ParamsType to annotate my own code

I say public in quotes because I could import this, but it feels wrong

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree having some form of public hints for the top-level APIs is potentially warranted. I started very conservative here because often times new code goes into Requests and immediately comes someone else's critical dependency. I don't want to create a binding contract that people start surfacing in their code, and then "break" when we need to update/tweak it.

I think once we're really happy with the types, the main argument parameters could be surfaced. I've deferred that for now until we can make a more informed decision. Presumably everything calling in is already typed sufficiently.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fair! thank you for doing this and considering carefully :)

Mapping[_ParamsMappingKeyType, _ParamsMappingValueType]
| tuple[tuple[_ParamsMappingKeyType, _ParamsMappingValueType], ...]
| Iterable[tuple[_ParamsMappingKeyType, _ParamsMappingValueType]]
| str
| bytes
| None
)

KVDataType: TypeAlias = Iterable[tuple[Any, Any]] | Mapping[Any, Any]

EncodableDataType: TypeAlias = KVDataType | str | bytes | SupportsRead[str | bytes]

DataType: TypeAlias = (
KVDataType
| Iterable[bytes | str]
| str
| bytes
| SupportsRead[str | bytes]
| None
)

BodyType: TypeAlias = (
bytes | str | Iterable[bytes | str] | SupportsRead[bytes | str] | None
)

HeadersType: TypeAlias = CaseInsensitiveDict[str] | Mapping[str, str | bytes]
HeadersUpdateType: TypeAlias = Mapping[str, str | bytes | None]

CookiesType: TypeAlias = RequestsCookieJar | Mapping[str, str]

# Building blocks for FilesType
_FileName: TypeAlias = str | None
_FileContent: TypeAlias = SupportsRead[str | bytes] | str | bytes
_FileSpecBasic: TypeAlias = tuple[_FileName, _FileContent]
_FileSpecWithContentType: TypeAlias = tuple[_FileName, _FileContent, str]
_FileSpecWithHeaders: TypeAlias = tuple[
_FileName, _FileContent, str, CaseInsensitiveDict[str] | Mapping[str, str]
]
_FileSpec: TypeAlias = (
_FileContent | _FileSpecBasic | _FileSpecWithContentType | _FileSpecWithHeaders
)
FilesType: TypeAlias = (
Mapping[str, _FileSpec] | Iterable[tuple[str, _FileSpec]] | None
)

AuthType: TypeAlias = (
tuple[str, str] | AuthBase | Callable[[PreparedRequest], PreparedRequest] | None
)

TimeoutType: TypeAlias = float | tuple[float | None, float | None] | None
ProxiesType: TypeAlias = MutableMapping[str, str]
HooksType: TypeAlias = dict[str, list["HookType"]] | None
VerifyType: TypeAlias = bool | str
CertType: TypeAlias = str | tuple[str, str] | None
JsonType: TypeAlias = (
None | bool | int | float | str | list["JsonType"] | dict[str, "JsonType"]
)
Loading
Loading