Repository: conda-forge/conda-smithy

Issue:
conda recipe lint
We should write a simple linter which checks the quality of our recipes.
I think I wrote this issue someplace else too, but can't find it now.

The kinds of things we want to check:
- license, about, homepage populated
- tests are defined
- other things...



Discussion:

Resolving patch:
diff --git a/conda_smithy/cli.py b/conda_smithy/cli.py
--- a/conda_smithy/cli.py
+++ b/conda_smithy/cli.py
@@ -10,6 +10,7 @@
 
 from . import ci_register
 from . import configure_feedstock
+from . import lint_recipe
 
 
 def generate_feedstock_content(target_directory, recipe_dir):
@@ -153,6 +154,25 @@ def main():
     args.subcommand_func(args)
 
 
+class RecipeLint(Subcommand):
+    subcommand = 'recipe-lint'
+    def __init__(self, parser):
+        subcommand_parser = Subcommand.__init__(self, parser)
+        subcommand_parser.add_argument("recipe_directory", default=[os.getcwd()], nargs='*')
+
+    def __call__(self, args):
+        all_good = True 
+        for recipe in args.recipe_directory:
+            lint = lint_recipe.main(os.path.join(recipe))
+            if lint:
+                all_good = False
+                print('{} has some lint:\n  {}'.format(recipe, '\n  '.join(lint)))
+            else:
+                print('{} is in fine form'.format(recipe))
+        # Exit code 1 for some lint, 0 for no lint.
+        sys.exit(int(not all_good))
+
+
 class Rerender(Subcommand):
     subcommand = 'rerender'
     def __init__(self, parser):
diff --git a/conda_smithy/lint_recipe.py b/conda_smithy/lint_recipe.py
new file mode 100644
--- /dev/null
+++ b/conda_smithy/lint_recipe.py
@@ -0,0 +1,46 @@
+import os
+
+import ruamel.yaml
+
+
+EXPECTED_SECTION_ORDER = ['package', 'source', 'build', 'requirements', 'test', 'app', 'about', 'extra']
+
+
+def lintify(meta):
+    lints = []
+
+    major_sections = list(meta.keys())
+
+    # 1: Top level meta.yaml keys should have a specific order.
+    section_order_sorted = sorted(major_sections, key=EXPECTED_SECTION_ORDER.index)
+    if major_sections != section_order_sorted:
+        lints.append('The top level meta keys are in an unexpected order. Expecting {}.'.format(section_order_sorted))
+
+    # 2: The about section should have a home, license and summary.
+    for about_item in ['home', 'license', 'summary']:
+        about_section = meta.get('about', {}) or {}
+        # if the section doesn't exist, or is just empty, lint it.
+        if not about_section.get(about_item, ''):
+            lints.append('The {} item is expected in the about section.'.format(about_item))
+
+    # 3: The recipe should have some maintainers.
+    extra_section = meta.get('extra', {}) or {}
+    if not extra_section.get('recipe-maintainers', []):
+        lints.append('The recipe could do with some maintainers listed in the "extra/recipe-maintainers" section.')
+
+    # 4: The recipe should have some tests.
+    if 'test' not in major_sections:
+        lints.append('The recipe must have some tests.')
+
+    return lints
+
+
+def main(recipe_dir):
+    recipe_dir = os.path.abspath(recipe_dir)
+    recipe_meta = os.path.join(recipe_dir, 'meta.yaml')
+    if not os.path.exists(recipe_dir):
+        raise IOError('Feedstock has no recipe/meta.yaml.')
+    with open(recipe_meta, 'r') as fh:
+        meta = ruamel.yaml.load(fh, ruamel.yaml.RoundTripLoader)
+    results = lintify(meta)
+    return results


