Repository: conda-forge/conda-smithy

Issue:
lint on most wheel-based python installs except `noarch: python` and `none-any.whl`
There are now several feedstocks that use pure python wheels with `noarch: python` recipes. This procedure is in principle safe, but an sdist is preferred. We should lint on any usage of wheels outside this one case. A second PR might also provide a hint that sdists are preferred.


Discussion:
cc @jaimergp 
One challenge is that many first time packagers on pypi will only publish wheels and not sdist. 

Should the message be condition on the existing of an sdist for the same version of the package?
Sorry. My comment did understand that you are ok with the wheel + no arch case

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
@@ -693,6 +693,43 @@ def check_pins_build_and_requirements(top_level):
     for out in outputs_section:
         check_pins_build_and_requirements(out)
 
+    # 27: Check usage of whl files as a source
+    pure_python_wheel_urls = []
+    compiled_wheel_urls = []
+    # We could iterate on `sources_section`, but that might miss platform specific selector lines
+    # ... so raw meta.yaml and regex it is...
+    pure_python_wheel_re = re.compile(r".*[:-]\s+(http.*-none-any\.whl)\s+.*")
+    wheel_re = re.compile(r".*[:-]\s+(http.*\.whl)\s+.*")
+    if recipe_dir is not None and os.path.exists(meta_fname):
+        with open(meta_fname, "rt") as f:
+            for line in f:
+                if match := pure_python_wheel_re.search(line):
+                    pure_python_wheel_urls.append(match.group(1))
+                elif match := wheel_re.search(line):
+                    compiled_wheel_urls.append(match.group(1))
+    if compiled_wheel_urls:
+        formatted_urls = ", ".join([f"`{url}`" for url in compiled_wheel_urls])
+        lints.append(
+            f"Detected compiled wheel(s) in source: {formatted_urls}. "
+            "This is disallowed. All packages should be built from source except in "
+            "rare and exceptional cases."
+        )
+    if pure_python_wheel_urls:
+        formatted_urls = ", ".join(
+            [f"`{url}`" for url in pure_python_wheel_urls]
+        )
+        if noarch_value == "python":  # this is ok, just hint
+            hints.append(
+                f"Detected pure Python wheel(s) in source: {formatted_urls}. "
+                "This is generally ok for pure Python wheels and noarch=python "
+                "packages but it's preferred to use a source distribution (sdist) if possible."
+            )
+        else:
+            lints.append(
+                f"Detected pure Python wheel(s) in source: {formatted_urls}. "
+                "This is discouraged. Please consider using a source distribution (sdist) instead."
+            )
+
     # hints
     # 1: suggest pip
     if "script" in build_section:
@@ -846,7 +883,7 @@ def check_pins_build_and_requirements(top_level):
             "[here]( https://conda-forge.org/docs/maintainer/adding_pkgs.html#spdx-identifiers-and-expressions )."
         )
 
-    # stdlib-related hints
+    # 5: stdlib-related hints
     build_reqs = requirements_section.get("build") or []
     run_reqs = requirements_section.get("run") or []
     constraints = requirements_section.get("run_constrained") or []


Test patch:
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
@@ -1800,5 +1800,103 @@ def test_lint_no_builds():
         assert not any(lint.startswith(expected_message) for lint in lints)
 
 
