From 181519f5474cfa221064e0970d1700e046b39828 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Tue, 9 Sep 2025 14:51:42 -0700 Subject: [PATCH 01/28] Set CONDA_PROTECT_FROZEN_ENVS to 0 --- constructor/header.sh | 2 ++ constructor/nsis/main.nsi.tmpl | 7 ++++--- constructor/osx/run_installation.sh | 2 ++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/constructor/header.sh b/constructor/header.sh index 8dfd30c32..bcdf33a58 100644 --- a/constructor/header.sh +++ b/constructor/header.sh @@ -591,6 +591,7 @@ shortcuts="" {%- set channels = final_channels|join(",") %} # shellcheck disable=SC2086 +CONDA_PROTECT_FROZEN_ENVS="0" \ CONDA_ROOT_PREFIX="$PREFIX" \ CONDA_REGISTER_ENVS="{{ register_envs }}" \ CONDA_SAFETY_CHECKS=disabled \ @@ -631,6 +632,7 @@ for env_pkgs in "${PREFIX}"/pkgs/envs/*/; do env_shortcuts="" {%- endif %} # shellcheck disable=SC2086 + CONDA_PROTECT_FROZEN_ENVS="0" \ CONDA_ROOT_PREFIX="$PREFIX" \ CONDA_REGISTER_ENVS="{{ register_envs }}" \ CONDA_SAFETY_CHECKS=disabled \ diff --git a/constructor/nsis/main.nsi.tmpl b/constructor/nsis/main.nsi.tmpl index 6dec989ef..efbb6fc76 100644 --- a/constructor/nsis/main.nsi.tmpl +++ b/constructor/nsis/main.nsi.tmpl @@ -1296,11 +1296,12 @@ Section "Install" {%- endfor %} System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_SAFETY_CHECKS", "disabled").r0' System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_EXTRA_SAFETY_CHECKS", "no").r0' - System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_ROOT_PREFIX", "$INSTDIR")".r0' - System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_PKGS_DIRS", "$INSTDIR\pkgs")".r0' + System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_ROOT_PREFIX", "$INSTDIR").r0' + System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_PKGS_DIRS", "$INSTDIR\pkgs").r0' + System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_PROTECT_FROZEN_ENVS", "0").r0' # Spinners in conda write a new character with each movement of the spinner. # For long installation times, this may cause a buffer overflow, crashing the installer. - System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_QUIET", "1")".r0' + System::Call 'kernel32::SetEnvironmentVariable(t,t)i("CONDA_QUIET", "1").r0' # Extra info for pre and post install scripts # NOTE: If more vars are added, make sure to update the examples/scripts tests too # There's a similar block for the pre_uninstall script, further down this file. diff --git a/constructor/osx/run_installation.sh b/constructor/osx/run_installation.sh index 4225ebac2..8aacee022 100644 --- a/constructor/osx/run_installation.sh +++ b/constructor/osx/run_installation.sh @@ -47,6 +47,7 @@ fi notify "Installing packages. This might take a few minutes." # shellcheck disable=SC2086 if ! \ +CONDA_PROTECT_FROZEN_ENVS="0" \ CONDA_REGISTER_ENVS="{{ register_envs }}" \ CONDA_ROOT_PREFIX="$PREFIX" \ CONDA_SAFETY_CHECKS=disabled \ @@ -93,6 +94,7 @@ for env_pkgs in "${PREFIX}"/pkgs/envs/*/; do fi # shellcheck disable=SC2086 + CONDA_PROTECT_FROZEN_ENVS="0" \ CONDA_ROOT_PREFIX="$PREFIX" \ CONDA_REGISTER_ENVS="{{ register_envs }}" \ CONDA_SAFETY_CHECKS=disabled \ From 3ec3015eff5fa1effb6fd3bc7aad9103e1519c74 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Tue, 9 Sep 2025 15:20:09 -0700 Subject: [PATCH 02/28] Implement version check for frozen env --- constructor/main.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/constructor/main.py b/constructor/main.py index f76e81ab8..3ba92e0ed 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -122,6 +122,12 @@ def main_build( ): sys.exit("Error: uninstalling with conda.exe requires conda-standalone 24.11.0 or newer.") + if (info.get("CONDA_PROTECT_FROZEN_ENVS") + and exe_type == StandaloneExe.CONDA + and exe_version + and exe_version <= Version("25.5.1")): + sys.exit("Error: installing with protected base environment requires conda-standalone newer than 25.5.1.") + logger.debug("conda packages download: %s", info["_download_dir"]) for key in ("welcome_image_text", "header_image_text"): From 1e59d8fa87bf7cdb5c525d67acea7c5ab3369129 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Tue, 9 Sep 2025 15:51:11 -0700 Subject: [PATCH 03/28] First attempt at testing --- examples/protected_base/conda-meta/frozen | 1 + examples/protected_base/construct.yaml | 22 ++++++++++++++++++++++ tests/test_examples.py | 15 +++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 examples/protected_base/conda-meta/frozen create mode 100644 examples/protected_base/construct.yaml diff --git a/examples/protected_base/conda-meta/frozen b/examples/protected_base/conda-meta/frozen new file mode 100644 index 000000000..1acece838 --- /dev/null +++ b/examples/protected_base/conda-meta/frozen @@ -0,0 +1 @@ +# This environment is protected. \ No newline at end of file diff --git a/examples/protected_base/construct.yaml b/examples/protected_base/construct.yaml new file mode 100644 index 000000000..6e6a3f9bc --- /dev/null +++ b/examples/protected_base/construct.yaml @@ -0,0 +1,22 @@ +# yaml-language-server: $schema=../../constructor/data/construct.schema.json +"$schema": "../../constructor/data/construct.schema.json" + +name: ProtectedBaseEnv +version: X +installer_type: all + +channels: + - defaults + +specs: + - python + - conda + +extra_envs: + default: + specs: + - python + - pip + +extra_files: + - frozen.json: conda-meta/frozen \ No newline at end of file diff --git a/tests/test_examples.py b/tests/test_examples.py index 5a64c867a..8583e0a9c 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1370,3 +1370,18 @@ def test_regressions(tmp_path, request): check_subprocess=True, uninstall=True, ) + +def test_frozen_environment(tmp_path, request): + input_path = _example_path("protected_base") + for installer, install_dir in create_installer(input_path, tmp_path): + frozen_file = install_dir / "conda-meta" / "frozen" + assert frozen_file.exists() + _run_installer( + input_path, + installer, + install_dir, + request=request, + check_subprocess=True, + uninstall=True + ) + assert frozen_file.exists() From 8150b81abdd6f62d068ceef07a60a484abbff3cb Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Thu, 11 Sep 2025 11:09:42 -0700 Subject: [PATCH 04/28] Fix version check --- constructor/main.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/constructor/main.py b/constructor/main.py index 3ba92e0ed..3ae939fb7 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -122,12 +122,6 @@ def main_build( ): sys.exit("Error: uninstalling with conda.exe requires conda-standalone 24.11.0 or newer.") - if (info.get("CONDA_PROTECT_FROZEN_ENVS") - and exe_type == StandaloneExe.CONDA - and exe_version - and exe_version <= Version("25.5.1")): - sys.exit("Error: installing with protected base environment requires conda-standalone newer than 25.5.1.") - logger.debug("conda packages download: %s", info["_download_dir"]) for key in ("welcome_image_text", "header_image_text"): @@ -183,6 +177,12 @@ def main_build( new_extras.append({orig: dest}) info[extra_type] = new_extras + if ("conda-meta/frozen" in info.get("extra_type", {}) + and exe_type == StandaloneExe.CONDA + and exe_version + and exe_version >= Version("25.5.0") and exe_version < Version("25.7.0")): + sys.exit("Error: installing with protected base environment requires conda-standalone newer than 25.5.x") + for key in "channels", "specs", "exclude", "packages", "menu_packages", "virtual_specs": if key in info: # ensure strings in those lists are stripped From 75406337036e21e7bc4e4e5f792fa92473ae590e Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Thu, 11 Sep 2025 11:10:48 -0700 Subject: [PATCH 05/28] Fix test --- tests/test_examples.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 8583e0a9c..54d6f28c5 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1375,13 +1375,12 @@ def test_frozen_environment(tmp_path, request): input_path = _example_path("protected_base") for installer, install_dir in create_installer(input_path, tmp_path): frozen_file = install_dir / "conda-meta" / "frozen" - assert frozen_file.exists() _run_installer( input_path, installer, install_dir, request=request, check_subprocess=True, - uninstall=True + uninstall=False ) assert frozen_file.exists() From c43f6bbf80c17f2d8c9528ac091748ec1f42ad81 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Mon, 15 Sep 2025 11:30:11 -0700 Subject: [PATCH 06/28] Change source in extra files mapping to use empty json --- examples/protected_base/conda-meta/frozen | 1 - examples/protected_base/frozen.json | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 examples/protected_base/conda-meta/frozen create mode 100644 examples/protected_base/frozen.json diff --git a/examples/protected_base/conda-meta/frozen b/examples/protected_base/conda-meta/frozen deleted file mode 100644 index 1acece838..000000000 --- a/examples/protected_base/conda-meta/frozen +++ /dev/null @@ -1 +0,0 @@ -# This environment is protected. \ No newline at end of file diff --git a/examples/protected_base/frozen.json b/examples/protected_base/frozen.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/examples/protected_base/frozen.json @@ -0,0 +1 @@ +{} \ No newline at end of file From bfce4276d4c245136cb412410c5f51246f3a4448 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Mon, 15 Sep 2025 14:39:04 -0700 Subject: [PATCH 07/28] Only search `extra_files` for frozen file and xfail if version is 25.5.x --- constructor/main.py | 2 +- examples/protected_base/frozen.json | 2 +- tests/test_examples.py | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/constructor/main.py b/constructor/main.py index 3ae939fb7..8509c7cb8 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -177,7 +177,7 @@ def main_build( new_extras.append({orig: dest}) info[extra_type] = new_extras - if ("conda-meta/frozen" in info.get("extra_type", {}) + if any(str(path).endswith("conda-meta/frozen") for path in info.get("extra_files", []) and exe_type == StandaloneExe.CONDA and exe_version and exe_version >= Version("25.5.0") and exe_version < Version("25.7.0")): diff --git a/examples/protected_base/frozen.json b/examples/protected_base/frozen.json index 9e26dfeeb..0967ef424 100644 --- a/examples/protected_base/frozen.json +++ b/examples/protected_base/frozen.json @@ -1 +1 @@ -{} \ No newline at end of file +{} diff --git a/tests/test_examples.py b/tests/test_examples.py index 54d6f28c5..4b7960d9c 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1371,6 +1371,14 @@ def test_regressions(tmp_path, request): uninstall=True, ) +@pytest.mark.xfail( + condition=(CONDA_EXE == StandaloneExe.CONDA and + CONDA_EXE_VERSION and + CONDA_EXE_VERSION >= Version("25.5.0") and + CONDA_EXE_VERSION < Version("25.7.0")), + reason="conda-standalone 25.5.x fails with protected base environments and older versions are ignored", + strict=True +) def test_frozen_environment(tmp_path, request): input_path = _example_path("protected_base") for installer, install_dir in create_installer(input_path, tmp_path): From 285d8d83fae889e29d103a74ee28c8b803bdaa29 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Mon, 15 Sep 2025 17:27:56 -0700 Subject: [PATCH 08/28] Handle str and dict instances --- constructor/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/constructor/main.py b/constructor/main.py index 8509c7cb8..c3c1e61ef 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -177,7 +177,9 @@ def main_build( new_extras.append({orig: dest}) info[extra_type] = new_extras - if any(str(path).endswith("conda-meta/frozen") for path in info.get("extra_files", []) + if (any((isinstance(path, str) and "/conda-meta/frozen" in path) or + (isinstance(path, dict) and any("conda-meta/frozen" in v for v in path.values())) + for path in info.get("extra_files", [])) and exe_type == StandaloneExe.CONDA and exe_version and exe_version >= Version("25.5.0") and exe_version < Version("25.7.0")): From 5ea8513385d0faff25a77feb77a371d9029851e2 Mon Sep 17 00:00:00 2001 From: Marco Esters Date: Tue, 16 Sep 2025 07:51:14 -0700 Subject: [PATCH 09/28] Add helper function for version ranges --- constructor/main.py | 25 +++++++++++++++---------- constructor/utils.py | 27 +++++++++++++++++++++++++++ tests/test_examples.py | 25 ++++++++++++------------- 3 files changed, 54 insertions(+), 23 deletions(-) diff --git a/constructor/main.py b/constructor/main.py index c3c1e61ef..6a7b33755 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -25,7 +25,7 @@ from .construct import parse as construct_parse from .construct import verify as construct_verify from .fcp import main as fcp_main -from .utils import StandaloneExe, identify_conda_exe, normalize_path, yield_lines +from .utils import StandaloneExe, check_version, identify_conda_exe, normalize_path, yield_lines DEFAULT_CACHE_DIR = os.getenv("CONSTRUCTOR_CACHE", "~/.conda/constructor") @@ -118,7 +118,7 @@ def main_build( sys.exit("Error: micromamba is not supported on Windows installers.") if info.get("uninstall_with_conda_exe") and not ( - exe_type == StandaloneExe.CONDA and exe_version and exe_version >= Version("24.11.0") + exe_type == StandaloneExe.CONDA and check_version(exe_version, min_version="24.11.0") ): sys.exit("Error: uninstalling with conda.exe requires conda-standalone 24.11.0 or newer.") @@ -177,13 +177,18 @@ def main_build( new_extras.append({orig: dest}) info[extra_type] = new_extras - if (any((isinstance(path, str) and "/conda-meta/frozen" in path) or - (isinstance(path, dict) and any("conda-meta/frozen" in v for v in path.values())) - for path in info.get("extra_files", [])) + if ( + any( + (isinstance(path, str) and "/conda-meta/frozen" in path) + or (isinstance(path, dict) and any("conda-meta/frozen" in v for v in path.values())) + for path in info.get("extra_files", []) + ) and exe_type == StandaloneExe.CONDA - and exe_version - and exe_version >= Version("25.5.0") and exe_version < Version("25.7.0")): - sys.exit("Error: installing with protected base environment requires conda-standalone newer than 25.5.x") + and check_version(exe_version, min_version="25.5.0", max_version="25.7.0") + ): + sys.exit( + "Error: installing with protected base environment requires conda-standalone newer than 25.5.x" + ) for key in "channels", "specs", "exclude", "packages", "menu_packages", "virtual_specs": if key in info: @@ -223,14 +228,14 @@ def main_build( "Will assume it is compatible with shortcuts." ) elif sys.platform != "win32" and ( - exe_type != StandaloneExe.CONDA or (exe_version and exe_version < Version("23.11.0")) + exe_type != StandaloneExe.CONDA or not check_version(exe_version, min_version="23.11.0") ): logger.warning("conda-standalone 23.11.0 or above is required for shortcuts on Unix.") info["_enable_shortcuts"] = "incompatible" # Add --no-rc option to CONDA_EXE command so that existing # .condarc files do not pollute the installation process. - if exe_type == StandaloneExe.CONDA and exe_version and exe_version >= Version("24.9.0"): + if exe_type == StandaloneExe.CONDA and check_version(exe_version, min_version="24.9.0"): info["_ignore_condarcs_arg"] = "--no-rc" elif exe_type == StandaloneExe.MAMBA: info["_ignore_condarcs_arg"] = "--no-rc" diff --git a/constructor/utils.py b/constructor/utils.py index 33a629b7d..eb89a09a6 100644 --- a/constructor/utils.py +++ b/constructor/utils.py @@ -25,6 +25,13 @@ from ruamel.yaml import YAML +try: + from conda.models.version import VersionOrder + + has_conda_interface = True +except ImportError: + has_conda_interface = False + logger = logging.getLogger(__name__) yaml = YAML(typ="rt") yaml.default_flow_style = False @@ -343,6 +350,26 @@ def identify_conda_exe(conda_exe: str | Path | None = None) -> tuple[StandaloneE return None, None +def check_version( + exe_version: str | VersionOrder | None = None, + min_version: str | None = None, + max_version: str | None = None, +) -> bool: + """Check if a version is within a version range. + + The minimum version is assumed to be inclusive, the maximum version is not inclusive. + """ + if not exe_version or not has_conda_interface: + return False + if isinstance(exe_version, str): + exe_version = VersionOrder(exe_version) + if min_version and exe_version < VersionOrder(min_version): + return False + if max_version and exe_version >= VersionOrder(max_version): + return False + return True + + def win_str_esc(s, newlines=True): maps = [("$", "$$"), ('"', '$\\"'), ("\t", "$\\t")] if newlines: diff --git a/tests/test_examples.py b/tests/test_examples.py index 4b7960d9c..5c30b8a19 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -21,7 +21,7 @@ from conda.models.version import VersionOrder as Version from ruamel.yaml import YAML -from constructor.utils import StandaloneExe, identify_conda_exe +from constructor.utils import StandaloneExe, check_version, identify_conda_exe if TYPE_CHECKING: from collections.abc import Generator, Iterable @@ -502,8 +502,7 @@ def test_example_mirrored_channels(tmp_path, request): @pytest.mark.xfail( ( CONDA_EXE == StandaloneExe.CONDA - and CONDA_EXE_VERSION is not None - and CONDA_EXE_VERSION < Version("23.11.0a0") + 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.", ) @@ -683,8 +682,7 @@ def test_example_scripts(tmp_path, request): @pytest.mark.skipif( ( CONDA_EXE == StandaloneExe.MAMBA - or CONDA_EXE_VERSION is None - or CONDA_EXE_VERSION < Version("23.11.0a0") + and not check_version(CONDA_EXE_VERSION, min_version="23.11.0a0") ), reason="menuinst v2 requires conda-standalone>=23.11.0; micromamba is not supported yet", ) @@ -1208,7 +1206,7 @@ def _get_dacl_information(filepath: Path) -> dict: @pytest.mark.xfail( - CONDA_EXE == StandaloneExe.CONDA and CONDA_EXE_VERSION < Version("24.9.0"), + CONDA_EXE == StandaloneExe.CONDA and not check_version(CONDA_EXE_VERSION, min_version="24.9.0"), reason="Pre-existing .condarc breaks installation", ) def test_ignore_condarc_files(tmp_path, monkeypatch, request): @@ -1258,7 +1256,7 @@ def test_ignore_condarc_files(tmp_path, monkeypatch, request): @pytest.mark.skipif( - CONDA_EXE == StandaloneExe.CONDA and CONDA_EXE_VERSION < Version("24.11.0"), + CONDA_EXE == StandaloneExe.CONDA and check_version(CONDA_EXE_VERSION, min_version="24.11.0"), reason="Requires conda-standalone 24.11.x or newer", ) @pytest.mark.skipif(not sys.platform == "win32", reason="Windows only") @@ -1371,13 +1369,14 @@ def test_regressions(tmp_path, request): uninstall=True, ) + @pytest.mark.xfail( - condition=(CONDA_EXE == StandaloneExe.CONDA and - CONDA_EXE_VERSION and - CONDA_EXE_VERSION >= Version("25.5.0") and - CONDA_EXE_VERSION < Version("25.7.0")), + condition=( + CONDA_EXE == StandaloneExe.CONDA + and check_version(CONDA_EXE_VERSION, min_version="25.5.0", max_version="25.7.0"), + ), reason="conda-standalone 25.5.x fails with protected base environments and older versions are ignored", - strict=True + strict=True, ) def test_frozen_environment(tmp_path, request): input_path = _example_path("protected_base") @@ -1389,6 +1388,6 @@ def test_frozen_environment(tmp_path, request): install_dir, request=request, check_subprocess=True, - uninstall=False + uninstall=False, ) assert frozen_file.exists() From 596f10dc622f5b30097259d401284d5da2b48790 Mon Sep 17 00:00:00 2001 From: Marco Esters Date: Tue, 16 Sep 2025 09:08:31 -0700 Subject: [PATCH 10/28] Remove stray comma --- tests/test_examples.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 5c30b8a19..ef8824e14 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1373,7 +1373,7 @@ def test_regressions(tmp_path, request): @pytest.mark.xfail( condition=( CONDA_EXE == StandaloneExe.CONDA - and check_version(CONDA_EXE_VERSION, min_version="25.5.0", max_version="25.7.0"), + and check_version(CONDA_EXE_VERSION, min_version="25.5.0", max_version="25.7.0") ), reason="conda-standalone 25.5.x fails with protected base environments and older versions are ignored", strict=True, From e3b8659761264c5fc7c77d7fa617543d083f6216 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Tue, 16 Sep 2025 10:01:47 -0700 Subject: [PATCH 11/28] Fix pre-commit errors --- constructor/main.py | 17 ++++++++++++----- examples/protected_base/construct.yaml | 2 +- tests/test_examples.py | 15 +++++++++------ 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/constructor/main.py b/constructor/main.py index c3c1e61ef..4fbbc3af1 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -177,13 +177,20 @@ def main_build( new_extras.append({orig: dest}) info[extra_type] = new_extras - if (any((isinstance(path, str) and "/conda-meta/frozen" in path) or - (isinstance(path, dict) and any("conda-meta/frozen" in v for v in path.values())) - for path in info.get("extra_files", [])) + if ( + any( + (isinstance(path, str) and "/conda-meta/frozen" in path) + or (isinstance(path, dict) and any("conda-meta/frozen" in v for v in path.values())) + for path in info.get("extra_files", []) + ) and exe_type == StandaloneExe.CONDA and exe_version - and exe_version >= Version("25.5.0") and exe_version < Version("25.7.0")): - sys.exit("Error: installing with protected base environment requires conda-standalone newer than 25.5.x") + and exe_version >= Version("25.5.0") + and exe_version < Version("25.7.0") + ): + sys.exit( + "Error: installing with protected base environment requires conda-standalone newer than 25.5.x" + ) for key in "channels", "specs", "exclude", "packages", "menu_packages", "virtual_specs": if key in info: diff --git a/examples/protected_base/construct.yaml b/examples/protected_base/construct.yaml index 6e6a3f9bc..edc81ffee 100644 --- a/examples/protected_base/construct.yaml +++ b/examples/protected_base/construct.yaml @@ -19,4 +19,4 @@ extra_envs: - pip extra_files: - - frozen.json: conda-meta/frozen \ No newline at end of file + - frozen.json: conda-meta/frozen diff --git a/tests/test_examples.py b/tests/test_examples.py index 4b7960d9c..19afdc541 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1371,13 +1371,16 @@ def test_regressions(tmp_path, request): uninstall=True, ) + @pytest.mark.xfail( - condition=(CONDA_EXE == StandaloneExe.CONDA and - CONDA_EXE_VERSION and - CONDA_EXE_VERSION >= Version("25.5.0") and - CONDA_EXE_VERSION < Version("25.7.0")), + condition=( + CONDA_EXE == StandaloneExe.CONDA + and CONDA_EXE_VERSION + and CONDA_EXE_VERSION >= Version("25.5.0") + and CONDA_EXE_VERSION < Version("25.7.0") + ), reason="conda-standalone 25.5.x fails with protected base environments and older versions are ignored", - strict=True + strict=True, ) def test_frozen_environment(tmp_path, request): input_path = _example_path("protected_base") @@ -1389,6 +1392,6 @@ def test_frozen_environment(tmp_path, request): install_dir, request=request, check_subprocess=True, - uninstall=False + uninstall=False, ) assert frozen_file.exists() From 023f8154b420b488e215c3d7583f0e1803e301be Mon Sep 17 00:00:00 2001 From: Marco Esters Date: Tue, 16 Sep 2025 13:55:17 -0700 Subject: [PATCH 12/28] Invert boolean logic for shortcut check --- constructor/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constructor/main.py b/constructor/main.py index 6a7b33755..147db20cd 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -228,7 +228,7 @@ def main_build( "Will assume it is compatible with shortcuts." ) elif sys.platform != "win32" and ( - exe_type != StandaloneExe.CONDA or not check_version(exe_version, min_version="23.11.0") + exe_type != StandaloneExe.CONDA or check_version(exe_version, max_version="23.11.0") ): logger.warning("conda-standalone 23.11.0 or above is required for shortcuts on Unix.") info["_enable_shortcuts"] = "incompatible" From 3255967737ac282283e8bc587100404837112628 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Tue, 16 Sep 2025 14:53:21 -0700 Subject: [PATCH 13/28] Use path for frozen file search --- constructor/main.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/constructor/main.py b/constructor/main.py index 147db20cd..30dee4ab2 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -15,6 +15,7 @@ import os import sys from os.path import abspath, expanduser, isdir, join +from pathlib import Path from textwrap import dedent from . import __version__ @@ -177,12 +178,19 @@ def main_build( new_extras.append({orig: dest}) info[extra_type] = new_extras - if ( - any( - (isinstance(path, str) and "/conda-meta/frozen" in path) - or (isinstance(path, dict) and any("conda-meta/frozen" in v for v in path.values())) - for path in info.get("extra_files", []) - ) + def has_frozen_file(extra_files: list[str | dict[str, str]]) -> bool: + def check_path(path_str: str) -> bool: + path = Path(path_str) + return path.name == "frozen" and path.parent.name == "conda-meta" + + for file in extra_files: + if isinstance(file, str) and check_path(file): + return True + elif isinstance(file, dict) and any(check_path(val) for val in file.values()): + return True + return False + + if (has_frozen_file(info.get("extra_files", [])) and exe_type == StandaloneExe.CONDA and check_version(exe_version, min_version="25.5.0", max_version="25.7.0") ): From 0a3acff4193bac8421578b61bf09d47d482b3763 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Tue, 16 Sep 2025 16:48:42 -0700 Subject: [PATCH 14/28] Add news file --- news/1058-add-support-for-frozen-envs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 news/1058-add-support-for-frozen-envs diff --git a/news/1058-add-support-for-frozen-envs b/news/1058-add-support-for-frozen-envs new file mode 100644 index 000000000..1c8e37ef6 --- /dev/null +++ b/news/1058-add-support-for-frozen-envs @@ -0,0 +1,19 @@ +### Enhancements + +* Add support for installing [protected conda environments](https://conda.org/learn/ceps/cep-0022#specification). (#1058) + +### Bug fixes + +* + +### Deprecations + +* + +### Docs + +* + +### Other + +* From 7e758da9b56522d61d62a648f10e1bed5e49632c Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Tue, 16 Sep 2025 17:27:35 -0700 Subject: [PATCH 15/28] Pre-commit fixes --- constructor/main.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/constructor/main.py b/constructor/main.py index 30dee4ab2..46d514d0c 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -9,6 +9,8 @@ CLI logic and main functions to run constructor on a given input file. """ +from __future__ import annotations + import argparse import json import logging @@ -190,7 +192,8 @@ def check_path(path_str: str) -> bool: return True return False - if (has_frozen_file(info.get("extra_files", [])) + if ( + has_frozen_file(info.get("extra_files", [])) and exe_type == StandaloneExe.CONDA and check_version(exe_version, min_version="25.5.0", max_version="25.7.0") ): From adc94a41fd5aa4fec5289e83f8646ae484da29ca Mon Sep 17 00:00:00 2001 From: Jaida Rice <100002667+Jrice1317@users.noreply.github.com> Date: Wed, 17 Sep 2025 11:44:52 -0700 Subject: [PATCH 16/28] Apply suggestions from code review Co-authored-by: jaimergp --- constructor/main.py | 10 +++++----- constructor/utils.py | 9 ++------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/constructor/main.py b/constructor/main.py index 46d514d0c..5239f3a55 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -181,14 +181,14 @@ def main_build( info[extra_type] = new_extras def has_frozen_file(extra_files: list[str | dict[str, str]]) -> bool: - def check_path(path_str: str) -> bool: + def is_conda_meta_frozen(path_str: str) -> bool: path = Path(path_str) - return path.name == "frozen" and path.parent.name == "conda-meta" + return path.parts == ("conda-meta", "frozen") or (len(path.parts) == 4 and path.parts[0] == "envs" and path.parts[-2:] == ("conda-meta", "frozen")) for file in extra_files: - if isinstance(file, str) and check_path(file): + if isinstance(file, str) and is_conda_meta_frozen(file): return True - elif isinstance(file, dict) and any(check_path(val) for val in file.values()): + elif isinstance(file, dict) and any(is_conda_meta_frozen(val) for val in file.values()): return True return False @@ -198,7 +198,7 @@ def check_path(path_str: str) -> bool: and check_version(exe_version, min_version="25.5.0", max_version="25.7.0") ): sys.exit( - "Error: installing with protected base environment requires conda-standalone newer than 25.5.x" + "Error: handling conda-meta/frozen marker files requires conda-standalone newer than 25.7.x" ) for key in "channels", "specs", "exclude", "packages", "menu_packages", "virtual_specs": diff --git a/constructor/utils.py b/constructor/utils.py index eb89a09a6..f9e8c69f2 100644 --- a/constructor/utils.py +++ b/constructor/utils.py @@ -25,12 +25,7 @@ from ruamel.yaml import YAML -try: - from conda.models.version import VersionOrder - - has_conda_interface = True -except ImportError: - has_conda_interface = False +from conda.models.version import VersionOrder logger = logging.getLogger(__name__) yaml = YAML(typ="rt") @@ -359,7 +354,7 @@ def check_version( The minimum version is assumed to be inclusive, the maximum version is not inclusive. """ - if not exe_version or not has_conda_interface: + if not exe_version: return False if isinstance(exe_version, str): exe_version = VersionOrder(exe_version) From 9265135e710db180c2a9e1766a4efbf353d42203 Mon Sep 17 00:00:00 2001 From: Jaida Rice <100002667+Jrice1317@users.noreply.github.com> Date: Thu, 18 Sep 2025 16:33:50 -0700 Subject: [PATCH 17/28] Apply suggestions from code review Co-authored-by: Marco Esters --- examples/protected_base/construct.yaml | 1 + tests/test_examples.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/protected_base/construct.yaml b/examples/protected_base/construct.yaml index edc81ffee..52aa462cd 100644 --- a/examples/protected_base/construct.yaml +++ b/examples/protected_base/construct.yaml @@ -20,3 +20,4 @@ extra_envs: extra_files: - frozen.json: conda-meta/frozen + - frozen.json: envs/defaults/conda-meta/frozen diff --git a/tests/test_examples.py b/tests/test_examples.py index 1c9b8d612..1e0af35c2 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1385,7 +1385,7 @@ def test_regressions(tmp_path, request): CONDA_EXE == StandaloneExe.CONDA and check_version(CONDA_EXE_VERSION, min_version="25.5.0", max_version="25.7.0") ), - reason="conda-standalone 25.5.x fails with protected base environments and older versions are ignored", + reason="conda-standalone 25.5.x fails with protected environments", strict=True, ) def test_frozen_environment(tmp_path, request): From c841a2210609f51a6f7f7c33db38a9dc68136925 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 19 Sep 2025 13:19:58 -0700 Subject: [PATCH 18/28] Fix typo --- examples/protected_base/construct.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/protected_base/construct.yaml b/examples/protected_base/construct.yaml index 52aa462cd..c43044761 100644 --- a/examples/protected_base/construct.yaml +++ b/examples/protected_base/construct.yaml @@ -17,7 +17,8 @@ extra_envs: specs: - python - pip + - conda extra_files: - frozen.json: conda-meta/frozen - - frozen.json: envs/defaults/conda-meta/frozen + - frozen.json: envs/default/conda-meta/frozen From 60b87bcea2d83de621d78f289ca90c2895e4323c Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 19 Sep 2025 13:20:38 -0700 Subject: [PATCH 19/28] Expand test to test base and extra envs --- tests/test_examples.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 1e0af35c2..74cb34afc 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1391,13 +1391,28 @@ def test_regressions(tmp_path, request): def test_frozen_environment(tmp_path, request): input_path = _example_path("protected_base") for installer, install_dir in create_installer(input_path, tmp_path): - frozen_file = install_dir / "conda-meta" / "frozen" _run_installer( input_path, installer, install_dir, request=request, - check_subprocess=True, uninstall=False, ) - assert frozen_file.exists() + + expected_frozen_paths = { + install_dir / "conda-meta" / "frozen", + install_dir / "envs" / "default" / "conda-meta" / "frozen", + } + + actual_frozen_paths = set() + for env in install_dir.glob("**/conda-meta/history"): + frozen_file = env.parent / "frozen" + assert frozen_file.exists() + actual_frozen_paths.add(frozen_file) + + assert expected_frozen_paths == actual_frozen_paths, ( + f"Expected: {sorted(str(p) for p in expected_frozen_paths)}\n" + f"Found: {sorted(str(p) for p in actual_frozen_paths)}" + ) + + From bee6aaf16363abb157258a02ee65067a25cf6984 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 19 Sep 2025 13:33:56 -0700 Subject: [PATCH 20/28] Fix pre-commit --- constructor/main.py | 6 +++++- constructor/utils.py | 3 +-- tests/test_examples.py | 2 -- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/constructor/main.py b/constructor/main.py index 5239f3a55..4feb62585 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -183,7 +183,11 @@ def main_build( def has_frozen_file(extra_files: list[str | dict[str, str]]) -> bool: def is_conda_meta_frozen(path_str: str) -> bool: path = Path(path_str) - return path.parts == ("conda-meta", "frozen") or (len(path.parts) == 4 and path.parts[0] == "envs" and path.parts[-2:] == ("conda-meta", "frozen")) + return path.parts == ("conda-meta", "frozen") or ( + len(path.parts) == 4 + and path.parts[0] == "envs" + and path.parts[-2:] == ("conda-meta", "frozen") + ) for file in extra_files: if isinstance(file, str) and is_conda_meta_frozen(file): diff --git a/constructor/utils.py b/constructor/utils.py index f9e8c69f2..705a42bd7 100644 --- a/constructor/utils.py +++ b/constructor/utils.py @@ -23,9 +23,8 @@ from shutil import rmtree from subprocess import CalledProcessError, check_call, check_output -from ruamel.yaml import YAML - from conda.models.version import VersionOrder +from ruamel.yaml import YAML logger = logging.getLogger(__name__) yaml = YAML(typ="rt") diff --git a/tests/test_examples.py b/tests/test_examples.py index 74cb34afc..0bd5b7f5c 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1414,5 +1414,3 @@ def test_frozen_environment(tmp_path, request): f"Expected: {sorted(str(p) for p in expected_frozen_paths)}\n" f"Found: {sorted(str(p) for p in actual_frozen_paths)}" ) - - From 98ba9a468f0e6a4c91389577b5872a159854f94c Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Thu, 25 Sep 2025 13:59:27 -0700 Subject: [PATCH 21/28] Apply code suggestions --- constructor/main.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/constructor/main.py b/constructor/main.py index 4feb62585..d7b02e7ce 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -165,21 +165,6 @@ def main_build( if isinstance(info[key], str): info[key] = list(yield_lines(join(dir_path, info[key]))) - # normalize paths to be copied; if they are relative, they must be to - # construct.yaml's parent (dir_path) - extras_types = ["extra_files", "temp_extra_files"] - for extra_type in extras_types: - extras = info.get(extra_type, ()) - new_extras = [] - for path in extras: - if isinstance(path, str): - new_extras.append(abspath(join(dir_path, path))) - elif isinstance(path, dict): - for orig, dest in path.items(): - orig = abspath(join(dir_path, orig)) - new_extras.append({orig: dest}) - info[extra_type] = new_extras - def has_frozen_file(extra_files: list[str | dict[str, str]]) -> bool: def is_conda_meta_frozen(path_str: str) -> bool: path = Path(path_str) @@ -205,6 +190,21 @@ def is_conda_meta_frozen(path_str: str) -> bool: "Error: handling conda-meta/frozen marker files requires conda-standalone newer than 25.7.x" ) + # normalize paths to be copied; if they are relative, they must be to + # construct.yaml's parent (dir_path) + extras_types = ["extra_files", "temp_extra_files"] + for extra_type in extras_types: + extras = info.get(extra_type, ()) + new_extras = [] + for path in extras: + if isinstance(path, str): + new_extras.append(abspath(join(dir_path, path))) + elif isinstance(path, dict): + for orig, dest in path.items(): + orig = abspath(join(dir_path, orig)) + new_extras.append({orig: dest}) + info[extra_type] = new_extras + for key in "channels", "specs", "exclude", "packages", "menu_packages", "virtual_specs": if key in info: # ensure strings in those lists are stripped From f58bb031bb985925dff65fdcfcd71328c742e218 Mon Sep 17 00:00:00 2001 From: Jaida Rice <100002667+Jrice1317@users.noreply.github.com> Date: Thu, 25 Sep 2025 14:02:36 -0700 Subject: [PATCH 22/28] Apply suggestions from code review Co-authored-by: Marco Esters --- tests/test_examples.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 0bd5b7f5c..f047868e8 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1407,10 +1407,7 @@ def test_frozen_environment(tmp_path, request): actual_frozen_paths = set() for env in install_dir.glob("**/conda-meta/history"): frozen_file = env.parent / "frozen" - assert frozen_file.exists() - actual_frozen_paths.add(frozen_file) + if frozen_file.exists(): + actual_frozen_paths.add(frozen_file) - assert expected_frozen_paths == actual_frozen_paths, ( - f"Expected: {sorted(str(p) for p in expected_frozen_paths)}\n" - f"Found: {sorted(str(p) for p in actual_frozen_paths)}" - ) + assert expected_frozen_paths == actual_frozen_paths From 5bfd5493a9701eee71fbf69e9bca2d162b3883c9 Mon Sep 17 00:00:00 2001 From: Marco Esters Date: Wed, 1 Oct 2025 09:21:10 -0700 Subject: [PATCH 23/28] Update tests/test_examples.py --- tests/test_examples.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index f047868e8..dbdbdaa0f 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1404,10 +1404,6 @@ def test_frozen_environment(tmp_path, request): install_dir / "envs" / "default" / "conda-meta" / "frozen", } - actual_frozen_paths = set() - for env in install_dir.glob("**/conda-meta/history"): - frozen_file = env.parent / "frozen" - if frozen_file.exists(): - actual_frozen_paths.add(frozen_file) + actual_frozen_paths = {frozen_file for frozen_file in expected_frozen_paths if frozen_file.exists()} assert expected_frozen_paths == actual_frozen_paths From dc496d530116fa5447e79934f3f8efc772976b7a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 16:22:39 +0000 Subject: [PATCH 24/28] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_examples.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index dbdbdaa0f..666f9b67a 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1404,6 +1404,8 @@ def test_frozen_environment(tmp_path, request): install_dir / "envs" / "default" / "conda-meta" / "frozen", } - actual_frozen_paths = {frozen_file for frozen_file in expected_frozen_paths if frozen_file.exists()} + actual_frozen_paths = { + frozen_file for frozen_file in expected_frozen_paths if frozen_file.exists() + } assert expected_frozen_paths == actual_frozen_paths From 630d0226ada35f18f58767580bf5a34d0603e3d0 Mon Sep 17 00:00:00 2001 From: Marco Esters Date: Wed, 1 Oct 2025 11:10:00 -0700 Subject: [PATCH 25/28] Swap test_frozen_environment with test_regressions --- tests/test_examples.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 666f9b67a..a22325ec1 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1367,19 +1367,6 @@ def test_output_files(tmp_path): assert files_exist == [] -def test_regressions(tmp_path, request): - 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, - ) - - @pytest.mark.xfail( condition=( CONDA_EXE == StandaloneExe.CONDA @@ -1409,3 +1396,16 @@ def test_frozen_environment(tmp_path, request): } assert expected_frozen_paths == actual_frozen_paths + + +def test_regressions(tmp_path, request): + 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, + ) From 289cd079c6bcb5445758562be5c0be4272b8e6c4 Mon Sep 17 00:00:00 2001 From: Marco Esters Date: Wed, 1 Oct 2025 12:48:49 -0700 Subject: [PATCH 26/28] Revert "Update tests/test_examples.py" This reverts commit 5bfd5493a9701eee71fbf69e9bca2d162b3883c9. --- tests/test_examples.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index a22325ec1..faa74471e 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1391,9 +1391,11 @@ def test_frozen_environment(tmp_path, request): install_dir / "envs" / "default" / "conda-meta" / "frozen", } - actual_frozen_paths = { - frozen_file for frozen_file in expected_frozen_paths if frozen_file.exists() - } + actual_frozen_paths = set() + for env in install_dir.glob("**/conda-meta/history"): + frozen_file = env.parent / "frozen" + if frozen_file.exists(): + actual_frozen_paths.add(frozen_file) assert expected_frozen_paths == actual_frozen_paths From 84d9326a80381180db62e0b0d239b662634136b8 Mon Sep 17 00:00:00 2001 From: Marco Esters Date: Wed, 1 Oct 2025 12:49:23 -0700 Subject: [PATCH 27/28] Remove installation directory after sh installations --- tests/test_examples.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_examples.py b/tests/test_examples.py index faa74471e..52c3a7498 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -317,6 +317,8 @@ def _run_installer( check=check_subprocess, options=options, ) + if request and ON_CI: + request.addfinalizer(lambda: shutil.rmtree(str(install_dir), ignore_errors=True)) elif installer.suffix == ".pkg": if request and ON_CI: request.addfinalizer(lambda: shutil.rmtree(str(install_dir), ignore_errors=True)) From 1e0b0b030b87bec2a423fc6d44f3c655794ecdf1 Mon Sep 17 00:00:00 2001 From: Marco Esters Date: Thu, 2 Oct 2025 07:44:37 -0700 Subject: [PATCH 28/28] Add comment to explain finalizer --- tests/test_examples.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_examples.py b/tests/test_examples.py index 52c3a7498..7d8913e2f 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -318,6 +318,7 @@ def _run_installer( options=options, ) if request and ON_CI: + # GitHub runners run out of disk space if installation directories are not cleaned up request.addfinalizer(lambda: shutil.rmtree(str(install_dir), ignore_errors=True)) elif installer.suffix == ".pkg": if request and ON_CI: