Tighten contract detection

This commit is contained in:
Adrian Chaves 2026-07-29 17:53:39 +02:00
parent c6352a0baa
commit a401ec11a5
2 changed files with 78 additions and 22 deletions

View File

@ -14,7 +14,7 @@ from scrapy.utils.python import get_spec
from scrapy.utils.spider import iterate_spider_output
if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Callable, Iterator
from twisted.python.failure import Failure
@ -116,31 +116,40 @@ class ContractsManager:
for contract in contracts:
self.contracts[contract.name] = contract
def tested_methods_from_spidercls(self, spidercls: type[Spider]) -> list[str]:
is_method = re.compile(r"^\s*@", re.MULTILINE).search
methods = []
for key, value in getmembers(spidercls):
if callable(value) and value.__doc__ and is_method(value.__doc__):
methods.append(key)
def _iter_contract_lines(self, docstring: str) -> Iterator[tuple[str, str]]:
"""Yield the ``(name, args)`` pair of every line of *docstring* that
declares a registered contract.
return methods
Lines that start with ``@`` but do not name a registered contract are
ignored, so that docstrings may include unrelated content such as
decorators in code examples.
"""
for line_ in docstring.split("\n"):
line = line_.strip()
if not line.startswith("@"):
continue
m = re.match(r"@(\w+)\s*(.*)", line)
if m is None:
continue
name, args = m.groups()
if name in self.contracts:
yield name, args
def tested_methods_from_spidercls(self, spidercls: type[Spider]) -> list[str]:
return [
key
for key, value in getmembers(spidercls)
if callable(value)
and value.__doc__
and any(self._iter_contract_lines(value.__doc__))
]
def extract_contracts(self, method: Callable[..., Any]) -> list[Contract]:
contracts: list[Contract] = []
assert method.__doc__ is not None
for line_ in method.__doc__.split("\n"):
line = line_.strip()
if line.startswith("@"):
m = re.match(r"@(\w+)\s*(.*)", line)
if m is None:
continue
name, args = m.groups()
args = re.split(r"\s+", args)
contracts.append(self.contracts[name](method, *args))
return contracts
return [
self.contracts[name](method, *re.split(r"\s+", args))
for name, args in self._iter_contract_lines(method.__doc__)
]
def from_spider(self, spider: Spider, results: TestResult) -> list[Request | None]:
requests: list[Request | None] = []

View File

@ -275,6 +275,34 @@ class InheritsDemoSpider(DemoSpider):
name = "inherits_demo_spider"
class UnregisteredAtLineSpider(Spider):
"""Spider whose docstrings contain ``@`` lines that are not contracts."""
name = "unregistered_at_line_spider"
@classmethod
def update_settings(cls, settings):
"""Docstring with a decorator in a code example:
.. code-block:: python
@classmethod
def update_settings(cls, settings): ...
"""
super().update_settings(settings)
def parse(self, response):
"""
@url http://scrapy.org
@returns items 1 1
An unregistered line must not break the registered ones:
@classmethod
"""
yield {"name": "test"}
class TestContractsManager:
contracts = [
UrlContract,
@ -550,6 +578,25 @@ class TestContractsManager:
request.callback(response)
self.should_succeed()
def test_unregistered_at_line_is_not_a_tested_method(self):
# A docstring line starting with @ that does not name a registered
# contract, e.g. a decorator in a code example, must be ignored.
tested_methods = self.conman.tested_methods_from_spidercls(
UnregisteredAtLineSpider
)
assert tested_methods == ["parse"]
def test_unregistered_at_line_is_skipped(self):
contracts = self.conman.extract_contracts(UnregisteredAtLineSpider().parse)
assert [type(contract) for contract in contracts] == [
UrlContract,
ReturnsContract,
]
def test_unregistered_at_line_does_not_break_checks(self):
self.conman.from_spider(UnregisteredAtLineSpider(), self.results)
self.should_succeed()
def test_custom_contracts(self):
self.conman.from_spider(CustomContractSuccessSpider(), self.results)
self.should_succeed()