Fix genspider --editor (#7683)

This commit is contained in:
Adrian 2026-06-30 12:05:42 +02:00 committed by GitHub
parent 52147017b4
commit 6ad8a043ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 106 additions and 7 deletions

View File

@ -1,12 +1,29 @@
import argparse from __future__ import annotations
import os import os
import shlex
import subprocess
import sys import sys
from typing import Any, ClassVar from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar
from scrapy.commands import ScrapyCommand from scrapy.commands import ScrapyCommand
from scrapy.exceptions import UsageError from scrapy.exceptions import UsageError
from scrapy.spiderloader import get_spider_loader from scrapy.spiderloader import get_spider_loader
if TYPE_CHECKING:
import argparse
def _edit_file(editor: str, file_path: str | os.PathLike[str]) -> int:
"""Open ``file_path`` with ``editor`` and return the editor exit code.
``editor`` may include arguments (e.g. ``"code -w"``); it is split with
:func:`shlex.split` and the file is passed as a separate argument, so no
shell is involved.
"""
return subprocess.call([*shlex.split(editor), os.fspath(file_path)]) # noqa: S603
class Command(ScrapyCommand): class Command(ScrapyCommand):
requires_project = True requires_project = True
@ -45,4 +62,4 @@ class Command(ScrapyCommand):
sfile = sys.modules[spidercls.__module__].__file__ sfile = sys.modules[spidercls.__module__].__file__
assert sfile assert sfile
sfile = sfile.replace(".pyc", ".py") sfile = sfile.replace(".pyc", ".py")
self.exitcode = os.system(f'{editor} "{sfile}"') # noqa: S605 self.exitcode = _edit_file(editor, Path(sfile))

View File

@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import os
import shutil import shutil
import string import string
from importlib import import_module from importlib import import_module
@ -10,12 +9,14 @@ from urllib.parse import urlparse
import scrapy import scrapy
from scrapy.commands import ScrapyCommand from scrapy.commands import ScrapyCommand
from scrapy.commands.edit import _edit_file
from scrapy.exceptions import UsageError from scrapy.exceptions import UsageError
from scrapy.spiderloader import get_spider_loader from scrapy.spiderloader import get_spider_loader
from scrapy.utils.template import render_templatefile, string_camelcase from scrapy.utils.template import render_templatefile, string_camelcase
if TYPE_CHECKING: if TYPE_CHECKING:
import argparse import argparse
import os
def sanitize_module_name(module_name: str) -> str: def sanitize_module_name(module_name: str) -> str:
@ -118,9 +119,11 @@ class Command(ScrapyCommand):
template_file = self._find_template(opts.template) template_file = self._find_template(opts.template)
if template_file: if template_file:
self._genspider(module, name, url, opts.template, template_file) spider_file = self._genspider(
module, name, url, opts.template, template_file
)
if opts.edit: if opts.edit:
self.exitcode = os.system(f'scrapy edit "{name}"') # noqa: S605 self.exitcode = _edit_file(self.settings["EDITOR"], spider_file)
def _generate_template_variables( def _generate_template_variables(
self, self,
@ -148,7 +151,7 @@ class Command(ScrapyCommand):
url: str, url: str,
template_name: str, template_name: str,
template_file: str | os.PathLike[str], template_file: str | os.PathLike[str],
) -> None: ) -> Path:
"""Generate the spider module, based on the given template""" """Generate the spider module, based on the given template"""
assert self.settings is not None assert self.settings is not None
tvars = self._generate_template_variables(module, name, url, template_name) tvars = self._generate_template_variables(module, name, url, template_name)
@ -168,6 +171,7 @@ class Command(ScrapyCommand):
) )
if spiders_module: if spiders_module:
print(f"in module:\n {spiders_module.__name__}.{module}") print(f"in module:\n {spiders_module.__name__}.{module}")
return Path(spider_file)
def _find_template(self, template: str) -> Path | None: def _find_template(self, template: str) -> Path | None:
template_file = Path(self.templates_dir, f"{template}.tmpl") template_file = Path(self.templates_dir, f"{template}.tmpl")

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -9,6 +10,13 @@ from tests.test_commands import TestProjectBase
from tests.utils.cmdline import call, proc from tests.utils.cmdline import call, proc
def write_recording_editor(editor: Path) -> None:
"""Create an executable editor script that writes the path it is asked to
open (its last argument) into the file given as its first argument."""
editor.write_text('#!/bin/sh\nprintf "%s" "$2" > "$1"\n', encoding="utf-8")
editor.chmod(0o755)
def find_in_file(filename: Path, regex: str) -> re.Match[str] | None: def find_in_file(filename: Path, regex: str) -> re.Match[str] | None:
"""Find first pattern occurrence in file""" """Find first pattern occurrence in file"""
pattern = re.compile(regex) pattern = re.compile(regex)
@ -63,6 +71,28 @@ class TestGenspiderCommand(TestProjectBase):
assert call("genspider", "--dump=basic", cwd=proj_path) == 0 assert call("genspider", "--dump=basic", cwd=proj_path) == 0
assert call("genspider", "-d", "basic", cwd=proj_path) == 0 assert call("genspider", "-d", "basic", cwd=proj_path) == 0
@pytest.mark.skipif(
sys.platform == "win32", reason="requires a POSIX shell editor script"
)
def test_edit(self, proj_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
spider = proj_path / self.project_name / "spiders" / "example2.py"
edited = proj_path / "edited.txt"
editor = proj_path / "fake-editor.sh"
write_recording_editor(editor)
# The extra argument exercises shlex-splitting of the EDITOR value.
monkeypatch.setenv("EDITOR", f"{editor} {edited}")
returncode, _, err = proc(
"genspider", "--edit", "example2", "example2.com", cwd=proj_path
)
assert returncode == 0, err
assert "ModuleNotFoundError" not in err
assert spider.exists()
assert (proj_path / edited.read_text(encoding="utf-8")).resolve() == (
spider.resolve()
)
def test_same_name_as_project(self, proj_path: Path) -> None: def test_same_name_as_project(self, proj_path: Path) -> None:
assert call("genspider", self.project_name, cwd=proj_path) == 2 assert call("genspider", self.project_name, cwd=proj_path) == 2
assert not ( assert not (
@ -168,6 +198,26 @@ class TestGenspiderStandaloneCommand:
call("genspider", "example", "example.com", cwd=tmp_path) call("genspider", "example", "example.com", cwd=tmp_path)
assert Path(tmp_path, "example.py").exists() assert Path(tmp_path, "example.py").exists()
@pytest.mark.skipif(
sys.platform == "win32", reason="requires a POSIX shell editor script"
)
def test_edit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
spider = tmp_path / "example.py"
edited = tmp_path / "edited.txt"
editor = tmp_path / "fake-editor.sh"
write_recording_editor(editor)
monkeypatch.setenv("EDITOR", f"{editor} {edited}")
returncode, _, err = proc(
"genspider", "--edit", "example", "example.com", cwd=tmp_path
)
assert returncode == 0, err
assert spider.exists()
assert (tmp_path / edited.read_text(encoding="utf-8")).resolve() == (
spider.resolve()
)
@pytest.mark.parametrize("force", [True, False]) @pytest.mark.parametrize("force", [True, False])
def test_same_name_as_existing_file(self, force: bool, tmp_path: Path) -> None: def test_same_name_as_existing_file(self, force: bool, tmp_path: Path) -> None:
file_name = "example" file_name = "example"

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import sys
from io import StringIO from io import StringIO
from shutil import copytree from shutil import copytree
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -420,6 +421,33 @@ class TestViewCommand:
assert "URL using the Scrapy downloader and show its" in command.long_desc() assert "URL using the Scrapy downloader and show its" in command.long_desc()
class TestEditCommand(TestProjectBase):
@pytest.mark.skipif(
sys.platform == "win32", reason="requires a POSIX shell editor script"
)
def test_edit(self, proj_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
spider = proj_path / self.project_name / "spiders" / "example.py"
edited = proj_path / "edited.txt"
editor = proj_path / "fake-editor.sh"
# Records the file it is asked to open ($2) into the file given as $1.
editor.write_text('#!/bin/sh\nprintf "%s" "$2" > "$1"\n', encoding="utf-8")
editor.chmod(0o755)
monkeypatch.setenv("EDITOR", f"{editor} {edited}")
assert call("genspider", "example", "example.com", cwd=proj_path) == 0
returncode, _, err = proc("edit", "example", cwd=proj_path)
assert returncode == 0, err
assert (proj_path / edited.read_text(encoding="utf-8")).resolve() == (
spider.resolve()
)
def test_edit_spider_not_found(self, proj_path: Path) -> None:
returncode, _, err = proc("edit", "nonexistent", cwd=proj_path)
assert returncode == 1
assert "Spider not found: nonexistent" in err
class TestHelpMessage(TestProjectBase): class TestHelpMessage(TestProjectBase):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"command", "command",