Move to mypy --strict with exceptions. (#7300)

* Move to mypy --strict with exceptions.

* Fix typing-tests.
This commit is contained in:
Andrey Rakhmatullin 2026-03-02 15:47:23 +05:00 committed by GitHub
parent 7010985e4f
commit e02ad08672
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 148 additions and 88 deletions

View File

@ -85,8 +85,20 @@ path = "scrapy/VERSION"
pattern = "^(?P<version>.+)$" pattern = "^(?P<version>.+)$"
[tool.mypy] [tool.mypy]
ignore_missing_imports = true strict = true
implicit_reexport = false allow_any_generics = true # 67 errors
allow_untyped_calls = true # 58 errors
extra_checks = false # weird addErrback() errors
untyped_calls_exclude = [
"twisted",
]
warn_return_any = false # 37 errors
[[tool.mypy.overrides]]
module = "tests.*"
allow_untyped_defs = true
allow_incomplete_defs = true # 48 errors
check_untyped_defs = false
# Interface classes are hard to support # Interface classes are hard to support
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
@ -101,14 +113,46 @@ ignore_errors = true
module = "twisted.internet.reactor" module = "twisted.internet.reactor"
follow_imports = "skip" follow_imports = "skip"
# FIXME: remove the following section once the issues are solved # just for twisted.version
[[tool.mypy.overrides]]
module = "twisted"
implicit_reexport = true
# TODO
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = "scrapy.settings.default_settings" module = "scrapy.settings.default_settings"
ignore_errors = true ignore_errors = true
# deprecated modules
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = "twisted" module = [
implicit_reexport = true "scrapy.core.downloader.webclient",
"scrapy.spiders.init",
"scrapy.utils.testsite",
"tests.test_webclient",
]
allow_any_generics = true
allow_untyped_calls = true
allow_untyped_defs = true
check_untyped_defs = false
warn_return_any = false
# usually no type hints
[[tool.mypy.overrides]]
module = [
# "IPython.*",
"bpython",
"brotli",
"brotlicffi",
"google.*",
"pydispatch.*",
"pyftpdlib.*",
"pytest_twisted",
"robotexclusionrulesparser",
"testfixtures",
"zope.interface.*",
]
ignore_missing_imports = true
[tool.bumpversion] [tool.bumpversion]
current_version = "2.14.1" current_version = "2.14.1"

View File

@ -55,7 +55,7 @@ class AddonManager:
) )
@classmethod @classmethod
def load_pre_crawler_settings(cls, settings: BaseSettings): def load_pre_crawler_settings(cls, settings: BaseSettings) -> None:
"""Update early settings that do not require a crawler instance, such as SPIDER_MODULES. """Update early settings that do not require a crawler instance, such as SPIDER_MODULES.
Similar to the load_settings method, this loads each add-on configured in the Similar to the load_settings method, this loads each add-on configured in the

View File

@ -43,7 +43,7 @@ class _BenchServer:
assert self.proc.stdout assert self.proc.stdout
self.proc.stdout.readline() self.proc.stdout.readline()
def __exit__(self, exc_type, exc_value, traceback) -> None: def __exit__(self, exc_type, exc_value, traceback) -> None: # type: ignore[no-untyped-def]
self.proc.kill() self.proc.kill()
self.proc.wait() self.proc.wait()
time.sleep(0.2) time.sleep(0.2)

View File

@ -1,9 +1,12 @@
import argparse import argparse
import time import time
from collections import defaultdict from collections import defaultdict
from collections.abc import AsyncIterator
from typing import Any
from unittest import TextTestResult as _TextTestResult from unittest import TextTestResult as _TextTestResult
from unittest import TextTestRunner from unittest import TextTestRunner
from scrapy import Spider
from scrapy.commands import ScrapyCommand from scrapy.commands import ScrapyCommand
from scrapy.contracts import ContractsManager from scrapy.contracts import ContractsManager
from scrapy.utils.conf import build_component_list from scrapy.utils.conf import build_component_list
@ -81,14 +84,14 @@ class Command(ScrapyCommand):
assert self.crawler_process assert self.crawler_process
spider_loader = self.crawler_process.spider_loader spider_loader = self.crawler_process.spider_loader
async def start(self): async def start(self: Spider) -> AsyncIterator[Any]:
for request in conman.from_spider(self, result): for request in conman.from_spider(self, result):
yield request yield request
with set_environ(SCRAPY_CHECK="true"): with set_environ(SCRAPY_CHECK="true"):
for spidername in args or spider_loader.list(): for spidername in args or spider_loader.list():
spidercls = spider_loader.load(spidername) spidercls = spider_loader.load(spidername)
spidercls.start = start # type: ignore[assignment,method-assign,return-value] spidercls.start = start # type: ignore[method-assign]
tested_methods = conman.tested_methods_from_spidercls(spidercls) tested_methods = conman.tested_methods_from_spidercls(spidercls)
if opts.list: if opts.list:

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import sys import sys
from argparse import Namespace # noqa: TC003 from argparse import Namespace # noqa: TC003
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
from w3lib.url import is_url from w3lib.url import is_url
@ -14,6 +14,7 @@ from scrapy.utils.spider import DefaultSpider, spidercls_for_request
if TYPE_CHECKING: if TYPE_CHECKING:
from argparse import ArgumentParser from argparse import ArgumentParser
from collections.abc import AsyncIterator
from scrapy import Spider from scrapy import Spider
@ -89,10 +90,10 @@ class Command(ScrapyCommand):
else: else:
spidercls = spidercls_for_request(spider_loader, request, spidercls) spidercls = spidercls_for_request(spider_loader, request, spidercls)
async def start(self): async def start(self: Spider) -> AsyncIterator[Any]:
yield request yield request
spidercls.start = start # type: ignore[method-assign,attr-defined] spidercls.start = start # type: ignore[method-assign]
self.crawler_process.crawl(spidercls) self.crawler_process.crawl(spidercls)
self.crawler_process.start() self.crawler_process.start()

View File

@ -189,7 +189,7 @@ class ContractsManager:
def eb_wrapper(failure: Failure) -> None: def eb_wrapper(failure: Failure) -> None:
case = _create_testcase(method, "errback") case = _create_testcase(method, "errback")
exc_info = failure.type, failure.value, failure.getTracebackObject() exc_info = failure.type, failure.value, failure.getTracebackObject()
results.addError(case, exc_info) results.addError(case, exc_info) # type: ignore[arg-type]
request.callback = cb_wrapper request.callback = cb_wrapper
request.errback = eb_wrapper request.errback = eb_wrapper

View File

@ -265,5 +265,5 @@ class HttpxDownloadHandler(BaseHttpDownloadHandler):
logger.warning(warning_msg) logger.warning(warning_msg)
raise DownloadCancelledError(warning_msg) raise DownloadCancelledError(warning_msg)
async def close(self): async def close(self) -> None:
await self._client.aclose() await self._client.aclose()

View File

@ -507,7 +507,7 @@ class ScrapyAgent:
} }
# deliverBody hangs for responses without body # deliverBody hangs for responses without body
if txresponse.length == 0: if cast("int", txresponse.length) == 0:
return { return {
"txresponse": txresponse, "txresponse": txresponse,
} }
@ -697,7 +697,8 @@ class _ResponseReader(Protocol):
return return
if reason.check(ResponseFailed) and any( if reason.check(ResponseFailed) and any(
r.check(_DataLoss) for r in reason.value.reasons r.check(_DataLoss)
for r in reason.value.reasons # type: ignore[union-attr]
): ):
if not self._fail_on_dataloss: if not self._fail_on_dataloss:
self._finish_response(flags=["dataloss"]) self._finish_response(flags=["dataloss"])

View File

