Remove Python 3.9 support (#7121)

This commit is contained in:
Andrey Rakhmatullin 2025-10-27 16:37:49 +05:00 committed by GitHub
parent e48a1bdde3
commit d414d393d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 175 additions and 186 deletions

View File

@ -20,10 +20,10 @@ jobs:
- python-version: "3.13" - python-version: "3.13"
env: env:
TOXENV: pylint TOXENV: pylint
- python-version: "3.9" - python-version: "3.10"
env: env:
TOXENV: typing TOXENV: typing
- python-version: "3.9" - python-version: "3.10"
env: env:
TOXENV: typing-tests TOXENV: typing-tests
- python-version: "3.13" # Keep in sync with .readthedocs.yml - python-version: "3.13" # Keep in sync with .readthedocs.yml

View File

@ -16,7 +16,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] python-version: ["3.10", "3.11", "3.12", "3.13"]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@ -17,9 +17,6 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- python-version: "3.9"
env:
TOXENV: py
- python-version: "3.10" - python-version: "3.10"
env: env:
TOXENV: py TOXENV: py
@ -40,19 +37,19 @@ jobs:
TOXENV: pypy3 TOXENV: pypy3
# pinned deps # pinned deps
- python-version: "3.9.21" - python-version: "3.10.19"
env: env:
TOXENV: pinned TOXENV: pinned
- python-version: "3.9.21" - python-version: "3.10.19"
env: env:
TOXENV: default-reactor-pinned TOXENV: default-reactor-pinned
- python-version: pypy3.11 - python-version: pypy3.11
env: env:
TOXENV: pypy3-pinned TOXENV: pypy3-pinned
- python-version: "3.9.21" - python-version: "3.10.19"
env: env:
TOXENV: extra-deps-pinned TOXENV: extra-deps-pinned
- python-version: "3.9.21" - python-version: "3.10.19"
env: env:
TOXENV: botocore-pinned TOXENV: botocore-pinned

View File

@ -17,9 +17,6 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- python-version: "3.9"
env:
TOXENV: py
- python-version: "3.10" - python-version: "3.10"
env: env:
TOXENV: py TOXENV: py
@ -37,10 +34,10 @@ jobs:
TOXENV: default-reactor TOXENV: default-reactor
# pinned deps # pinned deps
- python-version: "3.9.13" - python-version: "3.10.11"
env: env:
TOXENV: pinned TOXENV: pinned
- python-version: "3.9.13" - python-version: "3.10.11"
env: env:
TOXENV: extra-deps-pinned TOXENV: extra-deps-pinned

View File

@ -40,7 +40,7 @@
:alt: Ask DeepWiki :alt: Ask DeepWiki
Scrapy_ is a web scraping framework to extract structured data from websites. Scrapy_ is a web scraping framework to extract structured data from websites.
It is cross-platform, and requires Python 3.9+. It is maintained by Zyte_ It is cross-platform, and requires Python 3.10+. It is maintained by Zyte_
(formerly Scrapinghub) and `many other contributors`_. (formerly Scrapinghub) and `many other contributors`_.
.. _many other contributors: https://github.com/scrapy/scrapy/graphs/contributors .. _many other contributors: https://github.com/scrapy/scrapy/graphs/contributors

View File

@ -9,7 +9,7 @@ Installation guide
Supported Python versions Supported Python versions
========================= =========================
Scrapy requires Python 3.9+, either the CPython implementation (default) or Scrapy requires Python 3.10+, either the CPython implementation (default) or
the PyPy implementation (see :ref:`python:implementations`). the PyPy implementation (see :ref:`python:implementations`).
.. _intro-install-scrapy: .. _intro-install-scrapy:

View File

@ -70,7 +70,7 @@ The advantage of using the :class:`ImagesPipeline` for image files is that you
can configure some extra functions like generating thumbnails and filtering can configure some extra functions like generating thumbnails and filtering
the images based on their size. the images based on their size.
The Images Pipeline requires Pillow_ 8.0.0 or greater. It is used for The Images Pipeline requires Pillow_ 8.3.2 or greater. It is used for
thumbnailing and normalizing images to JPEG/RGB format. thumbnailing and normalizing images to JPEG/RGB format.
.. _Pillow: https://github.com/python-pillow/Pillow .. _Pillow: https://github.com/python-pillow/Pillow
@ -238,7 +238,7 @@ Amazon S3 storage
.. setting:: FILES_STORE_S3_ACL .. setting:: FILES_STORE_S3_ACL
.. setting:: IMAGES_STORE_S3_ACL .. setting:: IMAGES_STORE_S3_ACL
If botocore_ >= 1.4.87 is installed, :setting:`FILES_STORE` and If botocore_ >= 1.13.45 is installed, :setting:`FILES_STORE` and
:setting:`IMAGES_STORE` can represent an Amazon S3 bucket. Scrapy will :setting:`IMAGES_STORE` can represent an Amazon S3 bucket. Scrapy will
automatically upload the files to the bucket. automatically upload the files to the bucket.

View File