+@pytest.mark.parametrize(
+    "yaml_block,annotation",
+    [
+        pytest.param(
+            """
+            {% set name = "libconeangle" %}
+            {% set version = "0.1.1" %}
+
+            package:
+              name: {{ name|lower }}
+              version: {{ version }}
+
+            source:
+              url: https://pypi.io/packages/source/{{ name[0] }}/{{ name }}/libconeangle-{{ version }}.tar.gz  # [unix]
+              sha256: bc828be92fdf2d2d353b5e8bb95644068220d92809276312ff2d7bca0aa8b2d1  # [unix]
+              url: https://pypi.org/packages/cp{{ CONDA_PY }}/{{ name[0] }}/{{ name }}/{{ name }}-{{ version }}-cp{{ CONDA_PY }}-cp{{ CONDA_PY }}-win_amd64.whl  # [win]
+              sha256: 467a444ca9a46675b12d43b00462052dc00a16bc322944df8053b1573a492dce  # [win and py==38]
+              sha256: b35c0643c9f1dd1c933c0a6d91b7368c32a3255e76594dea27d918b71c1166ed  # [win and py==39]
+              sha256: a32e28b3e321bdb802f28a5f04d1df65071ab42eef06a6dc15ed656780c0361e  # [win and py==310]
+            build:
+              skip: true  # [py<38 or python_impl == 'pypy' or (win and py==311)]
+              script: {{ PYTHON }} -m pip install . -vv  # [unix]
+              script_env:  # [osx and arm64]
+                - SKBUILD_CONFIGURE_OPTIONS=-DWITH_CBOOL_EXITCODE=0 -DWITH_CBOOL_EXITCODE__TRYRUN_OUTPUT='' -Df03real128_EXITCODE=1 -Df03real128_EXITCODE__TRYRUN_OUTPUT='' -Df18errorstop_EXITCODE=1 -Df18errorstop_EXITCODE__TRYRUN_OUTPUT=''  # [osx and arm64]
+              script: {{ PYTHON }} -m pip install {{ name }}-{{ version }}-cp{{ CONDA_PY }}-cp{{ CONDA_PY }}-win_amd64.whl -vv  # [win]
+              number: 3
+            """,
+            "lint",
+            id="libconeangle",
+        ),
+        pytest.param(
+            """
+            {% set name = "junit-xml" %}
+            {% set version = "1.9" %}
+            {% set python_tag = 'py2.py3' %}
+            {% set use_wheel = True %}
+
+            package:
+              name: {{ name|lower }}
+              version: {{ version }}
+
+            source:
+            {% if use_wheel %}
+            - url: https://pypi.org/packages/{{ python_tag }}/{{ name[0] }}/{{ name }}/{{ name | replace('-', '_') }}-{{ version }}-{{ python_tag }}-none-any.whl
+              sha256: "ec5ca1a55aefdd76d28fcc0b135251d156c7106fa979686a4b48d62b761b4732"
+            {% else %}
+            - url: https://pypi.io/packages/source/{{ name[0] }}/{{ name }}/{{ name }}-{{ version }}.tar.gz
+              sha256: ""
+            {% endif %}
+
+            build:
+              noarch: python
+              number: 0
+              {% if use_wheel %}
+              script: "{{ PYTHON }} -m pip install --no-deps --ignore-installed --no-cache-dir -vvv *.whl"
+              {% else %}
+              script: "{{ PYTHON }} -m pip install --no-deps --ignore-installed --no-cache-dir -vvv ."
+              {% endif %}
+            """,
+            "hint",
+            id="junit-xml",
+        ),
+        pytest.param(
+            """
+            {% set name = "WeasyPrint" %}
+            {% set version = "62.1" %}
+
+            package:
+              name: {{ name|lower }}
+              version: {{ version }}
+
+            source:
+              url: https://files.pythonhosted.org/packages/py3/{{ (name|lower)[0] }}/{{ name|lower }}/{{ name|lower }}-{{ version }}-py3-none-any.whl
+              sha256: 654d4c266336cbf9acc4da118c7778ef5839717e6055d5b8f995cf50be200c46
+
+            build:
+              number: 0
+              noarch: python
+              entry_points:
+                - weasyprint = weasyprint.__main__:main
+              script: {{ PYTHON }} -m pip install {{ name|lower }}-{{ version }}-py3-none-any.whl -vv
+            """,
+            "hint",
+            id="weasyprint",
+        ),
+    ],
+)
+def test_lint_wheels(tmp_path, yaml_block, annotation):
+    (tmp_path / "meta.yaml").write_text(yaml_block)
+    expected_message = "wheel(s) in source"
+
+    lints, hints = linter.main(tmp_path, conda_forge=False, return_hints=True)
+    if annotation == "lint":
+        assert any(expected_message in lint for lint in lints)
+    else:
+        assert any(expected_message in hint for hint in hints)
+
+
 if __name__ == "__main__":
     unittest.main()