]>
Commit | Line | Data |
---|---|---|
53e6db90 DC |
1 | """Implementation of the StyleGuide used by Flake8.""" |
2 | from __future__ import annotations | |
3 | ||
4 | import argparse | |
5 | import contextlib | |
6 | import copy | |
7 | import enum | |
8 | import functools | |
9 | import logging | |
10 | from typing import Generator | |
11 | from typing import Sequence | |
12 | ||
13 | from flake8 import defaults | |
14 | from flake8 import statistics | |
15 | from flake8 import utils | |
16 | from flake8.formatting import base as base_formatter | |
17 | from flake8.violation import Violation | |
18 | ||
19 | __all__ = ("StyleGuide",) | |
20 | ||
21 | LOG = logging.getLogger(__name__) | |
22 | ||
23 | ||
24 | class Selected(enum.Enum): | |
25 | """Enum representing an explicitly or implicitly selected code.""" | |
26 | ||
27 | Explicitly = "explicitly selected" | |
28 | Implicitly = "implicitly selected" | |
29 | ||
30 | ||
31 | class Ignored(enum.Enum): | |
32 | """Enum representing an explicitly or implicitly ignored code.""" | |
33 | ||
34 | Explicitly = "explicitly ignored" | |
35 | Implicitly = "implicitly ignored" | |
36 | ||
37 | ||
38 | class Decision(enum.Enum): | |
39 | """Enum representing whether a code should be ignored or selected.""" | |
40 | ||
41 | Ignored = "ignored error" | |
42 | Selected = "selected error" | |
43 | ||
44 | ||
45 | def _explicitly_chosen( | |
46 | *, | |
47 | option: list[str] | None, | |
48 | extend: list[str] | None, | |
49 | ) -> tuple[str, ...]: | |
50 | ret = [*(option or []), *(extend or [])] | |
51 | return tuple(sorted(ret, reverse=True)) | |
52 | ||
53 | ||
54 | def _select_ignore( | |
55 | *, | |
56 | option: list[str] | None, | |
57 | default: tuple[str, ...], | |
58 | extended_default: list[str], | |
59 | extend: list[str] | None, | |
60 | ) -> tuple[str, ...]: | |
61 | # option was explicitly set, ignore the default and extended default | |
62 | if option is not None: | |
63 | ret = [*option, *(extend or [])] | |
64 | else: | |
65 | ret = [*default, *extended_default, *(extend or [])] | |
66 | return tuple(sorted(ret, reverse=True)) | |
67 | ||
68 | ||
69 | class DecisionEngine: | |
70 | """A class for managing the decision process around violations. | |
71 | ||
72 | This contains the logic for whether a violation should be reported or | |
73 | ignored. | |
74 | """ | |
75 | ||
76 | def __init__(self, options: argparse.Namespace) -> None: | |
77 | """Initialize the engine.""" | |
78 | self.cache: dict[str, Decision] = {} | |
79 | ||
80 | self.selected_explicitly = _explicitly_chosen( | |
81 | option=options.select, | |
82 | extend=options.extend_select, | |
83 | ) | |
84 | self.ignored_explicitly = _explicitly_chosen( | |
85 | option=options.ignore, | |
86 | extend=options.extend_ignore, | |
87 | ) | |
88 | ||
89 | self.selected = _select_ignore( | |
90 | option=options.select, | |
91 | default=(), | |
92 | extended_default=options.extended_default_select, | |
93 | extend=options.extend_select, | |
94 | ) | |
95 | self.ignored = _select_ignore( | |
96 | option=options.ignore, | |
97 | default=defaults.IGNORE, | |
98 | extended_default=options.extended_default_ignore, | |
99 | extend=options.extend_ignore, | |
100 | ) | |
101 | ||
102 | def was_selected(self, code: str) -> Selected | Ignored: | |
103 | """Determine if the code has been selected by the user. | |
104 | ||
105 | :param code: The code for the check that has been run. | |
106 | :returns: | |
107 | Selected.Implicitly if the selected list is empty, | |
108 | Selected.Explicitly if the selected list is not empty and a match | |
109 | was found, | |
110 | Ignored.Implicitly if the selected list is not empty but no match | |
111 | was found. | |
112 | """ | |
113 | if code.startswith(self.selected_explicitly): | |
114 | return Selected.Explicitly | |
115 | elif code.startswith(self.selected): | |
116 | return Selected.Implicitly | |
117 | else: | |
118 | return Ignored.Implicitly | |
119 | ||
120 | def was_ignored(self, code: str) -> Selected | Ignored: | |
121 | """Determine if the code has been ignored by the user. | |
122 | ||
123 | :param code: | |
124 | The code for the check that has been run. | |
125 | :returns: | |
126 | Selected.Implicitly if the ignored list is empty, | |
127 | Ignored.Explicitly if the ignored list is not empty and a match was | |
128 | found, | |
129 | Selected.Implicitly if the ignored list is not empty but no match | |
130 | was found. | |
131 | """ | |
132 | if code.startswith(self.ignored_explicitly): | |
133 | return Ignored.Explicitly | |
134 | elif code.startswith(self.ignored): | |
135 | return Ignored.Implicitly | |
136 | else: | |
137 | return Selected.Implicitly | |
138 | ||
139 | def make_decision(self, code: str) -> Decision: | |
140 | """Decide if code should be ignored or selected.""" | |
141 | selected = self.was_selected(code) | |
142 | ignored = self.was_ignored(code) | |
143 | LOG.debug( | |
144 | "The user configured %r to be %r, %r", | |
145 | code, | |
146 | selected, | |
147 | ignored, | |
148 | ) | |
149 | ||
150 | if isinstance(selected, Selected) and isinstance(ignored, Selected): | |
151 | return Decision.Selected | |
152 | elif isinstance(selected, Ignored) and isinstance(ignored, Ignored): | |
153 | return Decision.Ignored | |
154 | elif ( | |
155 | selected is Selected.Explicitly | |
156 | and ignored is not Ignored.Explicitly | |
157 | ): | |
158 | return Decision.Selected | |
159 | elif ( | |
160 | selected is not Selected.Explicitly | |
161 | and ignored is Ignored.Explicitly | |
162 | ): | |
163 | return Decision.Ignored | |
164 | elif selected is Ignored.Implicitly and ignored is Selected.Implicitly: | |
165 | return Decision.Ignored | |
166 | elif ( | |
167 | selected is Selected.Explicitly and ignored is Ignored.Explicitly | |
168 | ) or ( | |
169 | selected is Selected.Implicitly and ignored is Ignored.Implicitly | |
170 | ): | |
171 | # we only get here if it was in both lists: longest prefix wins | |
172 | select = next(s for s in self.selected if code.startswith(s)) | |
173 | ignore = next(s for s in self.ignored if code.startswith(s)) | |
174 | if len(select) > len(ignore): | |
175 | return Decision.Selected | |
176 | else: | |
177 | return Decision.Ignored | |
178 | else: | |
179 | raise AssertionError(f"unreachable {code} {selected} {ignored}") | |
180 | ||
181 | def decision_for(self, code: str) -> Decision: | |
182 | """Return the decision for a specific code. | |
183 | ||
184 | This method caches the decisions for codes to avoid retracing the same | |
185 | logic over and over again. We only care about the select and ignore | |
186 | rules as specified by the user in their configuration files and | |
187 | command-line flags. | |
188 | ||
189 | This method does not look at whether the specific line is being | |
190 | ignored in the file itself. | |
191 | ||
192 | :param code: The code for the check that has been run. | |
193 | """ | |
194 | decision = self.cache.get(code) | |
195 | if decision is None: | |
196 | decision = self.make_decision(code) | |
197 | self.cache[code] = decision | |
198 | LOG.debug('"%s" will be "%s"', code, decision) | |
199 | return decision | |
200 | ||
201 | ||
202 | class StyleGuideManager: | |
203 | """Manage multiple style guides for a single run.""" | |
204 | ||
205 | def __init__( | |
206 | self, | |
207 | options: argparse.Namespace, | |
208 | formatter: base_formatter.BaseFormatter, | |
209 | decider: DecisionEngine | None = None, | |
210 | ) -> None: | |
211 | """Initialize our StyleGuide. | |
212 | ||
213 | .. todo:: Add parameter documentation. | |
214 | """ | |
215 | self.options = options | |
216 | self.formatter = formatter | |
217 | self.stats = statistics.Statistics() | |
218 | self.decider = decider or DecisionEngine(options) | |
219 | self.style_guides: list[StyleGuide] = [] | |
220 | self.default_style_guide = StyleGuide( | |
221 | options, formatter, self.stats, decider=decider | |
222 | ) | |
223 | self.style_guides = [ | |
224 | self.default_style_guide, | |
225 | *self.populate_style_guides_with(options), | |
226 | ] | |
227 | ||
228 | self.style_guide_for = functools.lru_cache(maxsize=None)( | |
229 | self._style_guide_for | |
230 | ) | |
231 | ||
232 | def populate_style_guides_with( | |
233 | self, options: argparse.Namespace | |
234 | ) -> Generator[StyleGuide, None, None]: | |
235 | """Generate style guides from the per-file-ignores option. | |
236 | ||
237 | :param options: | |
238 | The original options parsed from the CLI and config file. | |
239 | :returns: | |
240 | A copy of the default style guide with overridden values. | |
241 | """ | |
242 | per_file = utils.parse_files_to_codes_mapping(options.per_file_ignores) | |
243 | for filename, violations in per_file: | |
244 | yield self.default_style_guide.copy( | |
245 | filename=filename, extend_ignore_with=violations | |
246 | ) | |
247 | ||
248 | def _style_guide_for(self, filename: str) -> StyleGuide: | |
249 | """Find the StyleGuide for the filename in particular.""" | |
250 | return max( | |
251 | (g for g in self.style_guides if g.applies_to(filename)), | |
252 | key=lambda g: len(g.filename or ""), | |
253 | ) | |
254 | ||
255 | @contextlib.contextmanager | |
256 | def processing_file( | |
257 | self, filename: str | |
258 | ) -> Generator[StyleGuide, None, None]: | |
259 | """Record the fact that we're processing the file's results.""" | |
260 | guide = self.style_guide_for(filename) | |
261 | with guide.processing_file(filename): | |
262 | yield guide | |
263 | ||
264 | def handle_error( | |
265 | self, | |
266 | code: str, | |
267 | filename: str, | |
268 | line_number: int, | |
269 | column_number: int, | |
270 | text: str, | |
271 | physical_line: str | None = None, | |
272 | ) -> int: | |
273 | """Handle an error reported by a check. | |
274 | ||
275 | :param code: | |
276 | The error code found, e.g., E123. | |
277 | :param filename: | |
278 | The file in which the error was found. | |
279 | :param line_number: | |
280 | The line number (where counting starts at 1) at which the error | |
281 | occurs. | |
282 | :param column_number: | |
283 | The column number (where counting starts at 1) at which the error | |
284 | occurs. | |
285 | :param text: | |
286 | The text of the error message. | |
287 | :param physical_line: | |
288 | The actual physical line causing the error. | |
289 | :returns: | |
290 | 1 if the error was reported. 0 if it was ignored. This is to allow | |
291 | for counting of the number of errors found that were not ignored. | |
292 | """ | |
293 | guide = self.style_guide_for(filename) | |
294 | return guide.handle_error( | |
295 | code, filename, line_number, column_number, text, physical_line | |
296 | ) | |
297 | ||
298 | ||
299 | class StyleGuide: | |
300 | """Manage a Flake8 user's style guide.""" | |
301 | ||
302 | def __init__( | |
303 | self, | |
304 | options: argparse.Namespace, | |
305 | formatter: base_formatter.BaseFormatter, | |
306 | stats: statistics.Statistics, | |
307 | filename: str | None = None, | |
308 | decider: DecisionEngine | None = None, | |
309 | ): | |
310 | """Initialize our StyleGuide. | |
311 | ||
312 | .. todo:: Add parameter documentation. | |
313 | """ | |
314 | self.options = options | |
315 | self.formatter = formatter | |
316 | self.stats = stats | |
317 | self.decider = decider or DecisionEngine(options) | |
318 | self.filename = filename | |
319 | if self.filename: | |
320 | self.filename = utils.normalize_path(self.filename) | |
321 | ||
322 | def __repr__(self) -> str: | |
323 | """Make it easier to debug which StyleGuide we're using.""" | |
324 | return f"<StyleGuide [{self.filename}]>" | |
325 | ||
326 | def copy( | |
327 | self, | |
328 | filename: str | None = None, | |
329 | extend_ignore_with: Sequence[str] | None = None, | |
330 | ) -> StyleGuide: | |
331 | """Create a copy of this style guide with different values.""" | |
332 | filename = filename or self.filename | |
333 | options = copy.deepcopy(self.options) | |
334 | options.extend_ignore = options.extend_ignore or [] | |
335 | options.extend_ignore.extend(extend_ignore_with or []) | |
336 | return StyleGuide( | |
337 | options, self.formatter, self.stats, filename=filename | |
338 | ) | |
339 | ||
340 | @contextlib.contextmanager | |
341 | def processing_file( | |
342 | self, filename: str | |
343 | ) -> Generator[StyleGuide, None, None]: | |
344 | """Record the fact that we're processing the file's results.""" | |
345 | self.formatter.beginning(filename) | |
346 | yield self | |
347 | self.formatter.finished(filename) | |
348 | ||
349 | def applies_to(self, filename: str) -> bool: | |
350 | """Check if this StyleGuide applies to the file. | |
351 | ||
352 | :param filename: | |
353 | The name of the file with violations that we're potentially | |
354 | applying this StyleGuide to. | |
355 | :returns: | |
356 | True if this applies, False otherwise | |
357 | """ | |
358 | if self.filename is None: | |
359 | return True | |
360 | return utils.matches_filename( | |
361 | filename, | |
362 | patterns=[self.filename], | |
363 | log_message=f'{self!r} does %(whether)smatch "%(path)s"', | |
364 | logger=LOG, | |
365 | ) | |
366 | ||
367 | def should_report_error(self, code: str) -> Decision: | |
368 | """Determine if the error code should be reported or ignored. | |
369 | ||
370 | This method only cares about the select and ignore rules as specified | |
371 | by the user in their configuration files and command-line flags. | |
372 | ||
373 | This method does not look at whether the specific line is being | |
374 | ignored in the file itself. | |
375 | ||
376 | :param code: | |
377 | The code for the check that has been run. | |
378 | """ | |
379 | return self.decider.decision_for(code) | |
380 | ||
381 | def handle_error( | |
382 | self, | |
383 | code: str, | |
384 | filename: str, | |
385 | line_number: int, | |
386 | column_number: int, | |
387 | text: str, | |
388 | physical_line: str | None = None, | |
389 | ) -> int: | |
390 | """Handle an error reported by a check. | |
391 | ||
392 | :param code: | |
393 | The error code found, e.g., E123. | |
394 | :param filename: | |
395 | The file in which the error was found. | |
396 | :param line_number: | |
397 | The line number (where counting starts at 1) at which the error | |
398 | occurs. | |
399 | :param column_number: | |
400 | The column number (where counting starts at 1) at which the error | |
401 | occurs. | |
402 | :param text: | |
403 | The text of the error message. | |
404 | :param physical_line: | |
405 | The actual physical line causing the error. | |
406 | :returns: | |
407 | 1 if the error was reported. 0 if it was ignored. This is to allow | |
408 | for counting of the number of errors found that were not ignored. | |
409 | """ | |
410 | disable_noqa = self.options.disable_noqa | |
411 | # NOTE(sigmavirus24): Apparently we're provided with 0-indexed column | |
412 | # numbers so we have to offset that here. | |
413 | if not column_number: | |
414 | column_number = 0 | |
415 | error = Violation( | |
416 | code, | |
417 | filename, | |
418 | line_number, | |
419 | column_number + 1, | |
420 | text, | |
421 | physical_line, | |
422 | ) | |
423 | error_is_selected = ( | |
424 | self.should_report_error(error.code) is Decision.Selected | |
425 | ) | |
426 | is_not_inline_ignored = error.is_inline_ignored(disable_noqa) is False | |
427 | if error_is_selected and is_not_inline_ignored: | |
428 | self.formatter.handle(error) | |
429 | self.stats.record(error) | |
430 | return 1 | |
431 | return 0 |