Repository: conda-forge/conda-smithy

Issue:
CLI: lint: `Feedstock has no recipe/meta.yaml` for v1 recipe
### Solution to issue cannot be found in the documentation.

- [x] I checked the documentation.

### Issue

The linter passes in CI on https://github.com/conda-forge/scipy-stubs-feedstock/pull/10, but locally I see

```
scipy-stubs-feedstock on 🎋 rattler [$] via 🐍 
❯ pixi run conda smithy lint
Traceback (most recent call last):
  File "/Users/lucascolley/programming/feedstocks/.pixi/envs/default/bin/conda-smithy", line 10, in <module>
    sys.exit(main())
             ^^^^^^
  File "/Users/lucascolley/programming/feedstocks/.pixi/envs/default/lib/python3.12/site-packages/conda_smithy/cli.py", line 767, in main
    args.subcommand_func(args)
  File "/Users/lucascolley/programming/feedstocks/.pixi/envs/default/lib/python3.12/site-packages/conda_smithy/cli.py", line 645, in __call__
    lints, hints = linter.main(
                   ^^^^^^^^^^^^
  File "/Users/lucascolley/programming/feedstocks/.pixi/envs/default/lib/python3.12/site-packages/conda_smithy/lint_recipe.py", line 734, in main
    raise OSError(
OSError: Feedstock has no recipe/meta.yaml
```

### Installed packages

```shell
scipy-stubs-feedstock on 🎋 rattler [$] via 🐍 
❯ pixi ls | rg smithy
conda-smithy                3.45.3               unix_pyhd81877a_0   174.1 KiB  conda  conda-smithy
```

### Environment info

```shell
n/a
```

Discussion:
mgorny: @jaimergp, could you tell me what you think of this? I've moved the logic into a function, so I could add tests for it later.
mgorny: Sure. Just tested against staged-recipes 29285 and both `.github/workflows/scripts/linter.py` and `conda smithy recipe-lint --conda-forge recipes/*` seem to work fine (i.e. the latter reports "fine" examples and lints for the new feedstock).

Patch:
diff --git a/conda_smithy/lint_recipe.py b/conda_smithy/lint_recipe.py
index d647837a..25221f7f 100644
--- a/conda_smithy/lint_recipe.py
+++ b/conda_smithy/lint_recipe.py
@@ -722,19 +722,49 @@ def _format_validation_msg(error: jsonschema.ValidationError):
     )
 
 
-def main(
-    recipe_dir, conda_forge=False, return_hints=False, feedstock_dir=None
-):
+def find_recipe_directory(
+    recipe_dir: str,
+    feedstock_dir: Optional[str],
+) -> tuple[str, str]:
+    """Find recipe directory and build tool"""
+
     recipe_dir = os.path.abspath(recipe_dir)
     build_tool = CONDA_BUILD_TOOL
-    if feedstock_dir:
+
+    # The logic below:
+    # 1. If `--feedstock-dir` is not specified, try looking for `recipe.yaml`
+    #    or `meta.yaml` in the specified recipe directory.
+    # 2. If there is none, look for `conda-forge.yml` -- perhaps the user
+    #    passed feedstock directory instead.  In that case, obtain
+    #    the recipe directory from `conda-forge.yml`.
+
+    if feedstock_dir is None:
+        if os.path.exists(os.path.join(recipe_dir, "recipe.yaml")):
+            return (recipe_dir, RATTLER_BUILD_TOOL)
+        elif os.path.exists(os.path.join(recipe_dir, "meta.yaml")):
+            return (recipe_dir, CONDA_BUILD_TOOL)
+        elif os.path.exists(os.path.join(recipe_dir, "conda-forge.yml")):
+            # passthrough to the feedstock_dir logic below
+            feedstock_dir = recipe_dir
+            recipe_dir = None
+
+    if feedstock_dir is not None:
         feedstock_dir = os.path.abspath(feedstock_dir)
         forge_config = _read_forge_config(feedstock_dir)
         if forge_config.get("conda_build_tool", "") == RATTLER_BUILD_TOOL:
             build_tool = RATTLER_BUILD_TOOL
-    else:
-        if os.path.exists(os.path.join(recipe_dir, "recipe.yaml")):
-            build_tool = RATTLER_BUILD_TOOL
+        if recipe_dir is None:
+            recipe_dir = os.path.join(
+                feedstock_dir, forge_config.get("recipe_dir", "recipe")
+            )
+
+    return (recipe_dir, build_tool)
+
+
+def main(
+    recipe_dir, conda_forge=False, return_hints=False, feedstock_dir=None
+):
+    recipe_dir, build_tool = find_recipe_directory(recipe_dir, feedstock_dir)
 
     if build_tool == RATTLER_BUILD_TOOL:
         recipe_file = os.path.join(recipe_dir, "recipe.yaml")
