chore: more ruff rules and overall minor improvements (#7386)

* overall prettyfication

- don't create empty mutable containers (lists, dicts) where it is appropriate
- removed from ignore section and applied some rules from ruff (but keep them ignored in tests)
- use `deque` in `_AsyncCooperatorAdapter` instead of `list.pop(0)`
- remove `f` prefix from strings without any formatting

* return `SIM300` to ignore

* apply ruff rules to all files

* another one

* remove extra space in pyproject.toml

* simplify `__getattr__` in `scrapy.utils.url`

* lazy `url_is_from_any_domain` and `url_is_from_spider`

* improve typing

* specify `arg_to_iter` with None as arg
This commit is contained in:
Albert Eduardovich N. 2026-04-03 17:00:13 +03:00 committed by GitHub
parent e3a8ff2b59
commit 58d85282cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
66 changed files with 308 additions and 196 deletions

View File

@ -415,25 +415,6 @@ ignore = [
"SIM115", "SIM115",
# Yoda condition detected # Yoda condition detected
"SIM300", "SIM300",
# Ones that we may want to address (fix, ignore per-line or move to "don't want to fix")
# Assigning to `os.environ` doesn't clear the environment.
"B003",
# Do not use mutable data structures for argument defaults.
"B006",
# Found useless expression.
"B018",
# No explicit stacklevel argument found.
"B028",
# Within an `except` clause, raise exceptions with `raise ... from`
"B904",
# `for` loop variable overwritten by assignment target
"PLW2901",
# Mutable class attributes should be annotated with `typing.ClassVar`
"RUF012",
# Use capitalized environment variable
"SIM112",
] ]
[tool.ruff.lint.flake8-tidy-imports] [tool.ruff.lint.flake8-tidy-imports]
@ -453,8 +434,28 @@ split-on-trailing-comma = false
"scrapy/linkextractors/__init__.py" = ["E402"] "scrapy/linkextractors/__init__.py" = ["E402"]
"scrapy/spiders/__init__.py" = ["E402"] "scrapy/spiders/__init__.py" = ["E402"]
# Skip bandit and allow blocking file I/O in tests "tests/**" = [
"tests/**" = ["ASYNC240", "S"] # Skip bandit and allow blocking file I/O in tests
"ASYNC240",
"S",
# Ones that we may want to address (fix, ignore per-line or move to "don't want to fix")
# Assigning to `os.environ` doesn't clear the environment.
"B003",
# Do not use mutable data structures for argument defaults.
"B006",
# Found useless expression.
"B018",
# No explicit stacklevel argument found.
"B028",
# Within an `except` clause, raise exceptions with `raise ... from`
"B904",
# `for` loop variable overwritten by assignment target
"PLW2901",
# Mutable class attributes should be annotated with `typing.ClassVar`
"RUF012",
# Use capitalized environment variable
"SIM112",
]
# Issues pending a review: # Issues pending a review:
"docs/conf.py" = ["E402"] "docs/conf.py" = ["E402"]

View File

@ -46,7 +46,7 @@ def _iter_command_classes(module_name: str) -> Iterable[type[ScrapyCommand]]:
inspect.isclass(obj) inspect.isclass(obj)
and issubclass(obj, ScrapyCommand) and issubclass(obj, ScrapyCommand)
and obj.__module__ == module.__name__ and obj.__module__ == module.__name__
and obj not in (ScrapyCommand, BaseRunSpiderCommand) and obj not in {ScrapyCommand, BaseRunSpiderCommand}
): ):
yield obj yield obj

View File

@ -10,7 +10,7 @@ import os
import warnings import warnings
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, ClassVar
from twisted.python import failure from twisted.python import failure
@ -30,7 +30,7 @@ class ScrapyCommand(ABC):
crawler_process: CrawlerProcessBase | None = None # set in scrapy.cmdline crawler_process: CrawlerProcessBase | None = None # set in scrapy.cmdline
# default settings to be used for this command instead of global defaults # default settings to be used for this command instead of global defaults
default_settings: dict[str, Any] = {} default_settings: ClassVar[dict[str, Any]] = {}
exitcode: int = 0 exitcode: int = 0
@ -115,7 +115,9 @@ class ScrapyCommand(ABC):
try: try:
self.settings.setdict(arglist_to_dict(opts.set), priority="cmdline") self.settings.setdict(arglist_to_dict(opts.set), priority="cmdline")
except ValueError: except ValueError:
raise UsageError("Invalid -s value, use -s NAME=VALUE", print_help=False) raise UsageError(
"Invalid -s value, use -s NAME=VALUE", print_help=False
) from None
if opts.logfile: if opts.logfile:
self.settings.set("LOG_ENABLED", True, priority="cmdline") self.settings.set("LOG_ENABLED", True, priority="cmdline")
@ -181,7 +183,9 @@ class BaseRunSpiderCommand(ScrapyCommand):
try: try:
opts.spargs = arglist_to_dict(opts.spargs) opts.spargs = arglist_to_dict(opts.spargs)
except ValueError: except ValueError:
raise UsageError("Invalid -a value, use -a NAME=VALUE", print_help=False) raise UsageError(
"Invalid -a value, use -a NAME=VALUE", print_help=False
) from None
if opts.output or opts.overwrite_output: if opts.output or opts.overwrite_output:
assert self.settings is not None assert self.settings is not None
feeds = feed_process_params_from_cli( feeds = feed_process_params_from_cli(

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import subprocess import subprocess
import sys import sys
import time import time
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, ClassVar
from urllib.parse import urlencode from urllib.parse import urlencode
import scrapy import scrapy
@ -18,7 +18,7 @@ if TYPE_CHECKING:
class Command(ScrapyCommand): class Command(ScrapyCommand):
default_settings = { default_settings: ClassVar[dict[str, Any]] = {
"LOG_LEVEL": "INFO", "LOG_LEVEL": "INFO",
"LOGSTATS_INTERVAL": 1, "LOGSTATS_INTERVAL": 1,
"CLOSESPIDER_TIMEOUT": 10, "CLOSESPIDER_TIMEOUT": 10,

View File

@ -2,7 +2,7 @@ import argparse
import time import time
from collections import defaultdict from collections import defaultdict
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from typing import Any from typing import Any, ClassVar
from unittest import TextTestResult as _TextTestResult from unittest import TextTestResult as _TextTestResult
from unittest import TextTestRunner from unittest import TextTestRunner
@ -44,7 +44,7 @@ class TextTestResult(_TextTestResult):
class Command(ScrapyCommand): class Command(ScrapyCommand):
requires_project = True requires_project = True
default_settings = {"LOG_ENABLED": False} default_settings: ClassVar[dict[str, Any]] = {"LOG_ENABLED": False}
def syntax(self) -> str: def syntax(self) -> str:
return "[options] <spider>" return "[options] <spider>"

View File

@ -1,6 +1,7 @@
import argparse import argparse
import os import os
import sys import sys
from typing import Any, ClassVar
from scrapy.commands import ScrapyCommand from scrapy.commands import ScrapyCommand
from scrapy.exceptions import UsageError from scrapy.exceptions import UsageError
@ -10,7 +11,7 @@ from scrapy.spiderloader import get_spider_loader
class Command(ScrapyCommand): class Command(ScrapyCommand):
requires_project = True requires_project = True
requires_crawler_process = False requires_crawler_process = False
default_settings = {"LOG_ENABLED": False} default_settings: ClassVar[dict[str, Any]] = {"LOG_ENABLED": False}
def syntax(self) -> str: def syntax(self) -> str:
return "<spider>" return "<spider>"

View File

@ -5,7 +5,7 @@ import shutil
import string import string
from importlib import import_module from importlib import import_module
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, ClassVar, cast
from urllib.parse import urlparse from urllib.parse import urlparse
import scrapy import scrapy
@ -47,7 +47,7 @@ def verify_url_scheme(url: str) -> str:
class Command(ScrapyCommand): class Command(ScrapyCommand):
requires_crawler_process = False requires_crawler_process = False
default_settings = {"LOG_ENABLED": False} default_settings: ClassVar[dict[str, Any]] = {"LOG_ENABLED": False}
def syntax(self) -> str: def syntax(self) -> str:
return "[options] <name> <domain>" return "[options] <name> <domain>"

View File

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any, ClassVar
from scrapy.commands import ScrapyCommand from scrapy.commands import ScrapyCommand
from scrapy.spiderloader import get_spider_loader from scrapy.spiderloader import get_spider_loader
@ -12,7 +12,7 @@ if TYPE_CHECKING:
class Command(ScrapyCommand): class Command(ScrapyCommand):
requires_project = True requires_project = True
requires_crawler_process = False requires_crawler_process = False
default_settings = {"LOG_ENABLED": False} default_settings: ClassVar[dict[str, Any]] = {"LOG_ENABLED": False}
def short_desc(self) -> str: def short_desc(self) -> str:
return "List available spiders" return "List available spiders"

View File

@ -4,7 +4,7 @@ import functools
import inspect import inspect
import json import json
import logging import logging
from typing import TYPE_CHECKING, Any, TypeVar, overload from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, overload
from itemadapter import ItemAdapter from itemadapter import ItemAdapter
from twisted.internet.defer import Deferred, maybeDeferred from twisted.internet.defer import Deferred, maybeDeferred
@ -39,8 +39,8 @@ class Command(BaseRunSpiderCommand):
requires_project = True requires_project = True
spider: Spider | None = None spider: Spider | None = None
items: dict[int, list[Any]] = {} items: ClassVar[dict[int, list[Any]]] = {}
requests: dict[int, list[Request]] = {} requests: ClassVar[dict[int, list[Request]]] = {}
spidercls: type[Spider] | None spidercls: type[Spider] | None
first_response = None first_response = None
@ -387,7 +387,7 @@ class Command(BaseRunSpiderCommand):
"Invalid -m/--meta value, pass a valid json string to -m or --meta. " "Invalid -m/--meta value, pass a valid json string to -m or --meta. "
'Example: --meta=\'{"foo" : "bar"}\'', 'Example: --meta=\'{"foo" : "bar"}\'',
print_help=False, print_help=False,
) ) from None
def process_request_cb_kwargs(self, opts: argparse.Namespace) -> None: def process_request_cb_kwargs(self, opts: argparse.Namespace) -> None:
if opts.cbkwargs: if opts.cbkwargs:
@ -398,7 +398,7 @@ class Command(BaseRunSpiderCommand):
"Invalid --cbkwargs value, pass a valid json string to --cbkwargs. " "Invalid --cbkwargs value, pass a valid json string to --cbkwargs. "
'Example: --cbkwargs=\'{"foo" : "bar"}\'', 'Example: --cbkwargs=\'{"foo" : "bar"}\'',
print_help=False, print_help=False,
) ) from None
def run(self, args: list[str], opts: argparse.Namespace) -> None: def run(self, args: list[str], opts: argparse.Namespace) -> None:
# parse arguments # parse arguments

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import sys import sys
from importlib import import_module from importlib import import_module
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any, ClassVar
from scrapy.commands import BaseRunSpiderCommand from scrapy.commands import BaseRunSpiderCommand
from scrapy.exceptions import UsageError from scrapy.exceptions import UsageError
@ -18,7 +18,7 @@ if TYPE_CHECKING:
def _import_file(filepath: str | PathLike[str]) -> ModuleType: def _import_file(filepath: str | PathLike[str]) -> ModuleType:
abspath = Path(filepath).resolve() abspath = Path(filepath).resolve()
if abspath.suffix not in (".py", ".pyw"): if abspath.suffix not in {".py", ".pyw"}:
raise ValueError(f"Not a Python source file: {abspath}") raise ValueError(f"Not a Python source file: {abspath}")
dirname = str(abspath.parent) dirname = str(abspath.parent)
sys.path = [dirname, *sys.path] sys.path = [dirname, *sys.path]
@ -30,7 +30,9 @@ def _import_file(filepath: str | PathLike[str]) -> ModuleType:
class Command(BaseRunSpiderCommand): class Command(BaseRunSpiderCommand):
default_settings = {"SPIDER_LOADER_CLASS": DummySpiderLoader} default_settings: ClassVar[dict[str, Any]] = {
"SPIDER_LOADER_CLASS": DummySpiderLoader
}
def syntax(self) -> str: def syntax(self) -> str:
return "[options] <spider_file>" return "[options] <spider_file>"
@ -50,7 +52,7 @@ class Command(BaseRunSpiderCommand):
try: try:
module = _import_file(filename) module = _import_file(filename)
except (ImportError, ValueError) as e: except (ImportError, ValueError) as e:
raise UsageError(f"Unable to load {str(filename)!r}: {e}\n") raise UsageError(f"Unable to load {str(filename)!r}: {e}\n") from e
spclasses = list(iter_spider_classes(module)) spclasses = list(iter_spider_classes(module))
if not spclasses: if not spclasses:
raise UsageError(f"No spider found in file: {filename}\n") raise UsageError(f"No spider found in file: {filename}\n")

