From 063bb93b7ba3fd68e5b3904cdbd73d237040c3da Mon Sep 17 00:00:00 2001 From: Phil Elson Date: Tue, 12 Jan 2016 05:29:55 +0000 Subject: [PATCH] Added a recipe linter for common problems with recipes. --- conda_smithy/cli.py | 20 +++++ conda_smithy/lint_recipe.py | 46 ++++++++++ conda_smithy/tests/test_lint_recipe.py | 117 +++++++++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 conda_smithy/lint_recipe.py create mode 100644 conda_smithy/tests/test_lint_recipe.py diff --git a/conda_smithy/cli.py b/conda_smithy/cli.py index a92e2157b..14dc439ac 100755 --- 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 index 000000000..db9aa2f14 --- /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 diff --git a/conda_smithy/tests/test_lint_recipe.py b/conda_smithy/tests/test_lint_recipe.py new file mode 100644 index 000000000..2ee02461b --- /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()