From 62ae7e0f50f61447f94c68a8d0eef50b0ec07ac5 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Thu, 6 Aug 2026 15:04:11 -0700 Subject: [PATCH 01/18] Move hash file calculation to utils for reusability --- constructor/build_outputs.py | 24 +++++++++-------- constructor/utils.py | 51 +++++++++++++++++++++++++++++------- 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/constructor/build_outputs.py b/constructor/build_outputs.py index efacdbe30..ec7c4279f 100644 --- a/constructor/build_outputs.py +++ b/constructor/build_outputs.py @@ -17,6 +17,7 @@ from . import __version__ from .conda_interface import VersionOrder +from .utils import hash_files logger = logging.getLogger(__name__) @@ -50,28 +51,29 @@ def dump_hash(info, algorithm=None): if not algorithm: logger.warning("`hash` requires an algorithm. No hash files will be output.") return "" + if isinstance(algorithm, str): algorithm = [algorithm] + algorithms = set(algorithm) - if any(algo not in hashlib.algorithms_available for algo in algorithms): - invalid = algorithms.difference(set(hashlib.algorithms_available)) - raise ValueError(f"Invalid algorithm: {', '.join(invalid)}") - BUFFER_SIZE = 65536 + checksums = hash_files(info["_outpath"], algorithms) + if isinstance(info["_outpath"], str): installers = [Path(info["_outpath"])] else: installers = [Path(outpath) for outpath in info["_outpath"]] + outpaths = [] + for installer in installers: - filehashes = {algo: hashlib.new(algo) for algo in algorithms} - with open(installer, "rb") as f: - while buffer := f.read(BUFFER_SIZE): - for algo in algorithms: - filehashes[algo].update(buffer) - for algo, filehash in filehashes.items(): + filehashes = checksums[str(installer)] + + for algo in algorithms: outpath = Path(f"{installer}.{algo}") + with open(outpath, "w", newline="\n") as f: - f.write(f"{filehash.hexdigest()} {installer.name}\n") + f.write(f"{filehashes[algo]} {installer.name}\n") + outpaths.append(str(outpath.absolute())) return ", ".join(outpaths) diff --git a/constructor/utils.py b/constructor/utils.py index 169132ee1..b824c57ec 100644 --- a/constructor/utils.py +++ b/constructor/utils.py @@ -76,16 +76,49 @@ def replace(match): return pat.sub(replace, data) -def hash_files(paths, algorithm="md5"): - h = hashlib.new(algorithm) +def hash_files(paths, algorithms): + """ + Calculate one or more hashes for each file in a single pass. + + Parameters + ---------- + paths + A path or iterable of paths to hash + algorithms + An iterable of hashlib algorithm names, such as ``md5`` or ``sha256`` + + Returns + ------- + dict[str, dict[str, str]] + A mapping of file paths to algorithm names and hexidecimal digest values. + """ + algorithms = set(algorithms) + + invalid = algorithms.difference(hashlib.algorithms_available) + if invalid: + raise ValueError(f"Invalid algorithm: {', '.join(sorted(invalid))}") + + BUFFER_SIZE = 65536 + + if isinstance(paths, (str, Path)): + paths = [paths] + + checksums = {} + for path in paths: - with open(path, "rb") as fi: - while True: - chunk = fi.read(262144) - if not chunk: - break - h.update(chunk) - return h.hexdigest() + path = Path(path) + filehashes = {algo: hashlib.new(algo) for algo in algorithms} + with path.open("rb") as f: + while buffer := f.read(BUFFER_SIZE): + for filehash in filehashes.values(): + filehash.update(buffer) + + checksums[str(path)] = { + algorithm: filehash.hexdigest() + for algorithm, filehash in filehashes.items() + } + + return checksums def make_VIProductVersion(version): From fbd2483cf65d3c15cb166fedbec3bcf84bf92767 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Thu, 6 Aug 2026 15:32:15 -0700 Subject: [PATCH 02/18] Calculate hashes once for all requested outputs --- constructor/build_outputs.py | 49 +++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/constructor/build_outputs.py b/constructor/build_outputs.py index ec7c4279f..ea6b6de64 100644 --- a/constructor/build_outputs.py +++ b/constructor/build_outputs.py @@ -31,15 +31,52 @@ def _validate_output(output): raise ValueError("'build_outputs' dicts can only have one key.") return {key: (value or {}) for (key, value) in output.items()} +def _needed_hash_algorithms(info): + """Return hash algorithms required by the requested build outputs.""" + algorithms = set() + + for output in info.get("build_outputs", ()): + name, config = next(iter(_validate_output(output).items())) + + if name == "info.json": + algorithms.add("sha256") + elif name == "hash": + algorithm = config.get("algorithm") + + if isinstance(algorithm, str): + algorithms.add(algorithm) + elif algorithm: + algorithms.update(algorithm) + + return algorithms + + +def _installer_paths(info): + """Return generated installer paths as Path objects.""" + outpath = info["_outpath"] + + if isinstance(outpath, str): + return [Path(outpath)] + + return [Path(path) for path in outpath] + def process_build_outputs(info): + algorithms = _needed_hash_algorithms(info) + + if algorithms: + info["_installer_hashes"] = hash_files( + info["_outpath"], + algorithms, + ) + for output in info.get("build_outputs", ()): output = _validate_output(output) name, config = output.popitem() handler = OUTPUT_HANDLERS.get(name) if not handler: raise ValueError( - f"'output_builds' key {name} is not recognized! " + f"'build_outputs' key {name} is not recognized! " f"Available keys: {tuple(OUTPUT_HANDLERS.keys())}" ) outpath = handler(info, **config) @@ -56,16 +93,10 @@ def dump_hash(info, algorithm=None): algorithm = [algorithm] algorithms = set(algorithm) - checksums = hash_files(info["_outpath"], algorithms) - - if isinstance(info["_outpath"], str): - installers = [Path(info["_outpath"])] - else: - installers = [Path(outpath) for outpath in info["_outpath"]] - + checksums = info["_installer_hashes"] outpaths = [] - for installer in installers: + for installer in _installer_paths(info): filehashes = checksums[str(installer)] for algo in algorithms: From ec672e7aa87667940cde987fd2f298fda9d5af50 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Thu, 6 Aug 2026 15:58:35 -0700 Subject: [PATCH 03/18] Add installer hashes to info.json --- constructor/build_outputs.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/constructor/build_outputs.py b/constructor/build_outputs.py index ea6b6de64..61f5e0ba8 100644 --- a/constructor/build_outputs.py +++ b/constructor/build_outputs.py @@ -92,12 +92,14 @@ def dump_hash(info, algorithm=None): if isinstance(algorithm, str): algorithm = [algorithm] - algorithms = set(algorithm) - checksums = info["_installer_hashes"] - outpaths = [] + installers = ( + [Path(info["_outpath"])] + if isinstance(info["_outpath"], str) + else [Path(path) for path in info["_outpath"]] + ) - for installer in _installer_paths(info): - filehashes = checksums[str(installer)] + for installer in installers: + filehashes = info["_installer_hashes"][str(installer)] for algo in algorithms: outpath = Path(f"{installer}.{algo}") @@ -118,6 +120,12 @@ def _serialize(obj): else: return repr(obj) + installer = Path(info["_outpath"]) + + output_info = info.copy() + output_info.pop("_installer_hashes", None) + output_info["_installer_hashes"] = info["_installer_hashes"][str(installer)] + outpath = os.path.join(info["_output_dir"], "info.json") with open(outpath, "w") as f: json.dump(info, f, indent=2, default=_serialize) From 8ed2fbb1225738dbde750e37922e70d6c9e6a49f Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Thu, 6 Aug 2026 16:20:56 -0700 Subject: [PATCH 04/18] Update schema and docs --- CONSTRUCT.md | 5 +++-- constructor/_schema.py | 19 ++++++++++++++++++- constructor/data/construct.schema.json | 2 +- docs/source/construct-yaml.md | 5 +++-- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 83f070738..935061b8c 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -390,8 +390,9 @@ Path to a post-install script. Some notes: `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`. `%INSTALLER_UNATTENDED%` will be `"1"` in silent mode (`/S`), `"0"` otherwise. - For Windows `.msi` installers, the script must be a `.bat` file. - The same variables as `.exe` installers are available, except - `%INSTALLER_TYPE%` is set to `MSI` and `%INSTALLER_UNATTENDED%` is not available. + The same variables as `.exe` installers are available. + `%INSTALLER_TYPE%` is set to `MSI`. + `%INSTALLER_UNATTENDED%` will be `"1"` in silent mode (`msiexec /qn`), `"0"` otherwise. If necessary, you can activate the installed `base` environment like this: diff --git a/constructor/_schema.py b/constructor/_schema.py index a9c4b0b6e..be90d230e 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -843,8 +843,25 @@ class ConstructorConfiguration(BaseModel): Additional artifacts to be produced after building the installer. It expects either a list of strings or single-key dictionaries. + Requesting `info.json` adds an `_installer_hashes` property containing + the SHA256 digest of the generated installer. Hash algorithms requested + through a `hash` build output are also included in this property. + + For example: + + ```json + "_installer_hashes": {{ + "sha256": "...", + "md5": "..." + }} + ``` + + The `hash` output continues to create separate checksum files. + Allowed strings / keys: {}. - """.format(", ".join([f"`{v}`" for v in BuildOutputs.__members__.values()])), + """.format( + ", ".join(f"`{member.value}`" for member in BuildOutputs) + ) ), ) uninstall_with_conda_exe: bool | None = None diff --git a/constructor/data/construct.schema.json b/constructor/data/construct.schema.json index e315b6a6d..9b6aa44c2 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -432,7 +432,7 @@ }, "build_outputs": { "default": [], - "description": "Additional artifacts to be produced after building the installer. It expects either a list of strings or single-key dictionaries.\nAllowed strings / keys: `hash`, `info.json`, `licenses`, `lockfile`, `pkgs_list`.", + "description": "Additional artifacts to be produced after building the installer. It expects either a list of strings or single-key dictionaries.\nRequesting `info.json` adds an `_installer_hashes` property containing the SHA256 digest of the generated installer. Hash algorithms requested through a `hash` build output are also included in this property.\nFor example:\n```json\n\"_installer_hashes\": {\n \"sha256\": \"...\",\n \"md5\": \"...\"\n}\n```\nThe `hash` output continues to create separate checksum files.\nAllowed strings / keys: `hash`, `info.json`, `licenses`, `lockfile`, `pkgs_list`.", "items": { "anyOf": [ { diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 83f070738..935061b8c 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -390,8 +390,9 @@ Path to a post-install script. Some notes: `%INSTALLER_PLAT%` environment variables. `%INSTALLER_TYPE%` is set to `EXE`. `%INSTALLER_UNATTENDED%` will be `"1"` in silent mode (`/S`), `"0"` otherwise. - For Windows `.msi` installers, the script must be a `.bat` file. - The same variables as `.exe` installers are available, except - `%INSTALLER_TYPE%` is set to `MSI` and `%INSTALLER_UNATTENDED%` is not available. + The same variables as `.exe` installers are available. + `%INSTALLER_TYPE%` is set to `MSI`. + `%INSTALLER_UNATTENDED%` will be `"1"` in silent mode (`msiexec /qn`), `"0"` otherwise. If necessary, you can activate the installed `base` environment like this: From b9053c7e0cad57b1f05bedfd758544171f23f339 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 12 Aug 2026 04:25:24 -0700 Subject: [PATCH 05/18] Handle single and multiple hashes --- constructor/build_outputs.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/constructor/build_outputs.py b/constructor/build_outputs.py index 0fb496bdf..64a8c0f77 100644 --- a/constructor/build_outputs.py +++ b/constructor/build_outputs.py @@ -105,7 +105,9 @@ def dump_hash(info, algorithm=None): return "" if isinstance(algorithm, str): - algorithm = [algorithm] + algorithms = [algorithm] + else: + algorithms = algorithm installers = ( [Path(info["_outpath"])] @@ -113,6 +115,7 @@ def dump_hash(info, algorithm=None): else [Path(path) for path in info["_outpath"]] ) + outpaths = [] for installer in installers: filehashes = info["_installer_hashes"][str(installer)] @@ -135,12 +138,18 @@ def _serialize(obj): else: return repr(obj) - installer = Path(info["_outpath"]) + installers = ( + [Path(info["_outpath"])] + if isinstance(info["_outpath"], str) + else [Path(path) for path in info["_outpath"]] + ) + + if info.get("_installer_hashes"): + info["hash"] = { + p.name: hashes for p, hashes in + ((p, info["_installer_hashes"][str(p)]) for p in installers) + } - output_info = info.copy() - output_info.pop("_installer_hashes", None) - output_info["_installer_hashes"] = info["_installer_hashes"][str(installer)] - # Packages installed in the environment running constructor. info["_build_environment_packages"] = get_build_env_records() outpath = os.path.join(info["_output_dir"], "info.json") From f2db4486b4ec5c01ae32660e0c5a6ceccb58c364 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 12 Aug 2026 04:27:54 -0700 Subject: [PATCH 06/18] Inetegration test verifying sha256 and md5 --- tests/test_examples.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_examples.py b/tests/test_examples.py index e5826730d..e0435269b 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -2027,6 +2027,16 @@ def test_output_files(tmp_path, installer_type): assert len(_records) > 0, f"Record for {env} is empty." assert isinstance(_records[0], dict), f"Record for {env} is not serialized." + expected_hashes = { + "sha256": hashlib.sha256((root_path / installer.name).read_bytes()).hexdigest(), + "md5": hashlib.md5((root_path / installer.name).read_bytes()).hexdigest(), + } + assert info_json.get("_installer_hashes") == expected_hashes + for algorithm, expected_hash in expected_hashes.items(): + hash_file = root_path / f"{installer.name}.{algorithm}" + assert hash_file.exists() + assert hash_file.read_text() == f"{expected_hash} {installer.name}\n" + @pytest.mark.parametrize( "installer_type", installer_types_for_example(_example_path("regressions")) From 5b9304eee334c35150655efabc3c2a8bbbdf19f4 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 12 Aug 2026 04:28:56 -0700 Subject: [PATCH 07/18] Update tests based off hash selection --- tests/test_outputs.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/tests/test_outputs.py b/tests/test_outputs.py index fa23df2f0..d46fcd00d 100644 --- a/tests/test_outputs.py +++ b/tests/test_outputs.py @@ -3,7 +3,7 @@ import pytest -from constructor.build_outputs import dump_hash +from constructor.build_outputs import dump_hash, _needed_hash_algorithms TEST_FILES = { "test.txt": { @@ -22,17 +22,18 @@ @pytest.mark.parametrize( "algorithm,context", ( - pytest.param("bad algorithm", pytest.raises(ValueError), id="invalid algorithm"), + pytest.param("not cached", pytest.raises(KeyError), id="invalid algorithm"), pytest.param("sha256", nullcontext(), id="string"), pytest.param(["sha256", "md5"], nullcontext(), id="list"), ), ) def test_hash_dump(tmp_path, algorithm, context): - info = {"_outpath": []} + info = {"_outpath": [], "_installer_hashes": {}} for file, data in TEST_FILES.items(): testfile = tmp_path / file testfile.write_text(data["content"]) info["_outpath"].append(str(testfile)) + info["_installer_hashes"][str(testfile)] = {algo: data[algo] for algo in ("sha256", "md5")} with context: dump_hash(info, algorithm=algorithm) if isinstance(algorithm, str): @@ -47,3 +48,21 @@ def test_hash_dump(tmp_path, algorithm, context): filehash, filename = content.strip().split() assert filename == Path(file).name assert filehash == TEST_FILES[filename][algo] + + +@pytest.mark.parametrize( + "build_outputs, expected_algorithms", + ( + pytest.param("no hashes", set(), id="neither info.json nor hash request"), + pytest.param(["info.json"], {"sha256"}, id="info.json only"), + pytest.param([{"hash": {"algorithm": "md5"}}], {"md5"}, id="no info.json, only md5 requested"), + pytest.param( + ["info.json", {"hash": {"algorithm": "md5"}}], + {"sha256", "md5"}, + id="both info.json and md5 requested", + ), + ), +) +def test_hash_algorithms(build_outputs, expected_algorithms): + info = {"build_outputs": build_outputs} + assert _needed_hash_algorithms(info) == expected_algorithms From fc8dc6e01cde6562766b5491b20c4a7ca3f637e4 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 12 Aug 2026 08:33:02 -0700 Subject: [PATCH 08/18] Update logic --- constructor/utils.py | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/constructor/utils.py b/constructor/utils.py index b824c57ec..c54e26d0c 100644 --- a/constructor/utils.py +++ b/constructor/utils.py @@ -78,21 +78,22 @@ def replace(match): def hash_files(paths, algorithms): """ - Calculate one or more hashes for each file in a single pass. + Calculate one or more hashes for the given files in a single pass. Parameters ---------- paths - A path or iterable of paths to hash + An iterable of paths to hash. algorithms An iterable of hashlib algorithm names, such as ``md5`` or ``sha256`` Returns ------- - dict[str, dict[str, str]] - A mapping of file paths to algorithm names and hexidecimal digest values. + dict[str, str] + A mapping of algorithm names to digest values. """ - algorithms = set(algorithms) + if isinstance(algorithms, str): + algorithms = [algorithms] invalid = algorithms.difference(hashlib.algorithms_available) if invalid: @@ -100,25 +101,22 @@ def hash_files(paths, algorithms): BUFFER_SIZE = 65536 - if isinstance(paths, (str, Path)): - paths = [paths] + hashes = {algo: hashlib.new(algo) for algo in algorithms} - checksums = {} - for path in paths: - path = Path(path) - filehashes = {algo: hashlib.new(algo) for algo in algorithms} - with path.open("rb") as f: - while buffer := f.read(BUFFER_SIZE): - for filehash in filehashes.values(): - filehash.update(buffer) + with open(path, "rb") as f: + while True: + chunk = f.read(BUFFER_SIZE) + if not chunk: + break - checksums[str(path)] = { - algorithm: filehash.hexdigest() - for algorithm, filehash in filehashes.items() - } + for filehash in filehashes.values(): + filehash.update(chunk) - return checksums + return { + algorithm: filehash.hexdigest() + for algorithm, filehash in hashes.items() + } def make_VIProductVersion(version): From a57f9ef5361c7a85bff4c7070c6ad00f4f715b78 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 12 Aug 2026 11:01:02 -0700 Subject: [PATCH 09/18] Ensure multiple paths still produce one digest --- constructor/build_outputs.py | 57 ++++++++++-------------------------- constructor/shar.py | 2 +- constructor/utils.py | 3 +- tests/test_examples.py | 1 + tests/test_outputs.py | 2 +- 5 files changed, 21 insertions(+), 44 deletions(-) diff --git a/constructor/build_outputs.py b/constructor/build_outputs.py index 64a8c0f77..653f82961 100644 --- a/constructor/build_outputs.py +++ b/constructor/build_outputs.py @@ -46,18 +46,18 @@ def _validate_output(output): raise ValueError("'build_outputs' dicts can only have one key.") return {key: (value or {}) for (key, value) in output.items()} -def _needed_hash_algorithms(info): +def _needed_hash_algorithms(info: dict) -> set[str]: """Return hash algorithms required by the requested build outputs.""" algorithms = set() for output in info.get("build_outputs", ()): - name, config = next(iter(_validate_output(output).items())) + output = _validate_output(output) + name, config = output.popitem() if name == "info.json": algorithms.add("sha256") elif name == "hash": algorithm = config.get("algorithm") - if isinstance(algorithm, str): algorithms.add(algorithm) elif algorithm: @@ -66,17 +66,7 @@ def _needed_hash_algorithms(info): return algorithms -def _installer_paths(info): - """Return generated installer paths as Path objects.""" - outpath = info["_outpath"] - - if isinstance(outpath, str): - return [Path(outpath)] - - return [Path(path) for path in outpath] - - -def process_build_outputs(info): +def process_build_outputs(info: dict): algorithms = _needed_hash_algorithms(info) if algorithms: @@ -87,19 +77,22 @@ def process_build_outputs(info): for output in info.get("build_outputs", ()): output = _validate_output(output) + name, config = output.popitem() + handler = OUTPUT_HANDLERS.get(name) if not handler: raise ValueError( f"'build_outputs' key {name} is not recognized! " f"Available keys: {tuple(OUTPUT_HANDLERS.keys())}" ) - outpath = handler(info, **config) + + outpath = handler(info, **config)'' if outpath: logger.info("build_outputs: '%s' created '%s'.", name, outpath) -def dump_hash(info, algorithm=None): +def dump_hash(info: dict, algorithm: str | None = None): if not algorithm: logger.warning("`hash` requires an algorithm. No hash files will be output.") return "" @@ -109,23 +102,17 @@ def dump_hash(info, algorithm=None): else: algorithms = algorithm - installers = ( - [Path(info["_outpath"])] - if isinstance(info["_outpath"], str) - else [Path(path) for path in info["_outpath"]] - ) - + installer = Path(info["_outpath"]) outpaths = [] - for installer in installers: - filehashes = info["_installer_hashes"][str(installer)] - for algo in algorithms: - outpath = Path(f"{installer}.{algo}") + for algo in algorithms: + outpath = Path(f"{installer}.{algo}") + + with open(outpath, "w", newline="\n") as f: + f.write(f"{info['_installer_hashes'][algo]} {installer.name}\n") - with open(outpath, "w", newline="\n") as f: - f.write(f"{filehashes[algo]} {installer.name}\n") + outpaths.append(str(outpath.absolute())) - outpaths.append(str(outpath.absolute())) return ", ".join(outpaths) @@ -138,18 +125,6 @@ def _serialize(obj): else: return repr(obj) - installers = ( - [Path(info["_outpath"])] - if isinstance(info["_outpath"], str) - else [Path(path) for path in info["_outpath"]] - ) - - if info.get("_installer_hashes"): - info["hash"] = { - p.name: hashes for p, hashes in - ((p, info["_installer_hashes"][str(p)]) for p in installers) - } - # Packages installed in the environment running constructor. info["_build_environment_packages"] = get_build_env_records() outpath = os.path.join(info["_output_dir"], "info.json") diff --git a/constructor/shar.py b/constructor/shar.py index e857aac7f..72164e4e5 100644 --- a/constructor/shar.py +++ b/constructor/shar.py @@ -89,7 +89,7 @@ def get_header(conda_exec, tarball, info): variables["installer_name"] = name variables["installer_version"] = info["version"] variables["installer_platform"] = info["_platform"] - variables["installer_md5"] = hash_files([conda_exec, *info["_internal_conda_files"], tarball]) + variables["installer_md5"] = hash_files([conda_exec, *info["_internal_conda_files"], tarball], "md5",)["md5"] variables["default_prefix"] = info.get("default_prefix", "${HOME:-/opt}/%s" % name.lower()) variables["first_payload_size"] = getsize(conda_exec) variables["second_payload_size"] = getsize(tarball) diff --git a/constructor/utils.py b/constructor/utils.py index c54e26d0c..ee4bfbf05 100644 --- a/constructor/utils.py +++ b/constructor/utils.py @@ -76,7 +76,7 @@ def replace(match): return pat.sub(replace, data) -def hash_files(paths, algorithms): +def hash_files(paths: list[Path], algorithms: list[str] | str) -> dict[str, str]: """ Calculate one or more hashes for the given files in a single pass. @@ -95,6 +95,7 @@ def hash_files(paths, algorithms): if isinstance(algorithms, str): algorithms = [algorithms] + algorithms = set(algorithms) invalid = algorithms.difference(hashlib.algorithms_available) if invalid: raise ValueError(f"Invalid algorithm: {', '.join(sorted(invalid))}") diff --git a/tests/test_examples.py b/tests/test_examples.py index e0435269b..bc6b07951 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -34,6 +34,7 @@ format_conda_exe_name, has_docker_buildx, identify_conda_exe, + hash_files, ) if TYPE_CHECKING: diff --git a/tests/test_outputs.py b/tests/test_outputs.py index d46fcd00d..7ac382c33 100644 --- a/tests/test_outputs.py +++ b/tests/test_outputs.py @@ -53,7 +53,7 @@ def test_hash_dump(tmp_path, algorithm, context): @pytest.mark.parametrize( "build_outputs, expected_algorithms", ( - pytest.param("no hashes", set(), id="neither info.json nor hash request"), + pytest.param([], set(), id="neither info.json nor hash request"), pytest.param(["info.json"], {"sha256"}, id="info.json only"), pytest.param([{"hash": {"algorithm": "md5"}}], {"md5"}, id="no info.json, only md5 requested"), pytest.param( From 2332500351137a18f81fdc58425e6591717fec99 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 12 Aug 2026 11:15:09 -0700 Subject: [PATCH 10/18] Fix typos and pre-commit errors --- constructor/_schema.py | 4 +--- constructor/build_outputs.py | 4 ++-- constructor/shar.py | 5 ++++- constructor/utils.py | 11 ++++------- tests/test_examples.py | 2 +- tests/test_outputs.py | 6 ++++-- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/constructor/_schema.py b/constructor/_schema.py index 27bedd388..5f6e6ed2c 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -873,9 +873,7 @@ class ConstructorConfiguration(BaseModel): The `hash` output continues to create separate checksum files. Allowed strings / keys: {}. - """.format( - ", ".join(f"`{member.value}`" for member in BuildOutputs) - ) + """.format(", ".join(f"`{member.value}`" for member in BuildOutputs)) ), ) uninstall_with_conda_exe: bool | None = None diff --git a/constructor/build_outputs.py b/constructor/build_outputs.py index 653f82961..dc3f712dc 100644 --- a/constructor/build_outputs.py +++ b/constructor/build_outputs.py @@ -4,7 +4,6 @@ Update documentation in `construct.py` if any changes are made. """ -import hashlib import json import logging import os @@ -46,6 +45,7 @@ def _validate_output(output): raise ValueError("'build_outputs' dicts can only have one key.") return {key: (value or {}) for (key, value) in output.items()} + def _needed_hash_algorithms(info: dict) -> set[str]: """Return hash algorithms required by the requested build outputs.""" algorithms = set() @@ -87,7 +87,7 @@ def process_build_outputs(info: dict): f"Available keys: {tuple(OUTPUT_HANDLERS.keys())}" ) - outpath = handler(info, **config)'' + outpath = handler(info, **config) if outpath: logger.info("build_outputs: '%s' created '%s'.", name, outpath) diff --git a/constructor/shar.py b/constructor/shar.py index 72164e4e5..44e5c7824 100644 --- a/constructor/shar.py +++ b/constructor/shar.py @@ -89,7 +89,10 @@ def get_header(conda_exec, tarball, info): variables["installer_name"] = name variables["installer_version"] = info["version"] variables["installer_platform"] = info["_platform"] - variables["installer_md5"] = hash_files([conda_exec, *info["_internal_conda_files"], tarball], "md5",)["md5"] + variables["installer_md5"] = hash_files( + [conda_exec, *info["_internal_conda_files"], tarball], + "md5", + )["md5"] variables["default_prefix"] = info.get("default_prefix", "${HOME:-/opt}/%s" % name.lower()) variables["first_payload_size"] = getsize(conda_exec) variables["second_payload_size"] = getsize(tarball) diff --git a/constructor/utils.py b/constructor/utils.py index ee4bfbf05..f9f01c07a 100644 --- a/constructor/utils.py +++ b/constructor/utils.py @@ -79,10 +79,10 @@ def replace(match): def hash_files(paths: list[Path], algorithms: list[str] | str) -> dict[str, str]: """ Calculate one or more hashes for the given files in a single pass. - + Parameters ---------- - paths + paths An iterable of paths to hash. algorithms An iterable of hashlib algorithm names, such as ``md5`` or ``sha256`` @@ -111,13 +111,10 @@ def hash_files(paths: list[Path], algorithms: list[str] | str) -> dict[str, str] if not chunk: break - for filehash in filehashes.values(): + for filehash in hashes.values(): filehash.update(chunk) - return { - algorithm: filehash.hexdigest() - for algorithm, filehash in hashes.items() - } + return {algorithm: filehash.hexdigest() for algorithm, filehash in hashes.items()} def make_VIProductVersion(version): diff --git a/tests/test_examples.py b/tests/test_examples.py index bc6b07951..436c85523 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import getpass import json import os @@ -34,7 +35,6 @@ format_conda_exe_name, has_docker_buildx, identify_conda_exe, - hash_files, ) if TYPE_CHECKING: diff --git a/tests/test_outputs.py b/tests/test_outputs.py index 7ac382c33..9eaa0ed23 100644 --- a/tests/test_outputs.py +++ b/tests/test_outputs.py @@ -3,7 +3,7 @@ import pytest -from constructor.build_outputs import dump_hash, _needed_hash_algorithms +from constructor.build_outputs import _needed_hash_algorithms, dump_hash TEST_FILES = { "test.txt": { @@ -55,7 +55,9 @@ def test_hash_dump(tmp_path, algorithm, context): ( pytest.param([], set(), id="neither info.json nor hash request"), pytest.param(["info.json"], {"sha256"}, id="info.json only"), - pytest.param([{"hash": {"algorithm": "md5"}}], {"md5"}, id="no info.json, only md5 requested"), + pytest.param( + [{"hash": {"algorithm": "md5"}}], {"md5"}, id="no info.json, only md5 requested" + ), pytest.param( ["info.json", {"hash": {"algorithm": "md5"}}], {"sha256", "md5"}, From 4f5dd0330ace1d88da42799868039e7b3e79e9b3 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 12 Aug 2026 11:15:40 -0700 Subject: [PATCH 11/18] Pre-commit fix --- 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 436c85523..3f3a06d7c 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,7 +1,7 @@ from __future__ import annotations -import hashlib import getpass +import hashlib import json import os import shutil From 6769a710549c2237c1881db03ba106cabea0fe9b Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 12 Aug 2026 14:38:32 -0700 Subject: [PATCH 12/18] Add news file --- constructor/build_outputs.py | 2 +- constructor/conda_interface.py | 5 +++-- news/1327-installer-hashes-to-info-json | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 news/1327-installer-hashes-to-info-json diff --git a/constructor/build_outputs.py b/constructor/build_outputs.py index dc3f712dc..4bd141d91 100644 --- a/constructor/build_outputs.py +++ b/constructor/build_outputs.py @@ -109,7 +109,7 @@ def dump_hash(info: dict, algorithm: str | None = None): outpath = Path(f"{installer}.{algo}") with open(outpath, "w", newline="\n") as f: - f.write(f"{info['_installer_hashes'][algo]} {installer.name}\n") + f.write(f"{info['_installer_hashes'][algo]} {installer_name}\n") outpaths.append(str(outpath.absolute())) diff --git a/constructor/conda_interface.py b/constructor/conda_interface.py index d40813165..e3861f854 100644 --- a/constructor/conda_interface.py +++ b/constructor/conda_interface.py @@ -168,8 +168,9 @@ def write_repodata(cache_dir, url, full_repodata, used_packages, info): data = deepcopy(full_repodata[original_key][original_package]) pkg_fn = join(info["_download_dir"], package) data["size"] = os.stat(pkg_fn).st_size - data["sha256"] = hash_files([pkg_fn], algorithm="sha256") - data["md5"] = hash_files([pkg_fn]) + hashes = hash_files([pkg_fn], ["sha256", "md5"]) + data["sha256"] = hashes["sha256"] + data["md5"] = hashes["md5"] used_repodata[key][package] = data # In conda <23.1, the first line of the JSON should contain cache metadata diff --git a/news/1327-installer-hashes-to-info-json b/news/1327-installer-hashes-to-info-json new file mode 100644 index 000000000..a191a5161 --- /dev/null +++ b/news/1327-installer-hashes-to-info-json @@ -0,0 +1,19 @@ +### Enhancements + +* Add installer hashes to `info.json` making it the single source of truth for installer checksums used in SBOM generation. (#1327) + +### Bug fixes + +* + +### Deprecations + +* + +### Docs + +* + +### Other + +* From f6a7f2adb83bf6fd35048fa4d0e0f26ed811b1c0 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 12 Aug 2026 15:03:23 -0700 Subject: [PATCH 13/18] Fix tests --- constructor/build_outputs.py | 4 ++-- tests/test_outputs.py | 26 +++++++++++++------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/constructor/build_outputs.py b/constructor/build_outputs.py index 4bd141d91..e19fc5a6e 100644 --- a/constructor/build_outputs.py +++ b/constructor/build_outputs.py @@ -71,7 +71,7 @@ def process_build_outputs(info: dict): if algorithms: info["_installer_hashes"] = hash_files( - info["_outpath"], + [info["_outpath"]], algorithms, ) @@ -109,7 +109,7 @@ def dump_hash(info: dict, algorithm: str | None = None): outpath = Path(f"{installer}.{algo}") with open(outpath, "w", newline="\n") as f: - f.write(f"{info['_installer_hashes'][algo]} {installer_name}\n") + f.write(f"{info['_installer_hashes'][algo]} {installer.name}\n") outpaths.append(str(outpath.absolute())) diff --git a/tests/test_outputs.py b/tests/test_outputs.py index 9eaa0ed23..a58bd2267 100644 --- a/tests/test_outputs.py +++ b/tests/test_outputs.py @@ -28,26 +28,26 @@ ), ) def test_hash_dump(tmp_path, algorithm, context): - info = {"_outpath": [], "_installer_hashes": {}} for file, data in TEST_FILES.items(): testfile = tmp_path / file testfile.write_text(data["content"]) - info["_outpath"].append(str(testfile)) - info["_installer_hashes"][str(testfile)] = {algo: data[algo] for algo in ("sha256", "md5")} + info = { + "_outpath": str(testfile), + "_installer_hashes": {algo: data[algo] for algo in ("sha256", "md5")}, + } with context: dump_hash(info, algorithm=algorithm) if isinstance(algorithm, str): algorithm = [algorithm] - for file in info["_outpath"]: - for algo in algorithm: - hashfile = Path(f"{file}.{algo}") - assert hashfile.exists() - with open(hashfile, newline="") as f: - content = f.read() - assert "\r" not in content - filehash, filename = content.strip().split() - assert filename == Path(file).name - assert filehash == TEST_FILES[filename][algo] + for algo in algorithm: + hashfile = Path(f"{testfile}.{algo}") + assert hashfile.exists() + with open(hashfile, newline="") as f: + content = f.read() + assert "\r" not in content + filehash, filename = content.strip().split() + assert filename == Path(file).name + assert filehash == TEST_FILES[filename][algo] @pytest.mark.parametrize( From 8b90819db09daf2fff1ba3a410baffe676000b18 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 12 Aug 2026 15:18:37 -0700 Subject: [PATCH 14/18] Update docs --- CONSTRUCT.md | 15 +++++++++++++++ docs/source/construct-yaml.md | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 935061b8c..23322a24b 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -670,6 +670,21 @@ Supports the same values as `extra_files`. Additional artifacts to be produced after building the installer. It expects either a list of strings or single-key dictionaries. +Requesting `info.json` adds an `_installer_hashes` property containing +the SHA256 digest of the generated installer. Hash algorithms requested +through a `hash` build output are also included in this property. + +For example: + +```json +"_installer_hashes": { + "sha256": "...", + "md5": "..." +} +``` + +The `hash` output continues to create separate checksum files. + Allowed strings / keys: `hash`, `info.json`, `licenses`, `lockfile`, `pkgs_list`. ### `uninstall_with_conda_exe` diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 935061b8c..23322a24b 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -670,6 +670,21 @@ Supports the same values as `extra_files`. Additional artifacts to be produced after building the installer. It expects either a list of strings or single-key dictionaries. +Requesting `info.json` adds an `_installer_hashes` property containing +the SHA256 digest of the generated installer. Hash algorithms requested +through a `hash` build output are also included in this property. + +For example: + +```json +"_installer_hashes": { + "sha256": "...", + "md5": "..." +} +``` + +The `hash` output continues to create separate checksum files. + Allowed strings / keys: `hash`, `info.json`, `licenses`, `lockfile`, `pkgs_list`. ### `uninstall_with_conda_exe` From ffebc34f71591d9438d43119ffd1d460fdd1a547 Mon Sep 17 00:00:00 2001 From: Jaida Rice <100002667+Jrice1317@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:03:05 -0700 Subject: [PATCH 15/18] Apply suggestions from code review Co-authored-by: Robin <34315751+lrandersson@users.noreply.github.com> --- constructor/build_outputs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/constructor/build_outputs.py b/constructor/build_outputs.py index e19fc5a6e..23185ddec 100644 --- a/constructor/build_outputs.py +++ b/constructor/build_outputs.py @@ -54,9 +54,9 @@ def _needed_hash_algorithms(info: dict) -> set[str]: output = _validate_output(output) name, config = output.popitem() - if name == "info.json": + if name == BuildOutputs.INFO_JSON: algorithms.add("sha256") - elif name == "hash": + elif name == BuildOutputs.HASH: algorithm = config.get("algorithm") if isinstance(algorithm, str): algorithms.add(algorithm) From f121a1537e1ff8d1d9609101032d0acbad06bbea Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 14 Aug 2026 12:12:47 -0700 Subject: [PATCH 16/18] Apply code review suggestions, add imports, and update docs --- CONSTRUCT.md | 15 --------------- constructor/_schema.py | 15 --------------- constructor/build_outputs.py | 1 + constructor/data/construct.schema.json | 2 +- constructor/utils.py | 2 +- docs/source/construct-yaml.md | 15 --------------- news/1327-installer-hashes-to-info-json | 2 +- tests/test_outputs.py | 1 - tests/test_utils.py | 13 +++++++++++++ 9 files changed, 17 insertions(+), 49 deletions(-) diff --git a/CONSTRUCT.md b/CONSTRUCT.md index 23322a24b..935061b8c 100644 --- a/CONSTRUCT.md +++ b/CONSTRUCT.md @@ -670,21 +670,6 @@ Supports the same values as `extra_files`. Additional artifacts to be produced after building the installer. It expects either a list of strings or single-key dictionaries. -Requesting `info.json` adds an `_installer_hashes` property containing -the SHA256 digest of the generated installer. Hash algorithms requested -through a `hash` build output are also included in this property. - -For example: - -```json -"_installer_hashes": { - "sha256": "...", - "md5": "..." -} -``` - -The `hash` output continues to create separate checksum files. - Allowed strings / keys: `hash`, `info.json`, `licenses`, `lockfile`, `pkgs_list`. ### `uninstall_with_conda_exe` diff --git a/constructor/_schema.py b/constructor/_schema.py index 5f6e6ed2c..45cc13651 100644 --- a/constructor/_schema.py +++ b/constructor/_schema.py @@ -857,21 +857,6 @@ class ConstructorConfiguration(BaseModel): Additional artifacts to be produced after building the installer. It expects either a list of strings or single-key dictionaries. - Requesting `info.json` adds an `_installer_hashes` property containing - the SHA256 digest of the generated installer. Hash algorithms requested - through a `hash` build output are also included in this property. - - For example: - - ```json - "_installer_hashes": {{ - "sha256": "...", - "md5": "..." - }} - ``` - - The `hash` output continues to create separate checksum files. - Allowed strings / keys: {}. """.format(", ".join(f"`{member.value}`" for member in BuildOutputs)) ), diff --git a/constructor/build_outputs.py b/constructor/build_outputs.py index 23185ddec..1822514c9 100644 --- a/constructor/build_outputs.py +++ b/constructor/build_outputs.py @@ -16,6 +16,7 @@ from conda.exports import default_prefix from . import __version__ +from ._schema import BuildOutputs from .conda_interface import VersionOrder from .utils import hash_files diff --git a/constructor/data/construct.schema.json b/constructor/data/construct.schema.json index 08f5797e7..da27759dd 100644 --- a/constructor/data/construct.schema.json +++ b/constructor/data/construct.schema.json @@ -432,7 +432,7 @@ }, "build_outputs": { "default": [], - "description": "Additional artifacts to be produced after building the installer. It expects either a list of strings or single-key dictionaries.\nRequesting `info.json` adds an `_installer_hashes` property containing the SHA256 digest of the generated installer. Hash algorithms requested through a `hash` build output are also included in this property.\nFor example:\n```json\n\"_installer_hashes\": {\n \"sha256\": \"...\",\n \"md5\": \"...\"\n}\n```\nThe `hash` output continues to create separate checksum files.\nAllowed strings / keys: `hash`, `info.json`, `licenses`, `lockfile`, `pkgs_list`.", + "description": "Additional artifacts to be produced after building the installer. It expects either a list of strings or single-key dictionaries.\nAllowed strings / keys: `hash`, `info.json`, `licenses`, `lockfile`, `pkgs_list`.", "items": { "anyOf": [ { diff --git a/constructor/utils.py b/constructor/utils.py index f9f01c07a..ee6fdfdca 100644 --- a/constructor/utils.py +++ b/constructor/utils.py @@ -98,7 +98,7 @@ def hash_files(paths: list[Path], algorithms: list[str] | str) -> dict[str, str] algorithms = set(algorithms) invalid = algorithms.difference(hashlib.algorithms_available) if invalid: - raise ValueError(f"Invalid algorithm: {', '.join(sorted(invalid))}") + raise ValueError(f"Invalid algorithm(s): {', '.join(sorted(invalid))}") BUFFER_SIZE = 65536 diff --git a/docs/source/construct-yaml.md b/docs/source/construct-yaml.md index 23322a24b..935061b8c 100644 --- a/docs/source/construct-yaml.md +++ b/docs/source/construct-yaml.md @@ -670,21 +670,6 @@ Supports the same values as `extra_files`. Additional artifacts to be produced after building the installer. It expects either a list of strings or single-key dictionaries. -Requesting `info.json` adds an `_installer_hashes` property containing -the SHA256 digest of the generated installer. Hash algorithms requested -through a `hash` build output are also included in this property. - -For example: - -```json -"_installer_hashes": { - "sha256": "...", - "md5": "..." -} -``` - -The `hash` output continues to create separate checksum files. - Allowed strings / keys: `hash`, `info.json`, `licenses`, `lockfile`, `pkgs_list`. ### `uninstall_with_conda_exe` diff --git a/news/1327-installer-hashes-to-info-json b/news/1327-installer-hashes-to-info-json index a191a5161..204640d2b 100644 --- a/news/1327-installer-hashes-to-info-json +++ b/news/1327-installer-hashes-to-info-json @@ -1,6 +1,6 @@ ### Enhancements -* Add installer hashes to `info.json` making it the single source of truth for installer checksums used in SBOM generation. (#1327) +* Add installer hashes to `info.json` making it the single source of truth for installer checksums. (#1327) ### Bug fixes diff --git a/tests/test_outputs.py b/tests/test_outputs.py index a58bd2267..155c9be97 100644 --- a/tests/test_outputs.py +++ b/tests/test_outputs.py @@ -22,7 +22,6 @@ @pytest.mark.parametrize( "algorithm,context", ( - pytest.param("not cached", pytest.raises(KeyError), id="invalid algorithm"), pytest.param("sha256", nullcontext(), id="string"), pytest.param(["sha256", "md5"], nullcontext(), id="list"), ), diff --git a/tests/test_utils.py b/tests/test_utils.py index c646589b3..6fa07efeb 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -6,6 +6,7 @@ get_condarc_content, make_VIProductVersion, normalize_path, + hash_files, ) @@ -107,3 +108,15 @@ def test_get_condarc_content_returns_none(): # write_condarc without channels should also return None info = {"write_condarc": True} assert get_condarc_content(info) is None + + +def test_invalid_algorithm(tmp_path): + """Test that hash_files raises a ValueError for invalid algorithm names.""" + + path = tmp_path / "test.txt" + path.write_text("test string") + try: + hash_files([path], "bad_algorithm") + assert False, "Expected ValueError for invalid algorithm" + except ValueError as e: + assert "bad_algorithm" in str(e) From 15246b61a5ff6ee302ac892db41eed0af3a34ae5 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 14 Aug 2026 14:27:33 -0700 Subject: [PATCH 17/18] Be more explicit with errors --- constructor/build_outputs.py | 9 ++++++++- constructor/utils.py | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/constructor/build_outputs.py b/constructor/build_outputs.py index 1822514c9..9227cd46e 100644 --- a/constructor/build_outputs.py +++ b/constructor/build_outputs.py @@ -107,10 +107,17 @@ def dump_hash(info: dict, algorithm: str | None = None): outpaths = [] for algo in algorithms: + try: + filehash = info["_installer_hashes"][algo] + except KeyError: + raise RuntimeError( + f"Hash for algorithm '{algo}' not found. " + f"Available algorithms: {', '.join(info.get('_installer_hashes', {}).keys())}" + ) from None outpath = Path(f"{installer}.{algo}") with open(outpath, "w", newline="\n") as f: - f.write(f"{info['_installer_hashes'][algo]} {installer.name}\n") + f.write(f"{filehash} {installer.name}\n") outpaths.append(str(outpath.absolute())) diff --git a/constructor/utils.py b/constructor/utils.py index ee6fdfdca..e03e7e846 100644 --- a/constructor/utils.py +++ b/constructor/utils.py @@ -98,7 +98,8 @@ def hash_files(paths: list[Path], algorithms: list[str] | str) -> dict[str, str] algorithms = set(algorithms) invalid = algorithms.difference(hashlib.algorithms_available) if invalid: - raise ValueError(f"Invalid algorithm(s): {', '.join(sorted(invalid))}") + invalid_algo = "algorithm" if len(invalid) == 1 else "algorithms" + raise ValueError(f"Invalid {invalid_algo}: {', '.join(sorted(invalid))}") BUFFER_SIZE = 65536 From 460997dd487e4c40b19c04f6ffbbcbb3e6ca26fc Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Mon, 17 Aug 2026 09:03:20 -0700 Subject: [PATCH 18/18] Fix pre-commit error, import and use pytest --- tests/test_utils.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 6fa07efeb..01486374b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,12 +1,14 @@ from os import sep +import pytest + from constructor.utils import ( bat_echo_esc, bat_env_var_esc, get_condarc_content, + hash_files, make_VIProductVersion, normalize_path, - hash_files, ) @@ -115,8 +117,5 @@ def test_invalid_algorithm(tmp_path): path = tmp_path / "test.txt" path.write_text("test string") - try: + with pytest.raises(ValueError, match="bad_algorithm"): hash_files([path], "bad_algorithm") - assert False, "Expected ValueError for invalid algorithm" - except ValueError as e: - assert "bad_algorithm" in str(e)