View File

@ -1,5 +1,6 @@
import argparse import argparse
import json import json
from typing import Any, ClassVar
from scrapy.commands import ScrapyCommand from scrapy.commands import ScrapyCommand
from scrapy.settings import BaseSettings from scrapy.settings import BaseSettings
@ -7,7 +8,7 @@ from scrapy.settings import BaseSettings
class Command(ScrapyCommand): class Command(ScrapyCommand):
requires_crawler_process = False requires_crawler_process = False
default_settings = {"LOG_ENABLED": False} default_settings: ClassVar[dict[str, Any]] = {"LOG_ENABLED": False}
def syntax(self) -> str: def syntax(self) -> str:
return "[options]" return "[options]"

View File

@ -7,7 +7,7 @@ See documentation in docs/topics/shell.rst
from __future__ import annotations from __future__ import annotations
from threading import Thread from threading import Thread
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, ClassVar
from scrapy.commands import ScrapyCommand from scrapy.commands import ScrapyCommand
from scrapy.http import Request from scrapy.http import Request
@ -23,7 +23,7 @@ if TYPE_CHECKING:
class Command(ScrapyCommand): class Command(ScrapyCommand):
default_settings = { default_settings: ClassVar[dict[str, Any]] = {
"DUPEFILTER_CLASS": "scrapy.dupefilters.BaseDupeFilter", "DUPEFILTER_CLASS": "scrapy.dupefilters.BaseDupeFilter",
"KEEP_ALIVE": True, "KEEP_ALIVE": True,
"LOGSTATS_INTERVAL": 0, "LOGSTATS_INTERVAL": 0,

View File

@ -6,7 +6,7 @@ from importlib.util import find_spec
from pathlib import Path from pathlib import Path
from shutil import copy2, copystat, ignore_patterns, move from shutil import copy2, copystat, ignore_patterns, move
from stat import S_IWUSR as OWNER_WRITE_PERMISSION from stat import S_IWUSR as OWNER_WRITE_PERMISSION
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any, ClassVar
import scrapy import scrapy
from scrapy.commands import ScrapyCommand from scrapy.commands import ScrapyCommand
@ -34,7 +34,7 @@ def _make_writable(path: Path) -> None:
class Command(ScrapyCommand): class Command(ScrapyCommand):
requires_crawler_process = False requires_crawler_process = False
default_settings = {"LOG_ENABLED": False} default_settings: ClassVar[dict[str, Any]] = {"LOG_ENABLED": False}
def syntax(self) -> str: def syntax(self) -> str:
return "<project_name> [project_dir]" return "<project_name> [project_dir]"
@ -90,7 +90,7 @@ class Command(ScrapyCommand):
_make_writable(dst) _make_writable(dst)
def run(self, args: list[str], opts: argparse.Namespace) -> None: def run(self, args: list[str], opts: argparse.Namespace) -> None:
if len(args) not in (1, 2): if len(args) not in {1, 2}:
raise UsageError raise UsageError
project_name = args[0] project_name = args[0]

View File

@ -1,4 +1,5 @@
import argparse import argparse
from typing import Any, ClassVar
import scrapy import scrapy
from scrapy.commands import ScrapyCommand from scrapy.commands import ScrapyCommand
@ -7,7 +8,7 @@ from scrapy.utils.versions import get_versions
class Command(ScrapyCommand): class Command(ScrapyCommand):
requires_crawler_process = False requires_crawler_process = False
default_settings = {"LOG_ENABLED": False} default_settings: ClassVar[dict[str, Any]] = {"LOG_ENABLED": False}
def syntax(self) -> str: def syntax(self) -> str:
return "[-v]" return "[-v]"

View File

@ -6,7 +6,7 @@ from collections.abc import AsyncGenerator, Iterable
from functools import wraps from functools import wraps
from inspect import getmembers from inspect import getmembers
from types import CoroutineType from types import CoroutineType
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, ClassVar, cast
from unittest import TestCase, TestResult from unittest import TestCase, TestResult
from scrapy.http import Request, Response from scrapy.http import Request, Response
@ -90,7 +90,7 @@ class Contract:
class ContractsManager: class ContractsManager:
contracts: dict[str, type[Contract]] = {} contracts: ClassVar[dict[str, type[Contract]]] = {}
def __init__(self, contracts: Iterable[type[Contract]]): def __init__(self, contracts: Iterable[type[Contract]]):
for contract in contracts: for contract in contracts:
@ -108,8 +108,8 @@ class ContractsManager:
def extract_contracts(self, method: Callable) -> list[Contract]: def extract_contracts(self, method: Callable) -> list[Contract]:
contracts: list[Contract] = [] contracts: list[Contract] = []
assert method.__doc__ is not None assert method.__doc__ is not None
for line in method.__doc__.split("\n"): for line_ in method.__doc__.split("\n"):
line = line.strip() line = line_.strip()
if line.startswith("@"): if line.startswith("@"):
m = re.match(r"@(\w+)\s*(.*)", line) m = re.match(r"@(\w+)\s*(.*)", line)

View File

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, ClassVar
from itemadapter import ItemAdapter, is_item from itemadapter import ItemAdapter, is_item
@ -68,7 +68,7 @@ class ReturnsContract(Contract):
""" """
name = "returns" name = "returns"
object_type_verifiers: dict[str | None, Callable[[Any], bool]] = { object_type_verifiers: ClassVar[dict[str | None, Callable[[Any], bool]]] = {
"request": lambda x: isinstance(x, Request), "request": lambda x: isinstance(x, Request),
"requests": lambda x: isinstance(x, Request), "requests": lambda x: isinstance(x, Request),
"item": is_item, "item": is_item,
@ -78,7 +78,7 @@ class ReturnsContract(Contract):
def __init__(self, *args: Any, **kwargs: Any): def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if len(self.args) not in [1, 2, 3]: if len(self.args) not in {1, 2, 3}:
raise ValueError( raise ValueError(
f"Incorrect argument quantity: expected 1, 2 or 3, got {len(self.args)}" f"Incorrect argument quantity: expected 1, 2 or 3, got {len(self.args)}"
) )

View File

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING
from w3lib.url import parse_data_uri from w3lib.url import parse_data_uri
@ -17,9 +17,8 @@ class DataURIDownloadHandler(BaseDownloadHandler):
uri = parse_data_uri(request.url) uri = parse_data_uri(request.url)
respcls = responsetypes.from_mimetype(uri.media_type) respcls = responsetypes.from_mimetype(uri.media_type)
resp_kwargs: dict[str, Any] = {}
if issubclass(respcls, TextResponse) and uri.media_type.split("/")[0] == "text": if issubclass(respcls, TextResponse) and uri.media_type.split("/")[0] == "text":
charset = uri.media_type_parameters.get("charset") charset = uri.media_type_parameters.get("charset")
resp_kwargs["encoding"] = charset return respcls(url=request.url, body=uri.data, encoding=charset)
return respcls(url=request.url, body=uri.data, **resp_kwargs) return respcls(url=request.url, body=uri.data)

View File

@ -7,6 +7,7 @@ from w3lib.url import file_uri_to_path
from scrapy.core.downloader.handlers.base import BaseDownloadHandler from scrapy.core.downloader.handlers.base import BaseDownloadHandler
from scrapy.responsetypes import responsetypes from scrapy.responsetypes import responsetypes
from scrapy.utils.asyncio import run_in_thread
if TYPE_CHECKING: if TYPE_CHECKING:
from scrapy import Request from scrapy import Request
@ -16,6 +17,6 @@ if TYPE_CHECKING:
class FileDownloadHandler(BaseDownloadHandler): class FileDownloadHandler(BaseDownloadHandler):
async def download_request(self, request: Request) -> Response: async def download_request(self, request: Request) -> Response:
filepath = file_uri_to_path(request.url) filepath = file_uri_to_path(request.url)
body = Path(filepath).read_bytes() # noqa: ASYNC240 body = await run_in_thread(Path(filepath).read_bytes)
respcls = responsetypes.from_args(filename=filepath, body=body) respcls = responsetypes.from_args(filename=filepath, body=body)
return respcls(url=request.url, body=body) return respcls(url=request.url, body=body)

View File

@ -33,7 +33,7 @@ from __future__ import annotations
import re import re
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, BinaryIO from typing import TYPE_CHECKING, BinaryIO, ClassVar
from urllib.parse import unquote from urllib.parse import unquote
from twisted.internet.protocol import ClientCreator, Protocol from twisted.internet.protocol import ClientCreator, Protocol
@ -79,7 +79,7 @@ _CODE_RE = re.compile(r"\d+")
class FTPDownloadHandler(BaseDownloadHandler): class FTPDownloadHandler(BaseDownloadHandler):
CODE_MAPPING: dict[str, int] = { CODE_MAPPING: ClassVar[dict[str, int]] = {
"550": 404, "550": 404,
"default": 503, "default": 503,
} }

View File

@ -147,7 +147,7 @@ class HTTP11DownloadHandler(BaseHttpDownloadHandler):
# issue a callback after `_disconnect_timeout` seconds. # issue a callback after `_disconnect_timeout` seconds.
# #
# See also https://github.com/scrapy/scrapy/issues/2653 # See also https://github.com/scrapy/scrapy/issues/2653
delayed_call = reactor.callLater(self._disconnect_timeout, d.callback, []) delayed_call = reactor.callLater(self._disconnect_timeout, d.callback, ())
try: try:
await maybe_deferred_to_future(d) await maybe_deferred_to_future(d)

View File

@ -265,7 +265,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin):
def _lose_connection_with_error(self, errors: list[BaseException]) -> None: def _lose_connection_with_error(self, errors: list[BaseException]) -> None:
"""Helper function to lose the connection with the error sent as a """Helper function to lose the connection with the error sent as a
reason""" reason"""
self._conn_lost_errors += errors self._conn_lost_errors.extend(errors)
assert self.transport is not None # typing assert self.transport is not None # typing
self.transport.loseConnection() self.transport.loseConnection()
@ -310,7 +310,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin):
if isinstance(e, FrameTooLargeError): if isinstance(e, FrameTooLargeError):
# hyper-h2 does not drop the connection in this scenario, we # hyper-h2 does not drop the connection in this scenario, we
# need to abort the connection manually. # need to abort the connection manually.
self._conn_lost_errors += [e] self._conn_lost_errors.append(e)
assert self.transport is not None # typing assert self.transport is not None # typing
self.transport.abortConnection() self.transport.abortConnection()
return return

View File

@ -22,6 +22,8 @@ from scrapy.utils._download_handlers import (
from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.httpobj import urlparse_cached
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Sequence
from hpack import HeaderTuple from hpack import HeaderTuple
from scrapy.core.http2.protocol import H2ClientProtocol from scrapy.core.http2.protocol import H2ClientProtocol
@ -150,7 +152,7 @@ class Stream:
# flow control window # flow control window
"flow_controlled_size": 0, "flow_controlled_size": 0,
# Headers received after sending the request # Headers received after sending the request
"headers": Headers({}), "headers": Headers(),
} }
def _cancel(_: Any) -> None: def _cancel(_: Any) -> None:
@ -391,7 +393,7 @@ class Stream:
def close( def close(
self, self,
reason: StreamCloseReason, reason: StreamCloseReason,
errors: list[BaseException] | None = None, errors: Sequence[BaseException] | None = None,
from_protocol: bool = False, from_protocol: bool = False,
) -> None: ) -> None:
"""Based on the reason sent we will handle each case.""" """Based on the reason sent we will handle each case."""
@ -405,7 +407,7 @@ class Stream:
# Have default value of errors as an empty list as # Have default value of errors as an empty list as
# some cases can add a list of exceptions # some cases can add a list of exceptions
errors = errors or [] errors = errors or ()
if not from_protocol: if not from_protocol:
self._protocol.pop_stream(self.stream_id) self._protocol.pop_stream(self.stream_id)
@ -470,7 +472,7 @@ class Stream:
self._deferred_response.errback(ResponseFailed(errors)) self._deferred_response.errback(ResponseFailed(errors))
elif reason is StreamCloseReason.INACTIVE: elif reason is StreamCloseReason.INACTIVE:
errors.insert(0, InactiveStreamClosed(self._request)) errors = (InactiveStreamClosed(self._request), *errors)
self._deferred_response.errback(ResponseFailed(errors)) self._deferred_response.errback(ResponseFailed(errors))
else: else:

View File

@ -461,9 +461,10 @@ class Scheduler(BaseScheduler):
except TypeError: # pragma: no cover except TypeError: # pragma: no cover
warn( warn(
f"The __init__ method of {global_object_name(self.pqclass)} " f"The __init__ method of {global_object_name(self.pqclass)} "
f"does not support a `start_queue_cls` keyword-only " "does not support a `start_queue_cls` keyword-only "
f"parameter.", "parameter.",
ScrapyDeprecationWarning, ScrapyDeprecationWarning,
stacklevel=2,
) )
return build_from_crawler( return build_from_crawler(
self.pqclass, self.pqclass,
@ -490,9 +491,10 @@ class Scheduler(BaseScheduler):
except TypeError: # pragma: no cover except TypeError: # pragma: no cover
warn( warn(
f"The __init__ method of {global_object_name(self.pqclass)} " f"The __init__ method of {global_object_name(self.pqclass)} "
f"does not support a `start_queue_cls` keyword-only " "does not support a `start_queue_cls` keyword-only "
f"parameter.", "parameter.",
ScrapyDeprecationWarning, ScrapyDeprecationWarning,
stacklevel=2,
) )
q = build_from_crawler( q = build_from_crawler(
self.pqclass, self.pqclass,
@ -521,7 +523,7 @@ class Scheduler(BaseScheduler):
def _read_dqs_state(self, dqdir: str) -> Any: def _read_dqs_state(self, dqdir: str) -> Any:
path = Path(dqdir, "active.json") path = Path(dqdir, "active.json")
if not path.exists(): if not path.exists():
return [] return ()
with path.open(encoding="utf-8") as f: with path.open(encoding="utf-8") as f:
return json.load(f) return json.load(f)

View File

@ -114,6 +114,7 @@ class SpiderMiddlewareManager(MiddlewareManager):
f"Scrapy 2.13 for details: " f"Scrapy 2.13 for details: "
f"https://docs.scrapy.org/en/2.13/news.html", f"https://docs.scrapy.org/en/2.13/news.html",
ScrapyDeprecationWarning, ScrapyDeprecationWarning,
stacklevel=2,
) )
def _add_middleware(self, mw: Any) -> None: def _add_middleware(self, mw: Any) -> None:
@ -502,6 +503,7 @@ class SpiderMiddlewareManager(MiddlewareManager):
f"copy-pasting. See the release notes of Scrapy 2.13 for " f"copy-pasting. See the release notes of Scrapy 2.13 for "
f"details: https://docs.scrapy.org/en/2.13/news.html", f"details: https://docs.scrapy.org/en/2.13/news.html",
ScrapyDeprecationWarning, ScrapyDeprecationWarning,
stacklevel=2,
) )
if ( if (

View File

@ -663,7 +663,7 @@ class CrawlerProcessBase(CrawlerRunnerBase):
resolver_class = load_object(self.settings["DNS_RESOLVER"]) resolver_class = load_object(self.settings["DNS_RESOLVER"])
# We pass self, which is CrawlerProcess, instead of Crawler here, # We pass self, which is CrawlerProcess, instead of Crawler here,
# which works because the default resolvers only use crawler.settings. # which works because the default resolvers only use crawler.settings.
resolver = build_from_crawler(resolver_class, self, reactor=reactor) # type: ignore[arg-type] resolver = build_from_crawler(resolver_class, self, reactor=reactor) # type: ignore[call-overload]
resolver.install_on_reactor() resolver.install_on_reactor()
tp = reactor.getThreadPool() tp = reactor.getThreadPool()
tp.adjustPoolsize(maxthreads=self.settings.getint("REACTOR_THREADPOOL_MAXSIZE")) tp.adjustPoolsize(maxthreads=self.settings.getint("REACTOR_THREADPOOL_MAXSIZE"))

View File

@ -139,7 +139,7 @@ class CookiesMiddleware:
for key in ("name", "value", "path", "domain"): for key in ("name", "value", "path", "domain"):
value = cookie.get(key) value = cookie.get(key)
if value is None: if value is None:
if key in ("name", "value"): if key in {"name", "value"}:
msg = f"Invalid cookie found in request {request}: {cookie} ('{key}' is missing)" msg = f"Invalid cookie found in request {request}: {cookie} ('{key}' is missing)"
logger.warning(msg) logger.warning(msg)
return None return None
@ -176,7 +176,7 @@ class CookiesMiddleware:
Extract cookies from the Request.cookies attribute Extract cookies from the Request.cookies attribute
""" """
if not request.cookies: if not request.cookies:
return [] return ()
cookies: Iterable[VerboseCookie] cookies: Iterable[VerboseCookie]
if isinstance(request.cookies, dict): if isinstance(request.cookies, dict):
cookies = tuple({"name": k, "value": v} for k, v in request.cookies.items()) cookies = tuple({"name": k, "value": v} for k, v in request.cookies.items())

View File

@ -107,7 +107,7 @@ class HttpCacheMiddleware:
return response return response
# Skip cached responses and uncacheable requests # Skip cached responses and uncacheable requests
if "cached" in response.flags or "_dont_cache" in request.meta: if "_dont_cache" in request.meta or "cached" in response.flags:
request.meta.pop("_dont_cache", None) request.meta.pop("_dont_cache", None)
return response return response

View File

@ -40,12 +40,13 @@ except ImportError:
pass pass
else: else:
try: try:
brotli.Decompressor.can_accept_more_data brotli.Decompressor.can_accept_more_data # noqa: B018
except AttributeError: # pragma: no cover except AttributeError: # pragma: no cover
warnings.warn( warnings.warn(
"You have brotli installed. But 'br' encoding support now requires " "You have brotli installed. But 'br' encoding support now requires "
"brotli's or brotlicffi's version >= 1.2.0. Please upgrade " "brotli's or brotlicffi's version >= 1.2.0. Please upgrade "
"brotli/brotlicffi to make Scrapy decode 'br' encoded responses.", "brotli/brotlicffi to make Scrapy decode 'br' encoded responses.",
stacklevel=2,
) )
else: else:
ACCEPTED_ENCODINGS.append(b"br") ACCEPTED_ENCODINGS.append(b"br")

View File

@ -69,7 +69,7 @@ class HttpProxyMiddleware:
_scheme = parsed.scheme _scheme = parsed.scheme
if ( if (
# 'no_proxy' is only supported by http schemes # 'no_proxy' is only supported by http schemes
_scheme not in ("http", "https") _scheme not in {"http", "https"}
or (parsed.hostname and not proxy_bypass(parsed.hostname)) or (parsed.hostname and not proxy_bypass(parsed.hostname))
) and _scheme in self.proxies: ) and _scheme in self.proxies:
scheme = _scheme scheme = _scheme

View File

@ -86,13 +86,13 @@ class OffsiteMiddleware:
"allowed_domains accepts only domains, not URLs. " "allowed_domains accepts only domains, not URLs. "
f"Ignoring URL entry {domain} in allowed_domains." f"Ignoring URL entry {domain} in allowed_domains."
) )
warnings.warn(message) warnings.warn(message, stacklevel=2)
elif port_pattern.search(domain): elif port_pattern.search(domain):
message = ( message = (
"allowed_domains accepts only domains without ports. " "allowed_domains accepts only domains without ports. "
f"Ignoring entry {domain} in allowed_domains." f"Ignoring entry {domain} in allowed_domains."
) )
warnings.warn(message) warnings.warn(message, stacklevel=2)
else: else:
domains.append(re.escape(domain)) domains.append(re.escape(domain))
regex = rf"^(.*\.)?({'|'.join(domains)})$" regex = rf"^(.*\.)?({'|'.join(domains)})$"

View File

@ -71,7 +71,7 @@ class BaseRedirectMiddleware:
return return
redirect_cls = global_object_name(self.__class__) redirect_cls = global_object_name(self.__class__)
referer_cls = global_object_name(RefererMiddleware) referer_cls = global_object_name(RefererMiddleware)
if self.__class__ in (RedirectMiddleware, MetaRefreshMiddleware): if self.__class__ in {RedirectMiddleware, MetaRefreshMiddleware}:
replacement = ( replacement = (
f"replace {redirect_cls} with a subclass that overrides the " f"replace {redirect_cls} with a subclass that overrides the "
f"handle_referer() method" f"handle_referer() method"
@ -208,14 +208,19 @@ class RedirectMiddleware(BaseRedirectMiddleware):
if ( if (
request.meta.get("dont_redirect", False) request.meta.get("dont_redirect", False)
or response.status or response.status
in getattr(self.crawler.spider, "handle_httpstatus_list", []) in getattr(self.crawler.spider, "handle_httpstatus_list", ())
or response.status in request.meta.get("handle_httpstatus_list", []) or response.status in request.meta.get("handle_httpstatus_list", ())
or request.meta.get("handle_httpstatus_all", False) or request.meta.get("handle_httpstatus_all", False)
): ):
return response return response
allowed_status = (301, 302, 303, 307, 308) if "Location" not in response.headers or response.status not in {
if "Location" not in response.headers or response.status not in allowed_status: 301,
302,
303,
307,
308,
}:
return response return response
assert response.headers["Location"] is not None assert response.headers["Location"] is not None
@ -235,8 +240,8 @@ class RedirectMiddleware(BaseRedirectMiddleware):
if urlparse_cached(redirected).scheme not in {"http", "https"}: if urlparse_cached(redirected).scheme not in {"http", "https"}:
return response return response
if (response.status in (301, 302) and request.method == "POST") or ( if (response.status in {301, 302} and request.method == "POST") or (
response.status == 303 and request.method not in ("GET", "HEAD") response.status == 303 and request.method not in {"GET", "HEAD"}
): ):
redirected = self._redirect_request_using_get( redirected = self._redirect_request_using_get(
request, response, redirected_url request, response, redirected_url

View File

@ -200,7 +200,7 @@ class S3FeedStorage(BlockingFeedStorage):
try: try:
import boto3.session # noqa: PLC0415 import boto3.session # noqa: PLC0415
except ImportError: except ImportError:
raise NotConfigured("missing boto3 library") raise NotConfigured("missing boto3 library") from None
u = urlparse(uri) u = urlparse(uri)
assert u.hostname assert u.hostname
self.bucketname: str = u.hostname self.bucketname: str = u.hostname
@ -250,10 +250,19 @@ class S3FeedStorage(BlockingFeedStorage):
def _store_in_thread(self, file: IO[bytes]) -> None: def _store_in_thread(self, file: IO[bytes]) -> None:
file.seek(0) file.seek(0)
kwargs: dict[str, Any] = {"ExtraArgs": {"ACL": self.acl}} if self.acl else {} if self.acl:
self.s3_client.upload_fileobj( self.s3_client.upload_fileobj(
Bucket=self.bucketname, Key=self.keyname, Fileobj=file, **kwargs Bucket=self.bucketname,
) Key=self.keyname,
Fileobj=file,
ExtraArgs={"ACL": self.acl},
)
else:
self.s3_client.upload_fileobj(
Bucket=self.bucketname,
Key=self.keyname,
Fileobj=file,
)
file.close() file.close()
@ -468,9 +477,13 @@ class FeedExporter:
# End: Backward compatibility for FEED_URI and FEED_FORMAT settings # End: Backward compatibility for FEED_URI and FEED_FORMAT settings
# 'FEEDS' setting takes precedence over 'FEED_URI' # 'FEEDS' setting takes precedence over 'FEED_URI'
for uri, feed_options in self.settings.getdict("FEEDS").items(): for settings_uri, feed_options in self.settings.getdict("FEEDS").items():
# handle pathlib.Path objects # handle pathlib.Path objects
uri = str(uri) if not isinstance(uri, Path) else uri.absolute().as_uri() uri = (
str(settings_uri)
if not isinstance(settings_uri, Path)
else settings_uri.absolute().as_uri()
)
self.feeds[uri] = feed_complete_default_values_from_settings( self.feeds[uri] = feed_complete_default_values_from_settings(
feed_options, self.settings feed_options, self.settings
) )
@ -692,9 +705,7 @@ class FeedExporter:
uri_params_function: str | UriParamsCallableT | None, uri_params_function: str | UriParamsCallableT | None,
slot: FeedSlot | None = None, slot: FeedSlot | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
params = {} params = {k: getattr(spider, k) for k in dir(spider)}
for k in dir(spider):
params[k] = getattr(spider, k)
utc_now = datetime.now(tz=timezone.utc) utc_now = datetime.now(tz=timezone.utc)
params["time"] = utc_now.replace(microsecond=0).isoformat().replace(":", "-") params["time"] = utc_now.replace(microsecond=0).isoformat().replace(":", "-")
params["batch_time"] = utc_now.isoformat().replace(":", "-") params["batch_time"] = utc_now.isoformat().replace(":", "-")

View File

@ -106,10 +106,10 @@ class RFC2616Policy:
if b"max-age" in cc or b"Expires" in response.headers: if b"max-age" in cc or b"Expires" in response.headers:
return True return True
# Firefox fallbacks this statuses to one year expiration if none is set # Firefox fallbacks this statuses to one year expiration if none is set
if response.status in (300, 301, 308): if response.status in {300, 301, 308}:
return True return True
# Other statuses without expiration requires at least one validator # Other statuses without expiration requires at least one validator
if response.status in (200, 203, 401): if response.status in {200, 203, 401}:
return b"Last-Modified" in response.headers or b"ETag" in response.headers return b"Last-Modified" in response.headers or b"ETag" in response.headers
# Any other is probably not eligible for caching # Any other is probably not eligible for caching
# Makes no sense to cache responses that does not contain expiration # Makes no sense to cache responses that does not contain expiration
@ -216,7 +216,7 @@ class RFC2616Policy:
return (date - lastmodified) / 10 return (date - lastmodified) / 10
# This request can be cached indefinitely # This request can be cached indefinitely
if response.status in (300, 301, 308): if response.status in {300, 301, 308}:
return self.MAXAGE return self.MAXAGE
# Insufficient information to compute freshness lifetime # Insufficient information to compute freshness lifetime

View File

@ -39,8 +39,8 @@ class MemoryUsage:
try: try:
# stdlib's resource module is only available on unix platforms. # stdlib's resource module is only available on unix platforms.
self.resource = import_module("resource") self.resource = import_module("resource")
except ImportError: except ImportError as exc:
raise NotConfigured raise NotConfigured from exc
self.crawler: Crawler = crawler self.crawler: Crawler = crawler
self.warned: bool = False self.warned: bool = False

View File

@ -10,6 +10,7 @@ from scrapy.utils.asyncio import AsyncioLoopingCall, create_looping_call
from scrapy.utils.serialize import ScrapyJSONEncoder from scrapy.utils.serialize import ScrapyJSONEncoder
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Sequence
from json import JSONEncoder from json import JSONEncoder
from twisted.internet.task import LoopingCall from twisted.internet.task import LoopingCall
@ -31,8 +32,8 @@ class PeriodicLog:
self, self,
stats: StatsCollector, stats: StatsCollector,
interval: float = 60.0, interval: float = 60.0,
ext_stats: dict[str, Any] = {}, ext_stats: dict[str, Any] | None = None,
ext_delta: dict[str, Any] = {}, ext_delta: dict[str, Any] | None = None,
ext_timing_enabled: bool = False, ext_timing_enabled: bool = False,
): ):
self.stats: StatsCollector = stats self.stats: StatsCollector = stats
@ -41,11 +42,19 @@ class PeriodicLog:
self.task: AsyncioLoopingCall | LoopingCall | None = None self.task: AsyncioLoopingCall | LoopingCall | None = None
self.encoder: JSONEncoder = ScrapyJSONEncoder(sort_keys=True, indent=4) self.encoder: JSONEncoder = ScrapyJSONEncoder(sort_keys=True, indent=4)
self.ext_stats_enabled: bool = bool(ext_stats) self.ext_stats_enabled: bool = bool(ext_stats)
self.ext_stats_include: list[str] = ext_stats.get("include", []) self.ext_stats_include: Sequence[str] = (
self.ext_stats_exclude: list[str] = ext_stats.get("exclude", []) ext_stats.get("include", ()) if ext_stats else ()
)
self.ext_stats_exclude: Sequence[str] = (
ext_stats.get("exclude", ()) if ext_stats else ()
)
self.ext_delta_enabled: bool = bool(ext_delta) self.ext_delta_enabled: bool = bool(ext_delta)
self.ext_delta_include: list[str] = ext_delta.get("include", []) self.ext_delta_include: Sequence[str] = (
self.ext_delta_exclude: list[str] = ext_delta.get("exclude", []) ext_delta.get("include", ()) if ext_delta else ()
)
self.ext_delta_exclude: Sequence[str] = (
ext_delta.get("exclude", ()) if ext_delta else ()
)
self.ext_timing_enabled: bool = ext_timing_enabled self.ext_timing_enabled: bool = ext_timing_enabled
@classmethod @classmethod
@ -143,7 +152,7 @@ class PeriodicLog:
return {"stats": stats} return {"stats": stats}
def param_allowed( def param_allowed(
self, stat_name: str, include: list[str], exclude: list[str] self, stat_name: str, include: Sequence[str], exclude: Sequence[str]
) -> bool: ) -> bool:
if not include and not exclude: if not include and not exclude:
return True return True

View File

@ -25,6 +25,7 @@ if TYPE_CHECKING:
warnings.warn( warnings.warn(
"The scrapy.extensions.statsmailer module is deprecated and will be " "The scrapy.extensions.statsmailer module is deprecated and will be "
"removed in a future release.", "removed in a future release.",
stacklevel=2,
category=ScrapyDeprecationWarning, category=ScrapyDeprecationWarning,
) )

View File

@ -54,14 +54,14 @@ class CookieJar:
if not IPV4_RE.search(req_host): if not IPV4_RE.search(req_host):
hosts = potential_domain_matches(req_host) hosts = potential_domain_matches(req_host)
if "." not in req_host: if "." not in req_host:
hosts += [req_host + ".local"] hosts.append(req_host + ".local")
else: else:
hosts = [req_host] hosts = [req_host]
cookies = [] cookies = []
for host in hosts: for host in hosts:
if host in self.jar._cookies: # type: ignore[attr-defined] if host in self.jar._cookies: # type: ignore[attr-defined]
cookies += self.jar._cookies_for_domain(host, wreq) # type: ignore[attr-defined] cookies.extend(self.jar._cookies_for_domain(host, wreq)) # type: ignore[attr-defined]
attrs = self.jar._cookie_attrs(cookies) # type: ignore[attr-defined] attrs = self.jar._cookie_attrs(cookies) # type: ignore[attr-defined]
if attrs and not wreq.has_header("Cookie"): if attrs and not wreq.has_header("Cookie"):

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, TypeAlias, cast from typing import TYPE_CHECKING, Any, ClassVar, 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
@ -39,7 +39,7 @@ FormdataType: TypeAlias = dict[str, FormdataVType] | list[FormdataKVType] | None
class FormRequest(Request): class FormRequest(Request):
__slots__ = () __slots__ = ()
valid_form_methods = ["GET", "POST"] valid_form_methods: ClassVar[list[str]] = ["GET", "POST"]
def __init__( def __init__(
self, *args: Any, formdata: FormdataType = None, **kwargs: Any self, *args: Any, formdata: FormdataType = None, **kwargs: Any
@ -153,7 +153,7 @@ def _get_form(
try: try:
form = forms[formnumber] form = forms[formnumber]
except IndexError: except IndexError:
raise IndexError(f"Form number {formnumber} not found in {response}") raise IndexError(f"Form number {formnumber} not found in {response}") from None
return cast("FormElement", form) return cast("FormElement", form)
@ -167,7 +167,7 @@ def _get_inputs(
try: try:
formdata_keys = dict(formdata or ()).keys() formdata_keys = dict(formdata or ()).keys()
except (ValueError, TypeError): except (ValueError, TypeError):
raise ValueError("formdata should be a dict or iterable of tuples") raise ValueError("formdata should be a dict or iterable of tuples") from None
if not formdata: if not formdata:
formdata = [] formdata = []

View File

@ -36,7 +36,9 @@ class JsonRequest(Request):
data_passed: bool = data is not None data_passed: bool = data is not None
if body_passed and data_passed: if body_passed and data_passed:
warnings.warn("Both body and data passed. data will be ignored") warnings.warn(
"Both body and data passed. data will be ignored", stacklevel=2
)
elif not body_passed and data_passed: elif not body_passed and data_passed:
kwargs["body"] = self._dumps(data) kwargs["body"] = self._dumps(data)
if "method" not in kwargs: if "method" not in kwargs:
@ -68,7 +70,9 @@ class JsonRequest(Request):
data_passed: bool = data is not None data_passed: bool = data is not None
if body_passed and data_passed: if body_passed and data_passed:
warnings.warn("Both body and data passed. data will be ignored") warnings.warn(
"Both body and data passed. data will be ignored", stacklevel=2
)
elif not body_passed and data_passed: elif not body_passed and data_passed:
kwargs["body"] = self._dumps(data) kwargs["body"] = self._dumps(data)

View File

@ -86,7 +86,7 @@ class Response(object_ref):
raise AttributeError( raise AttributeError(
"Response.cb_kwargs not available, this response " "Response.cb_kwargs not available, this response "
"is not tied to any request" "is not tied to any request"
) ) from None
@property @property
def meta(self) -> dict[str, Any]: def meta(self) -> dict[str, Any]:
@ -95,7 +95,7 @@ class Response(object_ref):
except AttributeError: except AttributeError:
raise AttributeError( raise AttributeError(
"Response.meta not available, this response is not tied to any request" "Response.meta not available, this response is not tied to any request"
) ) from None
@property @property
def url(self) -> str: def url(self) -> str:

View File

@ -297,7 +297,7 @@ def _url_from_selector(sel: parsel.Selector) -> str:
return strip_html5_whitespace(sel.root) return strip_html5_whitespace(sel.root)
if not hasattr(sel.root, "tag"): if not hasattr(sel.root, "tag"):
raise _InvalidSelector(f"Unsupported selector: {sel}") raise _InvalidSelector(f"Unsupported selector: {sel}")
if sel.root.tag not in ("a", "link"): if sel.root.tag not in {"a", "link"}:
raise _InvalidSelector( raise _InvalidSelector(
f"Only <a> and <link> elements are supported; got <{sel.root.tag}>" f"Only <a> and <link> elements are supported; got <{sel.root.tag}>"
) )

View File

@ -114,8 +114,8 @@ class LxmlParserLinkExtractor:
# pseudo lxml.html.HtmlElement.make_links_absolute(base_url) # pseudo lxml.html.HtmlElement.make_links_absolute(base_url)
try: try:
if self.strip: if self.strip:
attr_val = strip_html5_whitespace(attr_val) attr_val = strip_html5_whitespace(attr_val) # noqa: PLW2901 this is intended
attr_val = urljoin(base_url, attr_val) attr_val = urljoin(base_url, attr_val) # noqa: PLW2901
except ValueError: except ValueError:
continue # skipping bogus links continue # skipping bogus links
else: else:
@ -245,7 +245,7 @@ class LxmlLinkExtractor:
if self.allow_res if self.allow_res
else [True] else [True]
) )
denied = (regex.search(url) for regex in self.deny_res) if self.deny_res else [] denied = (regex.search(url) for regex in self.deny_res) if self.deny_res else ()
return any(allowed) and not any(denied) return any(allowed) and not any(denied)
def _process_links(self, links: list[Link]) -> list[Link]: def _process_links(self, links: list[Link]) -> list[Link]:

View File

@ -55,6 +55,7 @@ def _to_bytes_or_none(text: str | bytes | None) -> bytes | None:
warnings.warn( warnings.warn(
"The scrapy.mail module is deprecated and will be removed in a future release. " "The scrapy.mail module is deprecated and will be removed in a future release. "
"Please use a dedicated Python mail library instead.", "Please use a dedicated Python mail library instead.",
stacklevel=2,
category=ScrapyDeprecationWarning, category=ScrapyDeprecationWarning,
) )

View File

@ -18,7 +18,7 @@ from contextlib import suppress
from ftplib import FTP from ftplib import FTP
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import IO, TYPE_CHECKING, Any, NoReturn, Protocol, TypedDict, cast from typing import IO, TYPE_CHECKING, Any, ClassVar, NoReturn, Protocol, TypedDict, cast
from urllib.parse import urlparse from urllib.parse import urlparse
from itemadapter import ItemAdapter from itemadapter import ItemAdapter
@ -161,7 +161,7 @@ class S3FilesStore:
AWS_VERIFY = None AWS_VERIFY = None
POLICY = "private" # Overridden from settings.FILES_STORE_S3_ACL in FilesPipeline.from_crawler() POLICY = "private" # Overridden from settings.FILES_STORE_S3_ACL in FilesPipeline.from_crawler()
HEADERS = { HEADERS: ClassVar[dict[str, str]] = {
"Cache-Control": "max-age=172800", "Cache-Control": "max-age=172800",
} }
@ -226,7 +226,7 @@ class S3FilesStore:
Bucket=self.bucket, Bucket=self.bucket,
Key=key_name, Key=key_name,
Body=buf, Body=buf,
Metadata={k: str(v) for k, v in (meta or {}).items()}, Metadata={k: str(v) for k, v in meta.items()} if meta else {},
ACL=self.POLICY, ACL=self.POLICY,
**extra, **extra,
) )
@ -269,7 +269,9 @@ class S3FilesStore:
try: try:
kwarg = mapping[key] kwarg = mapping[key]
except KeyError: except KeyError:
raise TypeError(f'Header "{key}" is not supported by botocore') raise TypeError(
f'Header "{key}" is not supported by botocore'
) from None
extra[kwarg] = value extra[kwarg] = value
return extra return extra
@ -339,7 +341,7 @@ class GCSFilesStore:
blob_path = self._get_blob_path(path) blob_path = self._get_blob_path(path)
blob = self.bucket.blob(blob_path) blob = self.bucket.blob(blob_path)
blob.cache_control = self.CACHE_CONTROL blob.cache_control = self.CACHE_CONTROL
blob.metadata = {k: str(v) for k, v in (meta or {}).items()} blob.metadata = {k: str(v) for k, v in meta.items()} if meta else {}
return deferred_from_coro( return deferred_from_coro(
run_in_thread( run_in_thread(
blob.upload_from_string, blob.upload_from_string,
@ -435,7 +437,7 @@ class FilesPipeline(MediaPipeline):
MEDIA_NAME: str = "file" MEDIA_NAME: str = "file"
EXPIRES: int = 90 EXPIRES: int = 90
STORE_SCHEMES: dict[str, type[FilesStoreProtocol]] = { STORE_SCHEMES: ClassVar[dict[str, type[FilesStoreProtocol]]] = {
"": FSFilesStore, "": FSFilesStore,
"file": FSFilesStore, "file": FSFilesStore,
"s3": S3FilesStore, "s3": S3FilesStore,
@ -656,7 +658,7 @@ class FilesPipeline(MediaPipeline):
exc_info=True, exc_info=True,
extra={"spider": info.spider}, extra={"spider": info.spider},
) )
raise FileException(str(exc)) raise FileException(str(exc)) from exc
return { return {
"url": request.url, "url": request.url,

View File

@ -11,7 +11,7 @@ import hashlib
import warnings import warnings
from contextlib import suppress from contextlib import suppress
from io import BytesIO from io import BytesIO
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, ClassVar
from itemadapter import ItemAdapter from itemadapter import ItemAdapter
@ -49,7 +49,7 @@ class ImagesPipeline(FilesPipeline):
MIN_WIDTH: int = 0 MIN_WIDTH: int = 0
MIN_HEIGHT: int = 0 MIN_HEIGHT: int = 0
EXPIRES: int = 90 EXPIRES: int = 90
THUMBS: dict[str, tuple[int, int]] = {} THUMBS: ClassVar[dict[str, tuple[int, int]]] = {}
DEFAULT_IMAGES_URLS_FIELD = "image_urls" DEFAULT_IMAGES_URLS_FIELD = "image_urls"
DEFAULT_IMAGES_RESULT_FIELD = "images" DEFAULT_IMAGES_RESULT_FIELD = "images"
@ -76,7 +76,7 @@ class ImagesPipeline(FilesPipeline):
except ImportError: except ImportError:
raise NotConfigured( raise NotConfigured(
"ImagesPipeline requires installing Pillow 8.3.2 or later" "ImagesPipeline requires installing Pillow 8.3.2 or later"
) ) from None
super().__init__(store_uri, crawler=crawler) super().__init__(store_uri, crawler=crawler)
@ -191,7 +191,7 @@ class ImagesPipeline(FilesPipeline):
*, *,
response_body: BytesIO, response_body: BytesIO,
) -> tuple[Image.Image, BytesIO]: ) -> tuple[Image.Image, BytesIO]:
if image.format in ("PNG", "WEBP") and image.mode == "RGBA": if image.format in {"PNG", "WEBP"} and image.mode == "RGBA":
background = self._Image.new("RGBA", image.size, (255, 255, 255)) background = self._Image.new("RGBA", image.size, (255, 255, 255))
background.paste(image, image) background.paste(image, image)
image = background.convert("RGB") image = background.convert("RGB")

View File

@ -230,9 +230,9 @@ class MediaPipeline(ABC):
if isinstance(result, Failure): if isinstance(result, Failure):
# minimize cached information for failure # minimize cached information for failure
result.cleanFailure() result.cleanFailure()
result.frames = [] result.frames.clear()
if TWISTED_FAILURE_HAS_STACK: if TWISTED_FAILURE_HAS_STACK:
result.stack = [] # type: ignore[method-assign] result.stack.clear()
# This code fixes a memory leak by avoiding to keep references to # This code fixes a memory leak by avoiding to keep references to
# the Request and Response objects on the Media Pipeline cache. # the Request and Response objects on the Media Pipeline cache.
# #

View File

@ -350,8 +350,9 @@ class DownloaderAwarePriorityQueue:
self.crawler: Crawler = crawler self.crawler: Crawler = crawler
self.pqueues: dict[str, ScrapyPriorityQueue] = {} # slot -> priority queue self.pqueues: dict[str, ScrapyPriorityQueue] = {} # slot -> priority queue
for slot, startprios in (slot_startprios or {}).items(): if slot_startprios:
self.pqueues[slot] = self.pqfactory(slot, startprios) for slot, startprios in slot_startprios.items():
self.pqueues[slot] = self.pqfactory(slot, startprios)
def pqfactory( def pqfactory(
self, slot: str, startprios: Iterable[int] = () self, slot: str, startprios: Iterable[int] = ()

View File

@ -8,7 +8,7 @@ from __future__ import annotations
from io import StringIO from io import StringIO
from mimetypes import MimeTypes from mimetypes import MimeTypes
from pkgutil import get_data from pkgutil import get_data
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, ClassVar
from scrapy.http import Response from scrapy.http import Response
from scrapy.utils.misc import load_object from scrapy.utils.misc import load_object
@ -19,7 +19,7 @@ if TYPE_CHECKING:
class ResponseTypes: class ResponseTypes:
CLASSES = { CLASSES: ClassVar[dict[str, str]] = {
"text/html": "scrapy.http.HtmlResponse", "text/html": "scrapy.http.HtmlResponse",
"application/atom+xml": "scrapy.http.XmlResponse", "application/atom+xml": "scrapy.http.XmlResponse",
"application/rdf+xml": "scrapy.http.XmlResponse", "application/rdf+xml": "scrapy.http.XmlResponse",

View File

@ -184,15 +184,15 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
try: try:
return bool(int(got)) return bool(int(got))
except ValueError: except ValueError:
if got in ("True", "true"): if got in {"True", "true"}:
return True return True
if got in ("False", "false"): if got in {"False", "false"}:
return False return False
raise ValueError( raise ValueError(
"Supported values for boolean settings " "Supported values for boolean settings "
"are 0/1, True/False, '0'/'1', " "are 0/1, True/False, '0'/'1', "
"'True'/'False' and 'true'/'false'" "'True'/'False' and 'true'/'false'"
) ) from None
def getint(self, name: _SettingsKey, default: int = 0) -> int: def getint(self, name: _SettingsKey, default: int = 0) -> int:
""" """

View File

@ -77,6 +77,7 @@ class SpiderLoader:
warnings.warn( warnings.warn(
"There are several spiders with the same name:\n\n" "There are several spiders with the same name:\n\n"
f"{dupes_string}\n\n This can cause unexpected behavior.", f"{dupes_string}\n\n This can cause unexpected behavior.",
stacklevel=2,
category=UserWarning, category=UserWarning,
) )
@ -96,6 +97,7 @@ class SpiderLoader:
f"\n{traceback.format_exc()}Could not load spiders " f"\n{traceback.format_exc()}Could not load spiders "
f"from module '{name}'. " f"from module '{name}'. "
"See above traceback for details.", "See above traceback for details.",
stacklevel=2,
category=RuntimeWarning, category=RuntimeWarning,
) )
else: else:
@ -114,7 +116,7 @@ class SpiderLoader:
try: try:
return self._spiders[spider_name] return self._spiders[spider_name]
except KeyError: except KeyError:
raise KeyError(f"Spider not found: {spider_name}") raise KeyError(f"Spider not found: {spider_name}") from None
def find_by_request(self, request: Request) -> list[str]: def find_by_request(self, request: Request) -> list[str]:
""" """

View File

@ -88,5 +88,5 @@ class HttpErrorMiddleware:
{"response": response}, {"response": response},
extra={"spider": self.crawler.spider}, extra={"spider": self.crawler.spider},
) )
return [] return ()
return None return None

View File

@ -103,7 +103,7 @@ class ReferrerPolicy(ABC):
return self.tls_protected(url) return self.tls_protected(url)
def tls_protected(self, url: str) -> bool: def tls_protected(self, url: str) -> bool:
return urlparse(url).scheme in ("https", "ftps") return urlparse(url).scheme in {"https", "ftps"}
class NoReferrerPolicy(ReferrerPolicy): class NoReferrerPolicy(ReferrerPolicy):
@ -424,7 +424,7 @@ class RefererMiddleware(BaseSpiderMiddleware):
msg += " (import paths from the response Referrer-Policy header are not allowed)" msg += " (import paths from the response Referrer-Policy header are not allowed)"
if not warning_only: if not warning_only:
raise RuntimeError(msg) raise RuntimeError(msg)
warnings.warn(msg, RuntimeWarning) warnings.warn(msg, RuntimeWarning, stacklevel=2)
return None return None
def get_processed_request( def get_processed_request(

View File

@ -102,11 +102,12 @@ class CrawlSpider(Spider):
self._compile_rules() self._compile_rules()
if method_is_overridden(self.__class__, CrawlSpider, "_parse_response"): if method_is_overridden(self.__class__, CrawlSpider, "_parse_response"):
warnings.warn( warnings.warn(
f"The CrawlSpider._parse_response method, which the " "The CrawlSpider._parse_response method, which the "
f"{global_object_name(self.__class__)} class overrides, is " f"{global_object_name(self.__class__)} class overrides, is "
f"deprecated: it will be removed in future Scrapy releases. " "deprecated: it will be removed in future Scrapy releases. "
f"Please override the CrawlSpider.parse_with_rules method " "Please override the CrawlSpider.parse_with_rules method "
f"instead." "instead.",
stacklevel=2,
) )
def _parse(self, response: Response, **kwargs: Any) -> Any: def _parse(self, response: Response, **kwargs: Any) -> Any:
@ -118,7 +119,7 @@ class CrawlSpider(Spider):
) )
def parse_start_url(self, response: Response, **kwargs: Any) -> Any: def parse_start_url(self, response: Response, **kwargs: Any) -> Any:
return [] return ()
def process_results( def process_results(
self, response: Response, results: Iterable[Any] self, response: Response, results: Iterable[Any]
@ -209,8 +210,9 @@ class CrawlSpider(Spider):
def _compile_rules(self) -> None: def _compile_rules(self) -> None:
self._rules = [] self._rules = []
for rule in self.rules: for rule in self.rules:
self._rules.append(copy.copy(rule)) copied_rule = copy.copy(rule)
self._rules[-1]._compile(self) copied_rule._compile(self)
self._rules.append(copied_rule)
@classmethod @classmethod
def from_crawler(cls, crawler: Crawler, *args: Any, **kwargs: Any) -> Self: def from_crawler(cls, crawler: Crawler, *args: Any, **kwargs: Any) -> Self:

View File

@ -49,7 +49,7 @@ class SitemapSpider(Spider):
self._cbs: list[tuple[re.Pattern[str], CallbackT]] = [] self._cbs: list[tuple[re.Pattern[str], CallbackT]] = []
for r, c in self.sitemap_rules: for r, c in self.sitemap_rules:
if isinstance(c, str): if isinstance(c, str):
c = cast("CallbackT", getattr(self, c)) c = cast("CallbackT", getattr(self, c)) # noqa: PLW2901
self._cbs.append((regex(r), c)) self._cbs.append((regex(r), c))
self._follow: list[re.Pattern[str]] = [regex(x) for x in self.sitemap_follow] self._follow: list[re.Pattern[str]] = [regex(x) for x in self.sitemap_follow]

View File

@ -13,7 +13,7 @@ KnownShellsT = dict[str, Callable[..., EmbedFuncT]]
def _embed_ipython_shell( def _embed_ipython_shell(
namespace: dict[str, Any] = {}, banner: str = "" namespace: dict[str, Any] | None = None, banner: str = ""
) -> EmbedFuncT: ) -> EmbedFuncT:
"""Start an IPython Shell""" """Start an IPython Shell"""
try: try:
@ -28,7 +28,7 @@ def _embed_ipython_shell(
) )
@wraps(_embed_ipython_shell) @wraps(_embed_ipython_shell)
def wrapper(namespace: dict[str, Any] = namespace, banner: str = "") -> None: def wrapper(namespace: dict[str, Any] = namespace or {}, banner: str = "") -> None:
config = load_default_config() # type: ignore[no-untyped-call] config = load_default_config() # type: ignore[no-untyped-call]
# Always use .instance() to ensure _instance propagation to all parents # Always use .instance() to ensure _instance propagation to all parents
# this is needed for <TAB> completion works well for new imports # this is needed for <TAB> completion works well for new imports
@ -44,26 +44,26 @@ def _embed_ipython_shell(
def _embed_bpython_shell( def _embed_bpython_shell(
namespace: dict[str, Any] = {}, banner: str = "" namespace: dict[str, Any] | None = None, banner: str = ""
) -> EmbedFuncT: ) -> EmbedFuncT:
"""Start a bpython shell""" """Start a bpython shell"""
import bpython # noqa: PLC0415 import bpython # noqa: PLC0415
@wraps(_embed_bpython_shell) @wraps(_embed_bpython_shell)
def wrapper(namespace: dict[str, Any] = namespace, banner: str = "") -> None: def wrapper(namespace: dict[str, Any] = namespace or {}, banner: str = "") -> None:
bpython.embed(locals_=namespace, banner=banner) bpython.embed(locals_=namespace, banner=banner)
return wrapper return wrapper
def _embed_ptpython_shell( def _embed_ptpython_shell(
namespace: dict[str, Any] = {}, banner: str = "" namespace: dict[str, Any] | None = None, banner: str = ""
) -> EmbedFuncT: ) -> EmbedFuncT:
"""Start a ptpython shell""" """Start a ptpython shell"""
import ptpython.repl # noqa: PLC0415 # pylint: disable=import-error import ptpython.repl # noqa: PLC0415 # pylint: disable=import-error
@wraps(_embed_ptpython_shell) @wraps(_embed_ptpython_shell)
def wrapper(namespace: dict[str, Any] = namespace, banner: str = "") -> None: def wrapper(namespace: dict[str, Any] = namespace or {}, banner: str = "") -> None:
print(banner) print(banner)
ptpython.repl.embed(locals=namespace) ptpython.repl.embed(locals=namespace)
@ -71,7 +71,7 @@ def _embed_ptpython_shell(
def _embed_standard_shell( def _embed_standard_shell(
namespace: dict[str, Any] = {}, banner: str = "" namespace: dict[str, Any] | None = None, banner: str = ""
) -> EmbedFuncT: ) -> EmbedFuncT:
"""Start a standard python shell""" """Start a standard python shell"""
try: # readline module is only available on unix systems try: # readline module is only available on unix systems
@ -84,7 +84,7 @@ def _embed_standard_shell(
readline.parse_and_bind("tab:complete") # type: ignore[attr-defined,unused-ignore] 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 or {}, banner: str = "") -> None:
code.interact(banner=banner, local=namespace) code.interact(banner=banner, local=namespace)
return wrapper return wrapper

View File

@ -106,7 +106,7 @@ def curl_to_request_kwargs(
if argv: if argv:
msg = f"Unrecognized options: {', '.join(argv)}" msg = f"Unrecognized options: {', '.join(argv)}"
if ignore_unknown_options: if ignore_unknown_options:
warnings.warn(msg) warnings.warn(msg, stacklevel=2)
else: else:
raise ValueError(msg) raise ValueError(msg)

View File

@ -8,6 +8,7 @@ import asyncio
import inspect import inspect
import warnings import warnings
from asyncio import Future from asyncio import Future
from collections import deque
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 ( from typing import (
@ -230,7 +231,7 @@ class _AsyncCooperatorAdapter(Iterator, Generic[_T]):
self.callable_args: tuple[Any, ...] = callable_args self.callable_args: tuple[Any, ...] = callable_args
self.callable_kwargs: dict[str, Any] = callable_kwargs self.callable_kwargs: dict[str, Any] = callable_kwargs
self.finished: bool = False self.finished: bool = False
self.waiting_deferreds: list[Deferred[Any]] = [] self.waiting_deferreds: deque[Deferred[Any]] = deque()
self.anext_deferred: Deferred[_T] | None = None self.anext_deferred: Deferred[_T] | None = None
def _callback(self, result: _T) -> None: def _callback(self, result: _T) -> None:
@ -241,7 +242,7 @@ class _AsyncCooperatorAdapter(Iterator, Generic[_T]):
callable_result = self.callable( callable_result = self.callable(
result, *self.callable_args, **self.callable_kwargs result, *self.callable_args, **self.callable_kwargs
) )
d = self.waiting_deferreds.pop(0) d = self.waiting_deferreds.popleft()
if isinstance(callable_result, Deferred): if isinstance(callable_result, Deferred):
callable_result.chainDeferred(d) callable_result.chainDeferred(d)
else: else:

View File

@ -130,7 +130,7 @@ def create_deprecated_class(
# deprecated class is in jinja2 template). __module__ attribute is not # deprecated class is in jinja2 template). __module__ attribute is not
# important enough to raise an exception as users may be unable # important enough to raise an exception as users may be unable
# to fix inspect.stack() errors. # to fix inspect.stack() errors.
warnings.warn(f"Error detecting parent module: {e!r}") warnings.warn(f"Error detecting parent module: {e!r}", stacklevel=2)
return deprecated_cls return deprecated_cls
@ -160,6 +160,7 @@ def update_classpath(path: Any) -> Any:
warnings.warn( warnings.warn(
f"`{path}` class is deprecated, use `{new_path}` instead", f"`{path}` class is deprecated, use `{new_path}` instead",
ScrapyDeprecationWarning, ScrapyDeprecationWarning,
stacklevel=2,
) )
return new_path return new_path
return path return path

View File

@ -13,7 +13,7 @@ from contextlib import contextmanager
from functools import partial from functools import partial
from importlib import import_module from importlib import import_module
from pkgutil import iter_modules from pkgutil import iter_modules
from typing import IO, TYPE_CHECKING, Any, TypeVar, cast from typing import IO, TYPE_CHECKING, Any, ParamSpec, Protocol, TypeVar, overload
from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.item import Item from scrapy.item import Item
@ -28,9 +28,20 @@ if TYPE_CHECKING:
_ITERABLE_SINGLE_VALUES = dict, Item, str, bytes _ITERABLE_SINGLE_VALUES = dict, Item, str, bytes
T = TypeVar("T") _ITER_T = TypeVar("_ITER_T", bound=dict | Item | str | bytes)
_T = TypeVar("_T")
_T_co = TypeVar("_T_co", covariant=True)
_P = ParamSpec("_P")
@overload
def arg_to_iter(arg: None) -> tuple[()]: ...
@overload
def arg_to_iter(arg: _ITER_T) -> Iterable[_ITER_T]: ...
@overload
def arg_to_iter(arg: Iterable[_T]) -> Iterable[_T]: ...
@overload
def arg_to_iter(arg: _T) -> Iterable[_T]: ...
def arg_to_iter(arg: Any) -> Iterable[Any]: def arg_to_iter(arg: Any) -> Iterable[Any]:
"""Convert an argument to an iterable. The argument can be a None, single """Convert an argument to an iterable. The argument can be a None, single
value, or an iterable. value, or an iterable.
@ -38,9 +49,9 @@ def arg_to_iter(arg: Any) -> Iterable[Any]:
Exception: if arg is a dict, [arg] will be returned Exception: if arg is a dict, [arg] will be returned
""" """
if arg is None: if arg is None:
return [] return ()
if not isinstance(arg, _ITERABLE_SINGLE_VALUES) and hasattr(arg, "__iter__"): if not isinstance(arg, _ITERABLE_SINGLE_VALUES) and hasattr(arg, "__iter__"):
return cast("Iterable[Any]", arg) return arg
return [arg] return [arg]
@ -64,7 +75,7 @@ def load_object(path: str | Callable[..., Any]) -> Any:
try: try:
dot = path.rindex(".") dot = path.rindex(".")
except ValueError: except ValueError:
raise ValueError(f"Error loading object '{path}': not a full path") raise ValueError(f"Error loading object '{path}': not a full path") from None
module, name = path[:dot], path[dot + 1 :] module, name = path[:dot], path[dot + 1 :]
mod = import_module(module) mod = import_module(module)
@ -72,7 +83,9 @@ def load_object(path: str | Callable[..., Any]) -> Any:
try: try:
obj = getattr(mod, name) obj = getattr(mod, name)
except AttributeError: except AttributeError:
raise NameError(f"Module '{module}' doesn't define any object named '{name}'") raise NameError(
f"Module '{module}' doesn't define any object named '{name}'"
) from None
return obj return obj
@ -152,9 +165,40 @@ def rel_has_nofollow(rel: str | None) -> bool:
return rel is not None and "nofollow" in rel.replace(",", " ").split() return rel is not None and "nofollow" in rel.replace(",", " ").split()
class SupportsFromCrawler(Protocol[_T_co, _P]):
@classmethod
def from_crawler(
cls, crawler: Crawler, /, *args: _P.args, **kwargs: _P.kwargs
) -> _T_co: ...
@overload
def build_from_crawler( def build_from_crawler(
objcls: type[T], crawler: Crawler, /, *args: Any, **kwargs: Any objcls: SupportsFromCrawler[_T_co, _P],
) -> T: crawler: Crawler,
/,
*args: _P.args,
**kwargs: _P.kwargs,
) -> _T_co: ...
@overload
def build_from_crawler(
objcls: Callable[_P, _T_co],
crawler: Crawler,
/,
*args: _P.args,
**kwargs: _P.kwargs,
) -> _T_co: ...
def build_from_crawler(
objcls: Any,
crawler: Crawler,
/,
*args: Any,
**kwargs: Any,
) -> Any:
"""Construct a class instance using its ``from_crawler()`` or ``__init__()`` constructor. """Construct a class instance using its ``from_crawler()`` or ``__init__()`` constructor.
.. versionadded:: 2.12 .. versionadded:: 2.12
@ -164,14 +208,14 @@ def build_from_crawler(
Raises ``TypeError`` if the resulting instance is ``None``. Raises ``TypeError`` if the resulting instance is ``None``.
""" """
if hasattr(objcls, "from_crawler"): if hasattr(objcls, "from_crawler"):
instance = objcls.from_crawler(crawler, *args, **kwargs) # type: ignore[attr-defined] instance = objcls.from_crawler(crawler, *args, **kwargs)
method_name = "from_crawler" method_name = "from_crawler"
else: else:
instance = objcls(*args, **kwargs) instance = objcls(*args, **kwargs)
method_name = "__new__" method_name = "__new__"
if instance is None: if instance is None:
raise TypeError(f"{objcls.__qualname__}.{method_name} returned None") raise TypeError(f"{objcls.__qualname__}.{method_name} returned None")
return cast("T", instance) return instance
@contextmanager @contextmanager

View File

@ -20,7 +20,8 @@ def inside_project() -> bool:
import_module(scrapy_module) import_module(scrapy_module)
except ImportError as exc: except ImportError as exc:
warnings.warn( warnings.warn(
f"Cannot import scrapy settings module {scrapy_module}: {exc}" f"Cannot import scrapy settings module {scrapy_module}: {exc}",
stacklevel=2,
) )
else: else:
return True return True

View File

@ -76,7 +76,7 @@ class CallLaterOnce(Generic[_T]):
for d in self._deferreds: for d in self._deferreds:
call_later(0, d.callback, None) call_later(0, d.callback, None)
self._deferreds = [] self._deferreds.clear()
return result return result

View File

@ -169,7 +169,7 @@ def _get_method(obj: Any, name: Any) -> Any:
try: try:
return getattr(obj, name) return getattr(obj, name)
except AttributeError: except AttributeError:
raise ValueError(f"Method {name!r} not found in: {obj}") raise ValueError(f"Method {name!r} not found in: {obj}") from None
def request_to_curl(request: Request) -> str: def request_to_curl(request: Request) -> str:

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import os import os
import sys import sys
import warnings import warnings
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, ClassVar, cast
from twisted.internet.defer import Deferred from twisted.internet.defer import Deferred
from twisted.internet.protocol import ProcessProtocol from twisted.internet.protocol import ProcessProtocol
@ -21,12 +21,13 @@ if TYPE_CHECKING:
warnings.warn( warnings.warn(
"The scrapy.utils.testproc module is deprecated.", "The scrapy.utils.testproc module is deprecated.",
ScrapyDeprecationWarning, ScrapyDeprecationWarning,
stacklevel=2,
) )
class ProcessTest: class ProcessTest:
command: str | None = None command: str | None = None
prefix = [sys.executable, "-m", "scrapy.cmdline"] prefix: ClassVar[list[str]] = [sys.executable, "-m", "scrapy.cmdline"]
cwd = os.getcwd() # trial chdirs to temp dir # noqa: PTH109 cwd = os.getcwd() # trial chdirs to temp dir # noqa: PTH109
def execute( def execute(

View File

@ -9,6 +9,7 @@ from scrapy.exceptions import ScrapyDeprecationWarning
warnings.warn( warnings.warn(
"The scrapy.utils.testsite module is deprecated.", "The scrapy.utils.testsite module is deprecated.",
ScrapyDeprecationWarning, ScrapyDeprecationWarning,
stacklevel=2,
) )

View File

@ -19,13 +19,18 @@ from w3lib.url import parse_url as _parse_url
from scrapy.exceptions import ScrapyDeprecationWarning from scrapy.exceptions import ScrapyDeprecationWarning
_DEPRECATED_NAMES: frozenset[str] = frozenset(
{"_unquotepath", "_safe_chars", "parse_url", *_public_w3lib_objects}
)
def __getattr__(name: str) -> Any: def __getattr__(name: str) -> Any:
if name in ("_unquotepath", "_safe_chars", "parse_url", *_public_w3lib_objects): if name in _DEPRECATED_NAMES:
obj_type = "attribute" if name == "_safe_chars" else "function" obj_type = "attribute" if name == "_safe_chars" else "function"
warnings.warn( warnings.warn(
f"The scrapy.utils.url.{name} {obj_type} is deprecated, use w3lib.url.{name} instead.", f"The scrapy.utils.url.{name} {obj_type} is deprecated, use w3lib.url.{name} instead.",
ScrapyDeprecationWarning, ScrapyDeprecationWarning,
stacklevel=2,
) )
return getattr(import_module("w3lib.url"), name) return getattr(import_module("w3lib.url"), name)
@ -45,15 +50,18 @@ def url_is_from_any_domain(url: UrlT, domains: Iterable[str]) -> bool:
host = _parse_url(url).netloc.lower() host = _parse_url(url).netloc.lower()
if not host: if not host:
return False return False
domains = [d.lower() for d in domains] return any((host == d) or (host.endswith(f".{d}")) for d in map(str.lower, domains))
return any((host == d) or (host.endswith(f".{d}")) for d in domains)
def _spider_domains(spider: type[Spider]) -> Iterable[str]:
yield spider.name
if allowed_domains := getattr(spider, "allowed_domains", None):
yield from allowed_domains
def url_is_from_spider(url: UrlT, spider: type[Spider]) -> bool: def url_is_from_spider(url: UrlT, spider: type[Spider]) -> bool:
"""Return True if the url belongs to the given spider""" """Return True if the url belongs to the given spider"""
return url_is_from_any_domain( return url_is_from_any_domain(url, _spider_domains(spider))
url, [spider.name, *getattr(spider, "allowed_domains", [])]
)
def url_has_any_extension(url: UrlT, extensions: Iterable[str]) -> bool: def url_has_any_extension(url: UrlT, extensions: Iterable[str]) -> bool:
@ -184,11 +192,11 @@ def strip_url(
strip_default_port strip_default_port
and parsed_url.port and parsed_url.port
and (parsed_url.scheme, parsed_url.port) and (parsed_url.scheme, parsed_url.port)
in ( in {
("http", 80), ("http", 80),
("https", 443), ("https", 443),
("ftp", 21), ("ftp", 21),
) }
): ):
netloc = netloc.replace(f":{parsed_url.port}", "") netloc = netloc.replace(f":{parsed_url.port}", "")

View File

@ -88,7 +88,7 @@ class TestHttpErrorMiddleware:
def test_process_spider_exception( def test_process_spider_exception(
self, mw: HttpErrorMiddleware, res404: Response self, mw: HttpErrorMiddleware, res404: Response
) -> None: ) -> None:
assert mw.process_spider_exception(res404, HttpError(res404)) == [] assert mw.process_spider_exception(res404, HttpError(res404)) == ()
assert mw.process_spider_exception(res404, Exception()) is None assert mw.process_spider_exception(res404, Exception()) is None
def test_handle_httpstatus_list( def test_handle_httpstatus_list(