@ -160,7 +160,7 @@ class ExecutionEngine:
return scheduler_cls return scheduler_cls
def start( def start(
self, _start_request_processing=True self, _start_request_processing: bool = True
) -> Deferred[None]: # pragma: no cover ) -> Deferred[None]: # pragma: no cover
warnings.warn( warnings.warn(
"ExecutionEngine.start() is deprecated, use start_async() instead", "ExecutionEngine.start() is deprecated, use start_async() instead",
@ -263,12 +263,13 @@ class ExecutionEngine:
def unpause(self) -> None: def unpause(self) -> None:
self.paused = False self.paused = False
async def _process_start_next(self): async def _process_start_next(self) -> None:
"""Processes the next item or request from Spider.start(). """Processes the next item or request from Spider.start().
If a request, it is scheduled. If an item, it is sent to item If a request, it is scheduled. If an item, it is sent to item
pipelines. pipelines.
""" """
assert self._start is not None
try: try:
item_or_request = await self._start.__anext__() item_or_request = await self._start.__anext__()
except StopAsyncIteration: except StopAsyncIteration:
@ -286,6 +287,7 @@ class ExecutionEngine:
if isinstance(item_or_request, Request): if isinstance(item_or_request, Request):
self.crawl(item_or_request) self.crawl(item_or_request)
else: else:
assert self._slot is not None
_schedule_coro( _schedule_coro(
self.scraper.start_itemproc_async(item_or_request, response=None) self.scraper.start_itemproc_async(item_or_request, response=None)
) )

View File

@ -4,7 +4,7 @@ import ipaddress
import itertools import itertools
import logging import logging
from collections import deque from collections import deque
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, cast
from h2.config import H2Configuration from h2.config import H2Configuration
from h2.connection import H2Connection from h2.connection import H2Connection
@ -39,6 +39,7 @@ from scrapy.utils.deprecate import warn_on_deprecated_spider_attribute
if TYPE_CHECKING: if TYPE_CHECKING:
from ipaddress import IPv4Address, IPv6Address from ipaddress import IPv4Address, IPv6Address
from hpack import HeaderTuple
from twisted.internet.defer import Deferred from twisted.internet.defer import Deferred
from twisted.python.failure import Failure from twisted.python.failure import Failure
from twisted.web.client import URI from twisted.web.client import URI
@ -408,7 +409,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin):
except KeyError: except KeyError:
pass # We ignore server-initiated events pass # We ignore server-initiated events
else: else:
stream.receive_headers(event.headers) stream.receive_headers(cast("list[HeaderTuple]", event.headers))
def settings_acknowledged(self, event: SettingsAcknowledged) -> None: def settings_acknowledged(self, event: SettingsAcknowledged) -> None:
self.metadata["settings_acknowledged"] = True self.metadata["settings_acknowledged"] = True

View File

@ -460,7 +460,7 @@ class SpiderMiddlewareManager(MiddlewareManager):
start = await self._process_chain("process_start", start) start = await self._process_chain("process_start", start)
return start return start
def _check_deprecated_start_requests_use(self): def _check_deprecated_start_requests_use(self) -> None:
start_requests_cls = None start_requests_cls = None
start_cls = None start_cls = None
spidercls = self._spider.__class__ spidercls = self._spider.__class__

View File

@ -135,7 +135,7 @@ class Crawler:
"Overridden settings:\n%(settings)s", {"settings": pprint.pformat(d)} "Overridden settings:\n%(settings)s", {"settings": pprint.pformat(d)}
) )
def _apply_reactorless_default_settings(self): def _apply_reactorless_default_settings(self) -> None:
"""Change some setting defaults when not using a Twisted reactor. """Change some setting defaults when not using a Twisted reactor.
Some settings need different defaults when using and not using a Some settings need different defaults when using and not using a

View File

@ -34,8 +34,8 @@ class StackTraceDump:
def __init__(self, crawler: Crawler): def __init__(self, crawler: Crawler):
self.crawler: Crawler = crawler self.crawler: Crawler = crawler
try: try:
signal.signal(signal.SIGUSR2, self.dump_stacktrace) # type: ignore[attr-defined] signal.signal(signal.SIGUSR2, self.dump_stacktrace) # type: ignore[attr-defined,unused-ignore]
signal.signal(signal.SIGQUIT, self.dump_stacktrace) # type: ignore[attr-defined] signal.signal(signal.SIGQUIT, self.dump_stacktrace) # type: ignore[attr-defined,unused-ignore]
except AttributeError: except AttributeError:
# win32 platforms don't support SIGUSR signals # win32 platforms don't support SIGUSR signals
pass pass
@ -72,7 +72,7 @@ class Debugger:
def __init__(self) -> None: def __init__(self) -> None:
# win32 platforms don't support SIGUSR signals # win32 platforms don't support SIGUSR signals
with contextlib.suppress(AttributeError): with contextlib.suppress(AttributeError):
signal.signal(signal.SIGUSR2, self._enter_debugger) # type: ignore[attr-defined] signal.signal(signal.SIGUSR2, self._enter_debugger) # type: ignore[attr-defined,unused-ignore]
def _enter_debugger(self, signum: int, frame: FrameType | None) -> None: def _enter_debugger(self, signum: int, frame: FrameType | None) -> None:
assert frame assert frame

View File

@ -89,20 +89,20 @@ class ItemFilter:
return True # accept all items by default return True # accept all items by default
class IFeedStorage(Interface): class IFeedStorage(Interface): # type: ignore[misc]
"""Interface that all Feed Storages must implement""" """Interface that all Feed Storages must implement"""
# pylint: disable=no-self-argument # pylint: disable=no-self-argument
def __init__(uri, *, feed_options=None): # pylint: disable=super-init-not-called def __init__(uri, *, feed_options=None): # type: ignore[no-untyped-def] # pylint: disable=super-init-not-called
"""Initialize the storage with the parameters given in the URI and the """Initialize the storage with the parameters given in the URI and the
feed-specific options (see :setting:`FEEDS`)""" feed-specific options (see :setting:`FEEDS`)"""
def open(spider): def open(spider): # type: ignore[no-untyped-def]
"""Open the storage for the given spider. It must return a file-like """Open the storage for the given spider. It must return a file-like
object that will be used for the exporters""" object that will be used for the exporters"""
def store(file): def store(file): # type: ignore[no-untyped-def]
"""Store the given file stream""" """Store the given file stream"""

View File

