Skip to content

Commit 4a77746

Browse files
committed
Get all tox envs passing
1 parent 94cb8f6 commit 4a77746

24 files changed

+278
-208
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ jobs:
4040
include:
4141
- python-version: "3.9"
4242
toxenv: "mypy"
43-
- toxenv: "twinecheck"
43+
- toxenv: "pylint"
44+
- toxenv: "twine"
4445
- toxenv: "docs"
4546
steps:
4647
- uses: actions/checkout@v4

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ Release notes
55
0.1.0 (unreleased)
66
==================
77

8+
- Switched from being a `flake8 <https://flake8.pycqa.org/en/latest/>`_
9+
plugin to being a standalone tool, to support rules on non-Python files.
10+
811
- Dropped Python 3.8 support, added Python 3.9+ support.
912

1013
- Requires Scrapy 2.0.1+.

scrapy_lint/__init__.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from .linter import Linter
99

1010
if TYPE_CHECKING:
11-
from collections.abc import Generator
11+
from collections.abc import Generator, Sequence
1212

1313
from .issues import Issue
1414

@@ -25,17 +25,18 @@ def get_parser() -> ArgumentParser:
2525
return parser
2626

2727

28-
def lint() -> Generator[Issue]:
28+
def lint(args: Sequence[str]) -> Generator[Issue]:
2929
parser = get_parser()
30-
args = parser.parse_args()
31-
linter = Linter.from_args(args)
30+
parsed_args = parser.parse_args(args)
31+
linter = Linter.from_args(parsed_args)
3232
yield from linter.lint()
3333

3434

35-
def main() -> None:
35+
def main(args: Sequence[str] | None = None) -> None:
36+
args = args if args is not None else sys.argv[1:]
3637
try:
3738
found_issues = False
38-
for issue in lint():
39+
for issue in lint(args):
3940
found_issues = True
4041
print(issue)
4142
except Exception as e: # pylint: disable=broad-exception-caught

scrapy_lint/context.py

Lines changed: 1 addition & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -4,38 +4,19 @@
44
from configparser import ConfigParser
55
from dataclasses import dataclass
66
from functools import cached_property
7-
from pathlib import Path
87
from typing import TYPE_CHECKING, Any
98

109
from packaging.version import Version
11-
from ruamel.yaml import YAML
12-
from ruamel.yaml.error import YAMLError
1310

1411
from scrapy_lint.requirements import iter_requirement_lines
1512

1613
if TYPE_CHECKING:
17-
from ast import AST
1814
from collections.abc import Sequence
15+
from pathlib import Path
1916

2017
from packaging.requirements import Requirement
2118

2219

23-
@dataclass
24-
class Flake8File:
25-
tree: AST | None
26-
path: Path
27-
lines: Sequence[str] | None = None
28-
29-
@classmethod
30-
def from_params(
31-
cls,
32-
tree: AST | None,
33-
file_path: str,
34-
lines: Sequence[str] | None = None,
35-
):
36-
return cls(tree, Path(file_path).resolve(), lines)
37-
38-
3920
@dataclass
4021
class Project:
4122
root: Path
@@ -50,44 +31,6 @@ def setting_module_import_paths(self) -> Sequence[str]:
5031
return ()
5132
return tuple(config["settings"].values())
5233

53-
@staticmethod
54-
def find_requirements_file_path(
55-
root: Path | None,
56-
requirements_file_path: str | None,
57-
) -> Path | None:
58-
if requirements_file_path:
59-
return Path(requirements_file_path).resolve()
60-
if not root:
61-
return None
62-
63-
# Check scrapinghub.yml for requirements file
64-
scrapinghub_file = root / "scrapinghub.yml"
65-
yaml_parser = YAML(typ="safe")
66-
if scrapinghub_file.exists():
67-
try:
68-
with scrapinghub_file.open() as f:
69-
data = yaml_parser.load(f)
70-
except YAMLError:
71-
pass
72-
else:
73-
try:
74-
requirements_file_name = data.get("requirements", {}).get(
75-
"file",
76-
"",
77-
)
78-
except AttributeError:
79-
pass
80-
else:
81-
scrapinghub_requirements_file = root / requirements_file_name
82-
if scrapinghub_requirements_file.exists():
83-
return scrapinghub_requirements_file.resolve()
84-
85-
# Fall back to requirements.txt
86-
requirements_file = root / "requirements.txt"
87-
if requirements_file.exists():
88-
return requirements_file.resolve()
89-
return None
90-
9134
@cached_property
9235
def _requirements(self) -> dict[str, list[Requirement]]:
9336
content = self.requirements_text