@ -13,7 +13,7 @@ dependencies = [
"defusedxml>=0.7.1", "defusedxml>=0.7.1",
"itemadapter>=0.1.0", "itemadapter>=0.1.0",
"itemloaders>=1.0.1", "itemloaders>=1.0.1",
"lxml>=4.6.0", "lxml>=4.6.4",
"packaging", "packaging",
"parsel>=1.5.0", "parsel>=1.5.0",
"protego>=0.1.15", "protego>=0.1.15",
@ -35,7 +35,6 @@ classifiers = [
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python", "Programming Language :: Python",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
@ -49,7 +48,7 @@ classifiers = [
license = "BSD-3-Clause" license = "BSD-3-Clause"
license-files = ["LICENSE", "AUTHORS"] license-files = ["LICENSE", "AUTHORS"]
readme = "README.rst" readme = "README.rst"
requires-python = ">=3.9" requires-python = ">=3.10"
authors = [{ name = "Scrapy developers", email = "pablo@pablohoffman.com" }] authors = [{ name = "Scrapy developers", email = "pablo@pablohoffman.com" }]
maintainers = [{ name = "Pablo Hoffman", email = "pablo@pablohoffman.com" }] maintainers = [{ name = "Pablo Hoffman", email = "pablo@pablohoffman.com" }]
@ -375,6 +374,8 @@ ignore = [
"SIM115", "SIM115",
# Yoda condition detected # Yoda condition detected
"SIM300", "SIM300",
# removed in recent ruff
"UP038",
# Ones that we may want to address (fix, ignore per-line or move to "don't want to fix") # Ones that we may want to address (fix, ignore per-line or move to "don't want to fix")

View File

@ -6,7 +6,7 @@ import inspect
import os import os
import sys import sys
from importlib.metadata import entry_points from importlib.metadata import entry_points
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, ParamSpec
import scrapy import scrapy
from scrapy.commands import BaseRunSpiderCommand, ScrapyCommand, ScrapyHelpFormatter from scrapy.commands import BaseRunSpiderCommand, ScrapyCommand, ScrapyHelpFormatter
@ -20,9 +20,6 @@ from scrapy.utils.reactor import _asyncio_reactor_path
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
# typing.ParamSpec requires Python 3.10
from typing_extensions import ParamSpec
from scrapy.settings import BaseSettings, Settings from scrapy.settings import BaseSettings, Settings
_P = ParamSpec("_P") _P = ParamSpec("_P")
@ -67,11 +64,7 @@ def _get_commands_from_entry_points(
inproject: bool, group: str = "scrapy.commands" inproject: bool, group: str = "scrapy.commands"
) -> dict[str, ScrapyCommand]: ) -> dict[str, ScrapyCommand]:
cmds: dict[str, ScrapyCommand] = {} cmds: dict[str, ScrapyCommand] = {}
if sys.version_info >= (3, 10): for entry_point in entry_points(group=group):
eps = entry_points(group=group)
else:
eps = entry_points().get(group, ())
for entry_point in eps:
obj = entry_point.load() obj = entry_point.load()
if inspect.isclass(obj): if inspect.isclass(obj):
cmds[entry_point.name] = obj() cmds[entry_point.name] = obj()

View File

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Any, Callable from typing import TYPE_CHECKING, Any
from itemadapter import ItemAdapter, is_item from itemadapter import ItemAdapter, is_item
@ -9,6 +9,9 @@ from scrapy.contracts import Contract
from scrapy.exceptions import ContractFail from scrapy.exceptions import ContractFail
from scrapy.http import Request from scrapy.http import Request
if TYPE_CHECKING:
from collections.abc import Callable
# contracts # contracts
class UrlContract(Contract): class UrlContract(Contract):

View File

@ -7,7 +7,7 @@ import logging
import warnings import warnings
from collections import deque from collections import deque
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from typing import TYPE_CHECKING, Any, TypeVar, Union from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar
from twisted.internet.defer import Deferred, inlineCallbacks from twisted.internet.defer import Deferred, inlineCallbacks
from twisted.python.failure import Failure from twisted.python.failure import Failure
@ -53,7 +53,7 @@ logger = logging.getLogger(__name__)
_T = TypeVar("_T") _T = TypeVar("_T")
QueueTuple = tuple[Union[Response, Failure], Request, Deferred[None]] QueueTuple: TypeAlias = tuple[Response | Failure, Request, Deferred[None]]
class Slot: class Slot:

View File

@ -11,7 +11,7 @@ from collections.abc import AsyncIterator, Callable, Coroutine, Iterable
from functools import wraps from functools import wraps
from inspect import isasyncgenfunction, iscoroutine from inspect import isasyncgenfunction, iscoroutine
from itertools import islice from itertools import islice
from typing import TYPE_CHECKING, Any, TypeVar, Union, cast from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar, cast
from warnings import warn from warnings import warn
from twisted.internet.defer import Deferred, inlineCallbacks from twisted.internet.defer import Deferred, inlineCallbacks
@ -41,9 +41,9 @@ logger = logging.getLogger(__name__)
_T = TypeVar("_T") _T = TypeVar("_T")
ScrapeFunc = Callable[ ScrapeFunc: TypeAlias = Callable[
[Union[Response, Failure], Request], [Response | Failure, Request],
Coroutine[Any, Any, Union[Iterable[_T], AsyncIterator[_T]]], Coroutine[Any, Any, Iterable[_T] | AsyncIterator[_T]],
] ]
@ -179,7 +179,7 @@ class SpiderMiddlewareManager(MiddlewareManager):
yield from iterable yield from iterable
except Exception as ex: except Exception as ex:
exception_result = cast( exception_result = cast(
"Union[Failure, MutableChain[_T]]", "Failure | MutableChain[_T]",
self._process_spider_exception( self._process_spider_exception(
response, ex, exception_processor_index response, ex, exception_processor_index
), ),
@ -195,7 +195,7 @@ class SpiderMiddlewareManager(MiddlewareManager):
yield r yield r
except Exception as ex: except Exception as ex:
exception_result = cast( exception_result = cast(
"Union[Failure, MutableAsyncChain[_T]]", "Failure | MutableAsyncChain[_T]",
self._process_spider_exception( self._process_spider_exception(
response, ex, exception_processor_index response, ex, exception_processor_index
), ),
@ -241,9 +241,7 @@ class SpiderMiddlewareManager(MiddlewareManager):
# simplified when downgrading is removed. # simplified when downgrading is removed.
if dfd.called: if dfd.called:
# the result is available immediately if _process_spider_output didn't do downgrading # the result is available immediately if _process_spider_output didn't do downgrading
return cast( return cast("MutableChain[_T] | MutableAsyncChain[_T]", dfd.result)
"Union[MutableChain[_T], MutableAsyncChain[_T]]", dfd.result
)
# we forbid waiting here because otherwise we would need to return a deferred from # we forbid waiting here because otherwise we would need to return a deferred from
# _process_spider_exception too, which complicates the architecture # _process_spider_exception too, which complicates the architecture
msg = f"Async iterable returned from {global_object_name(method)} cannot be downgraded" msg = f"Async iterable returned from {global_object_name(method)} cannot be downgraded"

View File

@ -16,7 +16,7 @@ from collections.abc import Callable
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path, PureWindowsPath from pathlib import Path, PureWindowsPath
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from typing import IO, TYPE_CHECKING, Any, Optional, Protocol, TypeVar, cast from typing import IO, TYPE_CHECKING, Any, Protocol, TypeAlias, TypeVar, cast
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
from twisted.internet.defer import Deferred, DeferredList, maybeDeferred from twisted.internet.defer import Deferred, DeferredList, maybeDeferred
@ -50,7 +50,9 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
UriParamsCallableT = Callable[[dict[str, Any], Spider], Optional[dict[str, Any]]] UriParamsCallableT: TypeAlias = Callable[
[dict[str, Any], Spider], dict[str, Any] | None
]
_StorageT = TypeVar("_StorageT", bound="FeedStorageProtocol") _StorageT = TypeVar("_StorageT", bound="FeedStorageProtocol")

View File

@ -7,7 +7,7 @@ from email.utils import mktime_tz, parsedate_tz
from importlib import import_module from importlib import import_module
from pathlib import Path from pathlib import Path
from time import time from time import time
from typing import IO, TYPE_CHECKING, Any, cast from typing import IO, TYPE_CHECKING, Any, Concatenate, cast
from weakref import WeakKeyDictionary from weakref import WeakKeyDictionary
from w3lib.http import headers_dict_to_raw, headers_raw_to_dict from w3lib.http import headers_dict_to_raw, headers_raw_to_dict
@ -23,9 +23,6 @@ if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
from types import ModuleType from types import ModuleType
# typing.Concatenate requires Python 3.10
from typing_extensions import Concatenate
from scrapy.http.request import Request from scrapy.http.request import Request
from scrapy.settings import BaseSettings from scrapy.settings import BaseSettings
from scrapy.spiders import Spider from scrapy.spiders import Spider

View File

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, AnyStr, Union, cast from typing import TYPE_CHECKING, Any, AnyStr, TypeAlias, cast
from w3lib.http import headers_dict_to_raw from w3lib.http import headers_dict_to_raw
@ -15,7 +15,7 @@ if TYPE_CHECKING:
from typing_extensions import Self from typing_extensions import Self
_RawValueT = Union[bytes, str, int] _RawValue: TypeAlias = bytes | str | int
# isn't fully compatible typing-wise with either dict or CaselessDict, # isn't fully compatible typing-wise with either dict or CaselessDict,
@ -44,9 +44,9 @@ class Headers(CaselessDict):
"""Normalize key to bytes""" """Normalize key to bytes"""
return self._tobytes(key.title()) return self._tobytes(key.title())
def normvalue(self, value: _RawValueT | Iterable[_RawValueT]) -> list[bytes]: def normvalue(self, value: _RawValue | Iterable[_RawValue]) -> list[bytes]:
"""Normalize values to bytes""" """Normalize values to bytes"""
_value: Iterable[_RawValueT] _value: Iterable[_RawValue]
if value is None: if value is None:
_value = [] _value = []
elif isinstance(value, (str, bytes)): elif isinstance(value, (str, bytes)):
@ -58,7 +58,7 @@ class Headers(CaselessDict):
return [self._tobytes(x) for x in _value] return [self._tobytes(x) for x in _value]
def _tobytes(self, x: _RawValueT) -> bytes: def _tobytes(self, x: _RawValue) -> bytes:
if isinstance(x, bytes): if isinstance(x, bytes):
return x return x
if isinstance(x, str): if isinstance(x, str):
@ -87,15 +87,15 @@ class Headers(CaselessDict):
return self.normvalue(def_val) return self.normvalue(def_val)
return [] return []
def setlist(self, key: AnyStr, list_: Iterable[_RawValueT]) -> None: def setlist(self, key: AnyStr, list_: Iterable[_RawValue]) -> None:
self[key] = list_ self[key] = list_
def setlistdefault( def setlistdefault(
self, key: AnyStr, default_list: Iterable[_RawValueT] = () self, key: AnyStr, default_list: Iterable[_RawValue] = ()
) -> Any: ) -> Any:
return self.setdefault(key, default_list) return self.setdefault(key, default_list)
def appendlist(self, key: AnyStr, value: Iterable[_RawValueT]) -> None: def appendlist(self, key: AnyStr, value: Iterable[_RawValue]) -> None:
lst = self.getlist(key) lst = self.getlist(key)
lst.extend(self.normvalue(value)) lst.extend(self.normvalue(value))
self[key] = lst self[key] = lst

View File

@ -12,10 +12,11 @@ from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Any, Any,
AnyStr, AnyStr,
Concatenate,
NoReturn, NoReturn,
TypeAlias,
TypedDict, TypedDict,
TypeVar, TypeVar,
Union,
overload, overload,
) )
@ -33,13 +34,13 @@ if TYPE_CHECKING:
from twisted.python.failure import Failure from twisted.python.failure import Failure
# typing.Concatenate requires Python 3.10
# typing.NotRequired and typing.Self require Python 3.11 # typing.NotRequired and typing.Self require Python 3.11
from typing_extensions import Concatenate, NotRequired, Self from typing_extensions import NotRequired, Self
# circular import
from scrapy.http import Response from scrapy.http import Response
CallbackT = Callable[Concatenate[Response, ...], Any] CallbackT: TypeAlias = Callable[Concatenate[Response, ...], Any]
class VerboseCookie(TypedDict): class VerboseCookie(TypedDict):
@ -50,7 +51,7 @@ class VerboseCookie(TypedDict):
secure: NotRequired[bool] secure: NotRequired[bool]
CookiesT = Union[dict[str, str], list[VerboseCookie]] CookiesT: TypeAlias = dict[str, str] | list[VerboseCookie]
RequestTypeVar = TypeVar("RequestTypeVar", bound="Request") RequestTypeVar = TypeVar("RequestTypeVar", bound="Request")

View File

@ -8,7 +8,7 @@ See documentation in docs/topics/request-response.rst
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable from collections.abc import Iterable
from typing import TYPE_CHECKING, Any, Optional, Union, cast from typing import TYPE_CHECKING, Any, TypeAlias, cast
from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit
from parsel.csstranslator import HTMLTranslator from parsel.csstranslator import HTMLTranslator
@ -31,9 +31,9 @@ if TYPE_CHECKING:
from scrapy.http.response.text import TextResponse from scrapy.http.response.text import TextResponse
FormdataVType = Union[str, Iterable[str]] FormdataVType: TypeAlias = str | Iterable[str]
FormdataKVType = tuple[str, FormdataVType] FormdataKVType: TypeAlias = tuple[str, FormdataVType]
FormdataType = Optional[Union[dict[str, FormdataVType], list[FormdataKVType]]] FormdataType: TypeAlias = dict[str, FormdataVType] | list[FormdataKVType] | None
class FormRequest(Request): class FormRequest(Request):

View File

@ -9,7 +9,7 @@ import operator
import re import re
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from functools import partial from functools import partial
from typing import TYPE_CHECKING, Any, Union, cast from typing import TYPE_CHECKING, Any, TypeAlias, cast
from urllib.parse import urljoin, urlparse from urllib.parse import urljoin, urlparse
from lxml import etree from lxml import etree
@ -157,8 +157,8 @@ class LxmlParserLinkExtractor:
return links return links
_RegexT = Union[str, re.Pattern[str]] _Regex: TypeAlias = str | re.Pattern[str]
_RegexOrSeveralT = Union[_RegexT, Iterable[_RegexT]] _RegexOrSeveral: TypeAlias = _Regex | Iterable[_Regex]
class LxmlLinkExtractor: class LxmlLinkExtractor:
@ -166,8 +166,8 @@ class LxmlLinkExtractor:
def __init__( def __init__(
self, self,
allow: _RegexOrSeveralT = (), allow: _RegexOrSeveral = (),
deny: _RegexOrSeveralT = (), deny: _RegexOrSeveral = (),
allow_domains: str | Iterable[str] = (), allow_domains: str | Iterable[str] = (),
deny_domains: str | Iterable[str] = (), deny_domains: str | Iterable[str] = (),
restrict_xpaths: str | Iterable[str] = (), restrict_xpaths: str | Iterable[str] = (),
@ -179,7 +179,7 @@ class LxmlLinkExtractor:
deny_extensions: str | Iterable[str] | None = None, deny_extensions: str | Iterable[str] | None = None,
restrict_css: str | Iterable[str] = (), restrict_css: str | Iterable[str] = (),
strip: bool = True, strip: bool = True,
restrict_text: _RegexOrSeveralT | None = None, restrict_text: _RegexOrSeveral | None = None,
): ):
tags, attrs = set(arg_to_iter(tags)), set(arg_to_iter(attrs)) tags, attrs = set(arg_to_iter(tags)), set(arg_to_iter(attrs))
self.link_extractor = LxmlParserLinkExtractor( self.link_extractor = LxmlParserLinkExtractor(
@ -208,7 +208,7 @@ class LxmlLinkExtractor:
self.restrict_text: list[re.Pattern[str]] = self._compile_regexes(restrict_text) self.restrict_text: list[re.Pattern[str]] = self._compile_regexes(restrict_text)
@staticmethod @staticmethod
def _compile_regexes(value: _RegexOrSeveralT | None) -> list[re.Pattern[str]]: def _compile_regexes(value: _RegexOrSeveral | None) -> list[re.Pattern[str]]:
return [ return [
x if isinstance(x, re.Pattern) else re.compile(x) x if isinstance(x, re.Pattern) else re.compile(x)
for x in arg_to_iter(value) for x in arg_to_iter(value)

View File

@ -5,7 +5,7 @@ import pprint
import warnings import warnings
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections import defaultdict, deque from collections import defaultdict, deque
from typing import TYPE_CHECKING, Any, TypeVar, cast from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, cast
from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning
from scrapy.utils.defer import ensure_awaitable from scrapy.utils.defer import ensure_awaitable
@ -18,20 +18,18 @@ if TYPE_CHECKING:
from twisted.internet.defer import Deferred from twisted.internet.defer import Deferred
# typing.Concatenate and typing.ParamSpec require Python 3.10
# typing.Self requires Python 3.11 # typing.Self requires Python 3.11
from typing_extensions import Concatenate, ParamSpec, Self from typing_extensions import Self
from scrapy import Spider from scrapy import Spider
from scrapy.crawler import Crawler from scrapy.crawler import Crawler
from scrapy.settings import BaseSettings, Settings from scrapy.settings import BaseSettings, Settings
_P = ParamSpec("_P")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_T = TypeVar("_T") _T = TypeVar("_T")
_P = ParamSpec("_P")
class MiddlewareManager(ABC): class MiddlewareManager(ABC):

View File

@ -7,7 +7,7 @@ See documentation in docs/item-pipeline.rst
from __future__ import annotations from __future__ import annotations
import warnings import warnings
from typing import TYPE_CHECKING, Any, Callable, cast from typing import TYPE_CHECKING, Any, cast
from twisted.internet.defer import Deferred, DeferredList from twisted.internet.defer import Deferred, DeferredList
@ -22,7 +22,7 @@ from scrapy.utils.defer import (
from scrapy.utils.python import global_object_name from scrapy.utils.python import global_object_name
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable from collections.abc import Callable, Iterable
from twisted.python.failure import Failure from twisted.python.failure import Failure

View File

@ -69,7 +69,7 @@ class ImagesPipeline(FilesPipeline):
self._ImageOps = ImageOps self._ImageOps = ImageOps
except ImportError: except ImportError:
raise NotConfigured( raise NotConfigured(
"ImagesPipeline requires installing Pillow 8.0.0 or later" "ImagesPipeline requires installing Pillow 8.3.2 or later"
) )
super().__init__( super().__init__(

View File

@ -5,7 +5,7 @@ import logging
import warnings import warnings
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections import defaultdict from collections import defaultdict
from typing import TYPE_CHECKING, Any, Literal, TypedDict, Union, cast from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypedDict, cast
from twisted import version as twisted_version from twisted import version as twisted_version
from twisted.internet.defer import ( from twisted.internet.defer import (
@ -47,7 +47,9 @@ class FileInfo(TypedDict):
status: str status: str
FileInfoOrError = Union[tuple[Literal[True], FileInfo], tuple[Literal[False], Failure]] FileInfoOrError: TypeAlias = (
tuple[Literal[True], FileInfo] | tuple[Literal[False], Failure]
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -6,7 +6,7 @@ import warnings
from collections.abc import Iterable, Iterator, Mapping, MutableMapping from collections.abc import Iterable, Iterator, Mapping, MutableMapping
from importlib import import_module from importlib import import_module
from pprint import pformat from pprint import pformat
from typing import TYPE_CHECKING, Any, Union, cast from typing import TYPE_CHECKING, Any, TypeAlias, cast
from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.settings import default_settings from scrapy.settings import default_settings
@ -14,7 +14,7 @@ from scrapy.utils.misc import load_object
# The key types are restricted in BaseSettings._get_key() to ones supported by JSON, # The key types are restricted in BaseSettings._get_key() to ones supported by JSON,
# see https://github.com/scrapy/scrapy/issues/5383. # see https://github.com/scrapy/scrapy/issues/5383.
_SettingsKeyT = Union[bool, float, int, str, None] _SettingsKey: TypeAlias = bool | float | int | str | None
if TYPE_CHECKING: if TYPE_CHECKING:
from types import ModuleType from types import ModuleType
@ -25,7 +25,7 @@ if TYPE_CHECKING:
# typing.Self requires Python 3.11 # typing.Self requires Python 3.11
from typing_extensions import Self from typing_extensions import Self
_SettingsInputT = Union[SupportsItems[_SettingsKeyT, Any], str, None] _SettingsInput: TypeAlias = SupportsItems[_SettingsKey, Any] | str | None
SETTINGS_PRIORITIES: dict[str, int] = { SETTINGS_PRIORITIES: dict[str, int] = {
@ -76,7 +76,7 @@ class SettingsAttribute:
return f"<SettingsAttribute value={self.value!r} priority={self.priority}>" return f"<SettingsAttribute value={self.value!r} priority={self.priority}>"
class BaseSettings(MutableMapping[_SettingsKeyT, Any]): class BaseSettings(MutableMapping[_SettingsKey, Any]):
""" """
Instances of this class behave like dictionaries, but store priorities Instances of this class behave like dictionaries, but store priorities
along with their ``(key, value)`` pairs, and can be frozen (i.e. marked along with their ``(key, value)`` pairs, and can be frozen (i.e. marked
@ -100,13 +100,13 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
__default = object() __default = object()
def __init__(self, values: _SettingsInputT = None, priority: int | str = "project"): def __init__(self, values: _SettingsInput = None, priority: int | str = "project"):
self.frozen: bool = False self.frozen: bool = False
self.attributes: dict[_SettingsKeyT, SettingsAttribute] = {} self.attributes: dict[_SettingsKey, SettingsAttribute] = {}
if values: if values:
self.update(values, priority) self.update(values, priority)
def __getitem__(self, opt_name: _SettingsKeyT) -> Any: def __getitem__(self, opt_name: _SettingsKey) -> Any:
if opt_name not in self: if opt_name not in self:
return None return None
return self.attributes[opt_name].value return self.attributes[opt_name].value
@ -114,7 +114,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
def __contains__(self, name: Any) -> bool: def __contains__(self, name: Any) -> bool:
return name in self.attributes return name in self.attributes
def add_to_list(self, name: _SettingsKeyT, item: Any) -> None: def add_to_list(self, name: _SettingsKey, item: Any) -> None:
"""Append *item* to the :class:`list` setting with the specified *name* """Append *item* to the :class:`list` setting with the specified *name*
if *item* is not already in that list. if *item* is not already in that list.
@ -125,7 +125,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
if item not in value: if item not in value:
self.set(name, [*value, item], self.getpriority(name) or 0) self.set(name, [*value, item], self.getpriority(name) or 0)
def remove_from_list(self, name: _SettingsKeyT, item: Any) -> None: def remove_from_list(self, name: _SettingsKey, item: Any) -> None:
"""Remove *item* from the :class:`list` setting with the specified """Remove *item* from the :class:`list` setting with the specified
*name*. *name*.
@ -139,7 +139,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
raise ValueError(f"{item!r} not found in the {name} setting ({value!r}).") raise ValueError(f"{item!r} not found in the {name} setting ({value!r}).")
self.set(name, [v for v in value if v != item], self.getpriority(name) or 0) self.set(name, [v for v in value if v != item], self.getpriority(name) or 0)
def get(self, name: _SettingsKeyT, default: Any = None) -> Any: def get(self, name: _SettingsKey, default: Any = None) -> Any:
""" """
Get a setting value without affecting its original type. Get a setting value without affecting its original type.
@ -160,7 +160,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
return self[name] if self[name] is not None else default return self[name] if self[name] is not None else default
def getbool(self, name: _SettingsKeyT, default: bool = False) -> bool: def getbool(self, name: _SettingsKey, default: bool = False) -> bool:
""" """
Get a setting value as a boolean. Get a setting value as a boolean.
@ -190,7 +190,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
"'True'/'False' and 'true'/'false'" "'True'/'False' and 'true'/'false'"
) )
def getint(self, name: _SettingsKeyT, default: int = 0) -> int: def getint(self, name: _SettingsKey, default: int = 0) -> int:
""" """
Get a setting value as an int. Get a setting value as an int.
@ -202,7 +202,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
""" """
return int(self.get(name, default)) return int(self.get(name, default))
def getfloat(self, name: _SettingsKeyT, default: float = 0.0) -> float: def getfloat(self, name: _SettingsKey, default: float = 0.0) -> float:
""" """
Get a setting value as a float. Get a setting value as a float.
@ -215,7 +215,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
return float(self.get(name, default)) return float(self.get(name, default))
def getlist( def getlist(
self, name: _SettingsKeyT, default: list[Any] | None = None self, name: _SettingsKey, default: list[Any] | None = None
) -> list[Any]: ) -> list[Any]:
""" """
Get a setting value as a list. If the setting original type is a list, Get a setting value as a list. If the setting original type is a list,
@ -239,7 +239,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
return list(value) return list(value)
def getdict( def getdict(
self, name: _SettingsKeyT, default: dict[Any, Any] | None = None self, name: _SettingsKey, default: dict[Any, Any] | None = None
) -> dict[Any, Any]: ) -> dict[Any, Any]:
""" """
Get a setting value as a dictionary. If the setting original type is a Get a setting value as a dictionary. If the setting original type is a
@ -263,7 +263,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
def getdictorlist( def getdictorlist(
self, self,
name: _SettingsKeyT, name: _SettingsKey,
default: dict[Any, Any] | list[Any] | tuple[Any] | None = None, default: dict[Any, Any] | list[Any] | tuple[Any] | None = None,
) -> dict[Any, Any] | list[Any]: ) -> dict[Any, Any] | list[Any]:
"""Get a setting value as either a :class:`dict` or a :class:`list`. """Get a setting value as either a :class:`dict` or a :class:`list`.
@ -310,7 +310,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
) )
return copy.deepcopy(value) return copy.deepcopy(value)
def getwithbase(self, name: _SettingsKeyT) -> BaseSettings: def getwithbase(self, name: _SettingsKey) -> BaseSettings:
"""Get a composition of a dictionary-like setting and its `_BASE` """Get a composition of a dictionary-like setting and its `_BASE`
counterpart. counterpart.
@ -324,7 +324,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
compbs.update(self[name]) compbs.update(self[name])
return compbs return compbs
def getpriority(self, name: _SettingsKeyT) -> int | None: def getpriority(self, name: _SettingsKey) -> int | None:
""" """
Return the current numerical priority value of a setting, or ``None`` if Return the current numerical priority value of a setting, or ``None`` if
the given ``name`` does not exist. the given ``name`` does not exist.
@ -349,7 +349,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
def replace_in_component_priority_dict( def replace_in_component_priority_dict(
self, self,
name: _SettingsKeyT, name: _SettingsKey,
old_cls: type, old_cls: type,
new_cls: type, new_cls: type,
priority: int | None = None, priority: int | None = None,
@ -388,11 +388,11 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
) )
self.set(name, component_priority_dict, priority=self.getpriority(name) or 0) self.set(name, component_priority_dict, priority=self.getpriority(name) or 0)
def __setitem__(self, name: _SettingsKeyT, value: Any) -> None: def __setitem__(self, name: _SettingsKey, value: Any) -> None:
self.set(name, value) self.set(name, value)
def set( def set(
self, name: _SettingsKeyT, value: Any, priority: int | str = "project" self, name: _SettingsKey, value: Any, priority: int | str = "project"
) -> None: ) -> None:
""" """
Store a key/value attribute with a given priority. Store a key/value attribute with a given priority.
@ -422,7 +422,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
self.attributes[name].set(value, priority) self.attributes[name].set(value, priority)
def set_in_component_priority_dict( def set_in_component_priority_dict(
self, name: _SettingsKeyT, cls: type, priority: int | None self, name: _SettingsKey, cls: type, priority: int | None
) -> None: ) -> None:
"""Set the *cls* component in the *name* :ref:`component priority """Set the *cls* component in the *name* :ref:`component priority
dictionary <component-priority-dictionaries>` setting with *priority*. dictionary <component-priority-dictionaries>` setting with *priority*.
@ -447,7 +447,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
def setdefault( def setdefault(
self, self,
name: _SettingsKeyT, name: _SettingsKey,
default: Any = None, default: Any = None,
priority: int | str = "project", priority: int | str = "project",
) -> Any: ) -> Any:
@ -458,7 +458,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
return self.attributes[name].value return self.attributes[name].value
def setdefault_in_component_priority_dict( def setdefault_in_component_priority_dict(
self, name: _SettingsKeyT, cls: type, priority: int | None self, name: _SettingsKey, cls: type, priority: int | None
) -> None: ) -> None:
"""Set the *cls* component in the *name* :ref:`component priority """Set the *cls* component in the *name* :ref:`component priority
dictionary <component-priority-dictionaries>` setting with *priority* dictionary <component-priority-dictionaries>` setting with *priority*
@ -475,7 +475,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
component_priority_dict[cls] = priority component_priority_dict[cls] = priority
self.set(name, component_priority_dict, self.getpriority(name) or 0) self.set(name, component_priority_dict, self.getpriority(name) or 0)
def setdict(self, values: _SettingsInputT, priority: int | str = "project") -> None: def setdict(self, values: _SettingsInput, priority: int | str = "project") -> None:
self.update(values, priority) self.update(values, priority)
def setmodule( def setmodule(
@ -503,7 +503,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
self.set(key, getattr(module, key), priority) self.set(key, getattr(module, key), priority)
# BaseSettings.update() doesn't support all inputs that MutableMapping.update() supports # BaseSettings.update() doesn't support all inputs that MutableMapping.update() supports
def update(self, values: _SettingsInputT, priority: int | str = "project") -> None: # type: ignore[override] def update(self, values: _SettingsInput, priority: int | str = "project") -> None: # type: ignore[override]
""" """
Store key/value pairs with a given priority. Store key/value pairs with a given priority.
@ -527,7 +527,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
""" """
self._assert_mutability() self._assert_mutability()
if isinstance(values, str): if isinstance(values, str):
values = cast("dict[_SettingsKeyT, Any]", json.loads(values)) values = cast("dict[_SettingsKey, Any]", json.loads(values))
if values is not None: if values is not None:
if isinstance(values, BaseSettings): if isinstance(values, BaseSettings):
for name, value in values.items(): for name, value in values.items():
@ -536,7 +536,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
for name, value in values.items(): for name, value in values.items():
self.set(name, value, priority) self.set(name, value, priority)
def delete(self, name: _SettingsKeyT, priority: int | str = "project") -> None: def delete(self, name: _SettingsKey, priority: int | str = "project") -> None:
if name not in self: if name not in self:
raise KeyError(name) raise KeyError(name)
self._assert_mutability() self._assert_mutability()
@ -544,7 +544,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
if priority >= cast("int", self.getpriority(name)): if priority >= cast("int", self.getpriority(name)):
del self.attributes[name] del self.attributes[name]
def __delitem__(self, name: _SettingsKeyT) -> None: def __delitem__(self, name: _SettingsKey) -> None:
self._assert_mutability() self._assert_mutability()
del self.attributes[name] del self.attributes[name]
@ -584,26 +584,26 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
copy.freeze() copy.freeze()
return copy return copy
def __iter__(self) -> Iterator[_SettingsKeyT]: def __iter__(self) -> Iterator[_SettingsKey]:
return iter(self.attributes) return iter(self.attributes)
def __len__(self) -> int: def __len__(self) -> int:
return len(self.attributes) return len(self.attributes)
def _to_dict(self) -> dict[_SettingsKeyT, Any]: def _to_dict(self) -> dict[_SettingsKey, Any]:
return { return {
self._get_key(k): (v._to_dict() if isinstance(v, BaseSettings) else v) self._get_key(k): (v._to_dict() if isinstance(v, BaseSettings) else v)
for k, v in self.items() for k, v in self.items()
} }
def _get_key(self, key_value: Any) -> _SettingsKeyT: def _get_key(self, key_value: Any) -> _SettingsKey:
return ( return (
key_value key_value
if isinstance(key_value, (bool, float, int, str, type(None))) if isinstance(key_value, (bool, float, int, str, type(None)))
else str(key_value) else str(key_value)
) )
def copy_to_dict(self) -> dict[_SettingsKeyT, Any]: def copy_to_dict(self) -> dict[_SettingsKey, Any]:
""" """
Make a copy of current settings and convert to a dict. Make a copy of current settings and convert to a dict.
@ -626,7 +626,7 @@ class BaseSettings(MutableMapping[_SettingsKeyT, Any]):
else: else:
p.text(pformat(self.copy_to_dict())) p.text(pformat(self.copy_to_dict()))
def pop(self, name: _SettingsKeyT, default: Any = __default) -> Any: def pop(self, name: _SettingsKey, default: Any = __default) -> Any:
try: try:
value = self.attributes[name].value value = self.attributes[name].value
except KeyError: except KeyError:
@ -648,7 +648,7 @@ class Settings(BaseSettings):
described on :ref:`topics-settings-ref` already populated. described on :ref:`topics-settings-ref` already populated.
""" """
def __init__(self, values: _SettingsInputT = None, priority: int | str = "project"): def __init__(self, values: _SettingsInput = None, priority: int | str = "project"):
# Do not pass kwarg values here. We don't want to promote user-defined # Do not pass kwarg values here. We don't want to promote user-defined
# dicts, and we want to update, not replace, default dicts with the # dicts, and we want to update, not replace, default dicts with the
# values given by the user # values given by the user
@ -670,7 +670,7 @@ def iter_default_settings() -> Iterable[tuple[str, Any]]:
def overridden_settings( def overridden_settings(
settings: Mapping[_SettingsKeyT, Any], settings: Mapping[_SettingsKey, Any],
) -> Iterable[tuple[str, Any]]: ) -> Iterable[tuple[str, Any]]:
"""Return an iterable of the settings that have been overridden""" """Return an iterable of the settings that have been overridden"""
for name, defvalue in iter_default_settings(): for name, defvalue in iter_default_settings():

View File

@ -26,7 +26,7 @@ if TYPE_CHECKING:
from scrapy.crawler import Crawler from scrapy.crawler import Crawler
from scrapy.http.request import CallbackT from scrapy.http.request import CallbackT
from scrapy.settings import BaseSettings, _SettingsKeyT from scrapy.settings import BaseSettings, _SettingsKey
from scrapy.utils.log import SpiderLoggerAdapter from scrapy.utils.log import SpiderLoggerAdapter
@ -39,7 +39,7 @@ class Spider(object_ref):
""" """
name: str name: str
custom_settings: dict[_SettingsKeyT, Any] | None = None custom_settings: dict[_SettingsKey, Any] | None = None
#: Start URLs. See :meth:`start`. #: Start URLs. See :meth:`start`.
start_urls: list[str] start_urls: list[str]

View File

@ -10,7 +10,7 @@ from __future__ import annotations
import copy import copy
import warnings import warnings
from collections.abc import AsyncIterator, Awaitable, Callable from collections.abc import AsyncIterator, Awaitable, Callable
from typing import TYPE_CHECKING, Any, Optional, TypeVar, cast from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar, cast
from scrapy.http import HtmlResponse, Request, Response from scrapy.http import HtmlResponse, Request, Response
from scrapy.link import Link from scrapy.link import Link
@ -34,8 +34,8 @@ if TYPE_CHECKING:
_T = TypeVar("_T") _T = TypeVar("_T")
ProcessLinksT = Callable[[list[Link]], list[Link]] ProcessLinksT: TypeAlias = Callable[[list[Link]], list[Link]]
ProcessRequestT = Callable[[Request, Response], Optional[Request]] ProcessRequestT: TypeAlias = Callable[[Request, Response], Request | None]
def _identity(x: _T) -> _T: def _identity(x: _T) -> _T:

View File

@ -6,7 +6,7 @@ import asyncio
import logging import logging
import time import time
from collections.abc import AsyncIterator, Callable, Coroutine, Iterable from collections.abc import AsyncIterator, Callable, Coroutine, Iterable
from typing import TYPE_CHECKING, Any, TypeVar from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar
from twisted.internet.defer import Deferred from twisted.internet.defer import Deferred
from twisted.internet.task import LoopingCall from twisted.internet.task import LoopingCall
@ -17,15 +17,14 @@ from scrapy.utils.reactor import is_asyncio_reactor_installed, is_reactor_instal
if TYPE_CHECKING: if TYPE_CHECKING:
from twisted.internet.base import DelayedCall from twisted.internet.base import DelayedCall
# typing.Concatenate and typing.ParamSpec require Python 3.10
# typing.Self, typing.TypeVarTuple and typing.Unpack require Python 3.11 # typing.Self, typing.TypeVarTuple and typing.Unpack require Python 3.11
from typing_extensions import Concatenate, ParamSpec, Self, TypeVarTuple, Unpack from typing_extensions import Self, TypeVarTuple, Unpack
_P = ParamSpec("_P")
_Ts = TypeVarTuple("_Ts") _Ts = TypeVarTuple("_Ts")
_T = TypeVar("_T") _T = TypeVar("_T")
_P = ParamSpec("_P")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -6,7 +6,7 @@ import sys
from configparser import ConfigParser from configparser import ConfigParser
from operator import itemgetter from operator import itemgetter
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, cast from typing import TYPE_CHECKING, Any, cast
from scrapy.exceptions import UsageError from scrapy.exceptions import UsageError
from scrapy.settings import BaseSettings from scrapy.settings import BaseSettings
@ -14,7 +14,7 @@ from scrapy.utils.deprecate import update_classpath
from scrapy.utils.python import without_none_values from scrapy.utils.python import without_none_values
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Collection, Iterable, Mapping, MutableMapping from collections.abc import Callable, Collection, Iterable, Mapping, MutableMapping
def build_component_list( def build_component_list(

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import inspect import inspect
import warnings import warnings
from functools import wraps from functools import wraps
from typing import TYPE_CHECKING, Any, TypeVar, overload from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, overload
from twisted.internet.defer import Deferred, maybeDeferred from twisted.internet.defer import Deferred, maybeDeferred
from twisted.internet.threads import deferToThread from twisted.internet.threads import deferToThread
@ -13,13 +13,9 @@ from scrapy.exceptions import ScrapyDeprecationWarning
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Callable, Coroutine from collections.abc import AsyncGenerator, Callable, Coroutine
# typing.ParamSpec requires Python 3.10
from typing_extensions import ParamSpec
_P = ParamSpec("_P")
_T = TypeVar("_T") _T = TypeVar("_T")
_P = ParamSpec("_P")
def deprecated( def deprecated(

View File

@ -10,7 +10,16 @@ import warnings
from asyncio import Future from asyncio import Future
from collections.abc import Awaitable, Coroutine, Iterable, Iterator from collections.abc import Awaitable, Coroutine, Iterable, Iterator
from functools import wraps from functools import wraps
from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast, overload from typing import (
TYPE_CHECKING,
Any,
Concatenate,
Generic,
ParamSpec,
TypeVar,
cast,
overload,
)
from twisted.internet.defer import Deferred, DeferredList, fail, succeed from twisted.internet.defer import Deferred, DeferredList, fail, succeed
from twisted.internet.task import Cooperator from twisted.internet.task import Cooperator
@ -24,14 +33,10 @@ if TYPE_CHECKING:
from twisted.python.failure import Failure from twisted.python.failure import Failure
# typing.Concatenate and typing.ParamSpec require Python 3.10
from typing_extensions import Concatenate, ParamSpec
_P = ParamSpec("_P")
_T = TypeVar("_T") _T = TypeVar("_T")
_T2 = TypeVar("_T2") _T2 = TypeVar("_T2")
_P = ParamSpec("_P")
_DEFER_DELAY = 0.1 _DEFER_DELAY = 0.1
@ -327,7 +332,7 @@ def process_chain_both(
stacklevel=2, stacklevel=2,
) )
d: Deferred = Deferred() d: Deferred = Deferred()
for cb, eb in zip(callbacks, errbacks): for cb, eb in zip(callbacks, errbacks, strict=False):
d.addCallback(cb, *a, **kw) d.addCallback(cb, *a, **kw)
d.addErrback(eb, *a, **kw) d.addErrback(eb, *a, **kw)
if isinstance(input, failure.Failure): if isinstance(input, failure.Failure):

View File

@ -212,7 +212,7 @@ def csviter(
}, },
) )
continue continue
yield dict(zip(headers, row)) yield dict(zip(headers, row, strict=False))
@overload @overload

View File

@ -5,14 +5,14 @@ import pprint
import sys import sys
from collections.abc import MutableMapping from collections.abc import MutableMapping
from logging.config import dictConfig from logging.config import dictConfig
from typing import TYPE_CHECKING, Any, Optional, cast from typing import TYPE_CHECKING, Any, cast
from twisted.internet import asyncioreactor from twisted.internet import asyncioreactor
from twisted.python import log as twisted_log from twisted.python import log as twisted_log
from twisted.python.failure import Failure from twisted.python.failure import Failure
import scrapy import scrapy
from scrapy.settings import Settings, _SettingsKeyT from scrapy.settings import Settings, _SettingsKey
from scrapy.utils.versions import get_versions from scrapy.utils.versions import get_versions
if TYPE_CHECKING: if TYPE_CHECKING:
@ -35,7 +35,7 @@ def failure_to_exc_info(
return ( return (
failure.type, failure.type,
failure.value, failure.value,
cast("Optional[TracebackType]", failure.getTracebackObject()), cast("TracebackType | None", failure.getTracebackObject()),
) )
return None return None
@ -83,7 +83,7 @@ DEFAULT_LOGGING = {
def configure_logging( def configure_logging(
settings: Settings | dict[_SettingsKeyT, Any] | None = None, settings: Settings | dict[_SettingsKey, Any] | None = None,
install_root_handler: bool = True, install_root_handler: bool = True,
) -> None: ) -> None:
""" """

View File

@ -3,12 +3,12 @@ from __future__ import annotations
import signal import signal
from collections.abc import Callable from collections.abc import Callable
from types import FrameType from types import FrameType
from typing import Any, Optional, Union from typing import Any, TypeAlias
# copy of _HANDLER from typeshed/stdlib/signal.pyi # copy of _HANDLER from typeshed/stdlib/signal.pyi
SignalHandlerT = Union[ SignalHandlerT: TypeAlias = (
Callable[[int, Optional[FrameType]], Any], int, signal.Handlers, None Callable[[int, FrameType | None], Any] | int | signal.Handlers | None
] )
signal_names: dict[int, str] = {} signal_names: dict[int, str] = {}
for signame in dir(signal): for signame in dir(signal):

View File

@ -13,7 +13,7 @@ import weakref
from collections.abc import AsyncIterator, Iterable, Mapping from collections.abc import AsyncIterator, Iterable, Mapping
from functools import partial, wraps from functools import partial, wraps
from itertools import chain from itertools import chain
from typing import TYPE_CHECKING, Any, TypeVar, overload from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, overload
from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.utils.asyncgen import as_async_generator from scrapy.utils.asyncgen import as_async_generator
@ -22,15 +22,14 @@ if TYPE_CHECKING:
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator
from re import Pattern from re import Pattern
# typing.Concatenate and typing.ParamSpec require Python 3.10
# typing.Self requires Python 3.11 # typing.Self requires Python 3.11
from typing_extensions import Concatenate, ParamSpec, Self from typing_extensions import Self
_P = ParamSpec("_P")
_T = TypeVar("_T") _T = TypeVar("_T")
_KT = TypeVar("_KT") _KT = TypeVar("_KT")
_VT = TypeVar("_VT") _VT = TypeVar("_VT")
_P = ParamSpec("_P")
def flatten(x: Iterable[Any]) -> list[Any]: def flatten(x: Iterable[Any]) -> list[Any]:
@ -286,7 +285,7 @@ def get_spec(func: Callable[..., Any]) -> tuple[list[str], dict[str, Any]]:
firstdefault = len(spec.args) - len(defaults) firstdefault = len(spec.args) - len(defaults)
args = spec.args[:firstdefault] args = spec.args[:firstdefault]
kwargs = dict(zip(spec.args[firstdefault:], defaults)) kwargs = dict(zip(spec.args[firstdefault:], defaults, strict=False))
return args, kwargs return args, kwargs

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import sys import sys
from contextlib import suppress from contextlib import suppress
from typing import TYPE_CHECKING, Any, Generic, TypeVar from typing import TYPE_CHECKING, Any, Generic, ParamSpec, TypeVar
from warnings import catch_warnings, filterwarnings from warnings import catch_warnings, filterwarnings
from twisted.internet import asyncioreactor, error from twisted.internet import asyncioreactor, error
@ -19,14 +19,11 @@ if TYPE_CHECKING:
from twisted.internet.protocol import ServerFactory from twisted.internet.protocol import ServerFactory
from twisted.internet.tcp import Port from twisted.internet.tcp import Port
# typing.ParamSpec requires Python 3.10
from typing_extensions import ParamSpec
from scrapy.utils.asyncio import CallLaterResult from scrapy.utils.asyncio import CallLaterResult
_P = ParamSpec("_P")
_T = TypeVar("_T") _T = TypeVar("_T")
_P = ParamSpec("_P")
def listen_tcp(portrange: list[int], host: str, factory: ServerFactory) -> Port: # type: ignore[return] # pylint: disable=inconsistent-return-statements # noqa: RET503 def listen_tcp(portrange: list[int], host: str, factory: ServerFactory) -> Port: # type: ignore[return] # pylint: disable=inconsistent-return-statements # noqa: RET503

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, Union from typing import TYPE_CHECKING, 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
@ -37,7 +37,7 @@ if TYPE_CHECKING:
from scrapy import Spider from scrapy import Spider
UrlT = Union[str, bytes, ParseResult] UrlT: TypeAlias = str | bytes | ParseResult
def url_is_from_any_domain(url: UrlT, domains: Iterable[str]) -> bool: def url_is_from_any_domain(url: UrlT, domains: Iterable[str]) -> bool:

View File

@ -369,7 +369,7 @@ with multiples lines
est = [x for sublist in est for x in sublist] # flatten est = [x for sublist in est for x in sublist] # flatten
est = [x.lstrip().rstrip() for x in est] est = [x.lstrip().rstrip() for x in est]
it = iter(est) it = iter(est)
s = dict(zip(it, it)) s = dict(zip(it, it, strict=False))
assert s["engine.spider.name"] == crawler.spider.name assert s["engine.spider.name"] == crawler.spider.name
assert s["len(engine.scraper.slot.active)"] == "1" assert s["len(engine.scraper.slot.active)"] == "1"

View File

@ -1,13 +1,17 @@
from __future__ import annotations from __future__ import annotations
import datetime import datetime
from typing import Any, Callable from typing import TYPE_CHECKING, Any
from scrapy.extensions.periodic_log import PeriodicLog from scrapy.extensions.periodic_log import PeriodicLog
from scrapy.utils.test import get_crawler from scrapy.utils.test import get_crawler
from .spiders import MetaSpider from .spiders import MetaSpider
if TYPE_CHECKING:
from collections.abc import Callable
stats_dump_1 = { stats_dump_1 = {
"log_count/INFO": 10, "log_count/INFO": 10,
"log_count/WARNING": 1, "log_count/WARNING": 1,

View File

@ -2628,7 +2628,7 @@ class TestBatchDeliveries(TestFeedExportBase):
} }
data = await self.exported_data(items, settings) data = await self.exported_data(items, settings)
for fmt, expected in formats.items(): for fmt, expected in formats.items():
for expected_batch, got_batch in zip(expected, data[fmt]): for expected_batch, got_batch in zip(expected, data[fmt], strict=False):
assert got_batch == expected_batch assert got_batch == expected_batch
@deferred_f_from_coro_f @deferred_f_from_coro_f
@ -2652,7 +2652,7 @@ class TestBatchDeliveries(TestFeedExportBase):
} }
data = await self.exported_data(items, settings) data = await self.exported_data(items, settings)
for fmt, expected in formats.items(): for fmt, expected in formats.items():
for expected_batch, got_batch in zip(expected, data[fmt]): for expected_batch, got_batch in zip(expected, data[fmt], strict=False):
assert got_batch == expected_batch assert got_batch == expected_batch
@deferred_f_from_coro_f @deferred_f_from_coro_f

View File

@ -6,7 +6,7 @@ import re
import string import string
from ipaddress import IPv4Address from ipaddress import IPv4Address
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, cast from typing import TYPE_CHECKING, Any, cast
from unittest import mock from unittest import mock
from urllib.parse import urlencode from urllib.parse import urlencode
@ -39,7 +39,7 @@ from tests.mockserver.http_resources import LeafResource, Status
from tests.mockserver.utils import ssl_context_factory from tests.mockserver.utils import ssl_context_factory
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Coroutine, Generator from collections.abc import AsyncGenerator, Callable, Coroutine, Generator
from scrapy.core.http2.protocol import H2ClientProtocol from scrapy.core.http2.protocol import H2ClientProtocol

View File

@ -333,7 +333,7 @@ class TestResponseBase:
if response is None: if response is None:
response = self._links_response() response = self._links_response()
followed = response.follow_all(follow_obj) followed = response.follow_all(follow_obj)
for req, target in zip(followed, target_urls): for req, target in zip(followed, target_urls, strict=False):
assert req.url == target assert req.url == target
yield req yield req
@ -647,7 +647,7 @@ class TestTextResponse(TestResponseBase):
# select <a> elements # select <a> elements
for sellist in [resp.css("a"), resp.xpath("//a")]: for sellist in [resp.css("a"), resp.xpath("//a")]:
for sel, url in zip(sellist, urls): for sel, url in zip(sellist, urls, strict=False):
self._assert_followed_url(sel, url, response=resp) self._assert_followed_url(sel, url, response=resp)
# select <link> elements # select <link> elements
@ -659,7 +659,7 @@ class TestTextResponse(TestResponseBase):
# href attributes should work # href attributes should work
for sellist in [resp.css("a::attr(href)"), resp.xpath("//a/@href")]: for sellist in [resp.css("a::attr(href)"), resp.xpath("//a/@href")]:
for sel, url in zip(sellist, urls): for sel, url in zip(sellist, urls, strict=False):
self._assert_followed_url(sel, url, response=resp) self._assert_followed_url(sel, url, response=resp)
# non-a elements are not supported # non-a elements are not supported

View File

@ -16,7 +16,7 @@ class MyRequest2(Request):
@pytest.mark.mypy_testing @pytest.mark.mypy_testing
def mypy_test_headers(): def mypy_test_headers():
Request("data:,", headers=1) # E: Argument "headers" to "Request" has incompatible type "int"; expected "Union[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={})
Request("data:,", headers=[]) Request("data:,", headers=[])

View File

@ -7,7 +7,7 @@ from scrapy.http import HtmlResponse, Response, TextResponse
@pytest.mark.mypy_testing @pytest.mark.mypy_testing
def mypy_test_headers(): def mypy_test_headers():
Response("data:,", headers=1) # E: Argument "headers" to "Response" has incompatible type "int"; expected "Union[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={})
Response("data:,", headers=[]) Response("data:,", headers=[])

20
tox.ini
View File

@ -43,7 +43,7 @@ commands =
pytest {posargs:--cov-config=pyproject.toml --cov=scrapy --cov-report= --cov-report=term-missing --cov-report=xml --junitxml=testenv.junit.xml -o junit_family=legacy --durations=10 scrapy tests --doctest-modules} pytest {posargs:--cov-config=pyproject.toml --cov=scrapy --cov-report= --cov-report=term-missing --cov-report=xml --junitxml=testenv.junit.xml -o junit_family=legacy --durations=10 scrapy tests --doctest-modules}
[testenv:typing] [testenv:typing]
basepython = python3.9 basepython = python3.10
deps = deps =
mypy==1.16.1 mypy==1.16.1
typing-extensions==4.14.1 typing-extensions==4.14.1
@ -64,7 +64,7 @@ commands =
mypy {posargs:scrapy tests} mypy {posargs:scrapy tests}
[testenv:typing-tests] [testenv:typing-tests]
basepython = python3.9 basepython = python3.10
deps = deps =
{[test-requirements]deps} {[test-requirements]deps}
{[testenv:typing]deps} {[testenv:typing]deps}
@ -97,7 +97,7 @@ commands =
twine check dist/* twine check dist/*
[pinned] [pinned]
basepython = python3.9 basepython = python3.10
deps = deps =
# pytest 8.4.1 adds support for Twisted 25.5.0 but drops support for Twisted < 24.10.0 # pytest 8.4.1 adds support for Twisted 25.5.0 but drops support for Twisted < 24.10.0
pytest==8.4.0 pytest==8.4.0
@ -106,7 +106,7 @@ deps =
cryptography==37.0.0 cryptography==37.0.0
cssselect==0.9.1 cssselect==0.9.1
itemadapter==0.1.0 itemadapter==0.1.0
lxml==4.6.0 lxml==4.6.4
parsel==1.5.0 parsel==1.5.0
pyOpenSSL==22.0.0 pyOpenSSL==22.0.0
queuelib==1.4.2 queuelib==1.4.2
@ -151,17 +151,17 @@ deps =
basepython = {[pinned]basepython} basepython = {[pinned]basepython}
deps = deps =
{[pinned]deps} {[pinned]deps}
Pillow==8.0.0 Pillow==8.3.2
boto3==1.20.0 boto3==1.20.0
bpython==0.7.1 bpython==0.7.1
brotli==0.5.2; implementation_name != "pypy" brotli==0.5.2; implementation_name != "pypy"
brotlicffi==0.8.0; implementation_name == "pypy" brotlicffi==0.8.0; implementation_name == "pypy"
brotlipy brotlipy
google-cloud-storage==1.29.0 google-cloud-storage==1.29.0
ipython==2.0.0 ipython==7.1.0
robotexclusionrulesparser==1.6.2 robotexclusionrulesparser==1.6.2
uvloop==0.14.0; platform_system != "Windows" and implementation_name != "pypy" uvloop==0.16.0; platform_system != "Windows" and implementation_name != "pypy"
zstandard==0.1; implementation_name != "pypy" zstandard==0.16.0; implementation_name != "pypy"
setenv = setenv =
{[pinned]setenv} {[pinned]setenv}
commands = {[pinned]commands} commands = {[pinned]commands}
@ -254,7 +254,7 @@ commands =
[testenv:botocore] [testenv:botocore]
deps = deps =
{[testenv]deps} {[testenv]deps}
botocore>=1.4.87 botocore>=1.13.45
commands = commands =
pytest {posargs:--cov-config=pyproject.toml --cov=scrapy --cov-report=xml --cov-report= tests --junitxml=botocore.junit.xml -o junit_family=legacy -m requires_botocore} pytest {posargs:--cov-config=pyproject.toml --cov=scrapy --cov-report=xml --cov-report= tests --junitxml=botocore.junit.xml -o junit_family=legacy -m requires_botocore}
@ -262,7 +262,7 @@ commands =
basepython = {[pinned]basepython} basepython = {[pinned]basepython}
deps = deps =
{[pinned]deps} {[pinned]deps}
botocore==1.4.87 botocore==1.13.45
setenv = setenv =
{[pinned]setenv} {[pinned]setenv}
commands = commands =