It's all coming together.

This commit is contained in:
Elf M. Sternberg 2016-09-28 14:26:06 -07:00
parent 33c3d7a189
commit 9029eef623
7 changed files with 247 additions and 175 deletions

65
git_lint/__main__.py Normal file
View File

@ -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))

View File

@ -21,13 +21,6 @@ VERSION = '0.0.4'
NAME = 'git-lint' NAME = 'git-lint'
# ___ _ _ _
# / __|___ _ __ _ __ __ _ _ _ __| | | | (_)_ _ ___
# | (__/ _ \ ' \| ' \/ _` | ' \/ _` | | |__| | ' \/ -_)
# \___\___/_|_|_|_|_|_\__,_|_||_\__,_| |____|_|_||_\___|
#
# ___ __ _ ___ _ # ___ __ _ ___ _
# / __|___ _ _ / _(_)__ _ | _ \___ __ _ __| |___ _ _ # / __|___ _ _ / _(_)__ _ | _ \___ __ _ __| |___ _ _
# | (__/ _ \ ' \| _| / _` | | / -_) _` / _` / -_) '_| # | (__/ _ \ ' \| _| / _` | | / -_) _` / _` / -_) '_|
@ -69,7 +62,7 @@ def find_config_file(options, base):
# (commandLineDictionary, repositoryLocation) -> (configurationDictionary | exit) # (commandLineDictionary, repositoryLocation) -> (configurationDictionary | exit)
def get_config(options, base): def load_config(options, base):
"""Loads the git-lint configuration file. """Loads the git-lint configuration file.
Returns the configuration file as a dictionary of dictionaries. 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] return [file.replace(git_base + '/', '', 1) for file in files]
def make_match_filter_matcher(extensions): class MatchFilter:
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'$')
def __init__(self, config):
self.matcher = self.make_match_filter_matcher([v.linter.get('match', '') for v in config])
def make_match_filter(config): def __call__(self, path):
matcher = make_match_filter_matcher([v.linter.get('match', '') for v in config]) return self.matcher.search(path)
def match_filter(path): @staticmethod
return matcher.search(path) def make_match_filter_matcher(extensions):
trimmed = [s.strip() for s in reduce(operator.add,
return match_filter [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. # ICK. Mutation, references, and hidden assignment.
def group_by(iterable, field_id): 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. """ """ Returns the list of files against which we'll run the linters. """
def base_file_filter(files): 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) return ([os.path.relpath(f, cwd) for f in (extras_fullpathed - not_found)], not_found)
working_directory_trans = cwd_file_filter 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 working_directory_trans = base_file_filter
file_list_generator = working_list file_list_generator = working_list
if 'all' in cmdline: if 'all' in options:
file_list_generator = all_list file_list_generator = all_list
if 'staging' in cmdline: if 'staging' in options:
file_list_generator = staging_list file_list_generator = staging_list
return (working_directory_trans(remove_submodules(file_list_generator())), []) 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 class Runner:
provide a way to stash the repository, then restore it when def __init__(self, options):
complete. If possible, it attempts to restore the access and self.runner = ('staging' in options and self.staging_wrapper) or self.workspace_wrapper
modification times of the file in order to comfort IDEs that are
constantly monitoring file times.
"""
def __call__(run_linters, filenames):
return self.runner(run_linters, filenames)
@staticmethod
def staging_wrapper(run_linters, filenames): def staging_wrapper(run_linters, filenames):
def time_gather(f): def time_gather(f):
stats = os.stat(f) stats = os.stat(f)
@ -375,11 +367,10 @@ def pick_stash_runner(cmdline):
os.utime(filename, timepair) os.utime(filename, timepair)
return results return results
@staticmethod
def workspace_wrapper(run_linters, filenames): def workspace_wrapper(run_linters, filenames):
return run_linters() 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): class Linter:
def __init__(linters, filenames):
"""Run one linter against one file. self.linters = linters
self.filenames = filenames
If the result matches the error condition specified in the
configuration file, return the error code and messages, either
return nothing.
"""
@staticmethod
def encode_shell_messages(prefix, messages): def encode_shell_messages(prefix, messages):
return ['{}{}'.format(prefix, line) return ['{}{}'.format(prefix, line)
for line in messages.splitlines()] for line in messages.splitlines()]
cmd = linter['command'] + ' "' + filename + '"' @staticmethod
(out, err, returncode) = get_shell_response(cmd) def run_external_linter(filename, linter, linter_name):
failed = ((out and (linter.get('condition', 'error') == 'output')) or err or (not (returncode == 0))) """Run one linter against one file.
trimmed_filename = filename.replace(git_base + '/', '', 1)
if not failed:
return (trimmed_filename, linter_name, 0, [])
prefix = (((linter.get('print', 'false').strip().lower() != 'true') and ' ') If the result matches the error condition specified in the configuration file,
or ' {}: '.format(trimmed_filename)) return the error code and messages, either return nothing.
output = (encode_shell_messages(prefix, out) + """
((err and encode_shell_messages(prefix, err)) or []))
return (trimmed_filename, linter_name, (returncode or 1), output)
cmd = linter['command'] + ' "' + filename + '"'
def run_one_linter(linter, filenames): (out, err, returncode) = get_shell_response(cmd)
""" Runs one linter against a set of files failed = ((out and (linter.get('condition', 'error') == 'output')) or err or (not (returncode == 0)))
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):
trimmed_filename = filename.replace(git_base + '/', '', 1) trimmed_filename = filename.replace(git_base + '/', '', 1)
return (trimmed_filename, linter.name, 0, [' {}'.format(trimmed_filename)]) if not failed:
return (trimmed_filename, linter_name, 0, [])
def dryrunonce(linter, filenames):
match_filter = make_match_filter([linter]) prefix = (((linter.get('print', 'false').strip().lower() != 'true') and ' ')
files_to_check = [filename for filename in filenames if match_filter(filename)] or ' {}: '.format(trimmed_filename))
return [dryrunonefile(filename, linter) for filename in files_to_check] output = (encode_shell_messages(prefix, out) +
((err and encode_shell_messages(prefix, err)) or []))
return reduce(operator.add, [dryrunonce(linter, filenames) for linter in linters], []) return (trimmed_filename, linter_name, (returncode or 1), output)
# __ __ _ @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
def print_report(results, cmdline, unlintable_filenames, cant_lint_filenames, result as a list of successes and failures. Failures have a
broken_linter_names, unfindable_filenames): return code and the output of the lint process.
sort_position = 1 """
grouping = 'Linter: {}' match_filter = make_match_filter([linter])
if 'byfile' in cmdline: files = set([filename for filename in filenames if match_filter(filename)])
sort_position = 0 return [self.run_external_linter(filename, linter.linter, linter.name) for filename in files]
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 __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): def dryrun(self):
print(_('Usage: {} [options] [filenames]').format(name))
for item in options_list: def dryrunonefile(filename, linter):
print(' -{:<1} --{:<12} {}'.format(item[0], item[1], item[3])) trimmed_filename = filename.replace(git_base + '/', '', 1)
return sys.exit() 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): def run_linters(options, config, extras):
print('{} {} Copyright (c) 2009, 2016 Kennth M. "Elf" Sternberg'.format(name, version))
def run_gitlint(cmdline, config, extras):
def build_config_subset(keys): def build_config_subset(keys):
""" Returns a subset of the configuration, with only those linters mentioned in 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] return [item for item in config if item.name in keys]
""" Runs the requested linters """ """ 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 = MatchFilter(config)
is_lintable = make_match_filter(config)
lintable_filenames = set([filename for filename in all_filenames lintable_filenames = set([filename for filename in all_filenames
if is_lintable(filename)]) if is_lintable(filename)])
@ -520,26 +467,23 @@ def run_gitlint(cmdline, config, extras):
working_linter_names, broken_linter_names = get_linter_status(config) 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)) broken_linter_names))
cant_lint_filenames = [filename for filename in lintable_filenames cant_lint_filenames = [filename for filename in lintable_filenames
if cant_lint_filter(filename)] if cant_lint_filter(filename)]
if 'dryrun' in cmdline: if 'dryrun' in options:
return print_report( dryrun_results = dryrun(
dryrun( build_config_subset(working_linter_names), sorted(lintable_filenames))
build_config_subset(working_linter_names), sorted(lintable_filenames)), return (dryrun_results, unlintable_filenames, cant_lint_filenames,
cmdline, 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) 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)])

View File

@ -6,8 +6,17 @@
# This was a lot shorter and smarter in Hy... # 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 """Takes a table of options and the commandline, and returns a
dictionary of those options that appear on the commandline dictionary of those options that appear on the commandline
along with any extra arguments. along with any extra arguments.
@ -19,7 +28,7 @@ def make_rational_options(options, commandline):
The arguments as received by the start-up process 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 """Takes a list of option tuples, and returns a function that takes
the output of getopt and reduces it to the longopt key and the output of getopt and reduces it to the longopt key and
@ -28,17 +37,16 @@ def make_rational_options(options, commandline):
fullset = {} fullset = {}
for option in options: for option in options:
if not option[1]: if option[1]:
continue fullset['--' + option[1]] = option[1]
if option[0]: if option[0]:
fullset['-' + option[0]] = option[1] fullset['-' + option[0]] = option[1]
fullset['--' + option[1]] = option[1]
def rationalizer(acc, it): def streamliner(acc, it):
acc[fullset[it[0]]] = it[1] acc[fullset[it[0]]] = it[1]
return acc return acc
return rationalizer return streamliner
def remove_conflicted_options(options, request): def remove_conflicted_options(options, request):
"""Takes our list of option tuples, and a cleaned copy of what was """Takes our list of option tuples, and a cleaned copy of what was
@ -68,10 +76,10 @@ def make_rational_options(options, commandline):
optstringslong) optstringslong)
# Turns what getopt returns into something more human-readable # 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. # Remove any options that are superseded by others.
(retoptions, excluded) = remove_conflicted_options( (ret, excluded) = remove_conflicted_options(
optlist, reduce(rationalize_options, options, {})) optlist, reduce(streamline_options, options, {}))
return (retoptions, filenames, excluded) return (ret, filenames, excluded)

View File

@ -1,5 +1,7 @@
import gettext
_ = gettext.gettext
OPTIONS_LIST = [ OPTIONS = [
('o', 'only', True, ('o', 'only', True,
_('A comma-separated list of only those linters to run'), ['exclude']), _('A comma-separated list of only those linters to run'), ['exclude']),
('x', 'exclude', True, ('x', 'exclude', True,

49
git_lint/reporters.py Normal file
View File

@ -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', '')))))

View File

@ -23,13 +23,13 @@ test_requirements = [
] ]
setup( setup(
name='git_lint', name='git_linter',
version='0.0.2', version='0.0.4',
description="A git command to lint everything in your workspace (or stage) that was changed since the last commit.", 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, long_description=readme + '\n\n' + history,
author="Kenneth M. "Elf" Sternberg", author='Kenneth M. "Elf" Sternberg',
author_email='elf.sternberg@gmail.com', author_email='elf.sternberg@gmail.com',
url='https://github.com/elfsternberg/git_lint', url='https://github.com/elfsternberg/git_linter',
packages=[ packages=[
'git_lint', 'git_lint',
], ],
@ -37,16 +37,20 @@ setup(
'git_lint'}, 'git_lint'},
include_package_data=True, include_package_data=True,
install_requires=requirements, install_requires=requirements,
license="ISCL", license="MIT",
zip_safe=False, zip_safe=False,
keywords='git_lint', keywords='git lint style syntaxt development',
entry_points={
'console_scripts': [
'git-lint = git_lint.__main__:main'
]
},
classifiers=[ classifiers=[
'Development Status :: 2 - Pre-Alpha', 'Development Status :: 2 - Pre-Alpha',
'Intended Audience :: Developers', 'Intended Audience :: Developers',
'License :: OSI Approved :: ISC License (ISCL)', 'License :: OSI Approved :: ISC License (ISCL)',
'Natural Language :: English', 'Natural Language :: English',
"Programming Language :: Python :: 2", "Programming Language :: Python :: 2",
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.3',

View File

@ -1,5 +1,5 @@
[tox] [tox]
envlist = py26, py27, py33, py34, py35 envlist = py27, py33, py34, py35
[testenv] [testenv]
setenv = setenv =