lint: add shebang checker

BUG=None
TEST=`./cbuildbot/run_tests` passes

Change-Id: Ic46eaf13f7fe910938353baeaa6301002c6c68b3
Reviewed-on: https://chromium-review.googlesource.com/243773
Reviewed-by: Anatol Pomazau <anatol@google.com>
Trybot-Ready: Mike Frysinger <vapier@chromium.org>
Tested-by: Mike Frysinger <vapier@chromium.org>
Reviewed-by: Don Garrett <dgarrett@chromium.org>
Commit-Queue: Mike Frysinger <vapier@chromium.org>
diff --git a/cros/commands/lint.py b/cros/commands/lint.py
index d6adc0f..e4dfe89 100644
--- a/cros/commands/lint.py
+++ b/cros/commands/lint.py
@@ -26,6 +26,9 @@
 from pylint.interfaces import IAstroidChecker
 
 
+# pylint: disable=too-few-public-methods
+
+
 class DocStringChecker(BaseChecker):
   """PyLint AST based checker to verify PEP 257 compliance
 
@@ -38,8 +41,7 @@
 
   __implements__ = IAstroidChecker
 
-  # pylint: disable=too-few-public-methods,multiple-statements
-  # pylint: disable=class-missing-docstring
+  # pylint: disable=class-missing-docstring,multiple-statements
   class _MessageCP001(object): pass
   class _MessageCP002(object): pass
   class _MessageCP003(object): pass
@@ -54,8 +56,7 @@
   class _MessageCP012(object): pass
   class _MessageCP013(object): pass
   class _MessageCP014(object): pass
-  # pylint: enable=too-few-public-methods,multiple-statements
-  # pylint: enable=class-missing-docstring
+  # pylint: enable=class-missing-docstring,multiple-statements
 
   name = 'doc_string_checker'
   priority = -1
@@ -308,11 +309,9 @@
 
   __implements__ = IAstroidChecker
 
-  # pylint: disable=too-few-public-methods,multiple-statements
-  # pylint: disable=class-missing-docstring
+  # pylint: disable=class-missing-docstring,multiple-statements
   class _MessageR9100(object): pass
-  # pylint: enable=too-few-public-methods,multiple-statements
-  # pylint: enable=class-missing-docstring
+  # pylint: enable=class-missing-docstring,multiple-statements
 
   name = 'py3k_compat_checker'
   priority = -1
@@ -353,6 +352,55 @@
     self.saw_imports = True
 
 
+class SourceChecker(BaseChecker):
+  """Make sure we enforce py3k compatible features"""
+
+  __implements__ = IAstroidChecker
+
+  # pylint: disable=class-missing-docstring,multiple-statements
+  class _MessageR9200(object): pass
+  class _MessageR9201(object): pass
+  class _MessageR9202(object): pass
+  # pylint: enable=class-missing-docstring,multiple-statements
+
+  name = 'source_checker'
+  priority = -1
+  MSG_ARGS = 'offset:%(offset)i: {%(line)s}'
+  msgs = {
+      'R9200': ('Shebang should be #!/usr/bin/python2 or #!/usr/bin/python3',
+                ('bad-shebang'), _MessageR9200),
+      'R9201': ('Shebang is missing, but file is executable',
+                ('missing-shebang'), _MessageR9201),
+      'R9202': ('Shebang is set, but file is not executable',
+                ('spurious-shebang'), _MessageR9202),
+  }
+  options = ()
+
+  def visit_module(self, node):
+    """Called when the whole file has been read"""
+    stream = node.file_stream
+    stream.seek(0)
+    self._check_shebang(node, stream)
+
+  def _check_shebang(self, _node, stream):
+    """Verify the shebang is version specific"""
+    st = os.fstat(stream.fileno())
+    mode = st.st_mode
+    executable = bool(mode & 0o0111)
+
+    shebang = stream.readline()
+    if shebang[0:2] != '#!':
+      if executable:
+        self.add_message('R9201')
+      return
+    elif not executable:
+      self.add_message('R9202')
+
+    parts = shebang.split()
+    if parts[0] not in ('#!/usr/bin/python2', '#!/usr/bin/python3'):
+      self.add_message('R9200')
+
+
 def register(linter):
   """pylint will call this func to register all our checkers"""
   # Walk all the classes in this module and register ours.
diff --git a/cros/commands/lint_unittest.py b/cros/commands/lint_unittest.py
index 9e26a9d..5f7866a 100644
--- a/cros/commands/lint_unittest.py
+++ b/cros/commands/lint_unittest.py
@@ -7,9 +7,13 @@
 from __future__ import print_function
 
 import collections
+import StringIO
 
 from chromite.lib import cros_test_lib
-import lint
+from chromite.cros.commands import lint
+
+
+# pylint: disable=protected-access
 
 
 class TestNode(object):
@@ -31,7 +35,25 @@
     return self.args
 
 
-class DocStringCheckerTest(cros_test_lib.TestCase):
+class CheckerTestCase(cros_test_lib.TestCase):
+  """Helpers for Checker modules"""
+
+  def add_message(self, msg_id, node=None, line=None, args=None):
+    """Capture lint checks"""
+    # We include node.doc here explicitly so the pretty assert message
+    # inclues it in the output automatically.
+    doc = node.doc if node else ''
+    self.results.append((msg_id, doc, line, args))
+
+  def setUp(self):
+    assert hasattr(self, 'CHECKER'), 'TestCase must set CHECKER'
+
+    self.results = []
+    self.checker = self.CHECKER()
+    self.checker.add_message = self.add_message
+
+
+class DocStringCheckerTest(CheckerTestCase):
   """Tests for DocStringChecker module"""
 
   GOOD_FUNC_DOCSTRINGS = (
@@ -141,16 +163,7 @@
       """,
   )
 