@ -86,7 +86,7 @@ class TelnetConsole(protocol.ServerFactory):
class Portal: class Portal:
"""An implementation of IPortal""" """An implementation of IPortal"""
def login(self_, credentials, mind, *interfaces): # pylint: disable=no-self-argument def login(self_, credentials, mind, *interfaces): # type: ignore[no-untyped-def] # pylint: disable=no-self-argument
if not ( if not (
credentials.username == self.username.encode("utf8") credentials.username == self.username.encode("utf8")
and credentials.checkPassword(self.password.encode("utf8")) and credentials.checkPassword(self.password.encode("utf8"))

View File

@ -74,7 +74,7 @@ class CookieJar:
@property @property
def _cookies(self) -> dict[str, dict[str, dict[str, Cookie]]]: def _cookies(self) -> dict[str, dict[str, dict[str, Cookie]]]:
return self.jar._cookies # type: ignore[attr-defined,no-any-return] return self.jar._cookies # type: ignore[attr-defined]
def clear_session_cookies(self) -> None: def clear_session_cookies(self) -> None:
return self.jar.clear_session_cookies() return self.jar.clear_session_cookies()

View File

@ -108,7 +108,7 @@ def _urlencode(seq: Iterable[FormdataKVType], enc: str) -> str:
values = [ values = [
(to_bytes(k, enc), to_bytes(v, enc)) (to_bytes(k, enc), to_bytes(v, enc))
for k, vs in seq for k, vs in seq
for v in (cast("Iterable[str]", vs) if is_listlike(vs) else [cast("str", vs)]) for v in (vs if is_listlike(vs) else [cast("str", vs)])
] ]
return urlencode(values, doseq=True) return urlencode(values, doseq=True)

View File

@ -10,7 +10,7 @@ import asyncio
import warnings import warnings
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from twisted.internet.defer import Deferred, DeferredList from twisted.internet.defer import Deferred, DeferredList, FirstError
from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.middleware import MiddlewareManager from scrapy.middleware import MiddlewareManager
@ -80,6 +80,7 @@ class ItemPipelineManager(MiddlewareManager):
d2: Deferred[list[None]] = d.addCallback(lambda r: [x[1] for x in r]) d2: Deferred[list[None]] = d.addCallback(lambda r: [x[1] for x in r])
def eb(failure: Failure) -> Failure: def eb(failure: Failure) -> Failure:
assert isinstance(failure.value, FirstError)
return failure.value.subFailure return failure.value.subFailure
d2.addErrback(eb) d2.addErrback(eb)

View File

@ -305,7 +305,7 @@ class GCSFilesStore:
def stat_file( def stat_file(
self, path: str, info: MediaPipeline.SpiderInfo self, path: str, info: MediaPipeline.SpiderInfo
) -> Deferred[StatInfo]: ) -> Deferred[StatInfo]:
def _onsuccess(blob) -> StatInfo: def _onsuccess(blob: Any) -> StatInfo:
if blob: if blob:
checksum = base64.b64decode(blob.md5_hash).hex() checksum = base64.b64decode(blob.md5_hash).hex()
last_modified = time.mktime(blob.updated.timetuple()) last_modified = time.mktime(blob.updated.timetuple())

View File

@ -250,6 +250,7 @@ class MediaPipeline(ABC):
# the encapsulated exception when it is a StopIteration instance # the encapsulated exception when it is a StopIteration instance
context = getattr(result.value, "__context__", None) context = getattr(result.value, "__context__", None)
if isinstance(context, StopIteration): if isinstance(context, StopIteration):
assert result.value is not None
result.value.__context__ = None result.value.__context__ = None
info.downloading.remove(fp) info.downloading.remove(fp)

View File

@ -104,7 +104,7 @@ class Shell:
event_loop_path = self.crawler.settings["ASYNCIO_EVENT_LOOP"] event_loop_path = self.crawler.settings["ASYNCIO_EVENT_LOOP"]
set_asyncio_event_loop(event_loop_path) set_asyncio_event_loop(event_loop_path)
def crawl_request(_): def crawl_request(_: None) -> None:
assert self.crawler.engine is not None assert self.crawler.engine is not None
self.crawler.engine.crawl(request) self.crawler.engine.crawl(request)

View File

@ -101,14 +101,14 @@ class SignalManager:
kwargs.setdefault("sender", self.sender) kwargs.setdefault("sender", self.sender)
_signal.disconnect_all(signal, **kwargs) _signal.disconnect_all(signal, **kwargs)
async def wait_for(self, signal): async def wait_for(self, signal: Any) -> None:
"""Await the next *signal*. """Await the next *signal*.
See :ref:`start-requests-lazy` for an example. See :ref:`start-requests-lazy` for an example.
""" """
d = Deferred() d: Deferred[None] = Deferred()
def handle(): def handle() -> None:
self.disconnect(handle, signal) self.disconnect(handle, signal)
d.callback(None) d.callback(None)

View File

@ -27,7 +27,7 @@ class StatsCollector:
self._stats: StatsT = {} self._stats: StatsT = {}
self._crawler: Crawler = crawler self._crawler: Crawler = crawler
def __getattribute__(self, name): def __getattribute__(self, name: str) -> Any:
cached_name = f"_cached_{name}" cached_name = f"_cached_{name}"
try: try:
return super().__getattribute__(cached_name) return super().__getattribute__(cached_name)

View File

@ -27,8 +27,10 @@ class Root(Resource):
return b"" return b""
def _getarg(request, name: bytes, default: Any = None, type_=str): def _getarg(
return type_(request.args[name][0]) if name in request.args else default request: Request, name: bytes, default: Any = None, type_: type = str
) -> Any:
return type_(request.args[name][0]) if name in request.args else default # type: ignore[index,operator]
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -20,10 +20,10 @@ def _embed_ipython_shell(
from IPython.terminal.embed import InteractiveShellEmbed # noqa: T100,PLC0415 from IPython.terminal.embed import InteractiveShellEmbed # noqa: T100,PLC0415
from IPython.terminal.ipapp import load_default_config # noqa: PLC0415 from IPython.terminal.ipapp import load_default_config # noqa: PLC0415
except ImportError: except ImportError:
from IPython.frontend.terminal.embed import ( # type: ignore[no-redef] # noqa: T100,PLC0415 from IPython.frontend.terminal.embed import ( # type: ignore[import-not-found,no-redef] # noqa: T100,PLC0415
InteractiveShellEmbed, InteractiveShellEmbed,
) )
from IPython.frontend.terminal.ipapp import ( # type: ignore[no-redef] # noqa: PLC0415 from IPython.frontend.terminal.ipapp import ( # type: ignore[import-not-found,no-redef] # noqa: PLC0415
load_default_config, load_default_config,
) )
@ -81,7 +81,7 @@ def _embed_standard_shell(
else: else:
import rlcompleter # noqa: F401,PLC0415 import rlcompleter # noqa: F401,PLC0415
readline.parse_and_bind("tab:complete") # type: ignore[attr-defined] readline.parse_and_bind("tab:complete") # type: ignore[attr-defined,unused-ignore]
@wraps(_embed_standard_shell) @wraps(_embed_standard_shell)
def wrapper(namespace: dict[str, Any] = namespace, banner: str = "") -> None: def wrapper(namespace: dict[str, Any] = namespace, banner: str = "") -> None:

View File

@ -83,7 +83,7 @@ class CaselessDict(dict):
return dict.get(self, self.normkey(key), self.normvalue(def_val)) return dict.get(self, self.normkey(key), self.normvalue(def_val))
def setdefault(self, key: AnyStr, def_val: Any = None) -> Any: def setdefault(self, key: AnyStr, def_val: Any = None) -> Any:
return dict.setdefault(self, self.normkey(key), self.normvalue(def_val)) # type: ignore[arg-type] return dict.setdefault(self, self.normkey(key), self.normvalue(def_val))
# doesn't fully implement MutableMapping.update() # doesn't fully implement MutableMapping.update()
def update(self, seq: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]]) -> None: # type: ignore[override] def update(self, seq: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]]) -> None: # type: ignore[override]
@ -179,7 +179,7 @@ class LocalWeakReferencedCache(weakref.WeakKeyDictionary):
with contextlib.suppress(TypeError): with contextlib.suppress(TypeError):
super().__setitem__(key, value) super().__setitem__(key, value)
def __getitem__(self, key: _KT) -> _VT | None: # type: ignore[override] def __getitem__(self, key: _KT) -> _VT | None:
try: try:
return super().__getitem__(key) return super().__getitem__(key)
except (TypeError, KeyError): except (TypeError, KeyError):

