From 040b7dca18a880f8de652546168f8ad3f5b80e8d Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 18 Jun 2026 10:23:09 -0400 Subject: [PATCH 1/4] Refactor test examples to parametrize installer types, update one example --- constructor/main.py | 19 +++ tests/test_examples.py | 261 +++++++++++++++++++++++++++++------------ tests/test_main.py | 33 ++++++ 3 files changed, 237 insertions(+), 76 deletions(-) diff --git a/constructor/main.py b/constructor/main.py index 23ab3afa3..e596b3bb6 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -194,6 +194,7 @@ def main_build( conda_exe: str = "conda.exe", config_filename: str = "construct.yaml", debug: bool = False, + installer_type: str | None = None, ): logger.info("platform: %s", platform) if not os.path.isfile(conda_exe): @@ -216,6 +217,14 @@ def main_build( info["_debug"] = debug itypes = get_installer_type(info) + if installer_type: + if installer_type not in itypes: + sys.exit( + f"Error: installer type '{installer_type}' not available for this " + f"config/platform; allowed: {', '.join(itypes)}" + ) + itypes = (installer_type,) + if platform != cc_platform and "pkg" in itypes and not cc_platform.startswith("osx-"): sys.exit("Error: cannot construct a macOS 'pkg' installer on '%s'" % cc_platform) @@ -577,6 +586,15 @@ def main(argv=None): default="construct.yaml", ) + p.add_argument( + "--installer-type", + help="Build only this installer type (sh, pkg, exe, msi). " + "Primarily for testing; overrides 'installer_type' from the config.", + action="store", + metavar="TYPE", + dest="installer_type", + ) + p.add_argument( "dir_path", help="directory containing construct.yaml", @@ -649,6 +667,7 @@ def main(argv=None): conda_exe=conda_exe, config_filename=args.config_filename, debug=args.debug, + installer_type=args.installer_type, ) diff --git a/tests/test_examples.py b/tests/test_examples.py index ce8c0614a..eb2808ffd 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -25,6 +25,7 @@ from constructor.conda_interface import cc_platform from constructor.construct import parse as parse_construct +from constructor.main import get_installer_type from constructor.utils import ( StandaloneExe, check_version, @@ -610,17 +611,22 @@ def _run_installer( return process -def create_installer( +def _build_installers( input_dir: Path, workspace: Path, + installer_type: str | None = None, conda_exe=CONSTRUCTOR_CONDA_EXE, debug=CONSTRUCTOR_DEBUG, - with_spaces=False, timeout=420, config_filename="construct.yaml", extra_constructor_args: Iterable[str] = None, **env_vars, -) -> Generator[tuple[Path, Path], None, None]: +) -> Path: + """Run constructor and return the output directory holding the artifact(s). + + installer_type=None builds all applicable types (current behavior); a value + passes --installer-type to build exactly one. + """ if sys.platform.startswith("win") and conda_exe and _is_micromamba(conda_exe): pytest.skip("Micromamba is not supported on Windows yet.") @@ -638,7 +644,8 @@ def create_installer( "--config-filename", config_filename, ] - + if installer_type: + cmd += ["--installer-type", installer_type] if conda_exe: cmd.extend(["--conda-exe", conda_exe]) if debug: @@ -647,8 +654,87 @@ def create_installer( cmd.extend(extra_constructor_args) _execute(cmd, timeout=timeout, **env_vars) + return output_dir + +def _install_dir_for( + installer: Path, + input_dir: Path, + workspace: Path, + config_filename: str, + with_spaces: bool, +) -> Path: install_dir_prefix = "i n s t a l l" if with_spaces else "install" + if installer.suffix == ".pkg" and ON_CI: + return Path("~").expanduser() / calculate_install_dir(input_dir / config_filename) + elif installer.suffix == ".msi": + return calculate_msi_install_path(input_dir / config_filename) + else: + return workspace / f"{install_dir_prefix}-{installer.stem}-{installer.suffix[1:]}" + + +def installer_types_for_example( + example_path: Path, config_filename: str = "construct.yaml" +) -> tuple[str, ...]: + """Installer types this example builds on the current platform. + + Reuses constructor's own get_installer_type() so the test parametrization + matches what the build actually produces. + """ + info = parse_construct(str(example_path / config_filename), platform=cc_platform) + info["_platform"] = cc_platform + return get_installer_type(info) + + +def create_single_installer( + input_dir: Path, + workspace: Path, + installer_type: str, + *, + with_spaces=False, + timeout=420, + config_filename="construct.yaml", + extra_constructor_args: Iterable[str] = None, + **env_vars, +) -> tuple[Path, Path]: + """Build exactly one installer type; return (installer_path, install_dir).""" + output_dir = _build_installers( + input_dir, + workspace, + installer_type=installer_type, + timeout=timeout, + config_filename=config_filename, + extra_constructor_args=extra_constructor_args, + **env_vars, + ) + installer = next(output_dir.glob(f"*.{installer_type}"), None) + if installer is None: + raise FileNotFoundError(f"No .{installer_type} installer found in {output_dir}") + install_dir = _install_dir_for(installer, input_dir, workspace, config_filename, with_spaces) + return installer, install_dir + + +def create_installer( + input_dir: Path, + workspace: Path, + conda_exe=CONSTRUCTOR_CONDA_EXE, + debug=CONSTRUCTOR_DEBUG, + with_spaces=False, + timeout=420, + config_filename="construct.yaml", + extra_constructor_args: Iterable[str] = None, + **env_vars, +) -> Generator[tuple[Path, Path], None, None]: + output_dir = _build_installers( + input_dir, + workspace, + conda_exe=conda_exe, + debug=debug, + timeout=timeout, + config_filename=config_filename, + extra_constructor_args=extra_constructor_args, + **env_vars, + ) def _sort_by_extension(path): "Return shell installers first so they are run before the GUI ones" @@ -656,17 +742,9 @@ def _sort_by_extension(path): installers = (p for p in output_dir.iterdir() if p.suffix in (".exe", ".msi", ".sh", ".pkg")) for installer in sorted(installers, key=_sort_by_extension): - if installer.suffix == ".pkg" and ON_CI: - install_dir = Path("~").expanduser() / calculate_install_dir( - input_dir / config_filename - ) - elif installer.suffix == ".msi": - install_dir = calculate_msi_install_path(input_dir / config_filename) - else: - install_dir = ( - workspace / f"{install_dir_prefix}-{installer.stem}-{installer.suffix[1:]}" - ) - + install_dir = _install_dir_for( + installer, input_dir, workspace, config_filename, with_spaces + ) yield installer, install_dir if KEEP_ARTIFACTS_PATH: try: @@ -696,6 +774,17 @@ def _is_micromamba(path) -> bool: return name == StandaloneExe.MAMBA +def test_installer_types_for_example_matches_platform(): + """Validate any example with 'installer_type: all'; and that the resolved types match the platform set.""" + types = installer_types_for_example(_example_path("miniforge")) + if sys.platform.startswith("linux"): + assert types == ("sh",) + elif sys.platform == "darwin": + assert types == ("sh", "pkg") + elif sys.platform.startswith("win"): + assert types == ("exe", "msi") + + @pytest.fixture(params=["linux-aarch64"]) def platform_conda_exe(request, tmp_path) -> tuple[str, Path]: platform = request.param @@ -797,75 +886,95 @@ def test_example_mirrored_channels(tmp_path, request): assert condarc_data == expected_condarc -@pytest.mark.xfail( +def _check_miniforge( + input_path, installer_name, installer_version, installer_type, tmp_path, request +): + """Helper function for the existing miniforge examples.""" + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + if installer_type == "sh": + # try both batch and interactive installations + install_dirs = (install_dir / "batch", install_dir / "interactive") + installer_inputs = (None, f"\nyes\n{install_dir / 'interactive'}\nno\nno\n") + else: + install_dirs = (install_dir,) + installer_inputs = (None,) + for installer_input, install_dir in zip(installer_inputs, install_dirs): + _run_installer( + input_path, + installer, + install_dir, + installer_input=installer_input, + request=request, + # PKG installers use their own install path, so we can't check sentinels + # via `install_dir` + check_sentinels=installer_type != "pkg", + uninstall=False, + ) + # Check that key metadata files are in place + assert install_dir.glob("conda-meta/*.json") + assert install_dir.glob("pkgs/cache/*.json") # enables offline installs + # Check that the installer info file is in place + info_file_name = ".installer.info" + if installer_type == "msi": + info_file = install_dir / "base" / info_file_name + else: + info_file = install_dir / info_file_name + assert info_file.is_file() + installer_info = json.loads(info_file.read_text()) + assert installer_info["name"] == installer_name + assert installer_info["version"] == installer_version + assert installer_info["platform"] == cc_platform + assert installer_info["type"] == installer_type + if installer_type == "pkg" and ON_CI: + _sentinel_file_checks(input_path, Path(os.environ["HOME"]) / installer_name) + if installer_type == "exe": + for key in ("ProgramData", "AppData"): + start_menu_dir = Path( + os.environ[key], + "Microsoft/Windows/Start Menu/Programs/Miniforge3", + ) + if start_menu_dir.is_dir(): + assert list(start_menu_dir.glob("Miniforge*.lnk")) + break + else: + raise AssertionError("Could not find Start Menu folder for miniforge") + _run_uninstaller_exe(install_dir) + assert not list(start_menu_dir.glob("Miniforge*.lnk")) + elif installer_type == "msi": + # TODO: Start menus + _run_uninstaller_msi(installer, install_dir) + + +_MINIFORGE_XFAIL = pytest.mark.xfail( ( CONDA_EXE == StandaloneExe.CONDA and not check_version(CONDA_EXE_VERSION, min_version="23.11.0a0") ), reason="Known issue with conda-standalone<=23.10: shortcuts are created but not removed.", ) + + +@_MINIFORGE_XFAIL +@pytest.mark.parametrize("installer_type", installer_types_for_example(_example_path("miniforge"))) +def test_example_miniforge(tmp_path, request, installer_type): + _check_miniforge( + _example_path("miniforge"), "Miniforge3", "25.0.0-1", installer_type, tmp_path, request + ) + + +@_MINIFORGE_XFAIL @pytest.mark.parametrize( - "example, installer_name, installer_version", - [ - ("miniforge", "Miniforge3", "25.0.0-1"), - ("miniforge-mamba2", "Miniforge3-mamba2", "25.1.1-0"), - ], + "installer_type", installer_types_for_example(_example_path("miniforge-mamba2")) ) -def test_example_miniforge(tmp_path, request, example, installer_name, installer_version): - input_path = _example_path(example) - for installer, install_dir in create_installer(input_path, tmp_path): - if installer.suffix == ".sh": - # try both batch and interactive installations - install_dirs = (install_dir / "batch", install_dir / "interactive") - installer_inputs = (None, f"\nyes\n{install_dir / 'interactive'}\nno\nno\n") - else: - install_dirs = (install_dir,) - installer_inputs = (None,) - for installer_input, install_dir in zip(installer_inputs, install_dirs): - _run_installer( - input_path, - installer, - install_dir, - installer_input=installer_input, - request=request, - # PKG installers use their own install path, so we can't check sentinels - # via `install_dir` - check_sentinels=installer.suffix != ".pkg", - uninstall=False, - ) - # Check that key metadata files are in place - assert install_dir.glob("conda-meta/*.json") - assert install_dir.glob("pkgs/cache/*.json") # enables offline installs - # Check that the installer info file is in place - info_file_name = ".installer.info" - if installer.suffix == ".msi": - info_file = install_dir / "base" / info_file_name - else: - info_file = install_dir / info_file_name - assert info_file.is_file() - installer_info = json.loads(info_file.read_text()) - assert installer_info["name"] == installer_name - assert installer_info["version"] == installer_version - assert installer_info["platform"] == cc_platform - assert installer_info["type"] == installer.suffix[1:] - if installer.suffix == ".pkg" and ON_CI: - _sentinel_file_checks(input_path, Path(os.environ["HOME"]) / installer_name) - if installer.suffix == ".exe": - for key in ("ProgramData", "AppData"): - start_menu_dir = Path( - os.environ[key], - "Microsoft/Windows/Start Menu/Programs/Miniforge3", - ) - if start_menu_dir.is_dir(): - assert list(start_menu_dir.glob("Miniforge*.lnk")) - break - else: - raise AssertionError("Could not find Start Menu folder for miniforge") - _run_uninstaller_exe(install_dir) - assert not list(start_menu_dir.glob("Miniforge*.lnk")) - elif installer.suffix == ".msi": - # TODO: Start menus - _run_uninstaller_msi(installer, install_dir) +def test_example_miniforge_mamba2(tmp_path, request, installer_type): + _check_miniforge( + _example_path("miniforge-mamba2"), + "Miniforge3-mamba2", + "25.1.1-0", + installer_type, + tmp_path, + request, + ) def test_example_noconda(tmp_path, request): diff --git a/tests/test_main.py b/tests/test_main.py index e17f69f54..2f46d590d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,7 +1,22 @@ +import sys from textwrap import dedent +import pytest + from constructor.main import main +_CONSTRUCT = dedent( + """ + name: test_installer_type_flag + version: 1.0.0 + installer_type: all + channels: + - conda-forge + specs: + - ca-certificates + """ +) + def test_dry_run(tmp_path): inputfile = dedent( @@ -17,3 +32,21 @@ def test_dry_run(tmp_path): ) (tmp_path / "construct.yaml").write_text(inputfile) main([str(tmp_path), "--dry-run"]) + + +def test_installer_type_flag_valid(tmp_path): + """Test that --installer-type dont exit with error for valid types.""" + (tmp_path / "construct.yaml").write_text(_CONSTRUCT) + # No need to test all types + itype = "exe" if sys.platform.startswith("win") else "sh" + assert main([str(tmp_path), "--installer-type", itype, "--dry-run"]) is None + + +def test_installer_type_flag_invalid_for_platform(tmp_path): + """Test that a type not valid for the current platform/config exits with the expected error message.""" + (tmp_path / "construct.yaml").write_text(_CONSTRUCT) + # Pick a type that is never valid on this platform. + bad = "sh" if sys.platform.startswith("win") else "exe" + with pytest.raises(SystemExit) as exc: + main([str(tmp_path), "--installer-type", bad, "--dry-run"]) + assert "not available" in str(exc.value) From 8d42fd647bd657cc206512bfcef7fe9c29ffe1e3 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 18 Jun 2026 14:25:30 -0400 Subject: [PATCH 2/4] Migrate first half of tests --- tests/test_examples.py | 289 +++++++++++++++++++++++------------------ 1 file changed, 164 insertions(+), 125 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index eb2808ffd..6ea040ce2 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -808,16 +808,23 @@ def platform_conda_exe(request, tmp_path) -> tuple[str, Path]: return platform, conda_exe -def test_example_customize_controls(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("customize_controls")) +) +def test_example_customize_controls(tmp_path, request, installer_type): input_path = _example_path("customize_controls") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request) + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request) -def test_example_customized_welcome_conclusion(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", + installer_types_for_example(_example_path("customized_welcome_conclusion")), +) +def test_example_customized_welcome_conclusion(tmp_path, request, installer_type): input_path = _example_path("customized_welcome_conclusion") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request) + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request) @pytest.mark.parametrize("extra_pages", ("str", "list")) @@ -848,42 +855,50 @@ def test_example_extra_envs(tmp_path, request): _run_uninstaller_exe(install_dir=install_dir) -def test_example_extra_files(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("extra_files")) +) +def test_example_extra_files(tmp_path, request, installer_type): input_path = _example_path("extra_files") - for installer, install_dir in create_installer(input_path, tmp_path, with_spaces=True): - _run_installer( - input_path, - installer, - install_dir, - request=request, - check_sentinels=CONSTRUCTOR_VERBOSE, - check_subprocess=CONSTRUCTOR_VERBOSE, - ) + installer, install_dir = create_single_installer( + input_path, tmp_path, installer_type, with_spaces=True + ) + _run_installer( + input_path, + installer, + install_dir, + request=request, + check_sentinels=CONSTRUCTOR_VERBOSE, + check_subprocess=CONSTRUCTOR_VERBOSE, + ) -def test_example_mirrored_channels(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("mirrored_channels")) +) +def test_example_mirrored_channels(tmp_path, request, installer_type): input_path = _example_path("mirrored_channels") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request, uninstall=False) - - expected_condarc = { - "channels": ["conda-forge"], - "mirrored_channels": { - "conda-forge": [ - "https://conda.anaconda.org/conda-forge", - "https://conda.anaconda.org/mirror1", - "https://conda.anaconda.org/mirror2", - ] - }, - } + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request, uninstall=False) + + expected_condarc = { + "channels": ["conda-forge"], + "mirrored_channels": { + "conda-forge": [ + "https://conda.anaconda.org/conda-forge", + "https://conda.anaconda.org/mirror1", + "https://conda.anaconda.org/mirror2", + ] + }, + } - condarc_file = install_dir / ".condarc" - assert condarc_file.exists() + condarc_file = install_dir / ".condarc" + assert condarc_file.exists() - with open(condarc_file) as file: - condarc_data = YAML().load(file) + with open(condarc_file) as file: + condarc_data = YAML().load(file) - assert condarc_data == expected_condarc + assert condarc_data == expected_condarc def _check_miniforge( @@ -977,18 +992,26 @@ def test_example_miniforge_mamba2(tmp_path, request, installer_type): ) -def test_example_noconda(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", + installer_types_for_example(_example_path("noconda"), "constructor_input.yaml"), +) +def test_example_noconda(tmp_path, request, installer_type): input_path = _example_path("noconda") - for installer, install_dir in create_installer( - input_path, tmp_path, config_filename="constructor_input.yaml", with_spaces=True - ): - _run_installer( - input_path, - installer, - install_dir, - config_filename="constructor_input.yaml", - request=request, - ) + installer, install_dir = create_single_installer( + input_path, + tmp_path, + installer_type, + config_filename="constructor_input.yaml", + with_spaces=True, + ) + _run_installer( + input_path, + installer, + install_dir, + config_filename="constructor_input.yaml", + request=request, + ) @pytest.mark.skipif(sys.platform != "darwin", reason="macOS only") @@ -1108,10 +1131,13 @@ def test_macos_signing(tmp_path, self_signed_application_certificate_macos): assert validated_signatures == components -def test_example_scripts(tmp_path, request): +@pytest.mark.parametrize("installer_type", installer_types_for_example(_example_path("scripts"))) +def test_example_scripts(tmp_path, request, installer_type): input_path = _example_path("scripts") - for installer, install_dir in create_installer(input_path, tmp_path, with_spaces=True): - _run_installer(input_path, installer, install_dir, request=request) + installer, install_dir = create_single_installer( + input_path, tmp_path, installer_type, with_spaces=True + ) + _run_installer(input_path, installer, install_dir, request=request) @pytest.mark.skipif( @@ -1247,18 +1273,21 @@ def test_azure_signtool(tmp_path, request, monkeypatch, auth_method): _run_installer(input_path, installer, install_dir, request=request) -def test_example_use_channel_remap(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("use_channel_remap")) +) +def test_example_use_channel_remap(tmp_path, request, installer_type): input_path = _example_path("use_channel_remap") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request, uninstall=False) - p = subprocess.run( - [sys.executable, "-m", "conda", "list", "--prefix", install_dir, "--json"], - capture_output=True, - text=True, - ) - packages = json.loads(p.stdout) - for pkg in packages: - assert pkg["channel"] == "private_repo" + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request, uninstall=False) + p = subprocess.run( + [sys.executable, "-m", "conda", "list", "--prefix", install_dir, "--json"], + capture_output=True, + text=True, + ) + packages = json.loads(p.stdout) + for pkg in packages: + assert pkg["channel"] == "private_repo" def test_example_from_existing_env(tmp_path, request): @@ -1316,13 +1345,16 @@ def test_example_from_explicit(tmp_path, request): assert out == expected -def test_register_envs(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("register_envs")) +) +def test_register_envs(tmp_path, request, installer_type): """Verify that 'register_envs: False' results in the environment not being registered.""" input_path = _example_path("register_envs") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request) - environments_txt = Path("~/.conda/environments.txt").expanduser().read_text() - assert str(install_dir) not in environments_txt + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request) + environments_txt = Path("~/.conda/environments.txt").expanduser().read_text() + assert str(install_dir) not in environments_txt @pytest.mark.skipif(sys.platform != "darwin", reason="macOS only") @@ -1437,17 +1469,20 @@ def test_virtual_specs_failed(tmp_path, request): assert msg in process.stdout + process.stderr -def test_virtual_specs_ok(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("virtual_specs_ok")) +) +def test_virtual_specs_ok(tmp_path, request, installer_type): input_path = _example_path("virtual_specs_ok") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer( - input_path, - installer, - install_dir, - request=request, - check_subprocess=True, - uninstall=True, - ) + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer( + input_path, + installer, + install_dir, + request=request, + check_subprocess=True, + uninstall=True, + ) @pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix only") @@ -1859,58 +1894,62 @@ def test_uninstallation_standalone( shutil.rmtree(system_rc.parent) -def test_output_files(tmp_path): +@pytest.mark.parametrize("installer_type", installer_types_for_example(_example_path("outputs"))) +def test_output_files(tmp_path, installer_type): input_path = _example_path("outputs") - for installer, _ in create_installer(input_path, tmp_path): - files_expected = [ - f"{installer.name}.md5", - f"{installer.name}.sha256", - "info.json", - "licenses.json", - "pkg-list.base.txt", - "pkg-list.py310.txt", - "lockfile.base.txt", - "lockfile.py310.txt", - ] - files_not_expected = [ - "pkg-list.py311.txt", - "lockfile.py311.txt", - ] - root_path = installer.parent - files_exist = [file for file in files_expected if (root_path / file).exists()] - assert sorted(files_exist) == sorted(files_expected) - files_exist = [file for file in files_not_expected if (root_path / file).exists()] - assert files_exist == [] - - # Test that info.json contains serialized objects - info_json = json.loads((root_path / "info.json").read_text()) - assert isinstance(info_json.get("_conda_exe_version"), str) - _all_pkg_records = info_json.get("_all_pkg_records") - assert isinstance(_all_pkg_records, list), "Package record is not a list." - assert len(_all_pkg_records) > 0, "Package record is empty." - assert isinstance(_all_pkg_records[0], dict), "Package record not serialized." - _records = info_json.get("_records") - assert isinstance(_records, list), "Package record for base is not a list." - assert len(_records) > 0, "Package record for base is empty." - assert isinstance(_records[0], dict), "Package record for base is not serialized." - for env, env_info in info_json.get("_extra_envs_info", {}).items(): - _records = env_info.get("_records") - assert isinstance(_records, list), f"Record for {env} is not a list." - assert len(_records) > 0, f"Record for {env} is empty." - assert isinstance(_records[0], dict), f"Record for {env} is not serialized." - - -def test_regressions(tmp_path, request): + installer, _ = create_single_installer(input_path, tmp_path, installer_type) + files_expected = [ + f"{installer.name}.md5", + f"{installer.name}.sha256", + "info.json", + "licenses.json", + "pkg-list.base.txt", + "pkg-list.py310.txt", + "lockfile.base.txt", + "lockfile.py310.txt", + ] + files_not_expected = [ + "pkg-list.py311.txt", + "lockfile.py311.txt", + ] + root_path = installer.parent + files_exist = [file for file in files_expected if (root_path / file).exists()] + assert sorted(files_exist) == sorted(files_expected) + files_exist = [file for file in files_not_expected if (root_path / file).exists()] + assert files_exist == [] + + # Test that info.json contains serialized objects + info_json = json.loads((root_path / "info.json").read_text()) + assert isinstance(info_json.get("_conda_exe_version"), str) + _all_pkg_records = info_json.get("_all_pkg_records") + assert isinstance(_all_pkg_records, list), "Package record is not a list." + assert len(_all_pkg_records) > 0, "Package record is empty." + assert isinstance(_all_pkg_records[0], dict), "Package record not serialized." + _records = info_json.get("_records") + assert isinstance(_records, list), "Package record for base is not a list." + assert len(_records) > 0, "Package record for base is empty." + assert isinstance(_records[0], dict), "Package record for base is not serialized." + for env, env_info in info_json.get("_extra_envs_info", {}).items(): + _records = env_info.get("_records") + assert isinstance(_records, list), f"Record for {env} is not a list." + assert len(_records) > 0, f"Record for {env} is empty." + assert isinstance(_records[0], dict), f"Record for {env} is not serialized." + + +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("regressions")) +) +def test_regressions(tmp_path, request, installer_type): input_path = _example_path("regressions") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer( - input_path, - installer, - install_dir, - request=request, - check_subprocess=True, - uninstall=True, - ) + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer( + input_path, + installer, + install_dir, + request=request, + check_subprocess=True, + uninstall=True, + ) @pytest.mark.parametrize("no_registry", (0, 1)) From 770e775f346a70ae2cd689b208eb814747227de3 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 18 Jun 2026 14:54:53 -0400 Subject: [PATCH 3/4] Migrate more tests to new structure --- tests/test_examples.py | 353 ++++++++++++++++++++++------------------- 1 file changed, 190 insertions(+), 163 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 6ea040ce2..101b1b897 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -828,31 +828,35 @@ def test_example_customized_welcome_conclusion(tmp_path, request, installer_type @pytest.mark.parametrize("extra_pages", ("str", "list")) +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("exe_extra_pages")) +) @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") -def test_example_extra_pages_win(tmp_path, request, extra_pages, monkeypatch): +def test_example_extra_pages_win(tmp_path, request, extra_pages, installer_type, monkeypatch): if extra_pages == "list": monkeypatch.setenv("POST_INSTALL_PAGES_LIST", "1") input_path = _example_path("exe_extra_pages") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request) + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request) -def test_example_extra_envs(tmp_path, request): +@pytest.mark.parametrize("installer_type", installer_types_for_example(_example_path("extra_envs"))) +def test_example_extra_envs(tmp_path, request, installer_type): input_path = _example_path("extra_envs") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request, uninstall=False) - base = (install_dir / "base") if installer.suffix == ".msi" else install_dir - assert "@EXPLICIT" in (base / "conda-meta" / "initial-state.explicit.txt").read_text() - for env in base.glob("envs/*/conda-meta/"): - envtxt = env / "initial-state.explicit.txt" - assert envtxt.exists() - assert "@EXPLICIT" in envtxt.read_text() - - if sys.platform.startswith("win"): - if installer.suffix == ".msi": - _run_uninstaller_msi(installer, install_dir) - else: - _run_uninstaller_exe(install_dir=install_dir) + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request, uninstall=False) + base = (install_dir / "base") if installer_type == "msi" else install_dir + assert "@EXPLICIT" in (base / "conda-meta" / "initial-state.explicit.txt").read_text() + for env in base.glob("envs/*/conda-meta/"): + envtxt = env / "initial-state.explicit.txt" + assert envtxt.exists() + assert "@EXPLICIT" in envtxt.read_text() + + if sys.platform.startswith("win"): + if installer_type == "msi": + _run_uninstaller_msi(installer, install_dir) + else: + _run_uninstaller_exe(install_dir=install_dir) @pytest.mark.parametrize( @@ -1147,54 +1151,56 @@ def test_example_scripts(tmp_path, request, installer_type): ), reason="menuinst v2 requires conda-standalone>=23.11.0; micromamba is not supported yet", ) -def test_example_shortcuts(tmp_path, request): +@pytest.mark.parametrize("installer_type", installer_types_for_example(_example_path("shortcuts"))) +def test_example_shortcuts(tmp_path, request, installer_type): input_path = _example_path("shortcuts") - for installer, install_dir in create_installer(input_path, tmp_path): - # console_shortcut package uses hardcoded "Anaconda3" in its menu definition - distribution_name = "Anaconda3" - if sys.platform == "win32": - # Verify shortcuts don't exist before installation (not leftover from previous run (since EXE/MSI run in a loop)) - for key in ("ProgramData", "AppData"): - start_menu = Path(os.environ[key]) / "Microsoft/Windows/Start Menu/Programs" - package_1 = start_menu / "Package 1" - if package_1.is_dir(): - assert not (package_1 / "A.lnk").is_file(), "A.lnk exists before installation" - assert not (package_1 / "B.lnk").is_file(), "B.lnk exists before installation" - - _run_installer(input_path, installer, install_dir, request=request, uninstall=False) - # check that the shortcuts are created - if sys.platform == "win32": - for key in ("ProgramData", "AppData"): - start_menu = Path(os.environ[key]) / "Microsoft/Windows/Start Menu/Programs" - package_1 = start_menu / "Package 1" - console_shortcut_dir = start_menu / f"{distribution_name} (64-bit)" - if package_1.is_dir() and console_shortcut_dir.is_dir(): - assert (package_1 / "A.lnk").is_file() - assert (package_1 / "B.lnk").is_file() - # The shortcut created from the 'base' env - # should not exist because we filtered it out in the YAML - # We do expect one shortcut from 'another_env' - assert not (console_shortcut_dir / "Anaconda Prompt.lnk").is_file() - assert (console_shortcut_dir / "Anaconda Prompt (another_env).lnk").is_file() - break - else: - raise AssertionError(f"No shortcuts found! Expected '{distribution_name} (64-bit)'") - if installer.suffix == ".msi": - _run_uninstaller_msi(installer, install_dir) - else: - _run_uninstaller_exe(install_dir) - assert not (package_1 / "A.lnk").is_file() - assert not (package_1 / "B.lnk").is_file() - elif sys.platform == "darwin": - applications = Path("~/Applications").expanduser() - print("Shortcuts found:", sorted(applications.glob("**/*.app"))) - assert (applications / "A.app").exists() - assert (applications / "B.app").exists() - elif sys.platform == "linux": - applications = Path("~/.local/share/applications").expanduser() - print("Shortcuts found:", sorted(applications.glob("**/*.desktop"))) - assert (applications / "package-1_a.desktop").exists() - assert (applications / "package-1_b.desktop").exists() + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + # console_shortcut package uses hardcoded "Anaconda3" in its menu definition + distribution_name = "Anaconda3" + if sys.platform == "win32": + # Verify shortcuts don't exist before this installation (not leftover from a + # previous per-type run) + for key in ("ProgramData", "AppData"): + start_menu = Path(os.environ[key]) / "Microsoft/Windows/Start Menu/Programs" + package_1 = start_menu / "Package 1" + if package_1.is_dir(): + assert not (package_1 / "A.lnk").is_file(), "A.lnk exists before installation" + assert not (package_1 / "B.lnk").is_file(), "B.lnk exists before installation" + + _run_installer(input_path, installer, install_dir, request=request, uninstall=False) + # check that the shortcuts are created + if sys.platform == "win32": + for key in ("ProgramData", "AppData"): + start_menu = Path(os.environ[key]) / "Microsoft/Windows/Start Menu/Programs" + package_1 = start_menu / "Package 1" + console_shortcut_dir = start_menu / f"{distribution_name} (64-bit)" + if package_1.is_dir() and console_shortcut_dir.is_dir(): + assert (package_1 / "A.lnk").is_file() + assert (package_1 / "B.lnk").is_file() + # The shortcut created from the 'base' env + # should not exist because we filtered it out in the YAML + # We do expect one shortcut from 'another_env' + assert not (console_shortcut_dir / "Anaconda Prompt.lnk").is_file() + assert (console_shortcut_dir / "Anaconda Prompt (another_env).lnk").is_file() + break + else: + raise AssertionError(f"No shortcuts found! Expected '{distribution_name} (64-bit)'") + if installer_type == "msi": + _run_uninstaller_msi(installer, install_dir) + else: + _run_uninstaller_exe(install_dir) + assert not (package_1 / "A.lnk").is_file() + assert not (package_1 / "B.lnk").is_file() + elif sys.platform == "darwin": + applications = Path("~/Applications").expanduser() + print("Shortcuts found:", sorted(applications.glob("**/*.app"))) + assert (applications / "A.app").exists() + assert (applications / "B.app").exists() + elif sys.platform == "linux": + applications = Path("~/.local/share/applications").expanduser() + print("Shortcuts found:", sorted(applications.glob("**/*.desktop"))) + assert (applications / "package-1_a.desktop").exists() + assert (applications / "package-1_b.desktop").exists() def _verify_windows_signature(installer: Path): @@ -1216,7 +1222,8 @@ def _verify_windows_signature(installer: Path): @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") -def test_example_signing(tmp_path, request): +@pytest.mark.parametrize("installer_type", installer_types_for_example(_example_path("signing"))) +def test_example_signing(tmp_path, request, installer_type): input_path = _example_path("signing") cert_path = tmp_path / "self-signed-cert.pfx" cert_pwd = "1234" @@ -1225,15 +1232,16 @@ def test_example_signing(tmp_path, request): certificate_in_input_dir = input_path / "certificate.pfx" shutil.copy(str(cert_path), str(certificate_in_input_dir)) request.addfinalizer(lambda: certificate_in_input_dir.unlink()) - for installer, install_dir in create_installer( + installer, install_dir = create_single_installer( input_path, tmp_path, + installer_type, with_spaces=True, CONSTRUCTOR_SIGNING_CERTIFICATE=str(cert_path), CONSTRUCTOR_PFX_CERTIFICATE_PASSWORD=cert_pwd, - ): - _verify_windows_signature(installer) - _run_installer(input_path, installer, install_dir, request=request) + ) + _verify_windows_signature(installer) + _run_installer(input_path, installer, install_dir, request=request) @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") @@ -1241,11 +1249,12 @@ def test_example_signing(tmp_path, request): not shutil.which("azuresigntool") and not os.environ.get("AZURE_SIGNTOOL_PATH"), reason="AzureSignTool not available", ) +@pytest.mark.parametrize("installer_type", ["exe"]) @pytest.mark.parametrize( "auth_method", os.environ.get("AZURE_SIGNTOOL_TEST_AUTH_METHODS", "token,secret").split(","), ) -def test_azure_signtool(tmp_path, request, monkeypatch, auth_method): +def test_azure_signtool(tmp_path, request, monkeypatch, auth_method, installer_type): """Test signing installers with AzureSignTool. There are three ways to authenticate with Azure: tokens, secrets, and managed identities. @@ -1266,11 +1275,8 @@ def test_azure_signtool(tmp_path, request, monkeypatch, auth_method): else: pytest.skip(f"Unknown authentication method {auth_method}.") input_path = _example_path("azure_signtool") - for installer, install_dir in create_installer( - input_path, - tmp_path, - ): - _run_installer(input_path, installer, install_dir, request=request) + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request) @pytest.mark.parametrize( @@ -1290,59 +1296,74 @@ def test_example_use_channel_remap(tmp_path, request, installer_type): assert pkg["channel"] == "private_repo" -def test_example_from_existing_env(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("from_existing_env")) +) +def test_example_from_existing_env(tmp_path, request, installer_type): input_path = _example_path("from_existing_env") subprocess.check_call( [sys.executable, "-mconda", "create", "-p", tmp_path / "env", "-y", "python"] ) - for installer, install_dir in create_installer( + installer, install_dir = create_single_installer( input_path, tmp_path, + installer_type, CONSTRUCTOR_TEST_EXISTING_ENV=str(tmp_path / "env"), - ): - _run_installer(input_path, installer, install_dir, request=request) - if installer.suffix == ".pkg" and not ON_CI: - return - for pkg in PrefixData(install_dir, interoperability=True).iter_records(): - assert pkg["channel"] != "pypi" + ) + _run_installer(input_path, installer, install_dir, request=request) + if installer_type == "pkg" and not ON_CI: + return + for pkg in PrefixData(install_dir, interoperability=True).iter_records(): + assert pkg["channel"] != "pypi" -def test_example_from_env_txt(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("from_env_txt")) +) +def test_example_from_env_txt(tmp_path, request, installer_type): input_path = _example_path("from_env_txt") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request) - if installer.suffix == ".pkg" and not ON_CI: - return - for pkg in PrefixData(install_dir, interoperability=True).iter_records(): - assert pkg["channel"] != "pypi" + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request) + if installer_type == "pkg" and not ON_CI: + return + for pkg in PrefixData(install_dir, interoperability=True).iter_records(): + assert pkg["channel"] != "pypi" -def test_example_from_env_yaml(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("from_env_yaml")) +) +def test_example_from_env_yaml(tmp_path, request, installer_type): input_path = _example_path("from_env_yaml") - for installer, install_dir in create_installer(input_path, tmp_path, timeout=600): - _run_installer(input_path, installer, install_dir, request=request) - if installer.suffix == ".pkg" and not ON_CI: - return - for pkg in PrefixData(install_dir, interoperability=True).iter_records(): - assert pkg["channel"] != "pypi" + installer, install_dir = create_single_installer( + input_path, tmp_path, installer_type, timeout=600 + ) + _run_installer(input_path, installer, install_dir, request=request) + if installer_type == "pkg" and not ON_CI: + return + for pkg in PrefixData(install_dir, interoperability=True).iter_records(): + assert pkg["channel"] != "pypi" @pytest.mark.skipif(context.subdir != "linux-64", reason="Linux x64 only") -def test_example_from_explicit(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("from_explicit")) +) +def test_example_from_explicit(tmp_path, request, installer_type): input_path = _example_path("from_explicit") - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request) - if installer.suffix == ".pkg" and not ON_CI: - return - out = subprocess.check_output( - [sys.executable, "-mconda", "list", "-p", install_dir, "--explicit", "--md5"], - text=True, - ) - expected = (input_path / "explicit_linux-64.txt").read_text() - # Filter comments - out = [line for line in out.split("\n") if not line.startswith("#")] - expected = [line for line in expected.split("\n") if not line.startswith("#")] - assert out == expected + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + _run_installer(input_path, installer, install_dir, request=request) + if installer_type == "pkg" and not ON_CI: + return + out = subprocess.check_output( + [sys.executable, "-mconda", "list", "-p", install_dir, "--explicit", "--md5"], + text=True, + ) + expected = (input_path / "explicit_linux-64.txt").read_text() + # Filter comments + out = [line for line in out.split("\n") if not line.startswith("#")] + expected = [line for line in expected.split("\n") if not line.startswith("#")] + assert out == expected @pytest.mark.parametrize( @@ -1427,46 +1448,49 @@ def test_cross_build_example(tmp_path, platform_conda_exe): assert installer.exists() -def test_virtual_specs_failed(tmp_path, request): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("virtual_specs_failed")) +) +def test_virtual_specs_failed(tmp_path, request, installer_type): """Verify that virtual packages listed via 'virtual_specs' are satisfied.""" input_path = _example_path("virtual_specs_failed") - for installer, install_dir in create_installer(input_path, tmp_path): - process = _run_installer( - input_path, - installer, - install_dir, - request=request, - check_subprocess=False, - uninstall=False, - ) - # This example is configured to fail due to unsatisfiable virtual specs - if installer.suffix == ".exe": - with pytest.raises(AssertionError, match="Failed to check virtual specs"): - _check_installer_log(install_dir) - continue - elif installer.suffix == ".msi": - # MSI writes errors to install.log in the install directory - msi_post_install_log = install_dir / "install.log" - if msi_post_install_log.exists(): - log_content = msi_post_install_log.read_text(encoding="utf-8", errors="replace") - assert "Failed to check virtual specs" in log_content - else: - # If log doesn't exist, installation failed before post-install script ran - assert process.returncode != 0 - continue - elif installer.suffix == ".pkg": - if not ON_CI: - continue - # The GUI does provide a better message with the min version and so on - # but on the CLI we fail with this one instead - msg = "Cannot install on volume" + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + process = _run_installer( + input_path, + installer, + install_dir, + request=request, + check_subprocess=False, + uninstall=False, + ) + # This example is configured to fail due to unsatisfiable virtual specs + if installer_type == "exe": + with pytest.raises(AssertionError, match="Failed to check virtual specs"): + _check_installer_log(install_dir) + return + elif installer_type == "msi": + # MSI writes errors to install.log in the install directory + msi_post_install_log = install_dir / "install.log" + if msi_post_install_log.exists(): + log_content = msi_post_install_log.read_text(encoding="utf-8", errors="replace") + assert "Failed to check virtual specs" in log_content else: - # The shell installer has its own Bash code for __glibc and __osx - # Other virtual specs like __cuda are checked by conda-standalone/micromamba - # and will fail with solver errors like PackagesNotFound etc - msg = "Installer requires" - assert process.returncode != 0 - assert msg in process.stdout + process.stderr + # If log doesn't exist, installation failed before post-install script ran + assert process.returncode != 0 + return + elif installer_type == "pkg": + if not ON_CI: + return + # The GUI does provide a better message with the min version and so on + # but on the CLI we fail with this one instead + msg = "Cannot install on volume" + else: + # The shell installer has its own Bash code for __glibc and __osx + # Other virtual specs like __cuda are checked by conda-standalone/micromamba + # and will fail with solver errors like PackagesNotFound etc + msg = "Installer requires" + assert process.returncode != 0 + assert msg in process.stdout + process.stderr @pytest.mark.parametrize( @@ -1486,21 +1510,24 @@ def test_virtual_specs_ok(tmp_path, request, installer_type): @pytest.mark.skipif(sys.platform.startswith("win"), reason="Unix only") -def test_virtual_specs_override(tmp_path, request, monkeypatch): +@pytest.mark.parametrize( + "installer_type", installer_types_for_example(_example_path("virtual_specs_failed")) +) +def test_virtual_specs_override(tmp_path, request, monkeypatch, installer_type): input_path = _example_path("virtual_specs_failed") - for installer, install_dir in create_installer(input_path, tmp_path): - if installer.name.endswith(".pkg"): - continue - monkeypatch.setenv("CONDA_OVERRIDE_GLIBC", "20") - monkeypatch.setenv("CONDA_OVERRIDE_OSX", "30") - _run_installer( - input_path, - installer, - install_dir, - request=request, - check_subprocess=True, - uninstall=True, - ) + if installer_type == "pkg": + pytest.skip("Override test is not applicable to .pkg installers") + installer, install_dir = create_single_installer(input_path, tmp_path, installer_type) + monkeypatch.setenv("CONDA_OVERRIDE_GLIBC", "20") + monkeypatch.setenv("CONDA_OVERRIDE_OSX", "30") + _run_installer( + input_path, + installer, + install_dir, + request=request, + check_subprocess=True, + uninstall=True, + ) @pytest.mark.skipif(not ON_CI, reason="Run on CI only") From ee7be2a8399e3b8d2abc357ae0fb274efdc14341 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 18 Jun 2026 17:36:23 -0400 Subject: [PATCH 4/4] Try force shorter path --- tests/test_examples.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 101b1b897..8226ebfc4 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -663,12 +663,18 @@ def _install_dir_for( workspace: Path, config_filename: str, with_spaces: bool, + short: bool = False, ) -> Path: install_dir_prefix = "i n s t a l l" if with_spaces else "install" if installer.suffix == ".pkg" and ON_CI: return Path("~").expanduser() / calculate_install_dir(input_dir / config_filename) elif installer.suffix == ".msi": return calculate_msi_install_path(input_dir / config_filename) + elif short: + # Each parametrized test already has its own workspace, so the installer + # stem/suffix are not needed to keep install dirs distinct. Keeping the + # name short avoids exceeding MAX_PATH on Windows (see from_env_txt). + return workspace / install_dir_prefix else: return workspace / f"{install_dir_prefix}-{installer.stem}-{installer.suffix[1:]}" @@ -710,7 +716,9 @@ def create_single_installer( installer = next(output_dir.glob(f"*.{installer_type}"), None) if installer is None: raise FileNotFoundError(f"No .{installer_type} installer found in {output_dir}") - install_dir = _install_dir_for(installer, input_dir, workspace, config_filename, with_spaces) + install_dir = _install_dir_for( + installer, input_dir, workspace, config_filename, with_spaces, short=True + ) return installer, install_dir