-  def add_message(self, msg_id, node=None, line=None, args=None):
-    """Capture lint checks"""
-    # We include node.doc here explicitly so the pretty assert message
-    # inclues it in the output automatically.
-    self.results.append((msg_id, node.doc, line, args))
-
-  def setUp(self):
-    self.results = []
-    self.checker = lint.DocStringChecker()
-    self.checker.add_message = self.add_message
+  CHECKER = lint.DocStringChecker
 
   def testGood_visit_function(self):
     """Allow known good docstrings"""
@@ -183,7 +196,6 @@
 
   def testGood_check_first_line(self):
     """Verify _check_first_line accepts good inputs"""
-    # pylint: disable=W0212
     docstrings = (
         'Some string',
     )
@@ -196,7 +208,6 @@
 
   def testBad_check_first_line(self):
     """Verify _check_first_line rejects bad inputs"""
-    # pylint: disable=W0212
     docstrings = (
         '\nSome string\n',
     )
@@ -208,7 +219,6 @@
 
   def testGood_check_second_line_blank(self):
     """Verify _check_second_line_blank accepts good inputs"""
-    # pylint: disable=protected-access
     docstrings = (
         'Some string\n\nThis is the third line',
         'Some string',
@@ -222,7 +232,6 @@
 
   def testBad_check_second_line_blank(self):
     """Verify _check_second_line_blank rejects bad inputs"""
-    # pylint: disable=protected-access
     docstrings = (
         'Some string\nnonempty secondline',
     )
@@ -234,7 +243,6 @@
 
   def testGoodFuncVarKwArg(self):
     """Check valid inputs for *args and **kwargs"""
-    # pylint: disable=W0212
     for vararg in (None, 'args', '_args'):
       for kwarg in (None, 'kwargs', '_kwargs'):
         self.results = []
@@ -244,7 +252,6 @@
 
   def testMisnamedFuncVarKwArg(self):
     """Reject anything but *args and **kwargs"""
-    # pylint: disable=W0212
     for vararg in ('arg', 'params', 'kwargs', '_moo'):
       self.results = []
       node = TestNode(vararg=vararg)
@@ -259,7 +266,6 @@
 
   def testGoodFuncArgs(self):
     """Verify normal args in Args are allowed"""
-    # pylint: disable=W0212
     datasets = (
         ("""args are correct, and cls is ignored
 
@@ -295,7 +301,6 @@
 
   def testBadFuncArgs(self):
     """Verify bad/missing args in Args are caught"""
-    # pylint: disable=W0212
     datasets = (
         ("""missing 'bar'
 
@@ -331,3 +336,41 @@
       node = TestNode(doc=dc, args=args)
       self.checker._check_all_args_in_doc(node, node.lines)
       self.assertEqual(len(self.results), 1)
+
+
+class SourceCheckerTest(CheckerTestCase):
+  """Tests for SourceChecker module"""
+
+  CHECKER = lint.SourceChecker
+
+  def _testShebang(self, shebangs, exp, fileno):
+    """Helper for shebang tests"""
+    for shebang in shebangs:
+      self.results = []
+      node = TestNode()
+      stream = StringIO.StringIO(shebang)
+      stream.fileno = lambda: fileno
+      self.checker._check_shebang(node, stream)
+      self.assertEqual(len(self.results), exp,
+                       msg='processing shebang failed: %r' % shebang)
+
+  def testBadShebangNoExec(self):
+    """Verify _check_shebang rejects bad shebangs"""
+    shebangs = (
+        '#!/usr/bin/python\n',
+        '#! /usr/bin/python2 \n',
+        '#!/usr/bin/env python3\n',
+    )
+    with open('/dev/null') as f:
+      self._testShebang(shebangs, 2, f.fileno())
+
+  def testGoodShebang(self):
+    """Verify _check_shebang accepts good shebangs"""
+    shebangs = (
+        '#!/usr/bin/python2\n',
+        '#!/usr/bin/python2  \n',
+        '#!/usr/bin/python3\n',
+        '#!/usr/bin/python3\t\n',
+    )
+    with open('/bin/sh') as f:
+      self._testShebang(shebangs, 0, f.fileno())