View File

@ -21,7 +21,7 @@ from typing import (
overload, overload,
) )
from twisted.internet.defer import Deferred, DeferredList, fail, succeed from twisted.internet.defer import Deferred, DeferredList, FirstError, fail, succeed
from twisted.internet.task import Cooperator from twisted.internet.task import Cooperator
from twisted.python import failure from twisted.python import failure
@ -334,6 +334,7 @@ def process_parallel(
d2: Deferred[list[_T2]] = d.addCallback(lambda r: [x[1] for x in r]) d2: Deferred[list[_T2]] = d.addCallback(lambda r: [x[1] for x in r])
def eb(failure: Failure) -> Failure: def eb(failure: Failure) -> Failure:
assert isinstance(failure.value, FirstError)
return failure.value.subFailure return failure.value.subFailure
d2.addErrback(eb) d2.addErrback(eb)

View File

@ -60,7 +60,7 @@ def create_deprecated_class(
""" """
# https://github.com/python/mypy/issues/4177 # https://github.com/python/mypy/issues/4177
class DeprecatedClass(new_class.__class__): # type: ignore[misc, name-defined] class DeprecatedClass(new_class.__class__): # type: ignore[misc,name-defined]
# pylint: disable=no-self-argument # pylint: disable=no-self-argument
deprecated_class: type | None = None deprecated_class: type | None = None
warned_on_subclass: bool = False warned_on_subclass: bool = False

View File

@ -189,7 +189,7 @@ def log_scrapy_info(settings: Settings) -> None:
"Scrapy %(version)s started (bot: %(bot)s)", "Scrapy %(version)s started (bot: %(bot)s)",
{"version": scrapy.__version__, "bot": settings["BOT_NAME"]}, {"version": scrapy.__version__, "bot": settings["BOT_NAME"]},
) )
software = settings.getlist("LOG_VERSIONS") software: list[str] = settings.getlist("LOG_VERSIONS")
if not software: if not software:
return return
versions = pprint.pformat(dict(get_versions(software)), sort_dicts=False) versions = pprint.pformat(dict(get_versions(software)), sort_dicts=False)

