From d315f8b90bf972c51387335a6b3a90fba064d398 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Tue, 9 Sep 2025 15:20:09 -0700 Subject: [PATCH 01/14] Implement frozen environment version validation and testing - Add version check for frozen environments - Implement comprehensive testing for base and extra environments - Handle different datatypes for frozen file specifications - Add helper functions for version range validation --- constructor/main.py | 87 +++++++++++++++++++-------- news/1058-add-support-for-frozen-envs | 19 ++++++ tests/test_examples.py | 36 +++++++++++ 3 files changed, 118 insertions(+), 24 deletions(-) create mode 100644 news/1058-add-support-for-frozen-envs diff --git a/constructor/main.py b/constructor/main.py index abf03ad2b..759f34fe1 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -105,6 +105,68 @@ def _win_install_needs_python_exe(conda_exe: str, conda_exe_type: StandaloneExe return results.returncode == 2 +# Validate frozen environments +def validate_frozen_envs(info, exe_type, exe_version): + """Validate frozen environments. + + Checks: + - No conflicts between freeze_base/freeze_env and extra_files for same environment + - conda-standalone 25.5.x is not used (has known issues) + - Warns if conda-standalone < 25.5.0 (frozen files will be ignored) + + Stores frozen environment info in `_frozen_markers` dict. + """ + def get_frozen_env_from_path(dest: str) -> str | None: + """Extract environment name from frozen marker destination path.""" + parts = Path(dest).parts + if parts == ("conda-meta", "frozen"): + return "base" + if len(parts) == 4 and parts[0] == "envs" and parts[-2:] == ("conda-meta", "frozen"): + return parts[1] + return None + + # Collect environments using freeze_base/freeze_env + frozen_envs = {} + if info.get("freeze_base"): + frozen_envs["base"] = { + "method": "freeze_base", + "config": info["freeze_base"], + } + for env_name, env_config in info.get("extra_envs", {}).items(): + if env_config.get("freeze_env"): + frozen_envs[env_name] = { + "method": "freeze_env", + "config": env_config["freeze_env"], + } + + # Check for conflicts with extra_files + for file in info.get("extra_files", []): + if isinstance(file, dict): + for dest in file.values(): + env = get_frozen_env_from_path(dest) + if env and env in frozen_envs: + raise RuntimeError( + f"Environment '{env}' has frozen markers from both " + f"'{'freeze_base' if env == 'base' else 'freeze_env'}' and 'extra_files'. " + "Please use only one method to provide frozen markers for each environment.") + + info["_frozen_markers"] = frozen_envs + + # Conda-standalone version validation + if frozen_envs and exe_type == StandaloneExe.CONDA: + # Block conda-standalone 25.5.x (has known issues with frozen environments) + if check_version(exe_version, min_version="25.5.0", max_version="25.7.0"): + sys.exit( + "Error: conda-standalone 25.5.x has known issues with frozen environments. " + "Please use conda-standalone 25.7.0 or newer." + ) + # Warn for older versions (will ignore frozen files) + elif not check_version(exe_version, min_version="25.5.0"): + logger.warning( + "conda-standalone older than 25.5.0 does not support frozen environments. " + "Frozen marker files will be ignored at install time." + ) + def main_build( dir_path, output_dir=".", @@ -194,30 +256,7 @@ def main_build( if isinstance(info[key], str): info[key] = list(yield_lines(join(dir_path, info[key]))) - 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") - ) - - for file in extra_files: - if isinstance(file, str) and is_conda_meta_frozen(file): - return True - elif isinstance(file, dict) and any(is_conda_meta_frozen(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") - ): - sys.exit( - "Error: handling conda-meta/frozen marker files requires conda-standalone newer than 25.7.x" - ) + validate_frozen_envs(info, exe_type, exe_version) # normalize paths to be copied; if they are relative, they must be to # construct.yaml's parent (dir_path) 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 + +* diff --git a/tests/test_examples.py b/tests/test_examples.py index 2561d9c44..e68d04052 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1502,3 +1502,39 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): assert is_in_installed_apps_menu == (no_registry == 0), ( f"Unable to find program '{partial_name}' in the 'Installed apps' menu" ) + + +@pytest.mark.xfail( + 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 environments and older versions ignore frozen files", + 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): + _run_installer( + input_path, + installer, + install_dir, + request=request, + uninstall=False, + ) + + 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 ae8fd52d44e737a03d11e39adb700f5882749fee Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 26 Sep 2025 12:19:25 -0700 Subject: [PATCH 02/14] Add metadata for frozen environments in schema --- CONSTRUCT.md | 6 ++++++ constructor/_schema.py | 8 ++++++++ constructor/data/construct.schema.json | 28 ++++++++++++++++++++++++++ docs/source/construct-yaml.md | 6 ++++++ 4 files changed, 48 insertions(+) diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 210cc88e9..73f22d1b7 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -658,6 +658,12 @@ Allowed strings / keys: `hash`, `info.json`, `licenses`, `lockfile`, `pkgs_list` Use the standalone binary to perform the uninstallation on Windows. Requires conda-standalone 24.11.0 or newer. +### `frozen_file` + +Protect the base environment with a `frozen` marker file. Requires conda 25.5.0 or newer. This setting can be: + +`dict`: If set, the dictionary will be output into a `frozen` marker file to protect the `base` environment. If not used, the `base` environment will not be protected. See CEP-22 for the specification of the `frozen` file. + ## Available selectors - `aarch64` diff --git a/constructor/_schema.py b/constructor/_schema.py index a8ae97514..02621a353 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -98,6 +98,8 @@ class ExtraEnv(BaseModel): Same as the global option, but for this env. See global option for notes about overrides. """ + frozen_file: dict | None = None + "Same as the global option, but for this environment." class BuildOutputs(StrEnum): @@ -830,6 +832,12 @@ class ConstructorConfiguration(BaseModel): Use the standalone binary to perform the uninstallation on Windows. Requires conda-standalone 24.11.0 or newer. """ + frozen_file: dict | None = None + """ + Protect the base environment with a `frozen` marker file. Requires conda 25.5.0 or newer. This setting can be: + + `dict`: If set, the dictionary will be output into a `frozen` marker file to protect the `base` environment. If not used, the `base` environment will not be protected. See CEP-22 for the specification of the `frozen` file. + """ def fix_descriptions(obj): diff --git a/constructor/data/construct.schema.json b/constructor/data/construct.schema.json index 228b82946..1da057c56 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -124,6 +124,20 @@ "description": "Same as the global option, but for this env. See global option for notes about overrides.", "title": "Exclude" }, + "frozen_file": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Same as the global option, but for this environment.", + "title": "Frozen File" + }, "menu_packages": { "anyOf": [ { @@ -694,6 +708,20 @@ "title": "Extra Files", "type": "array" }, + "frozen_file": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Protect the base environment with a `frozen` marker file. Requires conda 25.5.0 or newer. This setting can be:\n`dict`: If set, the dictionary will be output into a `frozen` marker file to protect the `base` environment. If not used, the `base` environment will not be protected. See CEP-22 for the specification of the `frozen` file.", + "title": "Frozen File" + }, "header_image": { "anyOf": [ { diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 210cc88e9..73f22d1b7 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -658,6 +658,12 @@ Allowed strings / keys: `hash`, `info.json`, `licenses`, `lockfile`, `pkgs_list` Use the standalone binary to perform the uninstallation on Windows. Requires conda-standalone 24.11.0 or newer. +### `frozen_file` + +Protect the base environment with a `frozen` marker file. Requires conda 25.5.0 or newer. This setting can be: + +`dict`: If set, the dictionary will be output into a `frozen` marker file to protect the `base` environment. If not used, the `base` environment will not be protected. See CEP-22 for the specification of the `frozen` file. + ## Available selectors - `aarch64` From f2567500ac0063be41ee97e0188b6f2be03549c7 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Tue, 11 Nov 2025 15:34:50 -0800 Subject: [PATCH 03/14] Update schema for frozen environment metadata - Refine schema design for freeze_base and freeze_env - Add validation models to catch configuration errors early - Update examples and documentation - Separate base environment vs extra_env freezing logic --- CONSTRUCT.md | 16 ++++- constructor/_schema.py | 21 +++++-- constructor/data/construct.schema.json | 28 ++++++--- constructor/main.py | 82 +++++++++++++------------- docs/source/construct-yaml.md | 16 ++++- 5 files changed, 101 insertions(+), 62 deletions(-) diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 73f22d1b7..5d9fb159c 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -658,11 +658,21 @@ Allowed strings / keys: `hash`, `info.json`, `licenses`, `lockfile`, `pkgs_list` Use the standalone binary to perform the uninstallation on Windows. Requires conda-standalone 24.11.0 or newer. -### `frozen_file` +### `freeze_base` -Protect the base environment with a `frozen` marker file. Requires conda 25.5.0 or newer. This setting can be: +Protects the conda base environment against modifications by supported package managers. -`dict`: If set, the dictionary will be output into a `frozen` marker file to protect the `base` environment. If not used, the `base` environment will not be protected. See CEP-22 for the specification of the `frozen` file. +Supported package managers: + - `conda`: Protects against conda modifications + +For `conda`, the dictionary is written into the `frozen` marker file. +See CEP-22 for the `frozen` marker file specification. For example: + +``` +freeze_base: + conda: + message: "This base environment is frozen and cannot be modified." +``` ## Available selectors diff --git a/constructor/_schema.py b/constructor/_schema.py index 02621a353..6dcddb732 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -98,9 +98,8 @@ class ExtraEnv(BaseModel): Same as the global option, but for this env. See global option for notes about overrides. """ - frozen_file: dict | None = None - "Same as the global option, but for this environment." - + freeze_env: dict[Literal["conda"], dict] | None = None + "Same as `freeze_base`, but for this conda environment." class BuildOutputs(StrEnum): "Allowed keys in 'build_outputs' setting." @@ -832,11 +831,21 @@ class ConstructorConfiguration(BaseModel): Use the standalone binary to perform the uninstallation on Windows. Requires conda-standalone 24.11.0 or newer. """ - frozen_file: dict | None = None + freeze_base: dict[Literal["conda"], dict] | None = None """ - Protect the base environment with a `frozen` marker file. Requires conda 25.5.0 or newer. This setting can be: + Protects the conda base environment against modifications by supported package managers. + + Supported package managers: + - `conda`: Protects against conda modifications + + For `conda`, the dictionary is written into the `frozen` marker file. + See CEP-22 for the `frozen` marker file specification. For example: - `dict`: If set, the dictionary will be output into a `frozen` marker file to protect the `base` environment. If not used, the `base` environment will not be protected. See CEP-22 for the specification of the `frozen` file. + ``` + freeze_base: + conda: + message: "This base environment is frozen and cannot be modified." + ``` """ diff --git a/constructor/data/construct.schema.json b/constructor/data/construct.schema.json index 1da057c56..4811ccb8a 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -124,10 +124,16 @@ "description": "Same as the global option, but for this env. See global option for notes about overrides.", "title": "Exclude" }, - "frozen_file": { + "freeze_env": { "anyOf": [ { - "additionalProperties": true, + "additionalProperties": { + "additionalProperties": true, + "type": "object" + }, + "propertyNames": { + "const": "conda" + }, "type": "object" }, { @@ -135,8 +141,8 @@ } ], "default": null, - "description": "Same as the global option, but for this environment.", - "title": "Frozen File" + "description": "Same as `freeze_base`, but for this conda environment.", + "title": "Freeze Env" }, "menu_packages": { "anyOf": [ @@ -708,10 +714,16 @@ "title": "Extra Files", "type": "array" }, - "frozen_file": { + "freeze_base": { "anyOf": [ { - "additionalProperties": true, + "additionalProperties": { + "additionalProperties": true, + "type": "object" + }, + "propertyNames": { + "const": "conda" + }, "type": "object" }, { @@ -719,8 +731,8 @@ } ], "default": null, - "description": "Protect the base environment with a `frozen` marker file. Requires conda 25.5.0 or newer. This setting can be:\n`dict`: If set, the dictionary will be output into a `frozen` marker file to protect the `base` environment. If not used, the `base` environment will not be protected. See CEP-22 for the specification of the `frozen` file.", - "title": "Frozen File" + "description": "Protects the conda base environment against modifications by supported package managers.\nSupported package managers: - `conda`: Protects against conda modifications\nFor `conda`, the dictionary is written into the `frozen` marker file. See CEP-22 for the `frozen` marker file specification. For example:\n```\nfreeze_base:\n conda:\n message: \"This base environment is frozen and cannot be modified.\"\n```", + "title": "Freeze Base" }, "header_image": { "anyOf": [ diff --git a/constructor/main.py b/constructor/main.py index 759f34fe1..73d98e033 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -106,19 +106,20 @@ def _win_install_needs_python_exe(conda_exe: str, conda_exe_type: StandaloneExe # Validate frozen environments -def validate_frozen_envs(info, exe_type, exe_version): +def validate_frozen_envs(info, exe_type, exe_version) -> bool: """Validate frozen environments. Checks: - No conflicts between freeze_base/freeze_env and extra_files for same environment - - conda-standalone 25.5.x is not used (has known issues) - - Warns if conda-standalone < 25.5.0 (frozen files will be ignored) - - Stores frozen environment info in `_frozen_markers` dict. + - Conda-standalone version if frozen environments exist """ - def get_frozen_env_from_path(dest: str) -> str | None: - """Extract environment name from frozen marker destination path.""" - parts = Path(dest).parts + def get_frozen_env(path) -> str | None: + """Extract environment name from frozen marker destination path. + + Returns: + Environment name if frozen marker found in the path, otherwise None. + """ + parts = Path(path).parts if parts == ("conda-meta", "frozen"): return "base" if len(parts) == 4 and parts[0] == "envs" and parts[-2:] == ("conda-meta", "frozen"): @@ -126,46 +127,43 @@ def get_frozen_env_from_path(dest: str) -> str | None: return None # Collect environments using freeze_base/freeze_env - frozen_envs = {} - if info.get("freeze_base"): - frozen_envs["base"] = { - "method": "freeze_base", - "config": info["freeze_base"], - } + frozen_envs = set() + if info.get("freeze_base", {}).get("conda") is not None: + frozen_envs.add("base") for env_name, env_config in info.get("extra_envs", {}).items(): - if env_config.get("freeze_env"): - frozen_envs[env_name] = { - "method": "freeze_env", - "config": env_config["freeze_env"], - } + if env_config.get("freeze_env", {}).get("conda") is not None: + frozen_envs.add(env_name) - # Check for conflicts with extra_files - for file in info.get("extra_files", []): + # Collect environments using extra_files frozen markers + frozen_envs_extra_files = set() + paths = [] + for file in info.get("extra_files", ()): if isinstance(file, dict): - for dest in file.values(): - env = get_frozen_env_from_path(dest) - if env and env in frozen_envs: - raise RuntimeError( - f"Environment '{env}' has frozen markers from both " - f"'{'freeze_base' if env == 'base' else 'freeze_env'}' and 'extra_files'. " - "Please use only one method to provide frozen markers for each environment.") + paths.extend(file.values()) + elif isinstance(file, str): + paths.append(file) + + for path in paths: + if env := get_frozen_env(path): + frozen_envs_extra_files.add(env) + + if not frozen_envs and not frozen_envs_extra_files: + return - info["_frozen_markers"] = frozen_envs + # Check for conflicts with extra_files + if common_envs := frozen_envs.intersection(frozen_envs_extra_files): + raise RuntimeError( + f"Frozen marker files found from both " + f"'freeze_base / freeze_env' and 'extra_files' for the following environments: {', '.join(sorted(common_envs))}") # Conda-standalone version validation - if frozen_envs and exe_type == StandaloneExe.CONDA: - # Block conda-standalone 25.5.x (has known issues with frozen environments) - if check_version(exe_version, min_version="25.5.0", max_version="25.7.0"): - sys.exit( - "Error: conda-standalone 25.5.x has known issues with frozen environments. " - "Please use conda-standalone 25.7.0 or newer." - ) - # Warn for older versions (will ignore frozen files) - elif not check_version(exe_version, min_version="25.5.0"): - logger.warning( - "conda-standalone older than 25.5.0 does not support frozen environments. " - "Frozen marker files will be ignored at install time." - ) + if (exe_type == StandaloneExe.CONDA + and check_version(exe_version, min_version="25.5.0", max_version="25.7.0") + ): + sys.exit( + "Error: conda-standalone 25.5.x has known issues with frozen environments. " + "Please use conda-standalone 25.7.0 or newer." + ) def main_build( dir_path, diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 73f22d1b7..5d9fb159c 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -658,11 +658,21 @@ Allowed strings / keys: `hash`, `info.json`, `licenses`, `lockfile`, `pkgs_list` Use the standalone binary to perform the uninstallation on Windows. Requires conda-standalone 24.11.0 or newer. -### `frozen_file` +### `freeze_base` -Protect the base environment with a `frozen` marker file. Requires conda 25.5.0 or newer. This setting can be: +Protects the conda base environment against modifications by supported package managers. -`dict`: If set, the dictionary will be output into a `frozen` marker file to protect the `base` environment. If not used, the `base` environment will not be protected. See CEP-22 for the specification of the `frozen` file. +Supported package managers: + - `conda`: Protects against conda modifications + +For `conda`, the dictionary is written into the `frozen` marker file. +See CEP-22 for the `frozen` marker file specification. For example: + +``` +freeze_base: + conda: + message: "This base environment is frozen and cannot be modified." +``` ## Available selectors From 0c0646b4f40de141d78b4d1bb9baf4b7d936d8f6 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 19 Nov 2025 07:54:45 -0800 Subject: [PATCH 04/14] Write frozen files and implement installer support --- constructor/nsis/main.nsi.tmpl | 6 +++ constructor/preconda.py | 14 ++++++ constructor/shar.py | 8 ++++ constructor/winexe.py | 2 + examples/protected_base/construct.yaml | 11 +++-- examples/protected_base/frozen.json | 2 +- tests/test_examples.py | 65 +++++++++++++++----------- 7 files changed, 77 insertions(+), 31 deletions(-) diff --git a/constructor/nsis/main.nsi.tmpl b/constructor/nsis/main.nsi.tmpl index 3ebfe1a07..e1dffc07f 100644 --- a/constructor/nsis/main.nsi.tmpl +++ b/constructor/nsis/main.nsi.tmpl @@ -1637,6 +1637,12 @@ Section "Install" # channels and retain only the first transaction SetOutPath "{{ env.conda_meta }}" File "{{ env.history_abspath }}" + +{%- if env.frozen_abspath %} + # Add frozen marker file if configured + SetOutPath "{{ env.conda_meta }}" + File "{{ env.frozen_abspath }}" +{%- endif %} {%- endfor %} {%- for condarc in WRITE_CONDARC %} diff --git a/constructor/preconda.py b/constructor/preconda.py index 12201ee77..f5145dbd4 100644 --- a/constructor/preconda.py +++ b/constructor/preconda.py @@ -147,6 +147,7 @@ def write_files(info: dict, workspace: str): - `conda-meta/initial-state.explicit.txt`: Lockfile to provision the base environment. - `conda-meta/history`: Prepared history file with the right requested specs in input file. + - `conda-meta/frozen`: Frozen marker file used to protect conda environment state. - `pkgs/urls` and `pkgs/urls.txt`: Direct URLs of packages used, with and without MD5 hashes. - `pkgs/cache/*.json`: Trimmed repodata to mock offline channels in use. - `pkgs/channels.txt`: Channels in use. @@ -199,6 +200,9 @@ def write_files(info: dict, workspace: str): # (list of specs/dists to install) write_initial_state_explicit_txt(info, join(workspace, "conda-meta"), final_urls_md5s) + # base environment frozen marker files + write_frozen(info.get("freeze_base"), join(workspace, "conda-meta")) + for fn in files: os.chmod(join(workspace, fn), 0o664) @@ -218,6 +222,8 @@ def write_files(info: dict, workspace: str): write_channels_txt(info, env_pkgs, env_config) # shortcuts write_shortcuts_txt(info, env_pkgs, env_config) + # frozen marker file + write_frozen(env_config.get("freeze_env"), env_conda_meta) def write_conda_meta(info, dst_dir, final_urls_md5s, user_requested_specs=None): @@ -244,6 +250,14 @@ def write_conda_meta(info, dst_dir, final_urls_md5s, user_requested_specs=None): fh.write("\n".join(builder)) +def write_frozen(freeze_info, dst_dir): + if not freeze_info or "conda" not in freeze_info: + return + frozen_path = join(dst_dir, "frozen") + with open(frozen_path, "w") as ff: + json.dump(freeze_info["conda"], ff) + + def write_repodata_record(info, dst_dir): all_dists = info["_dists"].copy() for env_data in info.get("_extra_envs_info", {}).values(): diff --git a/constructor/shar.py b/constructor/shar.py index 745f95c61..a0b631267 100644 --- a/constructor/shar.py +++ b/constructor/shar.py @@ -173,12 +173,20 @@ def create(info, verbose=False): pre_t.addfile(tarinfo=tarfile.TarInfo("conda-meta/history")) post_t.add(join(tmp_dir, "conda-meta", "history"), "conda-meta/history") + if os.path.exists(join(tmp_dir, "conda-meta", "frozen")): + post_t.add(join(tmp_dir, "conda-meta", "frozen"), "conda-meta/frozen") + for env_name in info.get("_extra_envs_info", {}): pre_t.addfile(tarinfo=tarfile.TarInfo(f"envs/{env_name}/conda-meta/history")) post_t.add( join(tmp_dir, "envs", env_name, "conda-meta", "history"), f"envs/{env_name}/conda-meta/history", ) + if os.path.exists(join(tmp_dir, "envs", env_name, "conda-meta", "frozen")): + post_t.add( + join(tmp_dir, "envs", env_name, "conda-meta", "frozen"), + f"envs/{env_name}/conda-meta/frozen", + ) extra_files = copy_extra_files(info.get("extra_files", []), tmp_dir) for path in extra_files: diff --git a/constructor/winexe.py b/constructor/winexe.py index cd1cf05aa..d08c55dcc 100644 --- a/constructor/winexe.py +++ b/constructor/winexe.py @@ -87,6 +87,7 @@ def setup_envs_commands(info, dir_path): "lockfile_txt_abspath": join(dir_path, "conda-meta", "initial-state.explicit.txt"), "conda_meta": r"$INSTDIR\conda-meta", "history_abspath": join(dir_path, "conda-meta", "history"), + "frozen_abspath": join(dir_path, "conda-meta", "frozen") if info.get("freeze_base", {}).get("conda") is not None else "", "final_channels": get_final_channels(info), "shortcuts": shortcuts_flags(info), "register_envs": str(info.get("register_envs", True)).lower(), @@ -115,6 +116,7 @@ def setup_envs_commands(info, dir_path): ), "conda_meta": join("$INSTDIR", "envs", env_name, "conda-meta"), "history_abspath": join(dir_path, "envs", env_name, "conda-meta", "history"), + "frozen_abspath": join(dir_path, "envs", env_name, "conda-meta", "frozen") if env_info.get("freeze_env", {}).get("conda") is not None else "", "final_channels": get_final_channels(channel_info), "shortcuts": shortcuts_flags(env_info), "register_envs": str(info.get("register_envs", True)).lower(), diff --git a/examples/protected_base/construct.yaml b/examples/protected_base/construct.yaml index c43044761..7e2e09a49 100644 --- a/examples/protected_base/construct.yaml +++ b/examples/protected_base/construct.yaml @@ -12,13 +12,18 @@ specs: - python - conda +freeze_base: + conda: {} + extra_envs: - default: + env1: specs: - python - pip - conda + freeze_env: + conda: + message: This environment is frozen. extra_files: - - frozen.json: conda-meta/frozen - - frozen.json: envs/default/conda-meta/frozen + - frozen.json: envs/env2/conda-meta/frozen diff --git a/examples/protected_base/frozen.json b/examples/protected_base/frozen.json index 0967ef424..087c29648 100644 --- a/examples/protected_base/frozen.json +++ b/examples/protected_base/frozen.json @@ -1 +1 @@ -{} +{"message": "This env is frozen via extra_files."} diff --git a/tests/test_examples.py b/tests/test_examples.py index e68d04052..054547cf6 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -20,6 +20,7 @@ from conda.core.prefix_data import PrefixData from conda.models.version import VersionOrder as Version from ruamel.yaml import YAML +from contextlib import nullcontext from constructor.utils import ( StandaloneExe, @@ -1509,32 +1510,42 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): 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 environments and older versions ignore frozen files", + reason="conda-standalone 25.5.x fails with protected environments", 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): - _run_installer( - input_path, - installer, - install_dir, - request=request, - uninstall=False, - ) - - 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)}" - ) +@pytest.mark.parametrize( + "has_conflict", + ( + pytest.param(True, id="with-conflict"), + pytest.param(False, id="without-conflict"), + ), +) +def test_frozen_environment(tmp_path, request, has_conflict): + example_path = _example_path("protected_base") + context = pytest.raises(subprocess.CalledProcessError) if has_conflict else nullcontext() + + with context as c: + if has_conflict: + for installer, install_dir in create_installer(example_path, tmp_path): + _run_installer(example_path, installer, install_dir, request=request, uninstall=False) + else: + input_path = tmp_path / "input" + shutil.copytree(str(example_path), str(input_path)) + + with open(input_path / "construct.yaml") as f: + modified_config = YAML().load(f) + modified_config.pop("extra_files", None) + with open(input_path / "construct.yaml", "w") as f: + YAML().dump(modified_config, f) + + for installer, install_dir in create_installer(input_path, tmp_path): + _run_installer(input_path, installer, install_dir, request=request, uninstall=False) + + expected_frozen = { + install_dir / "conda-meta" / "frozen": modified_config["freeze_base"]["conda"], + install_dir / "envs" / "env1" / "conda-meta" / "frozen": modified_config["extra_envs"]["env1"]["freeze_env"]["conda"], + } + + for frozen_path, expected_content in expected_frozen.items(): + assert frozen_path.exists() + assert json.loads(frozen_path.read_text()) == expected_content From a3a3442a8cceddc95e52abbb66a1445c79263afb Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 7 Jan 2026 12:43:06 -0800 Subject: [PATCH 05/14] Refactor test for cleaner conflict validation --- tests/test_examples.py | 51 ++++++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 054547cf6..cf197b7e9 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1522,30 +1522,33 @@ def test_not_in_installed_menu_list_(tmp_path, request, no_registry): ) def test_frozen_environment(tmp_path, request, has_conflict): example_path = _example_path("protected_base") + input_path = tmp_path / "input" + context = pytest.raises(subprocess.CalledProcessError) if has_conflict else nullcontext() + shutil.copytree(str(example_path), str(input_path)) + + yaml = YAML() + with open(input_path / "construct.yaml") as f: + config = yaml.load(f) + + if has_conflict: + config.setdefault("extra_files", []).append({"frozen.json": "conda-meta/frozen"}) + with open(input_path / "construct.yaml", "w") as f: + yaml.dump(config, f) + with context as c: - if has_conflict: - for installer, install_dir in create_installer(example_path, tmp_path): - _run_installer(example_path, installer, install_dir, request=request, uninstall=False) - else: - input_path = tmp_path / "input" - shutil.copytree(str(example_path), str(input_path)) - - with open(input_path / "construct.yaml") as f: - modified_config = YAML().load(f) - modified_config.pop("extra_files", None) - with open(input_path / "construct.yaml", "w") as f: - YAML().dump(modified_config, f) - - for installer, install_dir in create_installer(input_path, tmp_path): - _run_installer(input_path, installer, install_dir, request=request, uninstall=False) - - expected_frozen = { - install_dir / "conda-meta" / "frozen": modified_config["freeze_base"]["conda"], - install_dir / "envs" / "env1" / "conda-meta" / "frozen": modified_config["extra_envs"]["env1"]["freeze_env"]["conda"], - } - - for frozen_path, expected_content in expected_frozen.items(): - assert frozen_path.exists() - assert json.loads(frozen_path.read_text()) == expected_content + for installer, install_dir in create_installer(input_path, tmp_path): + _run_installer(input_path, installer, install_dir, request=request, uninstall=False) + + expected_frozen = { + install_dir / "conda-meta" / "frozen": config["freeze_base"]["conda"], + install_dir / "envs" / "env1" / "conda-meta" / "frozen": config["extra_envs"]["env1"]["freeze_env"]["conda"], + } + + for frozen_path, expected_content in expected_frozen.items(): + assert frozen_path.is_file() + assert json.loads(frozen_path.read_text()) == expected_content + + if has_conflict: + assert all(s in c.value.stderr for s in ("RuntimeError", "freeze_base / freeze_env", "extra_files", "base")) From 52df9a4264c8fe4b50bc51a4c78b15f982e2ba37 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Tue, 13 Jan 2026 12:58:36 -0800 Subject: [PATCH 06/14] Update news file --- ...support-for-frozen-envs => 1149-add-support-for-frozen-envs} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename news/{1058-add-support-for-frozen-envs => 1149-add-support-for-frozen-envs} (77%) diff --git a/news/1058-add-support-for-frozen-envs b/news/1149-add-support-for-frozen-envs similarity index 77% rename from news/1058-add-support-for-frozen-envs rename to news/1149-add-support-for-frozen-envs index 1c8e37ef6..5c062cee8 100644 --- a/news/1058-add-support-for-frozen-envs +++ b/news/1149-add-support-for-frozen-envs @@ -1,6 +1,6 @@ ### Enhancements -* Add support for installing [protected conda environments](https://conda.org/learn/ceps/cep-0022#specification). (#1058) +* Add support for installing [protected conda environments](https://conda.org/learn/ceps/cep-0022#specification). (#1149) ### Bug fixes From 7023b019c0855f34b8c3e588411099103936148d Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Tue, 13 Jan 2026 13:38:46 -0800 Subject: [PATCH 07/14] Apply pre-commit fixes --- constructor/_schema.py | 1 + constructor/main.py | 15 +++++++++------ constructor/winexe.py | 8 ++++++-- tests/test_examples.py | 11 ++++++++--- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/constructor/_schema.py b/constructor/_schema.py index 6dcddb732..4d61bb65c 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -101,6 +101,7 @@ class ExtraEnv(BaseModel): freeze_env: dict[Literal["conda"], dict] | None = None "Same as `freeze_base`, but for this conda environment." + class BuildOutputs(StrEnum): "Allowed keys in 'build_outputs' setting." diff --git a/constructor/main.py b/constructor/main.py index 73d98e033..249d6fed2 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -109,10 +109,11 @@ def _win_install_needs_python_exe(conda_exe: str, conda_exe_type: StandaloneExe def validate_frozen_envs(info, exe_type, exe_version) -> bool: """Validate frozen environments. - Checks: - - No conflicts between freeze_base/freeze_env and extra_files for same environment - - Conda-standalone version if frozen environments exist + Checks: + - No conflicts between freeze_base/freeze_env and extra_files for same environment + - Conda-standalone version if frozen environments exist """ + def get_frozen_env(path) -> str | None: """Extract environment name from frozen marker destination path. @@ -154,17 +155,19 @@ def get_frozen_env(path) -> str | None: if common_envs := frozen_envs.intersection(frozen_envs_extra_files): raise RuntimeError( f"Frozen marker files found from both " - f"'freeze_base / freeze_env' and 'extra_files' for the following environments: {', '.join(sorted(common_envs))}") + f"'freeze_base / freeze_env' and 'extra_files' for the following environments: {', '.join(sorted(common_envs))}" + ) # Conda-standalone version validation - if (exe_type == StandaloneExe.CONDA - and check_version(exe_version, min_version="25.5.0", max_version="25.7.0") + if exe_type == StandaloneExe.CONDA and check_version( + exe_version, min_version="25.5.0", max_version="25.7.0" ): sys.exit( "Error: conda-standalone 25.5.x has known issues with frozen environments. " "Please use conda-standalone 25.7.0 or newer." ) + def main_build( dir_path, output_dir=".", diff --git a/constructor/winexe.py b/constructor/winexe.py index d08c55dcc..b9c271c7a 100644 --- a/constructor/winexe.py +++ b/constructor/winexe.py @@ -87,7 +87,9 @@ def setup_envs_commands(info, dir_path): "lockfile_txt_abspath": join(dir_path, "conda-meta", "initial-state.explicit.txt"), "conda_meta": r"$INSTDIR\conda-meta", "history_abspath": join(dir_path, "conda-meta", "history"), - "frozen_abspath": join(dir_path, "conda-meta", "frozen") if info.get("freeze_base", {}).get("conda") is not None else "", + "frozen_abspath": join(dir_path, "conda-meta", "frozen") + if info.get("freeze_base", {}).get("conda") is not None + else "", "final_channels": get_final_channels(info), "shortcuts": shortcuts_flags(info), "register_envs": str(info.get("register_envs", True)).lower(), @@ -116,7 +118,9 @@ def setup_envs_commands(info, dir_path): ), "conda_meta": join("$INSTDIR", "envs", env_name, "conda-meta"), "history_abspath": join(dir_path, "envs", env_name, "conda-meta", "history"), - "frozen_abspath": join(dir_path, "envs", env_name, "conda-meta", "frozen") if env_info.get("freeze_env", {}).get("conda") is not None else "", + "frozen_abspath": join(dir_path, "envs", env_name, "conda-meta", "frozen") + if env_info.get("freeze_env", {}).get("conda") is not None + else "", "final_channels": get_final_channels(channel_info), "shortcuts": shortcuts_flags(env_info), "register_envs": str(info.get("register_envs", True)).lower(), diff --git a/tests/test_examples.py b/tests/test_examples.py index cf197b7e9..6118dfc51 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -9,6 +9,7 @@ import time import warnings import xml.etree.ElementTree as ET +from contextlib import nullcontext from datetime import timedelta from functools import cache from pathlib import Path @@ -20,7 +21,6 @@ from conda.core.prefix_data import PrefixData from conda.models.version import VersionOrder as Version from ruamel.yaml import YAML -from contextlib import nullcontext from constructor.utils import ( StandaloneExe, @@ -1543,7 +1543,9 @@ def test_frozen_environment(tmp_path, request, has_conflict): expected_frozen = { install_dir / "conda-meta" / "frozen": config["freeze_base"]["conda"], - install_dir / "envs" / "env1" / "conda-meta" / "frozen": config["extra_envs"]["env1"]["freeze_env"]["conda"], + install_dir / "envs" / "env1" / "conda-meta" / "frozen": config["extra_envs"][ + "env1" + ]["freeze_env"]["conda"], } for frozen_path, expected_content in expected_frozen.items(): @@ -1551,4 +1553,7 @@ def test_frozen_environment(tmp_path, request, has_conflict): assert json.loads(frozen_path.read_text()) == expected_content if has_conflict: - assert all(s in c.value.stderr for s in ("RuntimeError", "freeze_base / freeze_env", "extra_files", "base")) + assert all( + s in c.value.stderr + for s in ("RuntimeError", "freeze_base / freeze_env", "extra_files", "base") + ) From 952f91016ccf564c07c1302dbab739d5919d87f4 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Tue, 13 Jan 2026 13:43:14 -0800 Subject: [PATCH 08/14] Remove duplication --- tests/test_examples.py | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/tests/test_examples.py b/tests/test_examples.py index 6118dfc51..d26b7991a 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1423,39 +1423,6 @@ def test_output_files(tmp_path): assert files_exist == [] -@pytest.mark.xfail( - 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 environments", - 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): - _run_installer( - input_path, - installer, - install_dir, - request=request, - uninstall=False, - ) - - 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" - if frozen_file.exists(): - actual_frozen_paths.add(frozen_file) - - 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): From a1c85a8365252e645869527d5231f44e038ccd29 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Thu, 22 Jan 2026 12:09:21 -0800 Subject: [PATCH 09/14] Add some type annotations per review suggestion --- constructor/main.py | 26 +++++++++++++------------- constructor/preconda.py | 12 ++++++------ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/constructor/main.py b/constructor/main.py index 249d6fed2..50965c08c 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -37,7 +37,7 @@ logger = logging.getLogger(__name__) -def get_installer_type(info): +def get_installer_type(info: dict): osname, unused_arch = info["_platform"].split("-") os_allowed = {"linux": ("sh",), "osx": ("sh", "pkg"), "win": ("exe",)} @@ -60,7 +60,7 @@ def get_installer_type(info): return (itype,) -def get_output_filename(info): +def get_output_filename(info: dict) -> str: try: return info["installer_filename"] except KeyError: @@ -106,7 +106,7 @@ def _win_install_needs_python_exe(conda_exe: str, conda_exe_type: StandaloneExe # Validate frozen environments -def validate_frozen_envs(info, exe_type, exe_version) -> bool: +def validate_frozen_envs(info: dict, exe_type: StandaloneExe | None, exe_version: Version | None) -> bool: """Validate frozen environments. Checks: @@ -114,7 +114,7 @@ def validate_frozen_envs(info, exe_type, exe_version) -> bool: - Conda-standalone version if frozen environments exist """ - def get_frozen_env(path) -> str | None: + def get_frozen_env(path: str) -> str | None: """Extract environment name from frozen marker destination path. Returns: @@ -169,15 +169,15 @@ def get_frozen_env(path) -> str | None: def main_build( - dir_path, - output_dir=".", - platform=cc_platform, - verbose=True, - cache_dir=DEFAULT_CACHE_DIR, - dry_run=False, - conda_exe="conda.exe", - config_filename="construct.yaml", - debug=False, + dir_path: str, + output_dir: str =".", + platform: str = cc_platform, + verbose: bool = True, + cache_dir: str = DEFAULT_CACHE_DIR, + dry_run: bool = False, + conda_exe: str = "conda.exe", + config_filename: str = "construct.yaml", + debug: bool = False, ): logger.info("platform: %s", platform) if not os.path.isfile(conda_exe): diff --git a/constructor/preconda.py b/constructor/preconda.py index f5145dbd4..6040e3271 100644 --- a/constructor/preconda.py +++ b/constructor/preconda.py @@ -56,7 +56,7 @@ ) -def write_index_cache(info, dst_dir, used_packages): +def write_index_cache(info: dict, dst_dir: str, used_packages): cache_dir = join(dst_dir, "cache") if not isdir(cache_dir): @@ -226,7 +226,7 @@ def write_files(info: dict, workspace: str): write_frozen(env_config.get("freeze_env"), env_conda_meta) -def write_conda_meta(info, dst_dir, final_urls_md5s, user_requested_specs=None): +def write_conda_meta(info: dict, dst_dir: str, final_urls_md5s: tuple, user_requested_specs=None): if user_requested_specs is None: user_requested_specs = info.get("user_requested_specs", info.get("specs", ())) @@ -250,7 +250,7 @@ def write_conda_meta(info, dst_dir, final_urls_md5s, user_requested_specs=None): fh.write("\n".join(builder)) -def write_frozen(freeze_info, dst_dir): +def write_frozen(freeze_info: dict | None, dst_dir: str): if not freeze_info or "conda" not in freeze_info: return frozen_path = join(dst_dir, "frozen") @@ -258,7 +258,7 @@ def write_frozen(freeze_info, dst_dir): json.dump(freeze_info["conda"], ff) -def write_repodata_record(info, dst_dir): +def write_repodata_record(info: dict, dst_dir: str): all_dists = info["_dists"].copy() for env_data in info.get("_extra_envs_info", {}).values(): all_dists += env_data["_dists"] @@ -285,7 +285,7 @@ def write_repodata_record(info, dst_dir): json.dump(rr_json, rf, indent=2, sort_keys=True) -def write_initial_state_explicit_txt(info, dst_dir, urls): +def write_initial_state_explicit_txt(info: dict, dst_dir: str, urls: tuple): """ urls is an iterable of tuples with url and md5 values """ @@ -307,7 +307,7 @@ def write_initial_state_explicit_txt(info, dst_dir, urls): envf.write(f"{url}#{md5}\n") -def write_channels_txt(info, dst_dir, env_config): +def write_channels_txt(info: dict, dst_dir: str, env_config: dict): env_config = env_config.copy() if "channels" not in env_config: env_config["channels"] = info.get("channels", ()) From cb50798f3005af36633a850b04ab7a7662abb633 Mon Sep 17 00:00:00 2001 From: Jaida Rice <100002667+Jrice1317@users.noreply.github.com> Date: Thu, 22 Jan 2026 12:16:36 -0800 Subject: [PATCH 10/14] Apply suggestion from code review Co-authored-by: Robin <34315751+lrandersson@users.noreply.github.com> --- news/1149-add-support-for-frozen-envs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/1149-add-support-for-frozen-envs b/news/1149-add-support-for-frozen-envs index 5c062cee8..e57efd64d 100644 --- a/news/1149-add-support-for-frozen-envs +++ b/news/1149-add-support-for-frozen-envs @@ -1,6 +1,6 @@ ### Enhancements -* Add support for installing [protected conda environments](https://conda.org/learn/ceps/cep-0022#specification). (#1149) +* Add support for installing [protected (frozen) conda environments](https://conda.org/learn/ceps/cep-0022#specification). (#1149) ### Bug fixes From 35cedb82fc856538bc518695ff7d9642f05e5996 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Thu, 22 Jan 2026 12:19:37 -0800 Subject: [PATCH 11/14] Pre-commit fixes --- constructor/main.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/constructor/main.py b/constructor/main.py index 50965c08c..9756bd60f 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -106,7 +106,9 @@ def _win_install_needs_python_exe(conda_exe: str, conda_exe_type: StandaloneExe # Validate frozen environments -def validate_frozen_envs(info: dict, exe_type: StandaloneExe | None, exe_version: Version | None) -> bool: +def validate_frozen_envs( + info: dict, exe_type: StandaloneExe | None, exe_version: Version | None +) -> bool: """Validate frozen environments. Checks: @@ -170,7 +172,7 @@ def get_frozen_env(path: str) -> str | None: def main_build( dir_path: str, - output_dir: str =".", + output_dir: str = ".", platform: str = cc_platform, verbose: bool = True, cache_dir: str = DEFAULT_CACHE_DIR, From 527a2b00537d3fb378e0b62d282b5433267848b3 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 23 Jan 2026 10:40:48 -0800 Subject: [PATCH 12/14] Replace sys exit with runtime error --- constructor/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constructor/main.py b/constructor/main.py index 9756bd60f..5a8ce9af7 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -164,7 +164,7 @@ def get_frozen_env(path: str) -> str | None: if exe_type == StandaloneExe.CONDA and check_version( exe_version, min_version="25.5.0", max_version="25.7.0" ): - sys.exit( + raise RuntimeError( "Error: conda-standalone 25.5.x has known issues with frozen environments. " "Please use conda-standalone 25.7.0 or newer." ) From c61bebfb6952830b459ec0311c1ee480b3287dba Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 23 Jan 2026 11:09:09 -0800 Subject: [PATCH 13/14] Add type annotations --- constructor/preconda.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/constructor/preconda.py b/constructor/preconda.py index 6040e3271..f928213e8 100644 --- a/constructor/preconda.py +++ b/constructor/preconda.py @@ -56,7 +56,7 @@ ) -def write_index_cache(info: dict, dst_dir: str, used_packages): +def write_index_cache(info: dict, dst_dir: str, used_packages: list[str]): cache_dir = join(dst_dir, "cache") if not isdir(cache_dir): @@ -226,7 +226,12 @@ def write_files(info: dict, workspace: str): write_frozen(env_config.get("freeze_env"), env_conda_meta) -def write_conda_meta(info: dict, dst_dir: str, final_urls_md5s: tuple, user_requested_specs=None): +def write_conda_meta( + info: dict, + dst_dir: str, + final_urls_md5s: tuple[str, str], + user_requested_specs: list[str] | None = None, +): if user_requested_specs is None: user_requested_specs = info.get("user_requested_specs", info.get("specs", ())) @@ -285,7 +290,7 @@ def write_repodata_record(info: dict, dst_dir: str): json.dump(rr_json, rf, indent=2, sort_keys=True) -def write_initial_state_explicit_txt(info: dict, dst_dir: str, urls: tuple): +def write_initial_state_explicit_txt(info: dict, dst_dir: str, urls: tuple[str, str]): """ urls is an iterable of tuples with url and md5 values """ @@ -318,7 +323,7 @@ def write_channels_txt(info: dict, dst_dir: str, env_config: dict): f.write(",".join(get_final_channels(env_config))) -def write_shortcuts_txt(info, dst_dir, env_config): +def write_shortcuts_txt(info: dict, dst_dir: str, env_config: dict): if "menu_packages" in env_config: contents = shortcuts_flags(env_config) else: From 32b3430cd7a929f95d0a6e4b6cb4866860fd4f60 Mon Sep 17 00:00:00 2001 From: Jaida Rice <100002667+Jrice1317@users.noreply.github.com> Date: Fri, 23 Jan 2026 11:11:01 -0800 Subject: [PATCH 14/14] Apply suggestions from code review Also added suggested type annotations for preconda.py Co-authored-by: Marco Esters --- constructor/nsis/main.nsi.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constructor/nsis/main.nsi.tmpl b/constructor/nsis/main.nsi.tmpl index e1dffc07f..8adddc336 100644 --- a/constructor/nsis/main.nsi.tmpl +++ b/constructor/nsis/main.nsi.tmpl @@ -1639,7 +1639,7 @@ Section "Install" File "{{ env.history_abspath }}" {%- if env.frozen_abspath %} - # Add frozen marker file if configured + # Add frozen marker file SetOutPath "{{ env.conda_meta }}" File "{{ env.frozen_abspath }}" {%- endif %}