This commit is contained in:
Adrián Chaves 2026-08-15 11:46:48 -05:00 committed by GitHub
commit 7376c5e334
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 396 additions and 45 deletions

View File

@ -341,8 +341,8 @@ list
* Syntax: ``scrapy list``
* Requires project: *yes*
List all available spiders in the current project. The output is one spider per
line.
List all :ref:`spiders <topics-spiders>` available in the current project. The
output is one spider per line.
Usage example::
@ -350,6 +350,9 @@ Usage example::
spider1
spider2
Which spiders are listed depends on the configured
:setting:`SPIDER_LOADER_CLASS`.
.. command:: edit
edit

View File

@ -2083,6 +2083,32 @@ The class that will be used for loading spiders, which must implement the
.. note:: This is a :ref:`pre-crawler setting <pre-crawler-settings>`.
.. setting:: SPIDER_LOADER_REQUIRE_NAME
SPIDER_LOADER_REQUIRE_NAME
--------------------------
Default: ``True``
.. note::
While the default value is ``True`` for historical reasons, this option is
disabled by default in the ``settings.py`` file generated by the
:command:`startproject` command.
By default, when loading spiders, Scrapy only loads
:class:`~scrapy.spiders.Spider` subclasses that have a non-empty
:attr:`~scrapy.Spider.name` unless they are decorated with
:func:`~scrapy.spiders.ignore_spider`.
If :setting:`SPIDER_LOADER_REQUIRE_NAME` is ``False``, Scrapy loads all
:class:`~scrapy.spiders.Spider` subclasses unless they are decorated with
:func:`~scrapy.spiders.ignore_spider`. If they do not have a non-empty
:attr:`~scrapy.Spider.name`, their fully-qualified class name is used
as a name.
.. note:: This is a :ref:`pre-crawler setting <pre-crawler-settings>`.
.. setting:: SPIDER_LOADER_WARN_ONLY
SPIDER_LOADER_WARN_ONLY

View File

@ -40,11 +40,16 @@ scrapy.Spider
.. attribute:: name
A string which defines the name for this spider. The spider name is how
the spider is located (and instantiated) by Scrapy, so it must be
unique. However, nothing prevents you from instantiating more than one
instance of the same spider. This is the most important spider attribute
and it's required.
A string which defines the name for this spider.
If :setting:`SPIDER_LOADER_REQUIRE_NAME` is ``True`` and you use the
default Scrapy spider loader (see :setting:`SPIDER_LOADER_CLASS`), a
non-empty name is required for the spider to be discoverable by the
Scrapy commands :command:`crawl` and :command:`list`.
The spider name must be unique to one spider class. If two or more
spiders have the same name, Scrapy commands :command:`crawl` and
:command:`runspider` will only be able to run one of the spiders.
If the spider scrapes a single domain, a common practice is to name the
spider after the domain, with or without the `TLD`_. So, for example, a
@ -1002,3 +1007,29 @@ Combine SitemapSpider with other sources of urls:
.. _robots.txt: https://www.robotstxt.org/
.. _TLD: https://en.wikipedia.org/wiki/Top-level_domain
.. _Scrapyd documentation: https://scrapyd.readthedocs.io/en/latest/
Base spiders
============
Base spiders are :class:`~scrapy.spiders.Spider` subclasses that are not meant
to be run by Scrapy. They are only meant to be subclassed to create regular
spiders or other base spiders. They are one way to share code between two or
more spiders.
Use the :func:`~scrapy.spiders.ignore_spider` decorator to mark any base spider
class:
.. autodecorator:: scrapy.spiders.ignore_spider
For example::
from scrapy.spiders import ignore_spider, Spider
@ignore_spider
class MyBaseSpider(Spider):
pass
If :setting:`SPIDER_LOADER_REQUIRE_NAME` is ``True`` (default), any
:class:`~scrapy.spiders.Spider` subclass without a ``name`` class attribute is
also ignored.

View File