scrapy_lint/finders/requirements.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,12 @@ def __init__(self, context: Context):
7171

7272
def lint(self, file: Path) -> Generator[Issue]:
7373
packages: set[str] = set()
74+
try:
75+
requirements_text = file.read_text(encoding="utf-8")
76+
except UnicodeDecodeError:
77+
return
7478
for line_number, name, requirement in iter_requirement_lines(
75-
file.read_text(encoding="utf-8").splitlines(),
79+
requirements_text.splitlines(),
7680
):
7781
packages.add(name)
7882
if name not in PACKAGES:

scrapy_lint/finders/settings/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,7 @@ def check_name(
257257

258258
def check_update(self, node: keyword | Constant) -> Generator[Issue]:
259259
name = node.value if isinstance(node, Constant) else node.arg
260-
assert isinstance(name, str)
261-
if name not in SETTINGS:
260+
if not isinstance(name, str) or name not in SETTINGS:
262261
return
263262
setting = SETTINGS[name]
264263
if setting.is_pre_crawler and not self.in_update_pre_crawler_settings:

scrapy_lint/finders/settings/values.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,13 +271,15 @@ def check_feed_fields(value: expr) -> Generator[Issue]:
271271
if value.value is not None:
272272
detail = f"{param!r} must be a list or a dict"
273273
yield Issue(INVALID_SETTING_VALUE, pos, detail)
274-
elif isinstance(value, (List, Set, Tuple)):
274+
return
275+
if isinstance(value, (List, Set, Tuple)):
275276
for index, elt in enumerate(value.elts):
276277
if isinstance(elt, Constant) and not isinstance(elt.value, str):
277278
detail = f"{param}[{index}] ({elt.value!r}) must be a string"
278279
pos_elt = Pos.from_node(elt)
279280
yield Issue(INVALID_SETTING_VALUE, pos_elt, detail)
280-
elif not is_dict(value):
281+
return
282+
if not is_dict(value):
281283
return
282284
assert isinstance(value, (Call, Dict))
283285
for elt_key, elt_value in iter_dict(value):

scrapy_lint/issues.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,6 @@ class Pos:
1212
line: int = 1
1313
column: int = 0
1414

15-
def __iter__(self):
16-
return iter([self.line, self.column])
17-
1815
@classmethod
1916
def from_node(cls, node, column: int | None = None, /) -> Pos:
2017
line = getattr(node, "lineno", 1)

scrapy_lint/linter.py

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
from pathlib import Path
66
from typing import TYPE_CHECKING, Any, Protocol
77

8+
from ruamel.yaml import YAML
9+
from ruamel.yaml.error import YAMLError
10+
811
try:
912
import tomllib # type: ignore[import-not-found]
1013
except ImportError: # Python < 3.11
@@ -128,20 +131,53 @@ def resolve_requirements_file(
128131
options: dict[str, Any],
129132
) -> Path | None:
130133
requirements_file: Path | None
131-
path_str = options.get("requirements_file", "requirements.txt")
132-
requirements_file = Path(path_str).resolve()
133-
if not requirements_file.exists():
134-
return None
135-
return requirements_file
134+
path_str = options.get("requirements_file")
135+
if path_str is not None:
136+
requirements_file = Path(path_str).resolve()
137+
if requirements_file.exists():
138+
return requirements_file
139+
140+
# Check scrapinghub.yml for requirements file
141+
scrapinghub_file = Path("scrapinghub.yml")
142+
yaml_parser = YAML(typ="safe")
143+
if scrapinghub_file.exists():
144+
try:
145+
with scrapinghub_file.open(encoding="utf-8") as f:
146+
data = yaml_parser.load(f)
147+
except (UnicodeDecodeError, YAMLError):
148+
pass
149+
else:
150+
try:
151+
requirements_file_name = data.get("requirements", {}).get(
152+
"file",
153+
"",
154+
)
155+
except AttributeError:
156+
pass
157+
else:
158+
if requirements_file_name and isinstance(
159+
requirements_file_name,
160+
str,
161+
):
162+
scrapinghub_requirements_file = Path(requirements_file_name)
163+
if scrapinghub_requirements_file.exists():
164+
return scrapinghub_requirements_file.resolve()
165+
166+
# Fall back to requirements.txt
167+
requirements_file = Path("requirements.txt")
168+
if requirements_file.exists():
169+
return requirements_file.resolve()
170+
171+
return None
136172

137173
@classmethod
138174
def load_options(cls, root: Path) -> dict[str, Any]:
139175
pyproject_path = root / "pyproject.toml"
140176
if not pyproject_path.exists():
141-
raise ValueError(f"{pyproject_path} does not exist.")
177+
return {}
142178
try:
143179
pyproject = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
144-
except tomllib.TOMLDecodeError as e:
180+
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as e:
145181
raise ValueError(f"Invalid pyproject.toml: {e}") from None
146182
return pyproject.get("tool", {}).get("scrapy-lint", {})
147183

@@ -169,17 +205,20 @@ def resolve_files(
169205
files.add(zyte_config_path)
170206
if requirements_path and requirements_path.exists():
171207
files.add(requirements_path)
172-
for python_file_path in path.glob("*/**.py"):
173-
if spec is None or not spec.match_file(python_file_path):
208+
for python_file_path in path.glob("**/*.py"):
209+
if spec is None or not spec.match_file(
210+
python_file_path.relative_to(root),
211+
):
174212
files.add(python_file_path)
175213
return sorted(files)
176214

177215
def lint(self) -> Generator[Issue]:
178216
for file in self.files:
217+
absolute_file = file.resolve()
179218
for issue in self.lint_file(file):
180219
if self.is_ignored(issue, file):
181220
continue
182-
issue.file = file.relative_to(self.root)
221+
issue.file = absolute_file.relative_to(self.root)
183222
yield issue
184223

185224
def is_ignored(self, issue: Issue, file: Path) -> bool:
@@ -192,15 +231,17 @@ def lint_file(self, file: Path) -> Generator[Issue]:
192231
yield from self.lint_python_file(file)
193232
elif file.name == "scrapinghub.yml":
194233
yield from ZyteCloudConfigIssueFinder(self.context).lint(file)
195-
elif file == self.requirements_file:
234+
elif self.requirements_file is not None and file == self.requirements_file:
196235
yield from RequirementsIssueFinder(self.context).lint(file)
197236

198237
def lint_python_file(self, file: Path) -> Generator[Issue]:
199238
try:
200239
with file.open("r", encoding="utf-8") as f:
201240
source = f.read()
202241
except UnicodeDecodeError as e:
203-
raise ValueError(f"Could not read {file}: {e}") from None
242+
raise ValueError(
243+
f"Could not read {file.relative_to(self.root)}: {e}",
244+
) from None
204245
tree = ast.parse(source, filename=str(file))
205246
setting_module_finder = SettingModuleIssueFinder(
206247
self.context,

tests/__init__.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
from contextlib import contextmanager
77
from dataclasses import dataclass
88
from pathlib import Path
9+
from tempfile import TemporaryDirectory
910
from typing import TYPE_CHECKING, Any, Callable, Union
1011

1112
import pytest
13+
import tomli_w
1214

1315
if sys.version_info >= (3, 10):
1416
from typing import TypeAlias
@@ -81,7 +83,7 @@ def chdir(path: str | Path):
8183
def cases(test_cases: Cases) -> Callable:
8284
def decorator(func):
8385
return pytest.mark.parametrize(
84-
("input", "expected", "flake8_options"),
86+
("files", "expected", "options"),
8587
test_cases,
8688
ids=range(len(test_cases)),
8789
)(func)
@@ -98,3 +100,30 @@ def iter_issues(
98100
yield issues
99101
return
100102
yield from issues
103+
104+
105+
@contextmanager
106+
def project(
107+
files: File | Sequence[File] | None = None,
108+
options: dict | None = None,
109+
):
110+
if isinstance(files, File):
111+
files = [files]
112+
elif files is None:
113+
files = []
114+
with TemporaryDirectory() as directory:
115+
for file in files:
116+
assert file.path
117+
file_path = Path(directory) / file.path
118+
file_path.parent.mkdir(parents=True, exist_ok=True)
119+
if isinstance(file.text, str):
120+
file_path.write_text(file.text)
121+
else:
122+
file_path.write_bytes(file.text)
123+
if options:
124+
options_path = Path(directory) / "pyproject.toml"
125+
toml_dict = {"tool": {"scrapy-lint": options}}
126+
with options_path.open("wb") as f:
127+
f.write(tomli_w.dumps(toml_dict).encode("utf-8"))
128+
with chdir(directory):
129+
yield directory

0 commit comments

Comments
 (0)