1"""Implementation of the StyleGuide used by Flake8.""" 2import argparse 3import collections 4import contextlib 5import copy 6import enum 7import functools 8import itertools 9import linecache 10import logging 11from typing import Dict 12from typing import Generator 13from typing import List 14from typing import Match 15from typing import Optional 16from typing import Sequence 17from typing import Set 18from typing import Tuple 19from typing import Union 20 21from flake8 import defaults 22from flake8 import statistics 23from flake8 import utils 24from flake8.formatting import base as base_formatter 25 26__all__ = ("StyleGuide",) 27 28LOG = logging.getLogger(__name__) 29 30 31class Selected(enum.Enum): 32 """Enum representing an explicitly or implicitly selected code.""" 33 34 Explicitly = "explicitly selected" 35 Implicitly = "implicitly selected" 36 37 38class Ignored(enum.Enum): 39 """Enum representing an explicitly or implicitly ignored code.""" 40 41 Explicitly = "explicitly ignored" 42 Implicitly = "implicitly ignored" 43 44 45class Decision(enum.Enum): 46 """Enum representing whether a code should be ignored or selected.""" 47 48 Ignored = "ignored error" 49 Selected = "selected error" 50 51 52@functools.lru_cache(maxsize=512) 53def find_noqa(physical_line: str) -> Optional[Match[str]]: 54 return defaults.NOQA_INLINE_REGEXP.search(physical_line) 55 56 57class Violation( 58 collections.namedtuple( 59 "Violation", 60 [ 61 "code", 62 "filename", 63 "line_number", 64 "column_number", 65 "text", 66 "physical_line", 67 ], 68 ) 69): 70 """Class representing a violation reported by Flake8.""" 71 72 def is_inline_ignored(self, disable_noqa: bool) -> bool: 73 """Determine if a comment has been added to ignore this line. 74 75 :param bool disable_noqa: 76 Whether or not users have provided ``--disable-noqa``. 77 :returns: 78 True if error is ignored in-line, False otherwise. 79 :rtype: 80 bool 81 """ 82 physical_line = self.physical_line 83 # TODO(sigmavirus24): Determine how to handle stdin with linecache 84 if disable_noqa: 85 return False 86 87 if physical_line is None: 88 physical_line = linecache.getline(self.filename, self.line_number) 89 noqa_match = find_noqa(physical_line) 90 if noqa_match is None: 91 LOG.debug("%r is not inline ignored", self) 92 return False 93 94 codes_str = noqa_match.groupdict()["codes"] 95 if codes_str is None: 96 LOG.debug("%r is ignored by a blanket ``# noqa``", self) 97 return True 98 99 codes = set(utils.parse_comma_separated_list(codes_str)) 100 if self.code in codes or self.code.startswith(tuple(codes)): 101 LOG.debug( 102 "%r is ignored specifically inline with ``# noqa: %s``", 103 self, 104 codes_str, 105 ) 106 return True 107 108 LOG.debug( 109 "%r is not ignored inline with ``# noqa: %s``", self, codes_str 110 ) 111 return False 112 113 def is_in(self, diff: Dict[str, Set[int]]) -> bool: 114 """Determine if the violation is included in a diff's line ranges. 115 116 This function relies on the parsed data added via 117 :meth:`~StyleGuide.add_diff_ranges`. If that has not been called and 118 we are not evaluating files in a diff, then this will always return 119 True. If there are diff ranges, then this will return True if the 120 line number in the error falls inside one of the ranges for the file 121 (and assuming the file is part of the diff data). If there are diff 122 ranges, this will return False if the file is not part of the diff 123 data or the line number of the error is not in any of the ranges of 124 the diff. 125 126 :returns: 127 True if there is no diff or if the error is in the diff's line 128 number ranges. False if the error's line number falls outside 129 the diff's line number ranges. 130 :rtype: 131 bool 132 """ 133 if not diff: 134 return True 135 136 # NOTE(sigmavirus24): The parsed diff will be a defaultdict with 137 # a set as the default value (if we have received it from 138 # flake8.utils.parse_unified_diff). In that case ranges below 139 # could be an empty set (which is False-y) or if someone else 140 # is using this API, it could be None. If we could guarantee one 141 # or the other, we would check for it more explicitly. 142 line_numbers = diff.get(self.filename) 143 if not line_numbers: 144 return False 145 146 return self.line_number in line_numbers 147 148 149class DecisionEngine: 150 """A class for managing the decision process around violations. 151 152 This contains the logic for whether a violation should be reported or 153 ignored. 154 """ 155 156 def __init__(self, options: argparse.Namespace) -> None: 157 """Initialize the engine.""" 158 self.cache: Dict[str, Decision] = {} 159 self.selected = tuple(options.select) 160 self.extended_selected = tuple( 161 sorted(options.extended_default_select, reverse=True) 162 ) 163 self.enabled_extensions = tuple(options.enable_extensions) 164 self.all_selected = tuple( 165 sorted( 166 itertools.chain( 167 self.selected, 168 options.extend_select, 169 self.enabled_extensions, 170 ), 171 reverse=True, 172 ) 173 ) 174 self.ignored = tuple( 175 sorted( 176 itertools.chain(options.ignore, options.extend_ignore), 177 reverse=True, 178 ) 179 ) 180 self.using_default_ignore = set(self.ignored) == set( 181 defaults.IGNORE 182 ).union(options.extended_default_ignore) 183 self.using_default_select = set(self.selected) == set(defaults.SELECT) 184 185 def _in_all_selected(self, code: str) -> bool: 186 return bool(self.all_selected) and code.startswith(self.all_selected) 187 188 def _in_extended_selected(self, code: str) -> bool: 189 return bool(self.extended_selected) and code.startswith( 190 self.extended_selected 191 ) 192 193 def was_selected(self, code: str) -> Union[Selected, Ignored]: 194 """Determine if the code has been selected by the user. 195 196 :param str code: 197 The code for the check that has been run. 198 :returns: 199 Selected.Implicitly if the selected list is empty, 200 Selected.Explicitly if the selected list is not empty and a match 201 was found, 202 Ignored.Implicitly if the selected list is not empty but no match 203 was found. 204 """ 205 if self._in_all_selected(code): 206 return Selected.Explicitly 207 208 if not self.all_selected and self._in_extended_selected(code): 209 # If it was not explicitly selected, it may have been implicitly 210 # selected because the check comes from a plugin that is enabled by 211 # default 212 return Selected.Implicitly 213 214 return Ignored.Implicitly 215 216 def was_ignored(self, code: str) -> Union[Selected, Ignored]: 217 """Determine if the code has been ignored by the user. 218 219 :param str code: 220 The code for the check that has been run. 221 :returns: 222 Selected.Implicitly if the ignored list is empty, 223 Ignored.Explicitly if the ignored list is not empty and a match was 224 found, 225 Selected.Implicitly if the ignored list is not empty but no match 226 was found. 227 """ 228 if self.ignored and code.startswith(self.ignored): 229 return Ignored.Explicitly 230 231 return Selected.Implicitly 232 233 def more_specific_decision_for(self, code: str) -> Decision: 234 select = find_first_match(code, self.all_selected) 235 extra_select = find_first_match(code, self.extended_selected) 236 ignore = find_first_match(code, self.ignored) 237 238 if select and ignore: 239 # If the violation code appears in both the select and ignore 240 # lists (in some fashion) then if we're using the default ignore 241 # list and a custom select list we should select the code. An 242 # example usage looks like this: 243 # A user has a code that would generate an E126 violation which 244 # is in our default ignore list and they specify select=E. 245 # We should be reporting that violation. This logic changes, 246 # however, if they specify select and ignore such that both match. 247 # In that case we fall through to our find_more_specific call. 248 # If, however, the user hasn't specified a custom select, and 249 # we're using the defaults for both select and ignore then the 250 # more specific rule must win. In most cases, that will be to 251 # ignore the violation since our default select list is very 252 # high-level and our ignore list is highly specific. 253 if self.using_default_ignore and not self.using_default_select: 254 return Decision.Selected 255 return find_more_specific(select, ignore) 256 if extra_select and ignore: 257 # At this point, select is false-y. Now we need to check if the 258 # code is in our extended select list and our ignore list. This is 259 # a *rare* case as we see little usage of the extended select list 260 # that plugins can use, so I suspect this section may change to 261 # look a little like the block above in which we check if we're 262 # using our default ignore list. 263 return find_more_specific(extra_select, ignore) 264 if select or (extra_select and self.using_default_select): 265 # Here, ignore was false-y and the user has either selected 266 # explicitly the violation or the violation is covered by 267 # something in the extended select list and we're using the 268 # default select list. In either case, we want the violation to be 269 # selected. 270 return Decision.Selected 271 if select is None and ( 272 extra_select is None or not self.using_default_ignore 273 ): 274 return Decision.Ignored 275 if (select is None and not self.using_default_select) and ( 276 ignore is None and self.using_default_ignore 277 ): 278 return Decision.Ignored 279 return Decision.Selected 280 281 def make_decision(self, code: str) -> Decision: 282 """Decide if code should be ignored or selected.""" 283 LOG.debug('Deciding if "%s" should be reported', code) 284 selected = self.was_selected(code) 285 ignored = self.was_ignored(code) 286 LOG.debug( 287 'The user configured "%s" to be "%s", "%s"', 288 code, 289 selected, 290 ignored, 291 ) 292 293 if ( 294 selected is Selected.Explicitly or selected is Selected.Implicitly 295 ) and ignored is Selected.Implicitly: 296 decision = Decision.Selected 297 elif ( 298 selected is Selected.Explicitly and ignored is Ignored.Explicitly 299 ) or ( 300 selected is Ignored.Implicitly and ignored is Selected.Implicitly 301 ): 302 decision = self.more_specific_decision_for(code) 303 elif selected is Ignored.Implicitly or ignored is Ignored.Explicitly: 304 decision = Decision.Ignored # pylint: disable=R0204 305 return decision 306 307 def decision_for(self, code: str) -> Decision: 308 """Return the decision for a specific code. 309 310 This method caches the decisions for codes to avoid retracing the same 311 logic over and over again. We only care about the select and ignore 312 rules as specified by the user in their configuration files and 313 command-line flags. 314 315 This method does not look at whether the specific line is being 316 ignored in the file itself. 317 318 :param str code: 319 The code for the check that has been run. 320 """ 321 decision = self.cache.get(code) 322 if decision is None: 323 decision = self.make_decision(code) 324 self.cache[code] = decision 325 LOG.debug('"%s" will be "%s"', code, decision) 326 return decision 327 328 329class StyleGuideManager: 330 """Manage multiple style guides for a single run.""" 331 332 def __init__( 333 self, 334 options: argparse.Namespace, 335 formatter: base_formatter.BaseFormatter, 336 decider: Optional[DecisionEngine] = None, 337 ) -> None: 338 """Initialize our StyleGuide. 339 340 .. todo:: Add parameter documentation. 341 """ 342 self.options = options 343 self.formatter = formatter 344 self.stats = statistics.Statistics() 345 self.decider = decider or DecisionEngine(options) 346 self.style_guides: List[StyleGuide] = [] 347 self.default_style_guide = StyleGuide( 348 options, formatter, self.stats, decider=decider 349 ) 350 self.style_guides = list( 351 itertools.chain( 352 [self.default_style_guide], 353 self.populate_style_guides_with(options), 354 ) 355 ) 356 357 def populate_style_guides_with( 358 self, options: argparse.Namespace 359 ) -> Generator["StyleGuide", None, None]: 360 """Generate style guides from the per-file-ignores option. 361 362 :param options: 363 The original options parsed from the CLI and config file. 364 :type options: 365 :class:`~argparse.Namespace` 366 :returns: 367 A copy of the default style guide with overridden values. 368 :rtype: 369 :class:`~flake8.style_guide.StyleGuide` 370 """ 371 per_file = utils.parse_files_to_codes_mapping(options.per_file_ignores) 372 for filename, violations in per_file: 373 yield self.default_style_guide.copy( 374 filename=filename, extend_ignore_with=violations 375 ) 376 377 @functools.lru_cache(maxsize=None) 378 def style_guide_for(self, filename: str) -> "StyleGuide": 379 """Find the StyleGuide for the filename in particular.""" 380 guides = sorted( 381 (g for g in self.style_guides if g.applies_to(filename)), 382 key=lambda g: len(g.filename or ""), 383 ) 384 if len(guides) > 1: 385 return guides[-1] 386 return guides[0] 387 388 @contextlib.contextmanager 389 def processing_file( 390 self, filename: str 391 ) -> Generator["StyleGuide", None, None]: 392 """Record the fact that we're processing the file's results.""" 393 guide = self.style_guide_for(filename) 394 with guide.processing_file(filename): 395 yield guide 396 397 def handle_error( 398 self, 399 code: str, 400 filename: str, 401 line_number: int, 402 column_number: Optional[int], 403 text: str, 404 physical_line: Optional[str] = None, 405 ) -> int: 406 """Handle an error reported by a check. 407 408 :param str code: 409 The error code found, e.g., E123. 410 :param str filename: 411 The file in which the error was found. 412 :param int line_number: 413 The line number (where counting starts at 1) at which the error 414 occurs. 415 :param int column_number: 416 The column number (where counting starts at 1) at which the error 417 occurs. 418 :param str text: 419 The text of the error message. 420 :param str physical_line: 421 The actual physical line causing the error. 422 :returns: 423 1 if the error was reported. 0 if it was ignored. This is to allow 424 for counting of the number of errors found that were not ignored. 425 :rtype: 426 int 427 """ 428 guide = self.style_guide_for(filename) 429 return guide.handle_error( 430 code, filename, line_number, column_number, text, physical_line 431 ) 432 433 def add_diff_ranges(self, diffinfo: Dict[str, Set[int]]) -> None: 434 """Update the StyleGuides to filter out information not in the diff. 435 436 This provides information to the underlying StyleGuides so that only 437 the errors in the line number ranges are reported. 438 439 :param dict diffinfo: 440 Dictionary mapping filenames to sets of line number ranges. 441 """ 442 for guide in self.style_guides: 443 guide.add_diff_ranges(diffinfo) 444 445 446class StyleGuide: 447 """Manage a Flake8 user's style guide.""" 448 449 def __init__( 450 self, 451 options: argparse.Namespace, 452 formatter: base_formatter.BaseFormatter, 453 stats: statistics.Statistics, 454 filename: Optional[str] = None, 455 decider: Optional[DecisionEngine] = None, 456 ): 457 """Initialize our StyleGuide. 458 459 .. todo:: Add parameter documentation. 460 """ 461 self.options = options 462 self.formatter = formatter 463 self.stats = stats 464 self.decider = decider or DecisionEngine(options) 465 self.filename = filename 466 if self.filename: 467 self.filename = utils.normalize_path(self.filename) 468 self._parsed_diff: Dict[str, Set[int]] = {} 469 470 def __repr__(self) -> str: 471 """Make it easier to debug which StyleGuide we're using.""" 472 return f"<StyleGuide [{self.filename}]>" 473 474 def copy( 475 self, 476 filename: Optional[str] = None, 477 extend_ignore_with: Optional[Sequence[str]] = None, 478 ) -> "StyleGuide": 479 """Create a copy of this style guide with different values.""" 480 filename = filename or self.filename 481 options = copy.deepcopy(self.options) 482 options.ignore.extend(extend_ignore_with or []) 483 return StyleGuide( 484 options, self.formatter, self.stats, filename=filename 485 ) 486 487 @contextlib.contextmanager 488 def processing_file( 489 self, filename: str 490 ) -> Generator["StyleGuide", None, None]: 491 """Record the fact that we're processing the file's results.""" 492 self.formatter.beginning(filename) 493 yield self 494 self.formatter.finished(filename) 495 496 def applies_to(self, filename: str) -> bool: 497 """Check if this StyleGuide applies to the file. 498 499 :param str filename: 500 The name of the file with violations that we're potentially 501 applying this StyleGuide to. 502 :returns: 503 True if this applies, False otherwise 504 :rtype: 505 bool 506 """ 507 if self.filename is None: 508 return True 509 return utils.matches_filename( 510 filename, 511 patterns=[self.filename], 512 log_message=f'{self!r} does %(whether)smatch "%(path)s"', 513 logger=LOG, 514 ) 515 516 def should_report_error(self, code: str) -> Decision: 517 """Determine if the error code should be reported or ignored. 518 519 This method only cares about the select and ignore rules as specified 520 by the user in their configuration files and command-line flags. 521 522 This method does not look at whether the specific line is being 523 ignored in the file itself. 524 525 :param str code: 526 The code for the check that has been run. 527 """ 528 return self.decider.decision_for(code) 529 530 def handle_error( 531 self, 532 code: str, 533 filename: str, 534 line_number: int, 535 column_number: Optional[int], 536 text: str, 537 physical_line: Optional[str] = None, 538 ) -> int: 539 """Handle an error reported by a check. 540 541 :param str code: 542 The error code found, e.g., E123. 543 :param str filename: 544 The file in which the error was found. 545 :param int line_number: 546 The line number (where counting starts at 1) at which the error 547 occurs. 548 :param int column_number: 549 The column number (where counting starts at 1) at which the error 550 occurs. 551 :param str text: 552 The text of the error message. 553 :param str physical_line: 554 The actual physical line causing the error. 555 :returns: 556 1 if the error was reported. 0 if it was ignored. This is to allow 557 for counting of the number of errors found that were not ignored. 558 :rtype: 559 int 560 """ 561 disable_noqa = self.options.disable_noqa 562 # NOTE(sigmavirus24): Apparently we're provided with 0-indexed column 563 # numbers so we have to offset that here. Also, if a SyntaxError is 564 # caught, column_number may be None. 565 if not column_number: 566 column_number = 0 567 error = Violation( 568 code, 569 filename, 570 line_number, 571 column_number + 1, 572 text, 573 physical_line, 574 ) 575 error_is_selected = ( 576 self.should_report_error(error.code) is Decision.Selected 577 ) 578 is_not_inline_ignored = error.is_inline_ignored(disable_noqa) is False 579 is_included_in_diff = error.is_in(self._parsed_diff) 580 if error_is_selected and is_not_inline_ignored and is_included_in_diff: 581 self.formatter.handle(error) 582 self.stats.record(error) 583 return 1 584 return 0 585 586 def add_diff_ranges(self, diffinfo: Dict[str, Set[int]]) -> None: 587 """Update the StyleGuide to filter out information not in the diff. 588 589 This provides information to the StyleGuide so that only the errors 590 in the line number ranges are reported. 591 592 :param dict diffinfo: 593 Dictionary mapping filenames to sets of line number ranges. 594 """ 595 self._parsed_diff = diffinfo 596 597 598def find_more_specific(selected: str, ignored: str) -> Decision: 599 if selected.startswith(ignored) and selected != ignored: 600 return Decision.Selected 601 return Decision.Ignored 602 603 604def find_first_match( 605 error_code: str, code_list: Tuple[str, ...] 606) -> Optional[str]: 607 startswith = error_code.startswith 608 for code in code_list: 609 if startswith(code): 610 break 611 else: 612 return None 613 return code 614