diff --git a/git_lint/__main__.py b/git_lint/__main__.py new file mode 100644 index 0000000..3988dec --- /dev/null +++ b/git_lint/__main__.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python +from .options import OPTIONS +from .option_handler import cleanup_options +from .reporters import print_report, print_help +from .git_lint import load_config, run_linters, git_base +from getopt import GetoptError + +import gettext +_ = gettext.gettext + +def main(*args): + if git_base is None: + sys.exit(_('A git repository was not found.')) + + (options, filenames, excluded_commands) = cleanup_options(OPTIONS, args) + + if len(excluded_commands) > 0: + print(_('These command line options were ignored due to option precedence.')) + for exc in excluded_commands: + print("\t{}".format(exc)) + + try: + config = load_config(options, git_base) + + if 'help' in options: + print_help(OPTIONS, NAME) + return 0 + + if 'version' in options: + from .reporters get print_version + print_version(NAME, VERSION) + return 0 + + if 'linters' in options: + from .gitSlint import get_linter_status + working_linter_names, broken_linter_names = get_linter_status(config) + print_linters(working_linter_names, broken_linter_names)) + return 0 + + (results, + unlintable_filenames, + cant_lint_filenames, + broken_linter_names, + unfindable_filenames) = run_linters(options, config, filenames) + + print_report(results, + unlintable_filenames, + cant_lint_filenames, + broken_linter_names, + unfindable_filenames, + options) + + if not len(results): + return 0 + + return max([i[2] for i in results if len(i)]) + + except GetoptError as err: + print_help(OPTIONS) + return 1 + + +if __name__ == '__main__': + import sys + sys.exit(main(*sys.argv)) diff --git a/git_lint/git_lint.py b/git_lint/git_lint.py index 303bc17..b329f55 100644 --- a/git_lint/git_lint.py +++ b/git_lint/git_lint.py @@ -21,13 +21,6 @@ VERSION = '0.0.4' NAME = 'git-lint' -# ___ _ _ _ -# / __|___ _ __ _ __ __ _ _ _ __| | | | (_)_ _ ___ -# | (__/ _ \ ' \| ' \/ _` | ' \/ _` | | |__| | ' \/ -_) -# \___\___/_|_|_|_|_|_\__,_|_||_\__,_| |____|_|_||_\___| -# - - # ___ __ _ ___ _ # / __|___ _ _ / _(_)__ _ | _ \___ __ _ __| |___ _ _ # | (__/ _ \ ' \| _| / _` | | / -_) _` / _` / -_) '_| @@ -69,7 +62,7 @@ def find_config_file(options, base): # (commandLineDictionary, repositoryLocation) -> (configurationDictionary | exit) -def get_config(options, base): +def load_config(options, base): """Loads the git-lint configuration file. Returns the configuration file as a dictionary of dictionaries. @@ -161,21 +154,21 @@ def base_file_cleaner(files): return [file.replace(git_base + '/', '', 1) for file in files] -def make_match_filter_matcher(extensions): - trimmed = [s.strip() for s in reduce(operator.add, - [ex.split(',') for ex in extensions], [])] - cleaned = [re.sub(r'^\.', '', s) for s in trimmed] - return re.compile(r'\.' + '|'.join(cleaned) + r'$') +class MatchFilter: + def __init__(self, config): + self.matcher = self.make_match_filter_matcher([v.linter.get('match', '') for v in config]) -def make_match_filter(config): - matcher = make_match_filter_matcher([v.linter.get('match', '') for v in config]) - - def match_filter(path): - return matcher.search(path) - - return match_filter - + def __call__(self, path): + return self.matcher.search(path) + + @staticmethod + def make_match_filter_matcher(extensions): + trimmed = [s.strip() for s in reduce(operator.add, + [ex.split(',') for ex in extensions], [])] + cleaned = [re.sub(r'^\.', '', s) for s in trimmed] + return re.compile(r'\.' + '|'.join(cleaned) + r'$') + # ICK. Mutation, references, and hidden assignment. def group_by(iterable, field_id): @@ -247,7 +240,7 @@ def print_linters(config): # \___\___|\__| |_|_/__/\__| \___/_| |_| |_|_\___/__/ # -def get_filelist(cmdline, extras): +def get_filelist(options, extras): """ Returns the list of files against which we'll run the linters. """ def base_file_filter(files): @@ -331,13 +324,13 @@ def get_filelist(cmdline, extras): return ([os.path.relpath(f, cwd) for f in (extras_fullpathed - not_found)], not_found) working_directory_trans = cwd_file_filter - if 'base' in cmdline or 'every' in cmdline: + if 'base' in options or 'every' in options: working_directory_trans = base_file_filter file_list_generator = working_list - if 'all' in cmdline: + if 'all' in options: file_list_generator = all_list - if 'staging' in cmdline: + if 'staging' in options: file_list_generator = staging_list return (working_directory_trans(remove_submodules(file_list_generator())), []) @@ -349,16 +342,15 @@ def get_filelist(cmdline, extras): # |___/\__\__,_\__, |_|_||_\__, | \_/\_/|_| \__,_| .__/ .__/\___|_| # |___/ |___/ |_| |_| -def pick_stash_runner(cmdline): - """Choose a runner. - This is the operation that will run the linters. It exists to - provide a way to stash the repository, then restore it when - complete. If possible, it attempts to restore the access and - modification times of the file in order to comfort IDEs that are - constantly monitoring file times. - """ +class Runner: + def __init__(self, options): + self.runner = ('staging' in options and self.staging_wrapper) or self.workspace_wrapper + def __call__(run_linters, filenames): + return self.runner(run_linters, filenames) + + @staticmethod def staging_wrapper(run_linters, filenames): def time_gather(f): stats = os.stat(f) @@ -375,11 +367,10 @@ def pick_stash_runner(cmdline): os.utime(filename, timepair) return results + @staticmethod def workspace_wrapper(run_linters, filenames): return run_linters() - return ('staging' in cmdline and staging_wrapper) or workspace_wrapper - # ___ _ _ _ # | _ \_ _ _ _ | (_)_ _| |_ _ __ __ _ ______ @@ -387,131 +378,87 @@ def pick_stash_runner(cmdline): # |_|_\\_,_|_||_| |_|_|_||_\__| | .__/\__,_/__/__/ # |_| -def run_external_linter(filename, linter, linter_name): - - """Run one linter against one file. - - If the result matches the error condition specified in the - configuration file, return the error code and messages, either - return nothing. - """ +class Linter: + def __init__(linters, filenames): + self.linters = linters + self.filenames = filenames + @staticmethod def encode_shell_messages(prefix, messages): return ['{}{}'.format(prefix, line) for line in messages.splitlines()] - cmd = linter['command'] + ' "' + filename + '"' - (out, err, returncode) = get_shell_response(cmd) - failed = ((out and (linter.get('condition', 'error') == 'output')) or err or (not (returncode == 0))) - trimmed_filename = filename.replace(git_base + '/', '', 1) - if not failed: - return (trimmed_filename, linter_name, 0, []) + @staticmethod + def run_external_linter(filename, linter, linter_name): + """Run one linter against one file. - prefix = (((linter.get('print', 'false').strip().lower() != 'true') and ' ') - or ' {}: '.format(trimmed_filename)) - output = (encode_shell_messages(prefix, out) + - ((err and encode_shell_messages(prefix, err)) or [])) - return (trimmed_filename, linter_name, (returncode or 1), output) + If the result matches the error condition specified in the configuration file, + return the error code and messages, either return nothing. + """ - -def run_one_linter(linter, filenames): - """ Runs one linter against a set of files - - Creates a match filter for the linter, extract the files to be - linted, and runs the linter against each file, returning the - result as a list of successes and failures. Failures have a - return code and the output of the lint process. - """ - match_filter = make_match_filter([linter]) - files = set([file for file in filenames if match_filter(file)]) - return [run_external_linter(file, linter.linter, linter.name) for file in files] - - -def build_lint_runner(linters, filenames): - - """ Returns a function to run a set of linters against a set of filenames - - This returns a function because it's going to be wrapped in a - runner to better handle stashing and restoring a staged commit. - """ - def lint_runner(): - return reduce(operator.add, - [run_one_linter(linter, filenames) for linter in linters], []) - return lint_runner - - -def dryrun(linters, filenames): - - def dryrunonefile(filename, linter): + cmd = linter['command'] + ' "' + filename + '"' + (out, err, returncode) = get_shell_response(cmd) + failed = ((out and (linter.get('condition', 'error') == 'output')) or err or (not (returncode == 0))) trimmed_filename = filename.replace(git_base + '/', '', 1) - return (trimmed_filename, linter.name, 0, [' {}'.format(trimmed_filename)]) - - def dryrunonce(linter, filenames): - match_filter = make_match_filter([linter]) - files_to_check = [filename for filename in filenames if match_filter(filename)] - return [dryrunonefile(filename, linter) for filename in files_to_check] - - return reduce(operator.add, [dryrunonce(linter, filenames) for linter in linters], []) + if not failed: + return (trimmed_filename, linter_name, 0, []) + + prefix = (((linter.get('print', 'false').strip().lower() != 'true') and ' ') + or ' {}: '.format(trimmed_filename)) + output = (encode_shell_messages(prefix, out) + + ((err and encode_shell_messages(prefix, err)) or [])) + return (trimmed_filename, linter_name, (returncode or 1), output) -# __ __ _ -# | \/ |__ _(_)_ _ -# | |\/| / _` | | ' \ -# |_| |_\__,_|_|_||_| -# - -def print_report(results, cmdline, unlintable_filenames, cant_lint_filenames, - broken_linter_names, unfindable_filenames): - sort_position = 1 - grouping = 'Linter: {}' - if 'byfile' in cmdline: - sort_position = 0 - grouping = 'Filename: {}' - grouped_results = group_by(results, sort_position) - for group in grouped_results: - print(grouping.format(group[0])) - for (filename, lintername, returncode, text) in group[1]: - print("\n".join(text)) - print("") - if len(broken_linter_names): - print("These linters could not be run:", ",".join(broken_linter_names)) - if len(cant_lint_filenames): - print("As a result, these files were not linted:") - print("\n".join([" {}".format(f) for f in cant_lint_filenames])) - if len(unlintable_filenames): - print("The following files had no recognizeable linters:") - print("\n".join([" {}".format(f) for f in unlintable_filenames])) - if len(unfindable_filenames): - print("The following files could not be found:") - print("\n".join([" {}".format(f) for f in unfindable_filenames])) + @staticmethod + def run_one_linter(linter, filenames): + """ Runs one linter against a set of files + + Creates a match filter for the linter, extract the files to be + linted, and runs the linter against each file, returning the + result as a list of successes and failures. Failures have a + return code and the output of the lint process. + """ + match_filter = make_match_filter([linter]) + files = set([filename for filename in filenames if match_filter(filename)]) + return [self.run_external_linter(filename, linter.linter, linter.name) for filename in files] + def __call__(self, linters, filenames): + """ Returns a function to run a set of linters against a set of filenames + + This returns a function because it's going to be wrapped in a + runner to better handle stashing and restoring a staged commit. + """ + return reduce(operator.add, + [run_one_linter(linter, self.filenames) for linter in self.linters], []) -def print_help(options_list, name): - print(_('Usage: {} [options] [filenames]').format(name)) - for item in options_list: - print(' -{:<1} --{:<12} {}'.format(item[0], item[1], item[3])) - return sys.exit() + def dryrun(self): + + def dryrunonefile(filename, linter): + trimmed_filename = filename.replace(git_base + '/', '', 1) + return (trimmed_filename, linter.name, 0, [' {}'.format(trimmed_filename)]) + + def dryrunonce(linter, filenames): + match_filter = MatchFilter([linter]) + files_to_check = [filename for filename in filenames if match_filter(filename)] + return [dryrunonefile(filename, linter) for filename in files_to_check] + + return reduce(operator.add, [dryrunonce(linter, self.filenames) for linter in self.linters], []) + -def print_version(name, version): - print('{} {} Copyright (c) 2009, 2016 Kennth M. "Elf" Sternberg'.format(name, version)) - - - -def run_gitlint(cmdline, config, extras): +def run_linters(options, config, extras): def build_config_subset(keys): """ Returns a subset of the configuration, with only those linters mentioned in keys """ return [item for item in config if item.name in keys] """ Runs the requested linters """ - all_filenames, unfindable_filenames = get_filelist(cmdline, extras) + all_filenames, unfindable_filenames = get_filelist(options, extras) - stash_runner = pick_stash_runner(cmdline) - - is_lintable = make_match_filter(config) + is_lintable = MatchFilter(config) lintable_filenames = set([filename for filename in all_filenames if is_lintable(filename)]) @@ -520,26 +467,23 @@ def run_gitlint(cmdline, config, extras): working_linter_names, broken_linter_names = get_linter_status(config) - cant_lint_filter = make_match_filter(build_config_subset( + cant_lint_filter = MatchFilter(build_config_subset( broken_linter_names)) cant_lint_filenames = [filename for filename in lintable_filenames if cant_lint_filter(filename)] - if 'dryrun' in cmdline: - return print_report( - dryrun( - build_config_subset(working_linter_names), sorted(lintable_filenames)), - cmdline, unlintable_filenames, cant_lint_filenames, + if 'dryrun' in options: + dryrun_results = dryrun( + build_config_subset(working_linter_names), sorted(lintable_filenames)) + return (dryrun_results, unlintable_filenames, cant_lint_filenames, + broken_linter_names, unfindable_filenames) + + runner = Runner(options) + linter = Linter(build_config_subset(working_linter_names), + sorted(lintable_filenames)) + results = runner(linter, lintable_filenames) + + return (results, unlintable_filenames, cant_lint_filenames, broken_linter_names, unfindable_filenames) - lint_runner = build_lint_runner( - build_config_subset(working_linter_names), sorted(lintable_filenames)) - - results = stash_runner(lint_runner, lintable_filenames) - - print_report(results, cmdline, unlintable_filenames, cant_lint_filenames, - broken_linter_names, unfindable_filenames) - if not len(results): - return 0 - return max([i[2] for i in results if len(i)]) diff --git a/git_lint/option_handler.py b/git_lint/option_handler.py index 85eea1d..81aa195 100644 --- a/git_lint/option_handler.py +++ b/git_lint/option_handler.py @@ -6,8 +6,17 @@ # This was a lot shorter and smarter in Hy... +# A lot of what you see here is separated from git_lint itself, since this will not be +# relevant to the operation of pre-commit. -def make_rational_options(options, commandline): +# ___ _ _ _ +# / __|___ _ __ _ __ __ _ _ _ __| | | | (_)_ _ ___ +# | (__/ _ \ ' \| ' \/ _` | ' \/ _` | | |__| | ' \/ -_) +# \___\___/_|_|_|_|_|_\__,_|_||_\__,_| |____|_|_||_\___| +# + + +def cleanup_options(options, commandline): """Takes a table of options and the commandline, and returns a dictionary of those options that appear on the commandline along with any extra arguments. @@ -19,7 +28,7 @@ def make_rational_options(options, commandline): The arguments as received by the start-up process """ - def make_options_rationalizer(options): + def make_option_streamliner(options): """Takes a list of option tuples, and returns a function that takes the output of getopt and reduces it to the longopt key and @@ -28,17 +37,16 @@ def make_rational_options(options, commandline): fullset = {} for option in options: - if not option[1]: - continue - if option[0]: - fullset['-' + option[0]] = option[1] - fullset['--' + option[1]] = option[1] + if option[1]: + fullset['--' + option[1]] = option[1] + if option[0]: + fullset['-' + option[0]] = option[1] - def rationalizer(acc, it): + def streamliner(acc, it): acc[fullset[it[0]]] = it[1] return acc - return rationalizer + return streamliner def remove_conflicted_options(options, request): """Takes our list of option tuples, and a cleaned copy of what was @@ -68,10 +76,10 @@ def make_rational_options(options, commandline): optstringslong) # Turns what getopt returns into something more human-readable - rationalize_options = make_options_rationalizer(options) + streamline_options = make_option_streamliner(options) # Remove any options that are superseded by others. - (retoptions, excluded) = remove_conflicted_options( - optlist, reduce(rationalize_options, options, {})) + (ret, excluded) = remove_conflicted_options( + optlist, reduce(streamline_options, options, {})) - return (retoptions, filenames, excluded) + return (ret, filenames, excluded) diff --git a/git_lint/options.py b/git_lint/options.py index 7f14f14..4de6235 100644 --- a/git_lint/options.py +++ b/git_lint/options.py @@ -1,5 +1,7 @@ +import gettext +_ = gettext.gettext -OPTIONS_LIST = [ +OPTIONS = [ ('o', 'only', True, _('A comma-separated list of only those linters to run'), ['exclude']), ('x', 'exclude', True, diff --git a/git_lint/reporters.py b/git_lint/reporters.py new file mode 100644 index 0000000..b37d25c --- /dev/null +++ b/git_lint/reporters.py @@ -0,0 +1,49 @@ +import gettext +_ = gettext.gettext + +def print_report(results, unlintable_filenames, cant_lint_filenames, + broken_linter_names, unfindable_filenames, options = {'bylinter': True}): + sort_position = 1 + grouping = _('Linter: {}') + if 'byfile' in options: + sort_position = 0 + grouping = _('Filename: {}') + grouped_results = group_by(results, sort_position) + for group in grouped_results: + print(grouping.format(group[0])) + for (filename, lintername, returncode, text) in group[1]: + print('\n'.join(text)) + print('') + if len(broken_linter_names): + print(_('These linters could not be run:'), ','.join(broken_linter_names)) + if len(cant_lint_filenames): + print(_('As a result, these files were not linted:')) + print('\n'.join([' {}'.format(f) for f in cant_lint_filenames])) + if len(unlintable_filenames): + print(_('The following files had no recognizeable linters:')) + print('\n'.join([' {}'.format(f) for f in unlintable_filenames])) + if len(unfindable_filenames): + print(_('The following files could not be found:')) + print('\n'.join([' {}'.format(f) for f in unfindable_filenames])) + + +def print_help(options, name): + print(_('Usage: {} [options] [filenames]').format(name)) + for item in options: + print(' -{:<1} --{:<12} {}'.format(item[0], item[1], item[3])) + return sys.exit() + + +def print_version(name, version): + print(_'{} {} Copyright (c) 2009, 2016 Kennth M. "Elf" Sternberg').format(name, version)) + + +def print_linters(working_linter_names, broken_linter_names): + print(_('Currently supported linters:')) + for linter in config: + print('{:<14} {}'.format(linter.name, + ((linter.name in broken_linter_names and + _('(WARNING: executable not found)') or + linter.linter.get('comment', ''))))) + + diff --git a/setup.py b/setup.py index bd4e95a..abd7d31 100644 --- a/setup.py +++ b/setup.py @@ -23,13 +23,13 @@ test_requirements = [ ] setup( - name='git_lint', - version='0.0.2', + name='git_linter', + version='0.0.4', description="A git command to lint everything in your workspace (or stage) that was changed since the last commit.", long_description=readme + '\n\n' + history, - author="Kenneth M. "Elf" Sternberg", + author='Kenneth M. "Elf" Sternberg', author_email='elf.sternberg@gmail.com', - url='https://github.com/elfsternberg/git_lint', + url='https://github.com/elfsternberg/git_linter', packages=[ 'git_lint', ], @@ -37,16 +37,20 @@ setup( 'git_lint'}, include_package_data=True, install_requires=requirements, - license="ISCL", + license="MIT", zip_safe=False, - keywords='git_lint', + keywords='git lint style syntaxt development', + entry_points={ + 'console_scripts': [ + 'git-lint = git_lint.__main__:main' + ] + }, classifiers=[ 'Development Status :: 2 - Pre-Alpha', 'Intended Audience :: Developers', 'License :: OSI Approved :: ISC License (ISCL)', 'Natural Language :: English', "Programming Language :: Python :: 2", - 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.3', diff --git a/tox.ini b/tox.ini index eb674fd..dea556a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py26, py27, py33, py34, py35 +envlist = py27, py33, py34, py35 [testenv] setenv =