diff --git a/scrapy/contracts/__init__.py b/scrapy/contracts/__init__.py index dbdbbd456..27b8d9271 100644 --- a/scrapy/contracts/__init__.py +++ b/scrapy/contracts/__init__.py @@ -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] = [] diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 698044de7..429f9c341 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -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()