@ -38,8 +38,6 @@ class ScrapyArgumentParser(argparse.ArgumentParser):
def _iter_command_classes(module_name: str) -> Iterable[type[ScrapyCommand]]:
# TODO: add `name` attribute to commands and merge this function with
# scrapy.utils.spider.iter_spider_classes
for module in walk_modules_iter(module_name):
for obj in vars(module).values():
if (

View File

@ -98,7 +98,7 @@ class Command(ScrapyCommand):
tested_methods = conman.tested_methods_from_spidercls(spidercls)
if opts.list:
for method in tested_methods:
contract_reqs[spidercls.name].append(method)
contract_reqs[spidername].append(method)
elif tested_methods:
self.crawler_process.crawl(spidercls)

View File

@ -53,10 +53,14 @@ class Command(BaseRunSpiderCommand):
module = _import_file(filename)
except (ImportError, ValueError) as e:
raise UsageError(f"Unable to load {str(filename)!r}: {e}\n") from e
spclasses = list(iter_spider_classes(module))
# The spider is looked up by file name, so it does not need a name of
# its own. Named spiders still win over nameless ones, which in a file
# with both are usually base spiders.
spclasses = list(iter_spider_classes(module, require_name=False))
if not spclasses:
raise UsageError(f"No spider found in file: {filename}\n")
spidercls = spclasses.pop()
named = [spcls for spcls in spclasses if getattr(spcls, "name", None)]
spidercls = (named or spclasses).pop()
assert self.crawler_process
self.crawler_process.crawl(spidercls, **opts.spargs)

View File

@ -550,6 +550,7 @@ SPIDER_CONTRACTS_BASE = {
}
SPIDER_LOADER_CLASS = "scrapy.spiderloader.SpiderLoader"
SPIDER_LOADER_REQUIRE_NAME = True
SPIDER_LOADER_WARN_ONLY = False
SPIDER_MIDDLEWARES = {}

View File

@ -55,6 +55,7 @@ class SpiderLoader:
"""
def __init__(self, settings: BaseSettings):
self.require_name: bool = settings.getbool("SPIDER_LOADER_REQUIRE_NAME")
self.spider_modules: list[str] = settings.getlist("SPIDER_MODULES")
self.warn_only: bool = settings.getbool("SPIDER_LOADER_WARN_ONLY")
self._spiders: dict[str, type[Spider]] = {}
@ -82,9 +83,10 @@ class SpiderLoader:
)
def _load_spiders(self, module: ModuleType) -> None:
for spcls in iter_spider_classes(module):
self._found[spcls.name].append((module.__name__, spcls.__name__))
self._spiders[spcls.name] = spcls
for spcls in iter_spider_classes(module, require_name=self.require_name):
name = spcls._default_name()
self._found[name].append((module.__name__, spcls.__name__))
self._spiders[name] = spcls
def _load_all_spiders(self) -> None:
for name in self.spider_modules:

View File

@ -8,11 +8,12 @@ from __future__ import annotations
import logging
import warnings
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any, TypeVar, cast
from scrapy import signals
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http import Request, Response
from scrapy.utils.python import global_object_name
from scrapy.utils.trackref import object_ref
from scrapy.utils.url import url_is_from_spider
@ -30,6 +31,21 @@ if TYPE_CHECKING:
from scrapy.utils.log import SpiderLoggerAdapter
_SpiderT = TypeVar("_SpiderT", bound="type[Spider]")
def ignore_spider(cls: _SpiderT) -> _SpiderT:
"""Mark a :class:`~scrapy.spiders.Spider` subclass to be ignored.
Marked spider classes are not available to the :command:`crawl`,
:command:`list` and :command:`runspider` commands. Only the decorated
class is marked; its subclasses are unaffected.
"""
cls._ignore_spider = True
return cls
@ignore_spider
class Spider(object_ref):
"""Base class that any spider must subclass.
@ -40,6 +56,7 @@ class Spider(object_ref):
name: str
custom_settings: dict[str, Any] | None = None
_ignore_spider: bool
#: Start URLs. See :meth:`start`.
start_urls: list[str]
@ -48,11 +65,23 @@ class Spider(object_ref):
if name is not None:
self.name: str = name
elif not getattr(self, "name", None):
raise ValueError(f"{type(self).__name__} must have a name")
self.name = type(self)._default_name()
self.__dict__.update(kwargs)
if not hasattr(self, "start_urls"):
self.start_urls: list[str] = []
@classmethod
def _default_name(cls) -> str:
"""Return the name under which spider loaders and commands know this
spider class, which falls back to its import path."""
return getattr(cls, "name", None) or global_object_name(cls)
@classmethod
def _is_ignored(cls) -> bool:
# The mark set by ignore_spider() is read from the class __dict__ so
# that subclasses do not inherit it.
return "_ignore_spider" in cls.__dict__
@property
def logger(self) -> SpiderLoggerAdapter:
# circular import

View File

@ -16,7 +16,7 @@ from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http import HtmlResponse, Request, Response
from scrapy.link import Link
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import Spider
from scrapy.spiders import Spider, ignore_spider
from scrapy.utils.asyncgen import collect_asyncgen
from scrapy.utils.deprecate import method_is_overridden
from scrapy.utils.python import global_object_name
@ -95,6 +95,7 @@ class Rule:
)
@ignore_spider
class CrawlSpider(Spider):
rules: Sequence[Rule] = ()
_rules: list[Rule]

