Repository: conda-forge/conda-smithy

Issue:
Teach linter to error on non-functional `# [py3x]` selectors
I've been maintaining packages for a while, but was unaware that that conda-build does not support `# [py37]` and higher. It's [mentioned](https://conda-forge.org/docs/maintainer/maintainer_faq.html?highlight=py37#mfaq-py37-selector) in the CF-docs, but that doesn't keep people from using them - since it's such a natural extension from `# [py36]` (which is working) -, and not getting what they're expecting.

Aside from recipes that are probably not working as intended, it also caused me to waste a fair amount of time chasing down a [problem](https://github.com/conda/conda/issues/10975) that was solely due to this detail of selector syntax (which is unfortunately compounded by conda not warning on unknown selectors: https://github.com/conda/conda-build/issues/4298).

In any case, I think the linter should error on `# [py3x]` (possibly only warn on `# [py36]`), so that people do not get caught out like that. Xref https://github.com/conda-forge/conda-smithy/issues/1026, which @jaimergp pointed out to me in https://github.com/conda-forge/conda-forge.github.io/issues/1526, but hasn't been followed up upon in 2.5 years.




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
@@ -208,21 +208,47 @@ def lintify(meta, recipe_dir=None, conda_forge=False):
 
     # 6: Selectors should be in a tidy form.
     if recipe_dir is not None and os.path.exists(meta_fname):
-        bad_selectors = []
-        bad_lines = []
+        bad_selectors, bad_lines = [], []
+        pyXY_selectors_lint, pyXY_lines_lint = [], []
+        pyXY_selectors_hint, pyXY_lines_hint = [], []
         # Good selectors look like ".*\s\s#\s[...]"
         good_selectors_pat = re.compile(r"(.+?)\s{2,}#\s\[(.+)\](?(2).*)$")
+        # Look out for py27, py35 selectors; we prefer py==35
+        pyXY_selectors_pat = re.compile(r".+#\s*\[.*?(py\d{2,3}).*\]")
         with io.open(meta_fname, "rt") as fh:
             for selector_line, line_number in selector_lines(fh):
                 if not good_selectors_pat.match(selector_line):
                     bad_selectors.append(selector_line)
                     bad_lines.append(line_number)
+                pyXY_matches = pyXY_selectors_pat.match(selector_line)
+                if pyXY_matches:
+                    for pyXY in pyXY_matches.groups():
+                        if int(pyXY[2:]) in (27, 34, 35, 36):
+                            # py27, py35 and so on are ok up to py36 (included); only warn
+                            pyXY_selectors_hint.append(selector_line)
+                            pyXY_lines_hint.append(line_number)
+                        else:
+                            pyXY_selectors_lint.append(selector_line)
+                            pyXY_lines_lint.append(line_number)
         if bad_selectors:
             lints.append(
                 "Selectors are suggested to take a "
                 "``<two spaces>#<one space>[<expression>]`` form."
                 " See lines {}".format(bad_lines)
             )
+        if pyXY_selectors_hint:
+            hints.append(
+                "Old-style Python selectors (py27, py34, py35, py36) are "
+                "deprecated. Instead, consider using the int ``py``. For "
+                "example: ``# [py>=36]``. See lines {}".format(pyXY_lines_hint)
+            )
+        if pyXY_selectors_lint:
+            lints.append(
+                "Old-style Python selectors (py27, py35, etc) are only available "
+                "for Python 2.7, 3.4, 3.5, and 3.6. Please use explicit comparisons "
+                "with the integer ``py``, e.g. ``# [py==37]`` or ``# [py>=37]``. "
+                "See lines {}".format(pyXY_lines_lint)
+            )
 
     # 7: The build section should have a build number.
     if build_section.get("number", None) is None:
@@ -302,7 +328,7 @@ def lintify(meta, recipe_dir=None, conda_forge=False):
 
     # 13: Check that the recipe name is valid
     recipe_name = package_section.get("name", "").strip()
-    if re.match("^[a-z0-9_\-.]+$", recipe_name) is None:
+    if re.match(r"^[a-z0-9_\-.]+$", recipe_name) is None:
         lints.append(
             "Recipe name has invalid characters. only lowercase alpha, numeric, "
             "underscores, hyphens and dots allowed"
@@ -671,7 +697,7 @@ def lintify(meta, recipe_dir=None, conda_forge=False):
     except license_expression.ExpressionError:
         parsed_licenses = [license]
 
-    licenseref_regex = re.compile("^LicenseRef[a-zA-Z0-9\-.]*$")
+    licenseref_regex = re.compile(r"^LicenseRef[a-zA-Z0-9\-.]*$")
     filtered_licenses = []
     for license in parsed_licenses:
         if not licenseref_regex.match(license):


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
@@ -270,6 +270,119 @@ def assert_selector(selector, is_good=True):
             assert_selector("name: foo_py3  #[py3k]", is_good=False)
             assert_selector("name: foo_py3 # [py3k]", is_good=False)
 
+    def test_pyXY_selectors(self):
+        with tmp_directory() as recipe_dir:
+
+            def assert_pyXY_selector(meta_string, is_good=False, kind="lint"):
+                assert kind in ("lint", "hint")
+                if kind == "hint":
+                    expected_start = "Old-style Python selectors (py27, py34, py35, py36) are deprecated"
+                else:
+                    expected_start = "Old-style Python selectors (py27, py35, etc) are only available"
+                with io.open(os.path.join(recipe_dir, "meta.yaml"), "w") as fh:
+                    fh.write(meta_string)
+                lints, hints = linter.main(recipe_dir, return_hints=True)
+                if is_good:
+                    message = (
+                        "Found lints or hints when there shouldn't have "
+                        "been for '{}'."
+                    ).format(meta_string)
+                else:
+                    message = (
+                        "Expected lints or hints for '{}', but didn't get any."
+                    ).format(meta_string)
+                problems = lints if kind == "lint" else hints
+                self.assertEqual(
+                    not is_good,
+                    any(
+                        problem.startswith(expected_start)
+                        for problem in problems
+                    ),
+                    message,
+                )
+
+            assert_pyXY_selector(
+                """
+                            build:
+                              noarch: python
+                              script:
+                                - echo "hello" # [py27]
+                            """,
+                kind="hint",
+            )
+            assert_pyXY_selector(
+                """
+                            build:
+                              noarch: python
+                              script:
+                                - echo "hello" # [py310]
+                            """,
+                kind="lint",
+            )
+            assert_pyXY_selector(
+                """
+                            build:
+                              noarch: python
+                              script:
+                                - echo "hello" # [py38]
+                            """,
+                kind="lint",
+            )
+            assert_pyXY_selector(
+                """
+                            build:
+                              noarch: python
+                              script:
+                                - echo "hello"   #   [py36]
+                            """,
+                kind="hint",
+            )
+            assert_pyXY_selector(
+                """
+                            build:
+                              noarch: python
+                              script:
+                                - echo "hello"  # [win or py37]
+                            """,
+                kind="lint",
+            )
+            assert_pyXY_selector(
+                """
+                            build:
+                              noarch: python
+                              script:
+                                - echo "hello"  # [py37 or win]
+                            """,
+                kind="lint",
+            )
+            assert_pyXY_selector(
+                """
+                            build:
+                              noarch: python
+                              script:
+                                - echo "hello"  # [unix or py37 or win]
+                            """,
+                kind="lint",
+            )
+            assert_pyXY_selector(
+                """
+                            build:
+                              noarch: python
+                              script:
+                                - echo "hello"  # [unix or py37 or py27]
+                            """,
+                kind="lint",
+            )
+            assert_pyXY_selector(
+                """
+                            build:
+                              noarch: python
+                              script:
+                                - echo "hello"  # [py==37]
+                            """,
+                is_good=True,
+            )
+
     def test_noarch_selectors(self):
         expected_start = "`noarch` packages can't have selectors."