@@ -742,9 +772,7 @@ def main(
         recipe_file = os.path.join(recipe_dir, "meta.yaml")
 
     if not os.path.exists(recipe_file):
-        raise OSError(
-            f"Feedstock has no recipe/{os.path.basename(recipe_file)}"
-        )
+        raise OSError(f"No recipe file found in {recipe_dir}")
 
     if build_tool == CONDA_BUILD_TOOL:
         with open(recipe_file, encoding="utf-8") as fh:
diff --git a/news/2250-recipe-dir-param.rst b/news/2250-recipe-dir-param.rst
new file mode 100644
index 00000000..c4c3cbcc
--- /dev/null
+++ b/news/2250-recipe-dir-param.rst
@@ -0,0 +1,24 @@
+**Added:**
+
+* <news item>
+
+**Changed:**
+
+* ``conda-smithy lint`` now can be run with the feedstock directory instead of
+  the recipe subdirectory. (#2250)
+
+**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 2a95b3ba..ce824486 100644
--- a/tests/test_lint_recipe.py
+++ b/tests/test_lint_recipe.py
@@ -13,7 +13,11 @@ import pytest
 
 import conda_smithy.lint_recipe as linter
 from conda_smithy.linter import hints
-from conda_smithy.linter.utils import VALID_PYTHON_BUILD_BACKENDS
+from conda_smithy.linter.utils import (
+    CONDA_BUILD_TOOL,
+    RATTLER_BUILD_TOOL,
+    VALID_PYTHON_BUILD_BACKENDS,
+)
 from conda_smithy.utils import get_yaml, render_meta_yaml
 
 _thisdir = os.path.abspath(os.path.dirname(__file__))
@@ -4257,5 +4261,106 @@ def test_bad_specs_report(tmp_path, spec, ok):
     assert all("has some malformed specs" not in hint for hint in hints) is ok
 
 
+@pytest.mark.parametrize(
+    "create_recipe_file,directory_param,expected_tool",
+    [
+        # recipe path passed
+        (None, "recipe", CONDA_BUILD_TOOL),
+        ("meta.yaml", "recipe", CONDA_BUILD_TOOL),
+        ("recipe.yaml", "recipe", RATTLER_BUILD_TOOL),
+        # no conda-forge.yml, incorrect path passed
+        (None, ".", CONDA_BUILD_TOOL),
+        ("meta.yaml", ".", CONDA_BUILD_TOOL),
+        ("recipe.yaml", ".", CONDA_BUILD_TOOL),
+    ],
+)
+def test_find_recipe_directory(
+    tmp_path, create_recipe_file, directory_param, expected_tool
+):
+    """
+    Test ``find_recipe_directory()`` without ``conda-forge.yml``
+
+    When ``find_recipe_directory()`` is passed a directory with
+    no ``conda-forge.yml``, and no ``--feedstock-directory`` is passed,
+    it should just return the input directory and guess tool from files
+    inside it.
+    """
+
+    tmp_path.joinpath("recipe").mkdir()
+    if create_recipe_file is not None:
+        tmp_path.joinpath("recipe", create_recipe_file).touch()
+
+    assert linter.find_recipe_directory(
+        str(tmp_path / directory_param), None
+    ) == (str(tmp_path / directory_param), expected_tool)
+
+
+@pytest.mark.parametrize(
+    "build_tool", [None, CONDA_BUILD_TOOL, RATTLER_BUILD_TOOL]
+)
+@pytest.mark.parametrize("recipe_dir", [None, "foo", "recipe"])
+def test_find_recipe_directory_via_conda_forge_yml(
+    tmp_path, build_tool, recipe_dir
+):
+    """
+    Test ``find_recipe_directory()`` with ``conda-forge.yml``
+
+    When ``find_recipe_directory()`` is passed a directory with
+    ``conda-forge.yml``, and no ``--feedstock-directory``, it should read
+    both the recipe directory and the tool type from it.
+    """
+
+    # create all files to verify that format is taken from conda-forge.yml
+    tmp_path.joinpath("recipe").mkdir()
+    tmp_path.joinpath("recipe", "meta.yaml").touch()
+    tmp_path.joinpath("recipe", "recipe.yaml").touch()
+
+    with tmp_path.joinpath("conda-forge.yml").open("w") as f:
+        if build_tool is not None:
+            f.write(f"conda_build_tool: {build_tool}\n")
+        if recipe_dir is not None:
+            f.write(f"recipe_dir: {recipe_dir}\n")
+
+    assert linter.find_recipe_directory(str(tmp_path), None) == (
+        str(tmp_path / (recipe_dir or "recipe")),
+        build_tool or CONDA_BUILD_TOOL,
+    )
+
+
+@pytest.mark.parametrize(
+    "build_tool", [None, CONDA_BUILD_TOOL, RATTLER_BUILD_TOOL]
+)
+@pytest.mark.parametrize("yaml_recipe_dir", [None, "foo", "recipe"])
+@pytest.mark.parametrize("directory_param", [".", "recipe"])
+def test_find_recipe_directory_with_feedstock_dir(
+    tmp_path, build_tool, yaml_recipe_dir, directory_param
+):
+    """
+    Test ``find_recipe_directory()`` with ``--feedstock-directory``
+
+    When ``find_recipe_directory()`` is passed a ``--feedstock-directory``,
+    it should read the tool type from it, but use the passed recipe
+    directory.
+    """
+
+    # create all files to verify that format is taken from conda-forge.yml
+    tmp_path.joinpath("recipe").mkdir()
+    tmp_path.joinpath("recipe", "meta.yaml").touch()
+    tmp_path.joinpath("recipe", "recipe.yaml").touch()
+
+    with tmp_path.joinpath("conda-forge.yml").open("w") as f:
+        if build_tool is not None:
+            f.write(f"conda_build_tool: {build_tool}\n")
+        if yaml_recipe_dir is not None:
+            f.write(f"recipe_dir: {yaml_recipe_dir}\n")
+
+    assert linter.find_recipe_directory(
+        str(tmp_path / directory_param), str(tmp_path)
+    ) == (
+        str(tmp_path / directory_param),
+        build_tool or CONDA_BUILD_TOOL,
+    )
+
+
 if __name__ == "__main__":
     unittest.main()