Test patch:
diff --git a/conda_smithy/tests/test_lint_recipe.py b/conda_smithy/tests/test_lint_recipe.py
new file mode 100644
--- /dev/null
+++ b/conda_smithy/tests/test_lint_recipe.py
@@ -0,0 +1,117 @@
+from __future__ import print_function
+from collections import OrderedDict
+import os
+import shutil
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+import conda_smithy.lint_recipe as linter
+
+
+class Test_linter(unittest.TestCase):
+    def test_bad_order(self):
+        meta = OrderedDict([['package', []],
+                            ['build', []],
+                            ['source', []]])
+        lints = linter.lintify(meta)
+        expected_message = "The top level meta keys are in an unexpected order. Expecting ['package', 'source', 'build']."
+        self.assertIn(expected_message, lints)
+
+    def test_missing_about_license_and_summary(self):
+        meta = {'about': {'home': 'a URL'}}
+        lints = linter.lintify(meta)
+        expected_message = "The license item is expected in the about section."
+        self.assertIn(expected_message, lints)
+
+        expected_message = "The summary item is expected in the about section."
+        self.assertIn(expected_message, lints)
+
+    def test_missing_about_home(self):
+        meta = {'about': {'license': 'BSD',
+                          'summary': 'A test summary'}}
+        lints = linter.lintify(meta)
+        expected_message = "The home item is expected in the about section."
+        self.assertIn(expected_message, lints)
+
+    def test_missing_about_home_empty(self):
+        meta = {'about': {'home': '',
+                          'summary': '',
+                          'license': ''}}
+        lints = linter.lintify(meta)
+        expected_message = "The home item is expected in the about section."
+        self.assertIn(expected_message, lints)
+
+        expected_message = "The license item is expected in the about section."
+        self.assertIn(expected_message, lints)
+
+        expected_message = "The summary item is expected in the about section."
+        self.assertIn(expected_message, lints)
+
+    def test_maintainers_section(self):
+        expected_message = 'The recipe could do with some maintainers listed in the "extra/recipe-maintainers" section.'
+
+        lints = linter.lintify({'extra': {'recipe-maintainers': []}})
+        self.assertIn(expected_message, lints)
+    
+        # No extra section at all.
+        lints = linter.lintify({})
+        self.assertIn(expected_message, lints)
+
+        lints = linter.lintify({'extra': {'recipe-maintainers': ['a']}})
+        self.assertNotIn(expected_message, lints)
+
+    def test_test_section(self):
+        expected_message = 'The recipe must have some tests.'
+
+        lints = linter.lintify({})
+        self.assertIn(expected_message, lints)
+
+        lints = linter.lintify({'test': {'imports': 'sys'}})
+        self.assertNotIn(expected_message, lints)
+
+
+class TestCLI_recipe_lint(unittest.TestCase):
+    def setUp(self):
+        self.tmp_dir = tempfile.mkdtemp('recipe_')
+
+    def tearDown(self):
+        shutil.rmtree(self.tmp_dir)
+
+    def test_cli_fail(self):
+        with open(os.path.join(self.tmp_dir, 'meta.yaml'), 'w') as fh:
+            fh.write(textwrap.dedent("""
+                package:
+                    name: 'test_package'
+                build: []
+                requirements: []
+                """))
+        child = subprocess.Popen(['conda-smithy', 'recipe-lint', self.tmp_dir],
+                                 stdout=subprocess.PIPE)
+        child.communicate()
+        self.assertEqual(child.returncode, 1)
+ 
+    def test_cli_success(self):
+        with open(os.path.join(self.tmp_dir, 'meta.yaml'), 'w') as fh:
+            fh.write(textwrap.dedent("""
+                package:
+                    name: 'test_package'
+                test: []
+                about:
+                    home: something
+                    license: something else
+                    summary: a test recipe
+                extra:
+                    recipe-maintainers:
+                        - a
+                        - b
+                """))
+        child = subprocess.Popen(['conda-smithy', 'recipe-lint', self.tmp_dir],
+                                 stdout=subprocess.PIPE)
+        child.communicate()
+        self.assertEqual(child.returncode, 0)
+
+
+if __name__ == '__main__':
+    unittest.main()