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

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import re
import sys
from pathlib import Path
import pytest
@ -9,6 +10,13 @@ from tests.test_commands import TestProjectBase
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:
"""Find first pattern occurrence in file"""
pattern = re.compile(regex)
@ -63,6 +71,28 @@ class TestGenspiderCommand(TestProjectBase):
assert call("genspider", "--dump=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:
assert call("genspider", self.project_name, cwd=proj_path) == 2
assert not (
@ -168,6 +198,26 @@ class TestGenspiderStandaloneCommand:
call("genspider", "example", "example.com", cwd=tmp_path)
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])
def test_same_name_as_existing_file(self, force: bool, tmp_path: Path) -> None:
file_name = "example"

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import argparse
import json
import sys
from io import StringIO
from shutil import copytree
from typing import TYPE_CHECKING
@ -420,6 +421,33 @@ class TestViewCommand:
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):
@pytest.mark.parametrize(
"command",