Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add some Google Styleguide formatting rules #4

Merged
merged 17 commits into from
Mar 6, 2017
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ python:
- "3.3"
- "3.4"
- "3.5"
- "3.6"
before_install:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sadly pylint doesn't support Python 3.6 yet and our Python 3.6 tests fail.

- pip install -U pip setuptools
install:
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ autolint: autopep8 lint
run_tests: clean
py.test --durations=10 .

test: autopep8 lint run_tests
test: autopep8 run_tests lint
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that we lint the linter with its own rules, we should run tests first.


setup_dev:
pip install -e .[dev]
Expand Down
2 changes: 1 addition & 1 deletion pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ persistent=yes

# List of plugins (as comma separated values of python modules names) to load,
# usually to register additional checkers.
load-plugins=
load-plugins=shopify_python
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's lint ourselves.



[MESSAGES CONTROL]
Expand Down
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
],
test_suite='tests',
install_requires=[
'pylint==1.6.5'
'pylint==1.6.5',
'six>=1.10.0',
],
extras_require={
'dev': [
Expand Down
10 changes: 9 additions & 1 deletion shopify_python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,12 @@
# Use of this source code is governed by a MIT-style license that can be found in the LICENSE file.
from __future__ import unicode_literals

__version__ = '0.0.0'
from pylint import lint
from shopify_python import google_styleguide


__version__ = '0.1.0'


def register(linter): # type: (lint.PyLinter) -> None
google_styleguide.register_checkers(linter)
160 changes: 160 additions & 0 deletions shopify_python/google_styleguide.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import sys

import astroid # pylint: disable=unused-import
import six

from pylint import checkers
from pylint import interfaces
from pylint import lint # pylint: disable=unused-import


if sys.version_info >= (3, 4):
import importlib.util # pylint: disable=no-name-in-module,import-error
else:
import importlib


def register_checkers(linter): # type: (lint.PyLinter) -> None
"""Register checkers."""
linter.register_checker(GoogleStyleGuideChecker(linter))


class GoogleStyleGuideChecker(checkers.BaseChecker):
"""
Pylint checker for the Google Python Style Guide.

See https://google.github.io/styleguide/pyguide.html

Checks that can't be implemented include:
- When capturing an exception, use as rather than a comma
"""
__implements__ = (interfaces.IAstroidChecker,)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should get in the habit of docstrings for at least these classes.


name = 'google-styleguide-checker'

msgs = {
'C2601': ('Imported an object that is not a package or module',
'import-modules-only',
'Use imports for packages and modules only'),
'C2602': ('Imported using a partial path',
'import-full-path',
'Import each module using the full pathname location of the module.'),
'C2603': ('Variable declared at the module level (i.e. global)',
'global-variable',
'Avoid global variables in favor of class variables'),
'C2604': ('Raised two-argument exception',
'two-arg-exception',
"Use either raise Exception('message') or raise Exception"),
'C2605': ('Raised deprecated string-exception',
'string-exception',
"Use either raise Exception('message') or raise Exception"),
'C2606': ('Caught StandardError',
'catch-standard-error',
"Don't catch StandardError"),
'C2607': ('Try body too long',
'try-too-long',
"The larger the try body, the more likely that an unexpected exception will be raised"),
'C2608': ('Except body too long',
'except-too-long',
"The larger the except body, the more likely that an exception will be raised"),
'C2609': ('Finally body too long',
'finally-too-long',
"The larger the except body, the more likely that an exception will be raised"),
}

max_try_exc_finally_body_size = 5

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems rather low, especially if it's counting raw lines of code instead of logical statements/expressions.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact, does it make sense to lint on this at all? I can easily see people disabling this check (or calling out to error handling functions) to avoid it if their error handling is sound.

Copy link
Contributor Author

@cfournie cfournie Mar 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's technically counting AST objects, not raw lines of code. E.g. an embedded try/except is one object (even if it has a try body with 5 lines and an except body with another 5).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm in favour of this linter rule. Its purpose is to educate and inform people of the expectation, and to catch the accidental violations we all make. If people intentionally rewrite their code to get around linter rules instead of adhering to the principle, that's what code review is for. People can also just disable the check with a comment.

I worry less about what the limit is than that there is a limit. 5 seems reasonable to me. I could be OK with <=10. Another option is to specify an overridable default, similar to the "max lines per module" rule.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now I'll remove these linter rules and address them in a future PR where I'll probably consider tree size as a measure of complexity over AST children (as it was implemented here).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If people intentionally rewrite their code to get around linter rules instead of adhering to the principle, that's what code review is for.

Code review is also for pointing out complexity problems like the ones this linter rule is meant to address. It's easier to spot too many lines of code in a single try block than too many lines of code split up into functions.

I'm in favour of the spirit of the rule, but not this particular implementation.

Another option is to specify an overridable default, similar to the "max lines per module" rule.

I'd be in favour of something like this. Number of lines might even make the best metric since they're easy to understand (and therefore adjust) by other teams on other projects.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now I'll remove these linter rules and address them in a future PR

👍


def visit_assign(self, node): # type: (astroid.Assign) -> None
self.__avoid_global_variables(node)

def visit_excepthandler(self, node): # type: (astroid.ExceptHandler) -> None
self.__dont_catch_standard_error(node)

def visit_tryexcept(self, node): # type: (astroid.TryExcept) -> None
self.__minimize_code_in_try_except(node)

def visit_tryfinally(self, node): # type: (astroid.TryFinally) -> None
self.__minimize_code_in_finally(node)

def visit_importfrom(self, node): # type: (astroid.ImportFrom) -> None
self.__import_modules_only(node)
self.__import_full_path_only(node)

def visit_raise(self, node): # type: (astroid.Raise) -> None
self.__dont_use_archaic_raise_syntax(node)

def __import_modules_only(self, node): # type: (astroid.ImportFrom) -> None
"""Use imports for packages and modules only."""

if hasattr(self.linter, 'config') and 'import-modules-only' in self.linter.config.disable:
return # Skip if disable to avoid side-effects from importing modules
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bails before we call importlib.import_module if this message is disabled.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


def can_import(module_name):
if sys.version_info >= (3, 4):
try:
return bool(importlib.util.find_spec(module_name))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This throws an ImportError when trying to import pyspark, which of course needs some path mangling to import dynamically. I think we should just ignore on ImportError.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

boo-urns

except AttributeError:
return False

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

else:
try:
importlib.import_module(module_name)
return True
except ImportError:
return False

if not node.level and node.modname != '__future__':
for name in node.names:
name, _ = name
parent_module = node.modname
child_module = '.'.join((node.modname, name)) # Rearrange "from x import y" as "import x.y"
if can_import(parent_module) and not can_import(child_module):
self.add_message('import-modules-only', node=node)

def __import_full_path_only(self, node): # type: (astroid.ImportFrom) -> None
"""Import each module using the full pathname location of the module."""
if node.level:
self.add_message('import-full-path', node=node)

def __avoid_global_variables(self, node): # type: (astroid.Assign) -> None
"""Avoid global variables."""

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please expand the docstring to explain how this works?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a comment below; I assume that implementation details like this should be a comment whereas the purpose of the method should be a docstring.

# Is this an assignment happening within a module? If so report on each assignment name
# whether its in a tuple or not
if isinstance(node.parent, astroid.Module):
for target in node.targets:
if hasattr(target, 'elts'):
for elt in target.elts:
if elt.name != '__version__':
self.add_message('global-variable', node=elt)
elif hasattr(target, 'name'):
if target.name != '__version__':
self.add_message('global-variable', node=target)

def __dont_use_archaic_raise_syntax(self, node): # type: (astroid.Raise) -> None
"""Don't use the two-argument form of raise or the string raise"""
children = list(node.get_children())
if len(children) > 1:
self.add_message('two-arg-exception', node=node)
elif len(children) == 1 and isinstance(children[0], six.string_types):
self.add_message('string-exception', node=node)

def __dont_catch_standard_error(self, node): # type: (astroid.ExceptHandler) -> None
"""
Never use catch-all 'except:' statements, or catch Exception or StandardError.

Pylint already handles bare-except and broad-except (for Exception).
"""
if hasattr(node.type, 'name') and node.type.name == 'StandardError':
self.add_message('catch-standard-error', node=node)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine to catch Exception as long as you re-raise later on in the handler. You might also want to support the exception chaining syntax.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(this is a weak opinion weakly held, btw)


def __minimize_code_in_try_except(self, node): # type: (astroid.TryExcept) -> None
"""Minimize the amount of code in a try/except block."""
if len(node.body) > self.max_try_exc_finally_body_size:
self.add_message('try-too-long', node=node)
for handler in node.handlers:
if len(handler.body) > self.max_try_exc_finally_body_size:
self.add_message('except-too-long', node=handler)

def __minimize_code_in_finally(self, node): # type: (astroid.TryFinally) -> None
"""Minimize the amount of code in a finally block."""
if len(node.finalbody) > self.max_try_exc_finally_body_size:
self.add_message('finally-too-long', node=node)
144 changes: 144 additions & 0 deletions tests/shopify_python/test_google_styleguide.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import sys

import astroid.test_utils
import pylint.testutils
import pytest

from shopify_python import google_styleguide


class TestGoogleStyleGuideChecker(pylint.testutils.CheckerTestCase):

CHECKER_CLASS = google_styleguide.GoogleStyleGuideChecker

def test_importing_function_fails(self):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a test for importing classes as well.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, and attributes.

root = astroid.builder.parse("""
from os.path import join
from io import FileIO
from os import environ
""")
messages = []
for node in root.body:
messages.append(pylint.testutils.Message('import-modules-only', node=node))
with self.assertAddsMessages(*messages):
self.walk(root)

def test_importing_modules_passes(self):
root = astroid.builder.parse("""
from __future__ import unicode_literals
from xml import dom
from xml import sax
from nonexistent_package import nonexistent_module
""")
with self.assertNoMessages():
self.walk(root)

def test_importing_relatively_fails(self):
root = astroid.builder.parse("""
from . import string
from .. import string
""")
messages = []
for node in root.body:
messages.append(pylint.testutils.Message('import-full-path', node=node))
with self.assertAddsMessages(*messages):
self.walk(root)

def test_global_variables_fail(self):
root = astroid.builder.parse("""
module_var, other_module_var = 10
__version__ = '0.0.0'
class MyClass(object):
class_var = 10
""")
with self.assertAddsMessages(
pylint.testutils.Message('global-variable', node=root.body[0].targets[0].elts[0]),
pylint.testutils.Message('global-variable', node=root.body[0].targets[0].elts[1]),
):
self.walk(root)

@pytest.mark.skipif(sys.version_info >= (3, 0), reason="Tests code that is Python 3 incompatible")
def test_using_archaic_raise_fails(self):
root = astroid.builder.parse("""
raise MyException, 'Error message'
raise 'Error message'
""")
node = root.body[0]
message = pylint.testutils.Message('two-arg-exception', node=node)
with self.assertAddsMessages(message):
self.walk(root)

def test_catch_standard_error_fails(self):
root = astroid.builder.parse("""
try:
pass
except StandardError:
pass
""")
node = root.body[0].handlers[0]
message = pylint.testutils.Message('catch-standard-error', node=node)
with self.assertAddsMessages(message):
self.walk(root)

def test_catch_blank_passes(self):
root = astroid.builder.parse("""
try:
pass
except:
pass
""")
with self.assertAddsMessages():
self.walk(root)

def test_try_exc_finally_size(self):
root = astroid.builder.parse("""
try:
# Comments are OK.
# They are needed to document
# complicated exception
# scenarious and should
# not be penalized.
l = 1
except AssertionError:
# Comments are OK.
# They are needed to document
# complicated exception
# scenarious and should
# not be penalized.
raise
finally:
# Comments are OK.
# They are needed to document
# complicated exception
# scenarious and should
# not be penalized.
l = 1

try:
l = 1
l = 2
l = 3
l = 4
l = 5
l = 6
except AssertionError:
l = 1
l = 2
l = 3
l = 4
l = 5
l = 6
finally:
l = 1
l = 2
l = 3
l = 4
l = 5
l = 6
""")
with self.assertAddsMessages(
pylint.testutils.Message('finally-too-long', node=root.body[1]),
pylint.testutils.Message('try-too-long', node=root.body[1].body[0]),
pylint.testutils.Message('except-too-long', node=root.body[1].body[0].handlers[0]),
):
self.walk(root)