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:

Patch:
diff --git a/conda_smithy/lint_recipe.py b/conda_smithy/lint_recipe.py
index a2236f2c..03654137 100644
--- a/conda_smithy/lint_recipe.py
+++ b/conda_smithy/lint_recipe.py
@@ -693,6 +693,43 @@ def lintify_meta_yaml(
     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 lintify_meta_yaml(
             "[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 []
diff --git a/news/1936-lint-whl.rst b/news/1936-lint-whl.rst
new file mode 100644
index 00000000..56fb9e0a
--- /dev/null
+++ b/news/1936-lint-whl.rst
@@ -0,0 +1,23 @@
+**Added:**
+
+* Lint / hint if a recipe uses Python wheels as its source. (#1935 via #1936)
+
+**Changed:**
+
+* <news item>
+
+**Deprecated:**
+
+* <news item>
+
+**Removed:**
+
+* <news item>
+
+**Fixed:**
+
+* <news item>
+
+**Security:**
+
+* <news item>


Test patch:
diff --git a/tests/test_lint_recipe.py b/tests/test_lint_recipe.py
index 1f40923e..0d816103 100644
--- 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()