diff options
| author | Ian Cordasco <graffatcolmingov@gmail.com> | 2016-06-25 10:12:13 -0500 |
|---|---|---|
| committer | Ian Cordasco <graffatcolmingov@gmail.com> | 2016-06-25 10:12:13 -0500 |
| commit | 1a2c68f5da8ae95b8a156ef6f6a772bf82cf0f88 (patch) | |
| tree | e125328f45274330a116d0ae659e20ad4c8367cf /src/flake8/checker.py | |
| parent | 5c8d767626a31560494996cd02ec5d654734aab2 (diff) | |
| download | flake8-1a2c68f5da8ae95b8a156ef6f6a772bf82cf0f88.tar.gz | |
Move flake8 into src
This is an emerging best practice and there is little reason to not
follow it
Diffstat (limited to 'src/flake8/checker.py')
| -rw-r--r-- | src/flake8/checker.py | 610 |
1 files changed, 610 insertions, 0 deletions
diff --git a/src/flake8/checker.py b/src/flake8/checker.py new file mode 100644 index 0000000..b875f44 --- /dev/null +++ b/src/flake8/checker.py @@ -0,0 +1,610 @@ +"""Checker Manager and Checker classes.""" +import errno +import logging +import os +import sys +import tokenize + +try: + import multiprocessing +except ImportError: + multiprocessing = None + +try: + import Queue as queue +except ImportError: + import queue + +from flake8 import defaults +from flake8 import exceptions +from flake8 import processor +from flake8 import utils + +LOG = logging.getLogger(__name__) + +SERIAL_RETRY_ERRNOS = set([ + # ENOSPC: Added by sigmavirus24 + # > On some operating systems (OSX), multiprocessing may cause an + # > ENOSPC error while trying to trying to create a Semaphore. + # > In those cases, we should replace the customized Queue Report + # > class with pep8's StandardReport class to ensure users don't run + # > into this problem. + # > (See also: https://gitlab.com/pycqa/flake8/issues/74) + errno.ENOSPC, + # NOTE(sigmavirus24): When adding to this list, include the reasoning + # on the lines before the error code and always append your error + # code. Further, please always add a trailing `,` to reduce the visual + # noise in diffs. +]) + + +class Manager(object): + """Manage the parallelism and checker instances for each plugin and file. + + This class will be responsible for the following: + + - Determining the parallelism of Flake8, e.g.: + + * Do we use :mod:`multiprocessing` or is it unavailable? + + * Do we automatically decide on the number of jobs to use or did the + user provide that? + + - Falling back to a serial way of processing files if we run into an + OSError related to :mod:`multiprocessing` + + - Organizing the results of each checker so we can group the output + together and make our output deterministic. + """ + + def __init__(self, style_guide, arguments, checker_plugins): + """Initialize our Manager instance. + + :param style_guide: + The instantiated style guide for this instance of Flake8. + :type style_guide: + flake8.style_guide.StyleGuide + :param list arguments: + The extra arguments parsed from the CLI (if any) + :param checker_plugins: + The plugins representing checks parsed from entry-points. + :type checker_plugins: + flake8.plugins.manager.Checkers + """ + self.arguments = arguments + self.style_guide = style_guide + self.options = style_guide.options + self.checks = checker_plugins + self.jobs = self._job_count() + self.process_queue = None + self.results_queue = None + self.statistics_queue = None + self.using_multiprocessing = self.jobs > 1 + self.processes = [] + self.checkers = [] + self.statistics = { + 'files': 0, + 'logical lines': 0, + 'physical lines': 0, + 'tokens': 0, + } + + if self.using_multiprocessing: + try: + self.process_queue = multiprocessing.Queue() + self.results_queue = multiprocessing.Queue() + self.statistics_queue = multiprocessing.Queue() + except OSError as oserr: + if oserr.errno not in SERIAL_RETRY_ERRNOS: + raise + self.using_multiprocessing = False + + @staticmethod + def _cleanup_queue(q): + while not q.empty(): + q.get_nowait() + + def _force_cleanup(self): + if self.using_multiprocessing: + for proc in self.processes: + proc.join(0.2) + self._cleanup_queue(self.process_queue) + self._cleanup_queue(self.results_queue) + self._cleanup_queue(self.statistics_queue) + + def _process_statistics(self): + all_statistics = self.statistics + if self.using_multiprocessing: + total_number_of_checkers = len(self.checkers) + statistics_gathered = 0 + while statistics_gathered < total_number_of_checkers: + try: + statistics = self.statistics_queue.get(block=False) + statistics_gathered += 1 + except queue.Empty: + break + + for statistic in defaults.STATISTIC_NAMES: + all_statistics[statistic] += statistics[statistic] + else: + statistics_generator = (checker.statistics + for checker in self.checkers) + for statistics in statistics_generator: + for statistic in defaults.STATISTIC_NAMES: + all_statistics[statistic] += statistics[statistic] + all_statistics['files'] += len(self.checkers) + + def _job_count(self): + # type: () -> Union[int, NoneType] + # First we walk through all of our error cases: + # - multiprocessing library is not present + # - we're running on windows in which case we know we have significant + # implemenation issues + # - the user provided stdin and that's not something we can handle + # well + # - we're processing a diff, which again does not work well with + # multiprocessing and which really shouldn't require multiprocessing + # - the user provided some awful input + if not multiprocessing: + LOG.warning('The multiprocessing module is not available. ' + 'Ignoring --jobs arguments.') + return 0 + + if (utils.is_windows() and + not utils.can_run_multiprocessing_on_windows()): + LOG.warning('The --jobs option is only available on Windows on ' + 'Python 2.7.11+ and 3.3+. We have detected that you ' + 'are running an unsupported version of Python on ' + 'Windows. Ignoring --jobs arguments.') + return 0 + + if utils.is_using_stdin(self.arguments): + LOG.warning('The --jobs option is not compatible with supplying ' + 'input using - . Ignoring --jobs arguments.') + return 0 + + if self.options.diff: + LOG.warning('The --diff option was specified with --jobs but ' + 'they are not compatible. Ignoring --jobs arguments.') + return 0 + + jobs = self.options.jobs + if jobs != 'auto' and not jobs.isdigit(): + LOG.warning('"%s" is not a valid parameter to --jobs. Must be one ' + 'of "auto" or a numerical value, e.g., 4.', jobs) + return 0 + + # If the value is "auto", we want to let the multiprocessing library + # decide the number based on the number of CPUs. However, if that + # function is not implemented for this particular value of Python we + # default to 1 + if jobs == 'auto': + try: + return multiprocessing.cpu_count() + except NotImplementedError: + return 0 + + # Otherwise, we know jobs should be an integer and we can just convert + # it to an integer + return int(jobs) + + def _results(self): + seen_done = 0 + LOG.info('Retrieving results') + while True: + result = self.results_queue.get() + if result == 'DONE': + seen_done += 1 + if seen_done >= self.jobs: + break + continue + + yield result + + def _handle_results(self, filename, results): + style_guide = self.style_guide + reported_results_count = 0 + for (error_code, line_number, column, text, physical_line) in results: + reported_results_count += style_guide.handle_error( + code=error_code, + filename=filename, + line_number=line_number, + column_number=column, + text=text, + physical_line=physical_line, + ) + return reported_results_count + + def _run_checks_from_queue(self): + LOG.info('Running checks in parallel') + for checker in iter(self.process_queue.get, 'DONE'): + LOG.debug('Running checker for file "%s"', checker.filename) + checker.run_checks(self.results_queue, self.statistics_queue) + self.results_queue.put('DONE') + + def is_path_excluded(self, path): + # type: (str) -> bool + """Check if a path is excluded. + + :param str path: + Path to check against the exclude patterns. + :returns: + True if there are exclude patterns and the path matches, + otherwise False. + :rtype: + bool + """ + exclude = self.options.exclude + if not exclude: + return False + basename = os.path.basename(path) + if utils.fnmatch(basename, exclude): + LOG.info('"%s" has been excluded', basename) + return True + + absolute_path = os.path.abspath(path) + match = utils.fnmatch(absolute_path, exclude) + LOG.info('"%s" has %sbeen excluded', absolute_path, + '' if match else 'not ') + return match + + def make_checkers(self, paths=None): + # type: (List[str]) -> NoneType + """Create checkers for each file.""" + if paths is None: + paths = self.arguments + filename_patterns = self.options.filename + + # NOTE(sigmavirus24): Yes this is a little unsightly, but it's our + # best solution right now. + def should_create_file_checker(filename): + """Determine if we should create a file checker.""" + matches_filename_patterns = utils.fnmatch( + filename, filename_patterns + ) + is_stdin = filename == '-' + file_exists = os.path.exists(filename) + return (file_exists and matches_filename_patterns) or is_stdin + + self.checkers = [ + FileChecker(filename, self.checks, self.style_guide) + for argument in paths + for filename in utils.filenames_from(argument, + self.is_path_excluded) + if should_create_file_checker(filename) + ] + + def report(self): + # type: () -> (int, int) + """Report all of the errors found in the managed file checkers. + + This iterates over each of the checkers and reports the errors sorted + by line number. + + :returns: + A tuple of the total results found and the results reported. + :rtype: + tuple(int, int) + """ + results_reported = results_found = 0 + for checker in self.checkers: + results = sorted(checker.results, key=lambda tup: (tup[2], tup[3])) + results_reported += self._handle_results(checker.filename, + results) + results_found += len(results) + return (results_found, results_reported) + + def run_parallel(self): + """Run the checkers in parallel.""" + LOG.info('Starting %d process workers', self.jobs) + for i in range(self.jobs): + proc = multiprocessing.Process( + target=self._run_checks_from_queue + ) + proc.daemon = True + proc.start() + self.processes.append(proc) + + final_results = {} + for (filename, results) in self._results(): + final_results[filename] = results + + for checker in self.checkers: + filename = checker.filename + checker.results = sorted(final_results.get(filename, []), + key=lambda tup: (tup[1], tup[2])) + + def run_serial(self): + """Run the checkers in serial.""" + for checker in self.checkers: + checker.run_checks(self.results_queue, self.statistics_queue) + + def run(self): + """Run all the checkers. + + This will intelligently decide whether to run the checks in parallel + or whether to run them in serial. + + If running the checks in parallel causes a problem (e.g., + https://gitlab.com/pycqa/flake8/issues/74) this also implements + fallback to serial processing. + """ + try: + if self.using_multiprocessing: + self.run_parallel() + else: + self.run_serial() + except OSError as oserr: + if oserr.errno not in SERIAL_RETRY_ERRNOS: + LOG.exception(oserr) + raise + LOG.warning('Running in serial after OS exception, %r', oserr) + self.run_serial() + + def start(self, paths=None): + """Start checking files. + + :param list paths: + Path names to check. This is passed directly to + :meth:`~Manager.make_checkers`. + """ + LOG.info('Making checkers') + self.make_checkers(paths) + if not self.using_multiprocessing: + return + + LOG.info('Populating process queue') + for checker in self.checkers: + self.process_queue.put(checker) + + for i in range(self.jobs): + self.process_queue.put('DONE') + + def stop(self): + """Stop checking files.""" + self._process_statistics() + for proc in self.processes: + LOG.info('Joining %s to the main process', proc.name) + proc.join() + + +class FileChecker(object): + """Manage running checks for a file and aggregate the results.""" + + def __init__(self, filename, checks, style_guide): + """Initialize our file checker. + + :param str filename: + Name of the file to check. + :param checks: + The plugins registered to check the file. + :type checks: + flake8.plugins.manager.Checkers + :param style_guide: + The initialized StyleGuide for this particular run. + :type style_guide: + flake8.style_guide.StyleGuide + """ + self.filename = filename + self.checks = checks + self.style_guide = style_guide + self.results = [] + self.processor = self._make_processor() + self.statistics = { + 'tokens': 0, + 'logical lines': 0, + 'physical lines': len(self.processor.lines), + } + + def _make_processor(self): + try: + return processor.FileProcessor(self.filename, + self.style_guide.options) + except IOError: + # If we can not read the file due to an IOError (e.g., the file + # does not exist or we do not have the permissions to open it) + # then we need to format that exception for the user. + # NOTE(sigmavirus24): Historically, pep8 has always reported this + # as an E902. We probably *want* a better error code for this + # going forward. + (exc_type, exception) = sys.exc_info()[:2] + message = '{0}: {1}'.format(exc_type.__name__, exception) + self.report('E902', 0, 0, message) + return None + + def report(self, error_code, line_number, column, text): + # type: (str, int, int, str) -> str + """Report an error by storing it in the results list.""" + if error_code is None: + error_code, text = text.split(' ', 1) + + physical_line = '' + # If we're recovering from a problem in _make_processor, we will not + # have this attribute. + if getattr(self, 'processor', None): + physical_line = self.processor.line_for(line_number) + + error = (error_code, line_number, column, text, physical_line) + self.results.append(error) + return error_code + + def run_check(self, plugin, **arguments): + """Run the check in a single plugin.""" + LOG.debug('Running %r with %r', plugin, arguments) + self.processor.keyword_arguments_for(plugin.parameters, arguments) + return plugin.execute(**arguments) + + def run_ast_checks(self): + """Run all checks expecting an abstract syntax tree.""" + try: + ast = self.processor.build_ast() + except (ValueError, SyntaxError, TypeError): + (exc_type, exception) = sys.exc_info()[:2] + if len(exception.args) > 1: + offset = exception.args[1] + if len(offset) > 2: + offset = offset[1:3] + else: + offset = (1, 0) + + self.report('E999', offset[0], offset[1], '%s: %s' % + (exc_type.__name__, exception.args[0])) + return + + for plugin in self.checks.ast_plugins: + checker = self.run_check(plugin, tree=ast) + # NOTE(sigmavirus24): If we want to allow for AST plugins that are + # not classes exclusively, we can do the following: + # retrieve_results = getattr(checker, 'run', lambda: checker) + # Otherwise, we just call run on the checker + for (line_number, offset, text, check) in checker.run(): + self.report( + error_code=None, + line_number=line_number, + column=offset, + text=text, + ) + + def run_logical_checks(self): + """Run all checks expecting a logical line.""" + comments, logical_line, mapping = self.processor.build_logical_line() + if not mapping: + return + self.processor.update_state(mapping) + + LOG.debug('Logical line: "%s"', logical_line.rstrip()) + + for plugin in self.checks.logical_line_plugins: + self.processor.update_checker_state_for(plugin) + results = self.run_check(plugin, logical_line=logical_line) or () + for offset, text in results: + offset = find_offset(offset, mapping) + line_number, column_offset = offset + self.report( + error_code=None, + line_number=line_number, + column=column_offset, + text=text, + ) + + self.processor.next_logical_line() + + def run_physical_checks(self, physical_line): + """Run all checks for a given physical line.""" + for plugin in self.checks.physical_line_plugins: + self.processor.update_checker_state_for(plugin) + result = self.run_check(plugin, physical_line=physical_line) + if result is not None: + column_offset, text = result + error_code = self.report( + error_code=None, + line_number=self.processor.line_number, + column=column_offset, + text=text, + ) + + self.processor.check_physical_error(error_code, physical_line) + + def process_tokens(self): + """Process tokens and trigger checks. + + This can raise a :class:`flake8.exceptions.InvalidSyntax` exception. + Instead of using this directly, you should use + :meth:`flake8.checker.FileChecker.run_checks`. + """ + parens = 0 + statistics = self.statistics + file_processor = self.processor + for token in file_processor.generate_tokens(): + statistics['tokens'] += 1 + self.check_physical_eol(token) + token_type, text = token[0:2] + processor.log_token(LOG, token) + if token_type == tokenize.OP: + parens = processor.count_parentheses(parens, text) + elif parens == 0: + if processor.token_is_newline(token): + self.handle_newline(token_type) + elif (processor.token_is_comment(token) and + len(file_processor.tokens) == 1): + self.handle_comment(token, text) + + if file_processor.tokens: + # If any tokens are left over, process them + self.run_physical_checks(file_processor.lines[-1]) + self.run_logical_checks() + + def run_checks(self, results_queue, statistics_queue): + """Run checks against the file.""" + if self.processor.should_ignore_file(): + return + + try: + self.process_tokens() + except exceptions.InvalidSyntax as exc: + self.report(exc.error_code, exc.line_number, exc.column_number, + exc.error_message) + + self.run_ast_checks() + + if results_queue is not None: + results_queue.put((self.filename, self.results)) + + logical_lines = self.processor.statistics['logical lines'] + self.statistics['logical lines'] = logical_lines + if statistics_queue is not None: + statistics_queue.put(self.statistics) + + def handle_comment(self, token, token_text): + """Handle the logic when encountering a comment token.""" + # The comment also ends a physical line + token = list(token) + token[1] = token_text.rstrip('\r\n') + token[3] = (token[2][0], token[2][1] + len(token[1])) + self.processor.tokens = [tuple(token)] + self.run_logical_checks() + + def handle_newline(self, token_type): + """Handle the logic when encountering a newline token.""" + if token_type == tokenize.NEWLINE: + self.run_logical_checks() + self.processor.reset_blank_before() + elif len(self.processor.tokens) == 1: + # The physical line contains only this token. + self.processor.visited_new_blank_line() + self.processor.delete_first_token() + else: + self.run_logical_checks() + + def check_physical_eol(self, token): + """Run physical checks if and only if it is at the end of the line.""" + if processor.is_eol_token(token): + # Obviously, a newline token ends a single physical line. + self.run_physical_checks(token[4]) + elif processor.is_multiline_string(token): + # Less obviously, a string that contains newlines is a + # multiline string, either triple-quoted or with internal + # newlines backslash-escaped. Check every physical line in the + # string *except* for the last one: its newline is outside of + # the multiline string, so we consider it a regular physical + # line, and will check it like any other physical line. + # + # Subtleties: + # - have to wind self.line_number back because initially it + # points to the last line of the string, and we want + # check_physical() to give accurate feedback + line_no = token[2][0] + with self.processor.inside_multiline(line_number=line_no): + for line in self.processor.split_line(token): + self.run_physical_checks(line + '\n') + + +def find_offset(offset, mapping): + """Find the offset tuple for a single offset.""" + if isinstance(offset, tuple): + return offset + + for token_offset, position in mapping: + if offset <= token_offset: + break + return (position[0], position[1] + offset - token_offset) |