View File

@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Any
from scrapy.exceptions import NotSupported
from scrapy.http import Response, TextResponse
from scrapy.selector import Selector
from scrapy.spiders import Spider
from scrapy.spiders import Spider, ignore_spider
from scrapy.utils.iterators import csviter, xmliter_lxml
from scrapy.utils.spider import iterate_spider_output
@ -20,6 +20,7 @@ if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
@ignore_spider
class XMLFeedSpider(Spider):
"""
This class intends to be the base class for spiders that scrape
@ -107,6 +108,7 @@ class XMLFeedSpider(Spider):
selector.register_namespace(prefix, uri)
@ignore_spider
class CSVFeedSpider(Spider):
"""Spider for parsing CSV feeds.
It receives a CSV file in a response; iterates through each of its rows,

View File

@ -8,7 +8,7 @@ from collections.abc import AsyncIterator, Iterable, Sequence # noqa: TC003
from typing import TYPE_CHECKING, Any, cast
from scrapy.http import Request, Response, XmlResponse
from scrapy.spiders import Spider
from scrapy.spiders import Spider, ignore_spider
from scrapy.utils._compression import _DecompressionMaxSizeExceeded
from scrapy.utils.gz import gunzip, gzip_magic_number
from scrapy.utils.sitemap import Sitemap, sitemap_urls_from_robots
@ -23,6 +23,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
@ignore_spider
class SitemapSpider(Spider):
sitemap_urls: Sequence[str] = ()
sitemap_rules: Sequence[tuple[re.Pattern[str] | str, str | CallbackT]] = [

View File

@ -83,5 +83,8 @@ DOWNLOAD_DELAY = 1
#HTTPCACHE_IGNORE_HTTP_CODES = []
#HTTPCACHE_STORAGE = "scrapy.extensions.httpcache.FilesystemCacheStorage"
# Allow listing and running spiders that do not have a name
SPIDER_LOADER_REQUIRE_NAME = False
# Set settings whose default value is deprecated to a future-proof value
FEED_EXPORT_ENCODING = "utf-8"

View File

@ -47,18 +47,27 @@ def iterate_spider_output(
return arg_to_iter(d)
def iter_spider_classes(module: ModuleType) -> Iterable[type[Spider]]:
"""Return an iterator over all spider classes defined in the given module
that can be instantiated (i.e. which have name)
def iter_spider_classes(
module: ModuleType,
*,
require_name: bool = True,
) -> Iterable[type[Spider]]:
"""Return an iterator over all :class:`~scrapy.spiders.Spider` subclasses
defined in the given module, excluding those marked with
:func:`scrapy.spiders.ignore_spider`.
If `require_name` is ``True`` (default), any
:class:`~scrapy.spiders.Spider` subclass without a non-empty
:attr:`~scrapy.Spider.name` is also excluded.
"""
for obj in vars(module).values():
if (
inspect.isclass(obj)
and issubclass(obj, Spider)
and obj.__module__ == module.__name__
and getattr(obj, "name", None)
):
yield obj
if not inspect.isclass(obj) or not issubclass(obj, Spider):
continue
if obj.__module__ != module.__name__ or obj._is_ignored():
continue
if require_name and not getattr(obj, "name", None):
continue
yield obj
@overload

View File

@ -28,7 +28,10 @@ def url_is_from_any_domain(url: UrlT, domains: Iterable[str]) -> bool:
def _spider_domains(spider: type[Spider]) -> Iterable[str]:
yield spider.name
# Spiders that get their name from their import path have no class-level
# name, and an import path is never a domain anyway.
if name := getattr(spider, "name", None):
yield name
if allowed_domains := getattr(spider, "allowed_domains", None):
yield from allowed_domains

View File

@ -122,6 +122,25 @@ class CheckSpider(scrapy.Spider):
"""
self._test_contract(proj_path, contracts, parse_def)
def test_check_list_nameless_spider(self, proj_path: Path) -> None:
spider = proj_path / self.project_name / "spiders" / "namelessspider.py"
spider.write_text(
'''
import scrapy
class NamelessSpider(scrapy.Spider):
def parse(self, response):
"""
@url data:,
"""
''',
encoding="utf-8",
)
name = f"{self.project_name}.spiders.namelessspider.NamelessSpider"
ret, out, err = proc("check", "-l", name, cwd=proj_path)
assert ret == 0, err
assert out == f"{name}\n * parse\n"
def test_SCRAPY_CHECK_set(self, proj_path: Path) -> None:
parse_def = """
import os

View File

@ -35,6 +35,24 @@ class TestCrawlCommand(TestProjectBase):
"running 'scrapy crawl' with more than one spider is not supported" in err
)
def test_nameless_spider(self, proj_path: Path) -> None:
spider_code = """
import scrapy
class MySpider(scrapy.Spider):
async def start(self):
self.logger.debug('It works!')
return
yield
"""
(proj_path / self.project_name / "spiders" / "myspider.py").write_text(
spider_code, encoding="utf-8"
)
name = f"{self.project_name}.spiders.myspider.MySpider"
_, _, log = proc("crawl", name, cwd=proj_path)
assert f"[{name}] DEBUG: It works!" in log
assert "Spider closed (finished)" in log
def test_no_output(self, proj_path: Path) -> None:
spider_code = """
import scrapy

View File

@ -132,6 +132,39 @@ class MySpider(scrapy.Spider):
assert ("[scrapy]" in log1) is value
assert ("[scrapy.core.engine]" in log1) is not value
def test_runspider_nameless_spider(self, tmp_path: Path) -> None:
nameless_spider = """
import scrapy
class MySpider(scrapy.Spider):
async def start(self):
self.logger.debug("It Works!")
return
yield
"""
log = self.get_log(tmp_path, nameless_spider)
assert "[myspider.MySpider] DEBUG: It Works!" in log
assert "INFO: Spider closed (finished)" in log
def test_runspider_prefers_named_spider(self, tmp_path: Path) -> None:
"""A base spider defined after the spider itself does not shadow it."""
base_last_spider = """
import scrapy
class MySpider(scrapy.Spider):
name = 'myspider'
async def start(self):
self.logger.debug("It Works!")
return
yield
class MyBaseSpider(scrapy.Spider):
pass
"""
log = self.get_log(tmp_path, base_last_spider)
assert "[myspider] DEBUG: It Works!" in log
def test_runspider_no_spider_found(self, tmp_path: Path) -> None:
log = self.get_log(tmp_path, "from scrapy.spiders import Spider\n")
assert "No spider found in file" in log

View File

@ -432,6 +432,31 @@ class TestMiscCommands(TestProjectBase):
subdir.mkdir(exist_ok=True)
assert call("list", cwd=subdir) == 0
@pytest.mark.parametrize("require_name", [False, True])
def test_list_nameless(self, proj_path: Path, require_name: bool) -> None:
(proj_path / self.project_name / "spiders" / "nameless.py").write_text(
"from scrapy import Spider\n"
"\n"
"\n"
"class NamelessSpider(Spider):\n"
" pass\n"
"\n"
"\n"
"class NamedSpider(Spider):\n"
' name = "named"\n',
encoding="utf-8",
)
returncode, out, err = proc(
"list",
"-s",
f"SPIDER_LOADER_REQUIRE_NAME={require_name}",
cwd=proj_path,
)
assert returncode == 0, err
nameless = f"{self.project_name}.spiders.nameless.NamelessSpider"
expected = ["named"] if require_name else ["named", nameless]
assert out.split() == expected
class TestCommandListing(TestProjectBase):
"""Tests for the command list that ``scrapy`` prints when called without a

View File

@ -1,6 +1,7 @@
import contextlib
import shutil
import sys
import warnings
from pathlib import Path
from unittest import mock
@ -13,6 +14,7 @@ from scrapy.crawler import CrawlerRunner
from scrapy.http import Request
from scrapy.settings import Settings
from scrapy.spiderloader import DummySpiderLoader, SpiderLoader, get_spider_loader
from tests.test_spiderloader.nameless_spiders.nameless1 import NamelessSpider
module_dir = Path(__file__).resolve().parent
@ -165,6 +167,69 @@ class TestSpiderLoader:
assert not spiders
class TestNamelessSpiderLoader:
module = "tests.test_spiderloader.nameless_spiders"
nameless1 = f"{module}.nameless1.NamelessSpider"
nameless2 = f"{module}.nameless2.NamelessSpider"
@pytest.fixture
def spider_loader(self):
settings = Settings(
{
"SPIDER_MODULES": [self.module],
"SPIDER_LOADER_REQUIRE_NAME": False,
}
)
return SpiderLoader.from_settings(settings)
def test_list(self, spider_loader):
assert set(spider_loader.list()) == {
"subclass",
self.nameless1,
self.nameless2,
}
def test_list_require_name(self):
settings = Settings({"SPIDER_MODULES": [self.module]})
spider_loader = SpiderLoader.from_settings(settings)
assert set(spider_loader.list()) == {"subclass"}
def test_load(self, spider_loader):
assert spider_loader.load(self.nameless1) is NamelessSpider
def test_instance_name(self, spider_loader):
"""Spiders are instantiated with the name that the loader knows them
by."""
for name in spider_loader.list():
assert spider_loader.load(name)().name == name
def test_find_by_request(self, spider_loader):
assert spider_loader.find_by_request(
Request("https://nameless.example.com")
) == [self.nameless1]
def test_no_dupename_warning(self):
settings = Settings(
{
"SPIDER_MODULES": [self.module],
"SPIDER_LOADER_REQUIRE_NAME": False,
}
)
with warnings.catch_warnings():
warnings.simplefilter("error", UserWarning)
SpiderLoader.from_settings(settings)
def test_crawler_runner_loading(self, spider_loader):
runner = CrawlerRunner(
{
"SPIDER_MODULES": [self.module],
"SPIDER_LOADER_REQUIRE_NAME": False,
}
)
crawler = runner.create_crawler(self.nameless1)
assert crawler.spidercls is NamelessSpider
class TestDuplicateSpiderNameLoader:
def test_dupename_warning(self, spider_loader_env):
settings, spiders_dir = spider_loader_env

View File

@ -0,0 +1,10 @@
from scrapy.spiders import Spider, ignore_spider
@ignore_spider
class IgnoredSpider(Spider):
name = "ignored"
class SubclassSpider(IgnoredSpider):
name = "subclass"

View File

@ -0,0 +1,5 @@
from scrapy.spiders import Spider
class NamelessSpider(Spider):
allowed_domains = ["nameless.example.com"]

View File

@ -0,0 +1,7 @@
from scrapy.spiders import Spider
# Same class name as in the nameless1 module, to check that nameless spiders
# are told apart by their full import path.
class NamelessSpider(Spider):
pass

View File

@ -3,15 +3,46 @@ from __future__ import annotations
from scrapy import Spider
from scrapy.http import Request
from scrapy.item import Item
from scrapy.spiders import ignore_spider
from scrapy.utils.spider import iter_spider_classes, iterate_spider_output
class MySpider1(Spider):
name = "myspider1"
class SpiderA(Spider):
pass
class MySpider2(Spider):
name = "myspider2"
@ignore_spider
class SpiderB(Spider):
pass
@ignore_spider
class SpiderC(Spider):
name = "c"
class SpiderA1(SpiderA):
name = "a1"
class SpiderA2(SpiderA):
pass
class SpiderB1(SpiderB):
name = "b1"
class SpiderB2(SpiderB):
pass
class SpiderC1(SpiderC):
name = "c1"
class SpiderC2(SpiderC):
pass
def test_iterate_spider_output():
@ -25,8 +56,23 @@ def test_iterate_spider_output():
assert list(iterate_spider_output([r, i, o])) == [r, i, o]
def test_iter_spider_classes():
def test_iter_spider_classes_require_name():
import tests.test_utils_spider # noqa: PLW0406,PLC0415
it = iter_spider_classes(tests.test_utils_spider)
assert set(it) == {MySpider1, MySpider2}
it = iter_spider_classes(tests.test_utils_spider, require_name=True)
assert set(it) == {SpiderA1, SpiderB1, SpiderC1, SpiderC2}
def test_iter_spider_classes_dont_require_name():
import tests.test_utils_spider # noqa: PLW0406,PLC0415
it = iter_spider_classes(tests.test_utils_spider, require_name=False)
assert set(it) == {
SpiderA,
SpiderA1,
SpiderA2,
SpiderB1,
SpiderB2,
SpiderC1,
SpiderC2,
}

View File

@ -11,6 +11,7 @@ from scrapy import signals
from scrapy.crawler import Crawler
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.settings import Settings
from scrapy.utils.python import global_object_name
from scrapy.utils.test import get_crawler, get_reactor_settings
from tests.utils.decorators import inline_callbacks_test
@ -35,12 +36,20 @@ class TestSpiderBase(ABC):
assert spider.foo == "bar"
def test_spider_without_name(self):
"""``__init__`` raises when the name is not provided."""
msg = "must have a name"
with pytest.raises(ValueError, match=msg):
self.spider_class()
with pytest.raises(ValueError, match=msg):
self.spider_class(somearg="foo")
"""Spiders with no name get their import path as name."""
assert not hasattr(self.spider_class, "name")
spider = self.spider_class()
assert spider.name == global_object_name(self.spider_class)
def test_ignored(self):
"""Base spiders shipped by Scrapy are ignored, their subclasses are
not."""
class Subclass(self.spider_class):
pass
assert self.spider_class._is_ignored()
assert not Subclass._is_ignored()
def test_from_crawler_crawler_and_settings_population(self):
crawler = get_crawler()

View File

@ -113,7 +113,8 @@ commands =
pre-commit run {posargs:--all-files}
[testenv:pylint]
# Some checks are Python-version-dependent, so pin the version used in CI.
# Version-dependent code and pylint suppressions require a fixed interpreter.
# Keep in sync with the pylint job in .github/workflows/checks.yml.
basepython = python3.14
deps =
{[testenv:extra-deps]deps}