Repository navigation
Expand file tree
/
Copy pathpython_parser.py
More file actions
168 lines (143 loc) · 5.85 KB
/
Copy pathpython_parser.py
File metadata and controls
168 lines (143 loc) · 5.85 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from fixit import Config, LintRule
from fixit.engine import LintRunner
from libcst import ParserSyntaxError
from algorithms_keeper.parser.files_parser import BaseFilesParser
from algorithms_keeper.parser.record import PullRequestReviewRecord
from algorithms_keeper.parser.rules import (
NamingConventionRule,
RequireDescriptiveNameRule,
RequireDoctestRule,
RequireTypeHintRule,
UseFstringRule,
)
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator, Mapping
from algorithms_keeper.utils import File
# Select only the bot's review rules, independent of Fixit's built-in defaults.
DEFAULT_RULES: frozenset[type[LintRule]] = frozenset(
{
NamingConventionRule,
RequireDescriptiveNameRule,
RequireDoctestRule,
RequireTypeHintRule,
UseFstringRule,
}
)
logger = logging.getLogger(__package__)
class PythonParser(BaseFilesParser):
"""Parser for all the Python files in the pull request.
This object performs no I/O of its own as the logic needs to be separated. This
means that the function instantiating the object needs to provide the source code
of the file to be parsed. For this, there's a helper function ``files_to_check``
to generate the list of all the valid Python files in the provided pull request. The
user function will request the file content from GitHub and pass it to the main
function ``parse``.
"""
_pr_report: PullRequestReviewRecord
_rules: set[type[LintRule]]
DOCS_EXTENSIONS: tuple[str, ...] = (".md", ".rst")
ACCEPTED_EXTENSIONS: tuple[str, ...] = (
# Configuration files
".ini",
".toml",
".yaml",
".yml",
".cfg",
# Data files
".csv",
".json",
".txt",
# Lock files (e.g. ``uv.lock``, ``poetry.lock``) are committed to keep CI
# reproducible; a transitive dependency bump lives only here, so allow it.
".lock",
# Good old Python file
".py",
*DOCS_EXTENSIONS,
)
# Extension-less files whose exact name is accepted (container/build tooling).
ACCEPTED_FILENAMES: tuple[str, ...] = ("Dockerfile",)
def __init__(
self,
pr_files: Iterable[File],
pull_request: Mapping[str, Any],
) -> None:
super().__init__(pr_files, pull_request)
self._pr_record = PullRequestReviewRecord()
# Collection of rules are going to be static for a pull request, so let's
# extract it out and store it.
self._rules = set(DEFAULT_RULES)
# If the pull request contains a test file as per the naming convention, there's
# no need to run ``RequireDoctestRule``.
if self._contains_testfile():
self._rules.discard(RequireDoctestRule)
@property
def labels_to_add(self) -> list[str]:
return self._pr_record.labels_to_add
@property
def labels_to_remove(self) -> list[str]:
return self._pr_record.labels_to_remove
def collect_comments(self) -> list[dict[str, Any]]:
return self._pr_record.collect_comments()
def collect_review_contents(self) -> list[str]:
return self._pr_record.collect_review_contents()
def files_to_check(self, ignore_modified: bool) -> Iterator[File]:
"""Generate all the ``File`` which should be checked.
The caller of this function should use a loop to generate and parse the files
one at a time. This way the labels will be filled only after all the files
have been parsed.
Ignores:
- Python test files
- Dunder filenames like *__init__.py*
- Optionally ignore files which were modified (Issue #11)
- Files in the *scripts/* directory (Issue #11)
"""
for file in self.pr_files:
filepath = file.path
if (
# If *ignore_modified* is ``True``, yield only the added files,
# otherwise yield all the files.
(not ignore_modified or file.status == "added")
and filepath.suffix == ".py"
and "scripts" not in filepath.parts
and not filepath.name.startswith("__")
and (
not (
filepath.name.startswith("test_")
or filepath.name.endswith("_test.py")
)
)
):
yield file
# Fill the labels **only** after all the files have been parsed.
self._pr_record.fill_labels(self.pr_labels)
def parse(self, file: File, source: bytes) -> None:
"""Run the lint engine on the given *source* for the *file*."""
try:
runner = LintRunner(file.path, source)
# Rules retain visitor state and violations; create them for each file.
reports = list(
runner.collect_violations(
[rule() for rule in self._rules],
Config(path=file.path, enable=[]),
)
)
self._pr_record.add_comments(reports, file.name)
except (SyntaxError, ParserSyntaxError) as exc:
self._pr_record.add_error(exc, file.name)
logger.info(
"Invalid Python code for the file: [%s] %s", file.name, self.pr_html_url
)
def _contains_testfile(self) -> bool:
"""Check whether any of the pull request files satisfy the naming convention
for test discovery."""
# https://docs.pytest.org/en/reorganize-docs/new-docs/user/naming_conventions.html
for file in self.pr_files:
filepath = file.path
if filepath.suffix == ".py" and (
filepath.name.startswith("test_") or filepath.name.endswith("_test.py")
):
return True
return False