diff --git a/conda_build/_rattler_build/compat.py b/conda_build/_rattler_build/compat.py index 0bf85dd187..3ead49e3c2 100644 --- a/conda_build/_rattler_build/compat.py +++ b/conda_build/_rattler_build/compat.py @@ -2,6 +2,9 @@ # SPDX-License-Identifier: BSD-3-Clause from __future__ import annotations +import os +import sys +import time from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING @@ -13,16 +16,17 @@ RattlerBuildError, RecipeParseError, ) +from rattler_build.debug import DebugSession from rattler_build.progress import SimpleProgressCallback from rattler_build.render import RenderConfig -from rattler_build.stage0 import Stage0Recipe +from rattler_build.stage0 import MultiOutputRecipe, Stage0Recipe from rattler_build.tool_config import PlatformConfig, ToolConfiguration from rattler_build.variant_config import VariantConfig from ..build import handle_anaconda_upload from ..config import CondaPkgFormat from ..exceptions import CondaBuildUserError -from ..utils import get_logger +from ..utils import get_logger, on_win if TYPE_CHECKING: import argparse @@ -48,6 +52,7 @@ class RecipeResult: recipe_path: str outputs: list[OutputResult] = field(default_factory=list) error: str | None = None + activation_string: str | None = None @property def failed(self) -> bool: @@ -125,6 +130,15 @@ def check_arguments_rattler( "channel", "override_channels", }, + "debug": { + "recipe", + "output_id", + "variant_config_files", + "exclusive_config_files", + "channel", + "override_channels", + "activate_string_only", + }, } # check for unsupported CLI arguments @@ -198,6 +212,62 @@ def process_recipe( print(yaml.safe_dump(data, indent=2, sort_keys=False)) return result + if command == "debug": + if isinstance(recipe, MultiOutputRecipe): + if not list(rendered): + sys.exit( + f"No rendered outputs were produced for {Path(recipe_path).resolve()}" + ) + if parsed_args.output_id is None: + output_names = sorted( + { + variant.recipe.to_dict().get("package", {}).get("name") + for variant in rendered + } + ) + raise CondaBuildUserError( + f"\nFound {len(output_names)} outputs in recipe. Please specify one " + f"using --output-id. Available outputs: {', '.join(output_names)}" + ) + else: + selected_output = None + for variant in rendered: + name = variant.recipe.to_dict().get("package", {}).get("name") + if name == parsed_args.output_id: + selected_output = variant + break + + if selected_output is None: + raise CondaBuildUserError( + f"Output '{parsed_args.output_id}' not found. " + ) + + else: + selected_output = rendered[0] + try: + session = DebugSession.create( + variant=selected_output, + tool_config=tool_config, + output_dir=os.path.join(output_dir, f"debug_{int(time.time() * 1000)}"), + channels=channels, + progress_callback=CondaProgressCallback(show_logs=True), + ) + except RattlerBuildError as e: + result.error = ( + f"Failed to setup debug scripts for output {selected_output}: {e}" + ) + return result + + result.activation_string = ( + "cd {work_dir} && {source} {activation_file}\n".format( + work_dir=session.paths.work_dir, + source="call" if on_win else "source", + activation_file=session.paths.build_env_script, + ) + ) + + return result + for i, variant in enumerate(rendered, 1): print( f"\nBuilding variant {i}/{len(rendered)} for recipe {Path(recipe_path).resolve()}" @@ -291,9 +361,11 @@ def process_recipe( return result -def run_rattler(command: str, parsed_args: argparse.Namespace, config: Config) -> int: +def run_rattler( + command: str, parsed_args: argparse.Namespace, config: Config +) -> str | int: """Run rattler-build for v1 recipes""" - if command not in ("build", "render"): + if command not in ("build", "debug", "render"): raise ValueError(f"Unrecognized subcommand: {command}") # Initialize configuration defaults @@ -416,7 +488,7 @@ def get_config_value(name): else: package_format = ".tar.bz2" - if command == "render": + if command in ("debug", "render"): recipes = [str(Path(parsed_args.recipe) / "recipe.yaml")] else: recipes = [ @@ -444,27 +516,48 @@ def get_config_value(name): ) ) - if command == "render": - failed = [r for r in recipe_results if r.failed] - if failed: - msg = "\n".join( - [ - "Recipe render failures:", - *[ - f" - {Path(r.recipe_path).resolve()}: {r.error or 'Unknown error'}" - for r in failed - ], - ] - ) - raise CondaBuildUserError(msg) - return 0 + if command == "render": + # we are expecting a single recipe result + result = recipe_results[0] + if result.failed: + recipe_path = Path(result.recipe_path).resolve() + + raise CondaBuildUserError( + "\n".join( + [ + "Error: × Failed to render recipe", + f" ╰─▶ {recipe_path}", + "", + f" {result.error}", + ] + ) + ) + return 0 + + if command == "debug": + # we are expecting a single recipe result + result = recipe_results[0] + if result.failed: + recipe_path = Path(result.recipe_path).resolve() + + raise CondaBuildUserError( + "\n".join( + [ + "Error: Failed to debug recipe", + f" ╰─▶ {recipe_path}", + "", + f" {result.error}", + ] + ) + ) + return result.activation_string - recipe_count = len(recipe_results) - total_outputs = sum(len(r.outputs) for r in recipe_results) - succeeded_outputs = sum( - 1 for r in recipe_results for output in r.outputs if output.success - ) - failed_outputs = total_outputs - succeeded_outputs + recipe_count = len(recipe_results) + total_outputs = sum(len(r.outputs) for r in recipe_results) + succeeded_outputs = sum( + 1 for r in recipe_results for output in r.outputs if output.success + ) + failed_outputs = total_outputs - succeeded_outputs print("\n=== Build summary ===") print( diff --git a/conda_build/api.py b/conda_build/api.py index fb8555c07c..083ef16b3f 100644 --- a/conda_build/api.py +++ b/conda_build/api.py @@ -40,6 +40,34 @@ StatsDict = dict[str, Any] +def _error_if_package_contains_recipe_yaml(pkg_path: str) -> None: + from contextlib import redirect_stdout + from io import StringIO + + import conda_package_handling.api + + buffer = StringIO() + with redirect_stdout(buffer): + conda_package_handling.api.list_contents( + pkg_path, + components=["info"], + ) + + pkg_contents = [ + line.strip() for line in buffer.getvalue().splitlines() if line.strip() + ] + + for recipe_yaml_file in ( + "info/recipe/recipe.yaml", + "info/recipe/rendered_recipe.yaml", + ): + if recipe_yaml_file in pkg_contents: + raise ValueError( + f"Package '{pkg_path}' contains v1 '{recipe_yaml_file.rsplit('/', 1)[-1]}' file, " + "which is currently not supported by conda debug." + ) + + def render( recipe_path: str | os.PathLike | Path, config: Config | None = None, @@ -659,6 +687,9 @@ def debug( ) else: test_input = recipe_or_package_path_or_metadata_tuples + + _error_if_package_contains_recipe_yaml(test_input) + # use the package to create an env and extract the test files. Stop short of running the tests. # tell people what steps to take next with log_context: diff --git a/conda_build/cli/main_debug.py b/conda_build/cli/main_debug.py index 2cc18fc51a..9cc0a876d8 100644 --- a/conda_build/cli/main_debug.py +++ b/conda_build/cli/main_debug.py @@ -4,12 +4,15 @@ import logging import sys +from pathlib import Path from typing import TYPE_CHECKING from conda.base.context import context from .. import api -from ..utils import on_win +from .._rattler_build.compat import check_arguments_rattler, run_rattler +from ..config import get_or_merge_config +from ..utils import is_v1_recipe, on_win from . import validators as valid from .main_render import get_render_parser @@ -98,12 +101,29 @@ def execute(args: Sequence[str] | None = None) -> int: parsed = parser.parse_args(args) context.__init__(argparse_args=parsed) - try: - activation_string = api.debug( - parsed.recipe_or_package_file_path, - verbose=(not parsed.activate_string_only), - **parsed.__dict__, + # mixed recipe formats found, error out + if (Path(parsed.recipe_or_package_file_path) / "recipe.yaml").is_file() and ( + Path(parsed.recipe_or_package_file_path) / "meta.yaml" + ).is_file(): + print( + "Cannot process several recipe versions at the same time!", file=sys.stderr ) + return 1 + + try: + if is_v1_recipe(parsed.recipe_or_package_file_path): + config = get_or_merge_config(None, **parsed.__dict__) + parsed_only_recipe = parser.parse_args([parsed.recipe_or_package_file_path]) + check_arguments_rattler(parser.prog.split()[-1], parsed, parsed_only_recipe) + parsed.recipe = parsed.recipe_or_package_file_path + command = parser.prog.split()[-1] + activation_string = run_rattler(command, parsed, config) + else: + activation_string = api.debug( + parsed.recipe_or_package_file_path, + verbose=(not parsed.activate_string_only), + **parsed.__dict__, + ) if not parsed.activate_string_only: print("#" * 80) @@ -113,7 +133,11 @@ def execute(args: Sequence[str] | None = None) -> int: print(activation_string) if not parsed.activate_string_only: - test_file = "conda_test_runner.bat" if on_win else "conda_test_runner.sh" + test_file = ( + f"conda_{'build' if is_v1_recipe(parsed.recipe_or_package_file_path) else 'test_runner'}." + f"{'bat' if on_win else 'sh'}" + ) + print( f"To run your tests, you might want to start with running the {test_file} file." ) diff --git a/conda_build/render.py b/conda_build/render.py index c78100d238..062b63a7cd 100644 --- a/conda_build/render.py +++ b/conda_build/render.py @@ -67,7 +67,7 @@ from . import environ, exceptions, source, utils from .config import CondaPkgFormat -from .exceptions import CondaBuildUserError, DependencyNeedsBuildingError +from .exceptions import CondaBuildUserError, DependencyNeedsBuildingError, RecipeError from .index import get_build_index from .metadata import MetaData, MetaDataTuple, combine_top_level_metadata_with_output from .utils import ( @@ -1082,6 +1082,8 @@ def render_recipe( m = MetaData(str(recipe), config=config) except exceptions.YamlParsingError as e: sys.exit(e.error_msg()) + except OSError as e: + raise RecipeError(str(e)) from e # important: set build id *before* downloading source. Otherwise source goes into a different # build folder. diff --git a/docs/source/user-guide/v1-recipes.rst b/docs/source/user-guide/v1-recipes.rst index a1f5aef84e..8dcb7a0303 100644 --- a/docs/source/user-guide/v1-recipes.rst +++ b/docs/source/user-guide/v1-recipes.rst @@ -9,8 +9,8 @@ To get started with building v1 recipes, simply invoke ``conda-build`` and pass the recipe's directory. ``conda-build`` will recognize the recipe format and handle the build through ``py-rattler-build``. -``conda-build`` currently supports v1 recipes in the ``conda build`` and -``conda render`` commands. +``conda-build`` currently supports v1 recipes in the ``conda build``, +``conda render`` and ``conda-debug`` commands. Build configuration is done in the same way as for v0 recipes. Many configuration settings are translated into rattler-build equivalents and passed to ``py-rattler-build``. The following ``conda-build`` command-line arguments (and their corresponding settings in ``~/.condarc`` file) are @@ -48,6 +48,17 @@ supported: - ``--recipe`` - ``--variant-config-files`` +``conda debug`` +================ + +- ``--activate-string-only`` +- ``--channel`` +- ``--exclusive-config-file`` +- ``--output-id`` +- ``--override-channels`` +- ``--recipe`` +- ``--variant-config-files`` + Package upload ============== @@ -88,7 +99,6 @@ For example, to authenticate with a private channel hosted on ``prefix.dev``: After this setup, ``conda-build`` will be able to access packages hosted on the private channel. - Limitations =========== diff --git a/news/5950-conda-debug-v1.md b/news/5950-conda-debug-v1.md new file mode 100644 index 0000000000..71bcb38a55 --- /dev/null +++ b/news/5950-conda-debug-v1.md @@ -0,0 +1,19 @@ +### Enhancements + +* Add support for v1 recipes to `conda-debug` (#5950). + +### Bug fixes + +* + +### Deprecations + +* + +### Docs + +* Add `conda-debug` command to v1 recipe docs. (#5950) + +### Other + +* diff --git a/recipe/meta.yaml b/recipe/meta.yaml index 966b2e63cf..71ed91e573 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -50,7 +50,7 @@ requirements: - pkginfo - psutil - py-lief - - py-rattler-build >=0.61.4 + - py-rattler-build >=0.65.1 - python - python-libarchive-c - pyyaml diff --git a/tests/cli/test_main_debug.py b/tests/cli/test_main_debug.py index ae4f22441d..0395536ad9 100644 --- a/tests/cli/test_main_debug.py +++ b/tests/cli/test_main_debug.py @@ -1,5 +1,6 @@ # Copyright (C) 2014 Anaconda, Inc # SPDX-License-Identifier: BSD-3-Clause +import os import sys from pathlib import Path from unittest import mock @@ -7,8 +8,13 @@ import pytest from pytest import CaptureFixture, MonkeyPatch +from conda_build.cli import main_build as build from conda_build.cli import main_debug as debug from conda_build.cli import validators as valid +from conda_build.exceptions import CondaBuildUserError +from conda_build.utils import on_win + +from ..utils import metadata_dir def test_main_debug_help_message(capsys: CaptureFixture, monkeypatch: MonkeyPatch): @@ -51,3 +57,80 @@ def test_main_debug_happy_path( assert captured.err == "" assert len(mock_debug.mock_calls) == 2 + + +def test_debug_v1_recipe(testing_workdir, capsys: CaptureFixture): + """ + Test conda-debug functionality for v1 recipe. The test uses a multi-output recipe. + """ + recipe_dir = os.path.join( + metadata_dir, "..", "variants", "33_v1_recipe_multi_output" + ) + + # Make sure that it fails with the expected message if output is not specified + args = [recipe_dir] + with pytest.raises( + CondaBuildUserError, + match=r"Found 2 outputs in recipe. Please specify one using --output-id.", + ): + debug.execute(args) + + # Setup scripts for the first output + args = [recipe_dir, "--output-id", "myproject-lib"] + assert debug.execute(args) == 0 + + captured = capsys.readouterr() + output = captured.out + assert "Test environment created for debugging." in output + assert "rattler-build_myproject-lib" in output + expected = ( + "To run your tests, you might want to start with running the conda_build.bat file." + if on_win + else "To run your tests, you might want to start with running the conda_build.sh file." + ) + assert expected in output + + # Setup scripts for the second output + # Build the recipe because second output depends on the first one + args = [recipe_dir, "--output-folder", testing_workdir] + build.execute(args) + + args = [recipe_dir, "--output-id", "myproject-tools", "-c", testing_workdir] + assert debug.execute(args) == 0 + + captured = capsys.readouterr() + output = captured.out + assert "Test environment created for debugging." in output + assert "rattler-build_myproject-tools" in output + assert expected in output + + +def test_error_if_package_contains_recipe_yaml(tmp_path: Path, capsys: CaptureFixture): + recipe_dir = Path(metadata_dir, "..", "variants", "32_v1_recipe") + out = tmp_path / "out" + + args = [ + str(recipe_dir), + "-c", + "conda-forge", + "--no-test", + "--output-folder", + str(out), + ] + build.execute(args) + + pkg_files = list((out / "noarch").glob("pytest*.conda")) + assert len(pkg_files) == 1, pkg_files + pkg_file = pkg_files[0] + + with pytest.raises(SystemExit) as exc: + debug.execute([str(pkg_file)]) + + assert exc.value.code == 1 + + captured = capsys.readouterr() + output = captured.out + captured.err + assert ( + "contains v1 'recipe.yaml' file, which is currently not supported by conda debug." + in output + ) diff --git a/tests/requirements.txt b/tests/requirements.txt index 5d00681860..160c88249a 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -17,7 +17,7 @@ pip pkginfo psutil py-lief -py-rattler-build >=0.61.4 # v1 recipe support +py-rattler-build >=0.65.1 # v1 recipe support pytest-rerunfailures # for handling flaky tests python >=3.10 python-libarchive-c