Repository: conda-forge/conda-smithy

Issue:
Linter supporting multiple sources
In `conda-build` 3, one is allowed to provide multiple targets to fetch in `source`. However `conda-smithy`'s linter does not recognize this option, which is fair as it's new. So it marks this as an error even though that is not the case.

ref: https://conda.io/docs/user-guide/tasks/build-packages/define-metadata.html#source-from-multiple-sources
ref: https://github.com/conda-forge/staged-recipes/pull/5719#issuecomment-384141975
ref: https://github.com/conda-forge/staged-recipes/commit/18aaec7c334a7ed8dbd530f8d31778dec40249e4


Discussion:

Resolving patch:
diff --git a/conda_smithy/lint_recipe.py b/conda_smithy/lint_recipe.py
--- a/conda_smithy/lint_recipe.py
+++ b/conda_smithy/lint_recipe.py
@@ -52,6 +52,9 @@ def __getitem__(self, name):
 
 
 def get_section(parent, name, lints):
+    if name == 'source':
+        return get_source_section(parent, lints)
+
     section = parent.get(name, {})
     if not isinstance(section, dict):
         lints.append('The "{}" section was expected to be a dictionary, but '
@@ -60,6 +63,19 @@ def get_section(parent, name, lints):
     return section
 
 
+def get_source_section(parent, lints):
+    section = parent.get('source', {})
+    if isinstance(section, dict):
+        return [ section ]
+    elif isinstance(section, list):
+        return section
+    else:
+        lints.append('The "source" section was expected to be a dictionary or '
+                     'a list, but got a {}.{}'.format(type(section).__module__,
+                         type(section).__name__))
+        return [ {} ]
+
+
 def lint_section_order(major_sections, lints):
     section_order_sorted = sorted(major_sections,
                                   key=EXPECTED_SECTION_ORDER.index)
@@ -89,7 +105,7 @@ def lintify(meta, recipe_dir=None, conda_forge=False):
     # find the meta.yaml within it.
     meta_fname = os.path.join(recipe_dir or '', 'meta.yaml')
 
-    source_section = get_section(meta, 'source', lints)
+    sources_section = get_section(meta, 'source', lints)
     build_section = get_section(meta, 'build', lints)
     requirements_section = get_section(meta, 'requirements', lints)
     test_section = get_section(meta, 'test', lints)
@@ -169,10 +185,11 @@ def lintify(meta, recipe_dir=None, conda_forge=False):
                      + '; instead saw: ' + ', '.join(seen_requirements) + '.')
 
     # 9: Files downloaded should have a hash.
-    if ('url' in source_section and
-            not ({'sha1', 'sha256', 'md5'} & set(source_section.keys()))):
-        lints.append('When defining a source/url please add a sha256, sha1 '
-                     'or md5 checksum (sha256 preferably).')
+    for source_section in sources_section:
+        if ('url' in source_section and
+                not ({'sha1', 'sha256', 'md5'} & set(source_section.keys()))):
+            lints.append('When defining a source/url please add a sha256, sha1 '
+                         'or md5 checksum (sha256 preferably).')
 
     # 10: License should not include the word 'license'.
     license = about_section.get('license', '').lower()
@@ -224,10 +241,17 @@ def lintify(meta, recipe_dir=None, conda_forge=False):
         if not expected_subsections:
             continue
         for subsection in get_section(meta, section, lints):
-            if subsection not in expected_subsections:
+            if section != 'source' and subsection not in expected_subsections:
                 lints.append('The {} section contained an unexpected '
                              'subsection name. {} is not a valid subsection'
                              ' name.'.format(section, subsection))
+            elif section == 'source':
+                for source_subsection in subsection:
+                    if source_subsection not in expected_subsections:
+                        lints.append('The {} section contained an unexpected '
+                                     'subsection name. {} is not a valid subsection'
+                                     ' name.'.format(section, source_subsection))
+
 
     # 17: noarch doesn't work with selectors
     if build_section.get('noarch') is not None and os.path.exists(meta_fname):


Test patch:
diff --git a/tests/recipes/multiple_sources/meta.yaml b/tests/recipes/multiple_sources/meta.yaml
new file mode 100644
--- /dev/null
+++ b/tests/recipes/multiple_sources/meta.yaml
@@ -0,0 +1,31 @@
+package:
+  name: test_cb3_multiple_sources
+  version: 1.0
+
+source:
+  - url: https://storage.googleapis.com/golang/go{{ version }}.src.tar.gz
+    sha256: {{ sha256 }}
+    folder: go
+  - url: https://storage.googleapis.com/golang/go1.4-bootstrap-20170531.tar.gz
+    sha256: 49f806f66762077861b7de7081f586995940772d29d4c45068c134441a743fa2
+    folder: go-bootstrap
+
+build:
+  number: 0
+
+requirements:
+  host:
+    - toolchain
+
+test:
+  commands:
+    - echo 'works'
+
+about:
+  home: home
+  summary: summary
+  license: BSD
+
+extra:
+  recipe-maintainers:
+    - gopher
diff --git a/tests/test_lint_recipe.py b/tests/test_lint_recipe.py
--- a/tests/test_lint_recipe.py
+++ b/tests/test_lint_recipe.py
@@ -466,18 +466,27 @@ def test_maintainer_exists(self):
     def test_bad_subheader(self):
         expected_message = 'The {} section contained an unexpected ' \
                            'subsection name. {} is not a valid subsection' \
-                           ' name.'.format('build', 'ski')
+                           ' name.'
         meta = {'build': {'skip': 'True',
                           'script': 'python setup.py install',
                           'number': 0}}
         lints, hints = linter.lintify(meta)
-        self.assertNotIn(expected_message, lints)
+        self.assertNotIn(expected_message.format('build', 'ski'), lints)
 
         meta = {'build': {'ski': 'True',
                           'script': 'python setup.py install',
                           'number': 0}}
         lints, hints = linter.lintify(meta)
-        self.assertIn(expected_message, lints)
+        self.assertIn(expected_message.format('build', 'ski'), lints)
+
+        meta = {'source': {'urll': 'http://test'}}
+        lints, hints = linter.lintify(meta)
+        self.assertIn(expected_message.format('source', 'urll'), lints)
+
+        meta = {'source': [{'urll': 'http://test'}, {'url': 'https://test'}]}
+        lints, hints = linter.lintify(meta)
+        self.assertIn(expected_message.format('source', 'urll'), lints)
+
 
     def test_outputs(self):
         meta = OrderedDict([['outputs', [{'name': 'asd'}]]])
@@ -509,6 +518,9 @@ def test_examples(self):
                                conda_forge=True)
         self.assertNotIn(msg, lints)
 
+    def test_multiple_sources(self):
+        lints = linter.main(os.path.join(_thisdir, 'recipes', 'multiple_sources'))
+        assert not lints
 
 class TestCLI_recipe_lint(unittest.TestCase):
     def test_cli_fail(self):