Improve test coverage for scrapy.cmdline (#7795)

This commit is contained in:
Adrian 2026-07-28 14:04:54 +02:00 committed by GitHub
parent 5a65bdcc18
commit ad816d2b3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 226 additions and 30 deletions

View File

@ -225,13 +225,11 @@ def _run_command(cmd: ScrapyCommand, args: list[str], opts: argparse.Namespace)
def _run_command_profiled(
cmd: ScrapyCommand, args: list[str], opts: argparse.Namespace
) -> None:
if opts.profile:
sys.stderr.write(f"scrapy: writing cProfile stats to {opts.profile!r}\n")
sys.stderr.write(f"scrapy: writing cProfile stats to {opts.profile!r}\n")
loc = locals()
p = cProfile.Profile()
p.runctx("cmd.run(args, opts)", globals(), loc)
if opts.profile:
p.dump_stats(opts.profile)
p.dump_stats(opts.profile)
if __name__ == "__main__":

View File

@ -3,14 +3,12 @@ from __future__ import annotations
import argparse
import json
import sys
from io import StringIO
from typing import TYPE_CHECKING
from unittest import mock
import pytest
import scrapy
from scrapy.cmdline import _pop_command_name, _print_unknown_command_msg
from scrapy.cmdline import _pop_command_name, execute
from scrapy.commands import ScrapyCommand, ScrapyHelpFormatter, view
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.settings import Settings
@ -153,12 +151,6 @@ class MySpider(scrapy.Spider):
self._append_settings(proj_mod_path, "LOG_LEVEL = 'DEBUG'\n")
@staticmethod
def _append_settings(proj_mod_path: Path, text: str) -> None:
"""Add text to the end of the project settings.py."""
with (proj_mod_path / "settings.py").open("a", encoding="utf-8") as f:
f.write(text)
@staticmethod
def _replace_custom_settings(
proj_mod_path: Path, spider_name: str, text: str
@ -347,23 +339,223 @@ class TestMiscCommands(TestProjectBase):
subdir.mkdir(exist_ok=True)
assert call("list", cwd=subdir) == 0
def test_command_not_found(self) -> None:
na_msg = """
The list command is not available from this location.
These commands are only available from within a project: check, crawl, edit, list, parse.
"""
not_found_msg = """
Unknown command: abc
"""
params = [
("list", False, na_msg),
("abc", False, not_found_msg),
("abc", True, not_found_msg),
]
for cmdname, inproject, message in params:
with mock.patch("sys.stdout", new=StringIO()) as out:
_print_unknown_command_msg(Settings(), cmdname, inproject)
assert out.getvalue().strip() == message.strip()
class TestCommandListing(TestProjectBase):
"""Tests for the command list that ``scrapy`` prints when called without a
command name."""
def test_outside_project(self) -> None:
returncode, out, err = proc()
assert returncode == 0, err
assert f"Scrapy {scrapy.__version__} - no active project" in out
assert "Available commands:" in out
assert "Create new project" in out
assert "More commands available when run from project directory" in out
assert 'Use "scrapy <command> -h" to see more info about a command' in out
def test_inside_project(self, proj_path: Path) -> None:
returncode, out, err = proc(cwd=proj_path)
assert returncode == 0, err
assert (
f"Scrapy {scrapy.__version__} - active project: {self.project_name}" in out
)
assert "List available spiders" in out
assert "More commands available when run from project directory" not in out
class TestUnknownCommand(TestProjectBase):
def test_outside_project(self) -> None:
returncode, out, err = proc("abc")
assert returncode == 2, err
assert f"Scrapy {scrapy.__version__} - no active project" in out
assert "Unknown command: abc" in out
assert 'Use "scrapy" to see available commands' in out
def test_inside_project(self, proj_path: Path) -> None:
returncode, out, err = proc("abc", cwd=proj_path)
assert returncode == 2, err
assert (
f"Scrapy {scrapy.__version__} - active project: {self.project_name}" in out
)
assert "Unknown command: abc" in out
def test_project_only_command_outside_project(self) -> None:
returncode, out, err = proc("list")
assert returncode == 2, err
assert "The list command is not available from this location." in out
assert (
"These commands are only available from within a project: "
"check, crawl, edit, list, parse." in out
)
class TestCommandsModule(TestProjectBase):
"""Tests for commands defined in the module of the COMMANDS_MODULE setting."""
@pytest.fixture
def proj_path_with_commands(self, proj_path: Path) -> Path:
commands_path = proj_path / self.project_name / "commands"
commands_path.mkdir()
(commands_path / "__init__.py").touch()
(commands_path / "mycmd.py").write_text(
"""
from scrapy.commands import ScrapyCommand
class Command(ScrapyCommand):
requires_crawler_process = False
def short_desc(self):
return "My custom command"
def run(self, args, opts):
print("My custom command ran")
""",
encoding="utf-8",
)
(commands_path / "helpcmd.py").write_text(
"""
from scrapy.commands import ScrapyCommand
from scrapy.exceptions import UsageError
class Command(ScrapyCommand):
requires_crawler_process = False
def short_desc(self):
return "My command that asks for its help message"
def run(self, args, opts):
raise UsageError
""",
encoding="utf-8",
)
(commands_path / "silentcmd.py").write_text(
"""
from scrapy.commands import ScrapyCommand
from scrapy.exceptions import UsageError
class Command(ScrapyCommand):
requires_crawler_process = False
def short_desc(self):
return "My command that fails silently"
def run(self, args, opts):
raise UsageError(print_help=False)
""",
encoding="utf-8",
)
self._append_settings(
proj_path / self.project_name,
f'\nCOMMANDS_MODULE = "{self.project_name}.commands"\n',
)
return proj_path
def test_listed(self, proj_path_with_commands: Path) -> None:
returncode, out, err = proc(cwd=proj_path_with_commands)
assert returncode == 0, err
assert "My custom command" in out
def test_run(self, proj_path_with_commands: Path) -> None:
returncode, out, err = proc("mycmd", cwd=proj_path_with_commands)
assert returncode == 0, err
assert "My custom command ran" in out
def test_usage_error(self, proj_path_with_commands: Path) -> None:
"""A message-less UsageError makes the help message be printed."""
returncode, out, err = proc("helpcmd", cwd=proj_path_with_commands)
assert returncode == 2, err
assert "scrapy helpcmd" in out
def test_usage_error_without_help(self, proj_path_with_commands: Path) -> None:
"""A message-less UsageError with print_help disabled prints nothing."""
returncode, out, err = proc("silentcmd", cwd=proj_path_with_commands)
assert returncode == 2, err
assert not out
class TestEntryPointCommands:
"""Tests for commands defined in the scrapy.commands entry point group."""
@staticmethod
def _write_dist(path: Path, entry_point: str) -> None:
"""Write into *path* a package with a command and a function, and the
metadata of an installed distribution that declares *entry_point* in
the scrapy.commands entry point group.
Since ``python -m scrapy.cmdline`` puts the current working directory
in the import path, running it with *path* as the working directory
makes Scrapy find that entry point.
"""
package_path = path / "mycmds"
package_path.mkdir()
(package_path / "__init__.py").touch()
(package_path / "mycmd.py").write_text(
"""
from scrapy.commands import ScrapyCommand
class Command(ScrapyCommand):
requires_crawler_process = False
def short_desc(self):
return "My entry point command"
def run(self, args, opts):
print("My entry point command ran")
def not_a_command():
pass
""",
encoding="utf-8",
)
dist_info_path = path / "mycmds-1.0.dist-info"
dist_info_path.mkdir()
(dist_info_path / "METADATA").write_text(
"Metadata-Version: 2.1\nName: mycmds\nVersion: 1.0\n", encoding="utf-8"
)
(dist_info_path / "entry_points.txt").write_text(
f"[scrapy.commands]\n{entry_point}\n", encoding="utf-8"
)
def test_listed(self, tmp_path: Path) -> None:
self._write_dist(tmp_path, "mycmd = mycmds.mycmd:Command")
returncode, out, err = proc(cwd=tmp_path)
assert returncode == 0, err
assert "My entry point command" in out
def test_run(self, tmp_path: Path) -> None:
self._write_dist(tmp_path, "mycmd = mycmds.mycmd:Command")
returncode, out, err = proc("mycmd", cwd=tmp_path)
assert returncode == 0, err
assert "My entry point command ran" in out
def test_not_a_class(self, tmp_path: Path) -> None:
self._write_dist(tmp_path, "mycmd = mycmds.mycmd:not_a_command")
returncode, _, err = proc("version", cwd=tmp_path)
assert returncode == 1
assert "ValueError: Invalid entry point mycmd" in err
class TestExecute:
"""Tests for calls to scrapy.cmdline.execute() from Python code, which the
command line does not cover."""
def test_argv(self, capsys: pytest.CaptureFixture[str]) -> None:
with pytest.raises(SystemExit) as exc_info:
execute(["scrapy", "version"])
assert exc_info.value.code == 0
assert scrapy.__version__ in capsys.readouterr().out
def test_settings(self, capsys: pytest.CaptureFixture[str]) -> None:
settings = Settings()
with pytest.raises(SystemExit) as exc_info:
execute(["scrapy", "settings", "--get", "BOT_NAME"], settings=settings)
assert exc_info.value.code == 0
assert capsys.readouterr().out.strip() == "scrapybot"
class TestBenchCommand:

View File

@ -32,3 +32,9 @@ class TestProjectBase:
proj_path = tmp_path / self.project_name
copytree(_proj_path_cached, proj_path)
return proj_path
@staticmethod
def _append_settings(proj_mod_path: Path, text: str) -> None:
"""Add text to the end of the project settings.py."""
with (proj_mod_path / "settings.py").open("a", encoding="utf-8") as f:
f.write(text)