View File

@ -80,11 +80,11 @@ class CallLaterOnce(Generic[_T]):
return result return result
async def wait(self): async def wait(self) -> None:
# circular import # circular import
from scrapy.utils.defer import maybe_deferred_to_future # noqa: PLC0415 from scrapy.utils.defer import maybe_deferred_to_future # noqa: PLC0415
d = Deferred() d: Deferred[None] = Deferred()
self._deferreds.append(d) self._deferreds.append(d)
await maybe_deferred_to_future(d) await maybe_deferred_to_future(d)

View File

@ -8,7 +8,7 @@ from __future__ import annotations
import re import re
import warnings import warnings
from importlib import import_module from importlib import import_module
from typing import TYPE_CHECKING, TypeAlias from typing import TYPE_CHECKING, Any, TypeAlias
from urllib.parse import ParseResult, urldefrag, urlparse, urlunparse from urllib.parse import ParseResult, urldefrag, urlparse, urlunparse
from warnings import warn from warnings import warn
@ -20,7 +20,7 @@ from w3lib.url import parse_url as _parse_url
from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.exceptions import ScrapyDeprecationWarning
def __getattr__(name: str): def __getattr__(name: str) -> Any:
if name in ("_unquotepath", "_safe_chars", "parse_url", *_public_w3lib_objects): if name in ("_unquotepath", "_safe_chars", "parse_url", *_public_w3lib_objects):
obj_type = "attribute" if name == "_safe_chars" else "function" obj_type = "attribute" if name == "_safe_chars" else "function"
warnings.warn( warnings.warn(

View File

@ -11,10 +11,10 @@ from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.settings.default_settings import LOG_VERSIONS from scrapy.settings.default_settings import LOG_VERSIONS
from scrapy.utils.ssl import get_openssl_version from scrapy.utils.ssl import get_openssl_version
_DEFAULT_SOFTWARE = ["Scrapy", *LOG_VERSIONS] _DEFAULT_SOFTWARE: list[str] = ["Scrapy", *LOG_VERSIONS]
def _version(item): def _version(item: str) -> str:
lowercase_item = item.lower() lowercase_item = item.lower()
if lowercase_item == "libxml2": if lowercase_item == "libxml2":
return ".".join(map(str, lxml.etree.LIBXML_VERSION)) return ".".join(map(str, lxml.etree.LIBXML_VERSION))
@ -28,7 +28,7 @@ def _version(item):
def get_versions( def get_versions(
software: list | None = None, software: list[str] | None = None,
) -> list[tuple[str, str]]: ) -> list[tuple[str, str]]:
software = software or _DEFAULT_SOFTWARE software = software or _DEFAULT_SOFTWARE
return [(item, _version(item)) for item in software] return [(item, _version(item)) for item in software]

View File

@ -47,7 +47,7 @@ class TestFTPBase(ABC):
def _get_factory(self, tmp_path: Path) -> FTPFactory: def _get_factory(self, tmp_path: Path) -> FTPFactory:
raise NotImplementedError raise NotImplementedError
@async_yield_fixture @async_yield_fixture # type: ignore[untyped-decorator]
async def server_url(self, tmp_path: Path) -> AsyncGenerator[str]: async def server_url(self, tmp_path: Path) -> AsyncGenerator[str]:
from twisted.internet import reactor from twisted.internet import reactor

View File

@ -10,8 +10,6 @@ from scrapy.utils.test import get_crawler
class TestHttpProxyMiddleware: class TestHttpProxyMiddleware:
failureException = AssertionError # type: ignore[assignment]
def setup_method(self): def setup_method(self):
self._oldenv = os.environ.copy() self._oldenv = os.environ.copy()

View File

@ -213,7 +213,7 @@ class TestHttps2ClientProtocol:
r.putChild(b"request-headers", RequestHeaders()) r.putChild(b"request-headers", RequestHeaders())
return Site(r, timeout=None) return Site(r, timeout=None)
@async_yield_fixture @async_yield_fixture # type: ignore[untyped-decorator]
async def server_port(self, site: Site) -> AsyncGenerator[int]: async def server_port(self, site: Site) -> AsyncGenerator[int]:
from twisted.internet import reactor from twisted.internet import reactor
@ -236,7 +236,7 @@ class TestHttps2ClientProtocol:
) + self.certificate_file.read_text(encoding="utf-8") ) + self.certificate_file.read_text(encoding="utf-8")
return PrivateCertificate.loadPEM(pem) return PrivateCertificate.loadPEM(pem)
@async_yield_fixture @async_yield_fixture # type: ignore[untyped-decorator]
async def client( async def client(
self, server_port: int, client_certificate: PrivateCertificate self, server_port: int, client_certificate: PrivateCertificate
) -> AsyncGenerator[H2ClientProtocol]: ) -> AsyncGenerator[H2ClientProtocol]:

View File

@ -527,7 +527,7 @@ class TestBodyOrStr:
r3 = _body_or_str(obj, unicode=False) r3 = _body_or_str(obj, unicode=False)
self._assert_type_and_value(r3, self.bbody, obj) self._assert_type_and_value(r3, self.bbody, obj)
assert type(r1) is type(r2) assert type(r1) is type(r2)
assert type(r1) is not type(r3) assert type(r1) is not type(r3) # type: ignore[comparison-overlap]
@staticmethod @staticmethod
def _assert_type_and_value( def _assert_type_and_value(

View File

@ -15,7 +15,7 @@ class MyRequest2(Request):
@pytest.mark.mypy_testing @pytest.mark.mypy_testing
def mypy_test_headers(): def mypy_test_headers() -> None:
Request("data:,", headers=1) # E: Argument "headers" to "Request" has incompatible type "int"; expected "Mapping[str, Any] | Iterable[tuple[str, Any]] | None" Request("data:,", headers=1) # E: Argument "headers" to "Request" has incompatible type "int"; expected "Mapping[str, Any] | Iterable[tuple[str, Any]] | None"
Request("data:,", headers=None) Request("data:,", headers=None)
Request("data:,", headers={}) Request("data:,", headers={})
@ -29,7 +29,7 @@ def mypy_test_headers():
@pytest.mark.mypy_testing @pytest.mark.mypy_testing
def mypy_test_copy(): def mypy_test_copy() -> None:
req = Request("data:,") req = Request("data:,")
reveal_type(req) # R: scrapy.http.request.Request reveal_type(req) # R: scrapy.http.request.Request
req_copy = req.copy() req_copy = req.copy()
@ -37,7 +37,7 @@ def mypy_test_copy():
@pytest.mark.mypy_testing @pytest.mark.mypy_testing
def mypy_test_copy_subclass(): def mypy_test_copy_subclass() -> None:
req = MyRequest("data:,") req = MyRequest("data:,")
reveal_type(req) # R: __main__.MyRequest reveal_type(req) # R: __main__.MyRequest
req_copy = req.copy() req_copy = req.copy()
@ -45,7 +45,7 @@ def mypy_test_copy_subclass():
@pytest.mark.mypy_testing @pytest.mark.mypy_testing
def mypy_test_replace(): def mypy_test_replace() -> None:
req = Request("data:,") req = Request("data:,")
reveal_type(req) # R: scrapy.http.request.Request reveal_type(req) # R: scrapy.http.request.Request
req_copy = req.replace(body=b"a") req_copy = req.replace(body=b"a")
@ -56,7 +56,7 @@ def mypy_test_replace():
@pytest.mark.mypy_testing @pytest.mark.mypy_testing
def mypy_test_replace_subclass(): def mypy_test_replace_subclass() -> None:
req = MyRequest("data:,") req = MyRequest("data:,")
reveal_type(req) # R: __main__.MyRequest reveal_type(req) # R: __main__.MyRequest
req_copy = req.replace(body=b"a") req_copy = req.replace(body=b"a")
@ -69,7 +69,7 @@ def mypy_test_replace_subclass():
@pytest.mark.mypy_testing @pytest.mark.mypy_testing
def mypy_test_jsonrequest_copy_replace(): def mypy_test_jsonrequest_copy_replace() -> None:
req = JsonRequest("data:,") req = JsonRequest("data:,")
reveal_type(req) # R: scrapy.http.request.json_request.JsonRequest reveal_type(req) # R: scrapy.http.request.json_request.JsonRequest
req_copy = req.copy() req_copy = req.copy()

View File

@ -6,7 +6,7 @@ from scrapy.http import HtmlResponse, Response, TextResponse
@pytest.mark.mypy_testing @pytest.mark.mypy_testing
def mypy_test_headers(): def mypy_test_headers() -> None:
Response("data:,", headers=1) # E: Argument "headers" to "Response" has incompatible type "int"; expected "Mapping[str, Any] | Iterable[tuple[str, Any]] | None" Response("data:,", headers=1) # E: Argument "headers" to "Response" has incompatible type "int"; expected "Mapping[str, Any] | Iterable[tuple[str, Any]] | None"
Response("data:,", headers=None) Response("data:,", headers=None)
Response("data:,", headers={}) Response("data:,", headers={})
@ -20,7 +20,7 @@ def mypy_test_headers():
@pytest.mark.mypy_testing @pytest.mark.mypy_testing
def mypy_test_copy(): def mypy_test_copy() -> None:
resp = Response("data:,") resp = Response("data:,")
reveal_type(resp) # R: scrapy.http.response.Response reveal_type(resp) # R: scrapy.http.response.Response
resp_copy = resp.copy() resp_copy = resp.copy()
@ -28,7 +28,7 @@ def mypy_test_copy():
@pytest.mark.mypy_testing @pytest.mark.mypy_testing
def mypy_test_copy_subclass(): def mypy_test_copy_subclass() -> None:
resp = HtmlResponse("data:,") resp = HtmlResponse("data:,")
reveal_type(resp) # R: scrapy.http.response.html.HtmlResponse reveal_type(resp) # R: scrapy.http.response.html.HtmlResponse
resp_copy = resp.copy() resp_copy = resp.copy()
@ -36,7 +36,7 @@ def mypy_test_copy_subclass():
@pytest.mark.mypy_testing @pytest.mark.mypy_testing
def mypy_test_replace(): def mypy_test_replace() -> None:
resp = Response("data:,") resp = Response("data:,")
reveal_type(resp) # R: scrapy.http.response.Response reveal_type(resp) # R: scrapy.http.response.Response
resp_copy = resp.replace(body=b"a") resp_copy = resp.replace(body=b"a")
@ -47,7 +47,7 @@ def mypy_test_replace():
@pytest.mark.mypy_testing @pytest.mark.mypy_testing
def mypy_test_replace_subclass(): def mypy_test_replace_subclass() -> None:
resp = HtmlResponse("data:,") resp = HtmlResponse("data:,")
reveal_type(resp) # R: scrapy.http.response.html.HtmlResponse reveal_type(resp) # R: scrapy.http.response.html.HtmlResponse
resp_copy = resp.replace(body=b"a") resp_copy = resp.replace(body=b"a")

35
tox.ini
View File

@ -41,22 +41,27 @@ commands =
[testenv:typing] [testenv:typing]
basepython = python3.10 basepython = python3.10
deps = deps =
mypy==1.18.2 mypy==1.19.1
typing-extensions==4.15.0 typing-extensions==4.15.0
types-defusedxml==0.7.0.20250822 Pillow==12.1.1
types-lxml==2025.8.25 Protego==0.6.0
types-pexpect==4.9.0.20250916 attrs==25.4.0
types-Pygments==2.19.0.20250809 boto3-stubs[s3]==1.42.59
botocore-stubs==1.40.59 botocore-stubs==1.42.41
boto3-stubs[s3]==1.40.59 h2==4.3.0
httpx==0.28.1 httpx==0.28.1
itemadapter==0.12.2 itemadapter==0.13.1
Protego==0.5.0 ptpython==3.0.32
w3lib==2.3.1 ipython
attrs >= 18.2.0 pyOpenSSL==25.3.0
Pillow >= 10.3.0 pytest==9.0.2
pyOpenSSL >= 24.2.1 types-Pygments==2.19.0.20251121
pytest >= 8.2.0 types-defusedxml==0.7.0.20250822
types-lxml==2026.2.16
types-pexpect==4.9.0.20260127
uvloop==0.22.1
w3lib==2.4.0
zstandard==0.25.0
commands = commands =
mypy {posargs:scrapy tests} mypy {posargs:scrapy tests}
@ -65,7 +70,7 @@ basepython = python3.10
deps = deps =
{[test-requirements]deps} {[test-requirements]deps}
{[testenv:typing]deps} {[testenv:typing]deps}
pytest-mypy-testing==0.1.3 pytest-mypy-testing==0.2.0
commands = commands =
pytest {posargs:tests_typing} pytest {posargs:tests_typing}