From 10f7272d4cdfbab3a95100172d2a2a333472e9ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6ster?= Date: Wed, 21 Oct 2026 12:49:12 +0200 Subject: [PATCH 01/13] feat: add --ensure flag to pin-conda-envs, allowing to specify package version constraints to be ensured by (re-)pinning --- .../workflow_developers/update_conda_envs.rst | 12 ++++ snakedeploy/client.py | 8 +++ snakedeploy/conda.py | 63 +++++++++++++++++-- tests/test_conda.py | 63 +++++++++++++++++++ 4 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 tests/test_conda.py diff --git a/docs/workflow_developers/update_conda_envs.rst b/docs/workflow_developers/update_conda_envs.rst index a43356f..aba371c 100644 --- a/docs/workflow_developers/update_conda_envs.rst +++ b/docs/workflow_developers/update_conda_envs.rst @@ -44,3 +44,15 @@ Or use the ``pin-conda-envs`` command after updating the conda environment files .. code:: console $ snakedeploy pin-conda-envs workflow/envs/*.yaml + +The ``--ensure`` option can ensure that package version constraints are +satisfied by the pinned environment. If all constraints are already satisfied +by the environment resolved from the YAML file, pinning is skipped. Otherwise +the environment is pinned (or re-pinned), and a warning is printed if the +result still does not satisfy every constraint. Constraints use a package +name followed by a Conda version specifier; quote specs containing operators +so the shell does not interpret them. Specify the option once per constraint: + +.. code:: console + + $ snakedeploy pin-conda-envs workflow/envs/*.yaml --ensure 'python>=3.12' --ensure 'numpy>=2' diff --git a/snakedeploy/client.py b/snakedeploy/client.py index 91a0f42..6923b0e 100644 --- a/snakedeploy/client.py +++ b/snakedeploy/client.py @@ -146,6 +146,13 @@ def get_parser(): default="conda", help="Conda frontend to use.", ) + pin_conda_envs.add_argument( + "--ensure", + action="append", + metavar="PACKAGE_SPEC", + help="Ensure a package version constraint is satisfied, pinning if needed. " + "Can be specified multiple times (e.g. --ensure 'python>=3.12').", + ) pin_conda_envs.add_argument( "--create-prs", action="store_true", @@ -316,6 +323,7 @@ def help(return_code=0): entity_regex=args.entity_regex, pr_add_label=args.pr_add_label, warn_on_error=args.warn_on_error, + ensure=args.ensure, ) elif args.subcommand == "update-conda-envs": update_conda_envs( diff --git a/snakedeploy/conda.py b/snakedeploy/conda.py index fb1133b..5cb456a 100644 --- a/snakedeploy/conda.py +++ b/snakedeploy/conda.py @@ -11,13 +11,34 @@ import yaml from packaging import version as packaging_version -from snakedeploy.conda_version import VersionOrder -from snakedeploy.exceptions import UserError +from snakedeploy.conda_version import VersionOrder, VersionSpec +from snakedeploy.exceptions import InvalidVersionSpec, UserError from snakedeploy.logger import logger from snakedeploy.prs import PR, get_repo from snakedeploy.utils import YamlDumper +def _parse_ensure_constraints(constraints): + parsed_constraints = [] + spec_re = re.compile( + r"^\s*([A-Za-z0-9_.-]+)\s*(==|!=|~=|<=|>=|=|<|>)\s*(\S+)\s*$" + ) + for constraint in constraints or (): + match = spec_re.match(constraint) + if match is None: + raise UserError( + f"Invalid --ensure constraint '{constraint}'; expected a package name " + "and version specifier, e.g. 'python<3.12'." + ) + package, operator, version = match.groups() + version_spec = operator + version + try: + parsed_constraints.append((package.lower(), VersionSpec(version_spec))) + except InvalidVersionSpec as e: + raise UserError(f"Invalid --ensure constraint '{constraint}': {e}") from e + return parsed_constraints + + def pin_conda_envs( conda_env_paths: list, conda_frontend="mamba", @@ -25,9 +46,11 @@ def pin_conda_envs( pr_add_label=False, entity_regex=None, warn_on_error=False, + ensure=None, ): """Pin given conda envs by creating ..pin.txt files with explicit URLs for all packages in each env.""" + ensure = _parse_ensure_constraints(ensure) return CondaEnvProcessor(conda_frontend=conda_frontend).process( conda_env_paths, update_envs=False, @@ -36,6 +59,7 @@ def pin_conda_envs( pr_add_label=pr_add_label, entity_regex=entity_regex, warn_on_error=warn_on_error, + ensure=ensure, ) @@ -82,6 +106,7 @@ def process( pr_add_label: bool = False, entity_regex: str | None = None, warn_on_error: bool = False, + ensure=None, ): repo = None if create_prs: @@ -127,7 +152,7 @@ def process( or not self.get_pin_file_path(conda_env_path).exists() ): logger.info(f"Pinning {conda_env_path}...") - self.update_pinning(conda_env_path, pr) + self.update_pinning(conda_env_path, pr, ensure=ensure) except sp.CalledProcessError as e: msg = f"Failed for conda env {conda_env_path}:\n{e.stderr}\n{e.stdout}" if warn_on_error: @@ -248,7 +273,7 @@ def downgraded(): def get_pin_file_path(self, conda_env_path): return Path(conda_env_path).with_suffix(f".{self.info['platform']}.pin.txt") - def update_pinning(self, conda_env_path, pr=None): + def update_pinning(self, conda_env_path, pr=None, ensure=None): pin_file = self.get_pin_file_path(conda_env_path) old_content = None updated = False @@ -258,6 +283,27 @@ def update_pinning(self, conda_env_path, pr=None): with tempfile.TemporaryDirectory(dir=".", prefix=".") as tmpdir: self.exec_conda(f"env create --prefix {tmpdir} --file {conda_env_path}") + unsatisfied_constraints = [] + if ensure: + packages = json.loads( + self.exec_conda(f"list --json --prefix {tmpdir}").stdout + ) + package_versions = { + package["name"].lower(): package["version"] for package in packages + } + unsatisfied_constraints = [ + (package, version_spec) + for package, version_spec in ensure + if package not in package_versions + or not version_spec.match(package_versions[package]) + ] + if not unsatisfied_constraints: + logger.info( + f"All --ensure constraints are satisfied for {conda_env_path}; " + "skipping pinning." + ) + self.exec_conda(f"env remove --prefix {tmpdir} -y") + return self.exec_conda( f"list --explicit --md5 --prefix {tmpdir} > {tmpdir}/pin.txt" ) @@ -279,6 +325,15 @@ def update_pinning(self, conda_env_path, pr=None): is_updated=old_content is not None, msg=msg, ) + if unsatisfied_constraints: + details = ", ".join( + f"{package}{version_spec.spec}" + for package, version_spec in unsatisfied_constraints + ) + logger.warning( + f"Could not satisfy --ensure constraints for {conda_env_path} " + f"after pinning: {details}." + ) self.exec_conda(f"env remove --prefix {tmpdir} -y") def exec_conda(self, subcmd): diff --git a/tests/test_conda.py b/tests/test_conda.py new file mode 100644 index 0000000..f8ec3f2 --- /dev/null +++ b/tests/test_conda.py @@ -0,0 +1,63 @@ +import json +from types import SimpleNamespace + +import pytest + +from snakedeploy.conda import CondaEnvProcessor, _parse_ensure_constraints +from snakedeploy.exceptions import UserError + + +@pytest.mark.parametrize( + ("constraint", "version", "matches"), + [ + ("python<3.12", "3.11", True), + ("python >=3.12", "3.11", False), + ("python=3.11", "3.11.0", True), + ], +) +def test_parse_ensure_constraints(constraint, version, matches): + package, version_spec = _parse_ensure_constraints([constraint])[0] + + assert package == "python" + assert version_spec.match(version) is matches + + +def test_parse_invalid_ensure_constraint(): + with pytest.raises(UserError, match="Invalid --ensure constraint"): + _parse_ensure_constraints(["python 3.12"]) + + +@pytest.mark.parametrize( + ("constraint", "should_pin"), + [("python<3.12", False), ("python>=3.12", True)], +) +def test_update_pinning_only_when_ensure_constraint_violated( + tmp_path, monkeypatch, constraint, should_pin +): + monkeypatch.chdir(tmp_path) + processor = CondaEnvProcessor.__new__(CondaEnvProcessor) + processor.info = {"platform": "linux-64"} + commands = [] + + def exec_conda(command): + commands.append(command) + if command.startswith("list --json"): + return SimpleNamespace( + stdout=json.dumps([{"name": "python", "version": "3.11.0"}]) + ) + if "list --explicit" in command: + pin_path = command.split(" > ", maxsplit=1)[1] + (tmp_path / pin_path).write_text("@EXPLICIT\n") + return SimpleNamespace(stdout="") + + processor.exec_conda = exec_conda + ensure = _parse_ensure_constraints([constraint]) + warnings = [] + monkeypatch.setattr("snakedeploy.conda.logger.warning", warnings.append) + + processor.update_pinning("environment.yaml", ensure=ensure) + + pin_file = tmp_path / "environment.linux-64.pin.txt" + assert pin_file.exists() is should_pin + assert any("list --explicit" in command for command in commands) is should_pin + assert bool(warnings) is should_pin From 85d8f89c93f3c568702fd59101332c7798c52b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6ster?= Date: Wed, 21 Oct 2026 12:54:00 +0200 Subject: [PATCH 02/13] do not require ensured package to be present, just to match the version --- snakedeploy/client.py | 1 + snakedeploy/conda.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/snakedeploy/client.py b/snakedeploy/client.py index 6923b0e..c7257cd 100644 --- a/snakedeploy/client.py +++ b/snakedeploy/client.py @@ -151,6 +151,7 @@ def get_parser(): action="append", metavar="PACKAGE_SPEC", help="Ensure a package version constraint is satisfied, pinning if needed. " + "Ensured packages don't need to be present, just their version constraints need to be satisfied. " "Can be specified multiple times (e.g. --ensure 'python>=3.12').", ) pin_conda_envs.add_argument( diff --git a/snakedeploy/conda.py b/snakedeploy/conda.py index 5cb456a..f46e49d 100644 --- a/snakedeploy/conda.py +++ b/snakedeploy/conda.py @@ -294,8 +294,8 @@ def update_pinning(self, conda_env_path, pr=None, ensure=None): unsatisfied_constraints = [ (package, version_spec) for package, version_spec in ensure - if package not in package_versions - or not version_spec.match(package_versions[package]) + if package in package_versions + and not version_spec.match(package_versions[package]) ] if not unsatisfied_constraints: logger.info( From 748198ebef140444e5ff9cc2d3064a3b6591ca39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6ster?= Date: Wed, 21 Oct 2026 17:41:51 +0200 Subject: [PATCH 03/13] fix --- snakedeploy/conda.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/snakedeploy/conda.py b/snakedeploy/conda.py index f46e49d..09d81d3 100644 --- a/snakedeploy/conda.py +++ b/snakedeploy/conda.py @@ -282,7 +282,10 @@ def update_pinning(self, conda_env_path, pr=None, ensure=None): old_content = infile.read() with tempfile.TemporaryDirectory(dir=".", prefix=".") as tmpdir: - self.exec_conda(f"env create --prefix {tmpdir} --file {conda_env_path}") + if pin_file.exists(): + self.exec_conda(f"create --prefix {tmpdir} --file {pin_file}") + else: + self.exec_conda(f"env create --prefix {tmpdir} --file {conda_env_path}") unsatisfied_constraints = [] if ensure: packages = json.loads( @@ -297,9 +300,9 @@ def update_pinning(self, conda_env_path, pr=None, ensure=None): if package in package_versions and not version_spec.match(package_versions[package]) ] - if not unsatisfied_constraints: + if not unsatisfied_constraints and pin_file.exists(): logger.info( - f"All --ensure constraints are satisfied for {conda_env_path}; " + f"All --ensure constraints are satisfied for {conda_env_path} and {pin_file}; " "skipping pinning." ) self.exec_conda(f"env remove --prefix {tmpdir} -y") From f602908d6027114369051590ef6f028da0f402c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6ster?= Date: Wed, 21 Oct 2026 17:42:06 +0200 Subject: [PATCH 04/13] fmt --- snakedeploy/conda.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/snakedeploy/conda.py b/snakedeploy/conda.py index 09d81d3..4c9af55 100644 --- a/snakedeploy/conda.py +++ b/snakedeploy/conda.py @@ -20,9 +20,7 @@ def _parse_ensure_constraints(constraints): parsed_constraints = [] - spec_re = re.compile( - r"^\s*([A-Za-z0-9_.-]+)\s*(==|!=|~=|<=|>=|=|<|>)\s*(\S+)\s*$" - ) + spec_re = re.compile(r"^\s*([A-Za-z0-9_.-]+)\s*(==|!=|~=|<=|>=|=|<|>)\s*(\S+)\s*$") for constraint in constraints or (): match = spec_re.match(constraint) if match is None: From 15630e4a174d6d07d0093fbca88e8b1e3410d9ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6ster?= Date: Sat, 24 Oct 2026 12:21:16 +0200 Subject: [PATCH 05/13] error handling --- snakedeploy/client.py | 2 +- snakedeploy/conda.py | 32 ++++++++++++-------------------- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/snakedeploy/client.py b/snakedeploy/client.py index c7257cd..ef1d3e8 100644 --- a/snakedeploy/client.py +++ b/snakedeploy/client.py @@ -150,7 +150,7 @@ def get_parser(): "--ensure", action="append", metavar="PACKAGE_SPEC", - help="Ensure a package version constraint is satisfied, pinning if needed. " + help="Ensure a package version constraint is satisfied after pinning. " "Ensured packages don't need to be present, just their version constraints need to be satisfied. " "Can be specified multiple times (e.g. --ensure 'python>=3.12').", ) diff --git a/snakedeploy/conda.py b/snakedeploy/conda.py index 4c9af55..e3f67c4 100644 --- a/snakedeploy/conda.py +++ b/snakedeploy/conda.py @@ -280,10 +280,7 @@ def update_pinning(self, conda_env_path, pr=None, ensure=None): old_content = infile.read() with tempfile.TemporaryDirectory(dir=".", prefix=".") as tmpdir: - if pin_file.exists(): - self.exec_conda(f"create --prefix {tmpdir} --file {pin_file}") - else: - self.exec_conda(f"env create --prefix {tmpdir} --file {conda_env_path}") + self.exec_conda(f"env create --prefix {tmpdir} --file {conda_env_path}") unsatisfied_constraints = [] if ensure: packages = json.loads( @@ -298,19 +295,23 @@ def update_pinning(self, conda_env_path, pr=None, ensure=None): if package in package_versions and not version_spec.match(package_versions[package]) ] - if not unsatisfied_constraints and pin_file.exists(): - logger.info( - f"All --ensure constraints are satisfied for {conda_env_path} and {pin_file}; " - "skipping pinning." - ) - self.exec_conda(f"env remove --prefix {tmpdir} -y") - return self.exec_conda( f"list --explicit --md5 --prefix {tmpdir} > {tmpdir}/pin.txt" ) with open(f"{tmpdir}/pin.txt", "r") as infile: new_content = infile.read() updated = old_content != new_content + + if unsatisfied_constraints: + details = ", ".join( + f"{package}{version_spec.spec}" + for package, version_spec in unsatisfied_constraints + ) + raise UserError( + f"Could not satisfy --ensure constraints for {conda_env_path}" + f": {details}." + ) + if updated: with open(pin_file, "w") as outfile: outfile.write(new_content) @@ -326,15 +327,6 @@ def update_pinning(self, conda_env_path, pr=None, ensure=None): is_updated=old_content is not None, msg=msg, ) - if unsatisfied_constraints: - details = ", ".join( - f"{package}{version_spec.spec}" - for package, version_spec in unsatisfied_constraints - ) - logger.warning( - f"Could not satisfy --ensure constraints for {conda_env_path} " - f"after pinning: {details}." - ) self.exec_conda(f"env remove --prefix {tmpdir} -y") def exec_conda(self, subcmd): From c409d07f3cb822be879a024fd4cd1eed394c6cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6ster?= Date: Sat, 24 Oct 2026 13:18:21 +0200 Subject: [PATCH 06/13] refactoring, also allow --ensure in update envs --- snakedeploy/client.py | 20 +++-- snakedeploy/conda.py | 169 ++++++++++++++++++++++---------------- snakedeploy/exceptions.py | 22 +++++ 3 files changed, 133 insertions(+), 78 deletions(-) diff --git a/snakedeploy/client.py b/snakedeploy/client.py index ef1d3e8..02e5726 100644 --- a/snakedeploy/client.py +++ b/snakedeploy/client.py @@ -146,14 +146,7 @@ def get_parser(): default="conda", help="Conda frontend to use.", ) - pin_conda_envs.add_argument( - "--ensure", - action="append", - metavar="PACKAGE_SPEC", - help="Ensure a package version constraint is satisfied after pinning. " - "Ensured packages don't need to be present, just their version constraints need to be satisfied. " - "Can be specified multiple times (e.g. --ensure 'python>=3.12').", - ) + add_ensure_arg(pin_conda_envs) pin_conda_envs.add_argument( "--create-prs", action="store_true", @@ -206,6 +199,16 @@ def add_create_pr_args(subparser, entity: str): help="Add a label to the PR. Has to be used in combination with --entity-regex.", ) + def add_ensure_arg(subparser): + subparser.add_argument( + "--ensure", + action="append", + metavar="PACKAGE_SPEC", + help="Ensure a package version constraint is satisfied after updating. " + "Ensured packages don't need to be present, just their version constraints need to be satisfied. " + "Can be specified multiple times (e.g. --ensure 'python>=3.12').", + ) + update_conda_envs = subparsers.add_parser( "update-conda-envs", help="Update given conda environment definition files (in YAML format) " @@ -227,6 +230,7 @@ def add_create_pr_args(subparser, entity: str): action="store_true", help="also pin the updated environments (see pin-conda-envs subcommand).", ) + add_ensure_arg(update_conda_envs) add_create_pr_args(update_conda_envs, "environment") update_conda_envs.add_argument( "--warn-on-error", diff --git a/snakedeploy/conda.py b/snakedeploy/conda.py index e3f67c4..33fe724 100644 --- a/snakedeploy/conda.py +++ b/snakedeploy/conda.py @@ -12,7 +12,11 @@ from packaging import version as packaging_version from snakedeploy.conda_version import VersionOrder, VersionSpec -from snakedeploy.exceptions import InvalidVersionSpec, UserError +from snakedeploy.exceptions import ( + InvalidVersionSpec, + UnsatisfiedCondaConstraintsError, + UserError, +) from snakedeploy.logger import logger from snakedeploy.prs import PR, get_repo from snakedeploy.utils import YamlDumper @@ -48,7 +52,6 @@ def pin_conda_envs( ): """Pin given conda envs by creating ..pin.txt files with explicit URLs for all packages in each env.""" - ensure = _parse_ensure_constraints(ensure) return CondaEnvProcessor(conda_frontend=conda_frontend).process( conda_env_paths, update_envs=False, @@ -57,7 +60,7 @@ def pin_conda_envs( pr_add_label=pr_add_label, entity_regex=entity_regex, warn_on_error=warn_on_error, - ensure=ensure, + ensure=_parse_ensure_constraints(ensure), ) @@ -69,6 +72,7 @@ def update_conda_envs( pr_add_label=False, entity_regex=None, warn_on_error=False, + ensure=None, ): """Update the given conda env definitions such that all dependencies in them are set to the latest feasible versions.""" @@ -80,6 +84,7 @@ def update_conda_envs( pr_add_label=pr_add_label, entity_regex=entity_regex, warn_on_error=warn_on_error, + ensure=_parse_ensure_constraints(ensure), ) @@ -116,57 +121,71 @@ def process( logger.info( f"No conda envs found at given paths: {', '.join(conda_env_paths)}" ) + errors = [] for conda_env_path in conda_envs: - if create_prs: - if pr_add_label and not entity_regex: - raise UserError( - "Cannot add label to PR without --entity-regex specified." - ) - - assert pin_envs or update_envs, ( - "bug: either pin_envs or update_envs must be True" - ) - mode = "bump" if update_envs else "pin" - pr = PR( - f"perf: auto{mode} {conda_env_path}", - f"Automatic {mode} of {conda_env_path}.", - f"auto{mode}/{conda_env_path.replace('/', '-')}", - repo, - entity=conda_env_path, - label_entity_regex=entity_regex if pr_add_label else None, - ) - else: - pr = None try: - updated = False - if update_envs: - logger.info(f"Updating {conda_env_path}...") - updated = self.update_env( - conda_env_path, pr=pr, warn_on_error=warn_on_error + if create_prs: + if pr_add_label and not entity_regex: + raise UserError( + "Cannot add label to PR without --entity-regex specified." + ) + + assert pin_envs or update_envs, ( + "bug: either pin_envs or update_envs must be True" + ) + mode = "bump" if update_envs else "pin" + pr = PR( + f"perf: auto{mode} {conda_env_path}", + f"Automatic {mode} of {conda_env_path}.", + f"auto{mode}/{conda_env_path.replace('/', '-')}", + repo, + entity=conda_env_path, + label_entity_regex=entity_regex if pr_add_label else None, ) - if pin_envs and ( - not update_envs - or updated - or not self.get_pin_file_path(conda_env_path).exists() - ): - logger.info(f"Pinning {conda_env_path}...") - self.update_pinning(conda_env_path, pr, ensure=ensure) - except sp.CalledProcessError as e: - msg = f"Failed for conda env {conda_env_path}:\n{e.stderr}\n{e.stdout}" - if warn_on_error: - logger.warning(msg) - continue else: - raise UserError(msg) - if create_prs: - assert pr is not None - pr.create() + pr = None + try: + updated = False + if update_envs: + logger.info(f"Updating {conda_env_path}...") + updated = self.update_env( + conda_env_path, + pr=pr, + warn_on_error=warn_on_error, + ensure=ensure, + ) + if pin_envs and ( + not update_envs + or updated + or not self.get_pin_file_path(conda_env_path).exists() + ): + logger.info(f"Pinning {conda_env_path}...") + self.update_pinning( + conda_env_path, + pr, + ensure=ensure if not update_envs else None, + ) + except sp.CalledProcessError as e: + msg = f"Failed for conda env {conda_env_path}:\n{e.stderr}\n{e.stdout}" + if warn_on_error: + logger.warning(msg) + continue + else: + raise UserError(msg) + if create_prs: + assert pr is not None + pr.create() + except UserError as e: + errors.append(e) + if errors: + raise UserError(suberrors=errors) def update_env( self, conda_env_path, pr=None, warn_on_error=False, + ensure=None, ): spec_re = re.compile("(?P[^=>< ]+)[ =><]+") with open(conda_env_path, "r") as infile: @@ -193,15 +212,20 @@ def process_dependency(dep): ) ) - def get_pkg_versions(conda_env_path): + def get_pkg_versions(conda_env_path, ensure=None): with tempfile.TemporaryDirectory(dir=".", prefix=".") as tmpdir: self.exec_conda(f"env create --file {conda_env_path} --prefix {tmpdir}") - results = json.loads( - self.exec_conda(f"list --json --prefix {tmpdir}").stdout + package_info = self.get_package_info(tmpdir) + pkg_versions = self.get_package_versions(package_info) + unsatisfied_constraints = self.get_unsatified_constraints( + package_versions=pkg_versions, ensure=ensure ) - pkg_versions = {pkg["name"]: pkg["version"] for pkg in results} + if unsatisfied_constraints: + raise UnsatisfiedCondaConstraintsError( + unsatisfied_constraints, conda_env_path + ) self.exec_conda(f"env remove --prefix {tmpdir} -y") - return pkg_versions, results + return pkg_versions, package_info logger.info("Resolving prior versions...") prior_pkg_versions, _ = get_pkg_versions(conda_env_path) @@ -271,6 +295,24 @@ def downgraded(): def get_pin_file_path(self, conda_env_path): return Path(conda_env_path).with_suffix(f".{self.info['platform']}.pin.txt") + def get_package_info(self, prefix): + return json.loads(self.exec_conda(f"list --json --prefix {prefix}").stdout) + + def get_package_versions(self, package_info): + return {package["name"].lower(): package["version"] for package in package_info} + + def get_unsatified_constraints(self, package_versions, ensure=None): + unsatisfied_constraints = [] + if ensure: + unsatisfied_constraints = [ + (package, version_spec) + for package, version_spec in ensure + if package in package_versions + and not version_spec.match(package_versions[package]) + ] + + return unsatisfied_constraints + def update_pinning(self, conda_env_path, pr=None, ensure=None): pin_file = self.get_pin_file_path(conda_env_path) old_content = None @@ -281,20 +323,12 @@ def update_pinning(self, conda_env_path, pr=None, ensure=None): with tempfile.TemporaryDirectory(dir=".", prefix=".") as tmpdir: self.exec_conda(f"env create --prefix {tmpdir} --file {conda_env_path}") - unsatisfied_constraints = [] - if ensure: - packages = json.loads( - self.exec_conda(f"list --json --prefix {tmpdir}").stdout - ) - package_versions = { - package["name"].lower(): package["version"] for package in packages - } - unsatisfied_constraints = [ - (package, version_spec) - for package, version_spec in ensure - if package in package_versions - and not version_spec.match(package_versions[package]) - ] + unsatisfied_constraints = self.get_unsatified_constraints( + package_versions=self.get_package_versions( + self.get_package_info(tmpdir) + ), + ensure=ensure, + ) self.exec_conda( f"list --explicit --md5 --prefix {tmpdir} > {tmpdir}/pin.txt" ) @@ -303,13 +337,8 @@ def update_pinning(self, conda_env_path, pr=None, ensure=None): updated = old_content != new_content if unsatisfied_constraints: - details = ", ".join( - f"{package}{version_spec.spec}" - for package, version_spec in unsatisfied_constraints - ) - raise UserError( - f"Could not satisfy --ensure constraints for {conda_env_path}" - f": {details}." + raise UnsatisfiedCondaConstraintsError( + unsatisfied_constraints, conda_env_path ) if updated: diff --git a/snakedeploy/exceptions.py b/snakedeploy/exceptions.py index 69b250f..88c6139 100644 --- a/snakedeploy/exceptions.py +++ b/snakedeploy/exceptions.py @@ -42,3 +42,25 @@ def __init__(self, vspec, message): class UserError(ValueError): """An error that is presented as simple error message without a stack trace""" + + def __init__(self, message: str = "", suberrors: list | None = None): + if message is not None: + message = str(message) + if suberrors is not None: + if message: + message = f"{message.strip()}:\n" + message += "\n".join(str(err) for err in suberrors) + super().__init__(message) + + +class UnsatisfiedCondaConstraintsError(UserError): + """Thrown if there are unsatisfied conda constraints""" + + def __init__(self, unsatisfied_constraints, conda_env_path): + details = ", ".join( + f"{package}{version_spec.spec}" + for package, version_spec in unsatisfied_constraints + ) + super().__init__( + f"Could not satisfy --ensure constraints for {conda_env_path}: {details}." + ) From 30f94236b6e5e81884869ce779ceee3817c36c3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6ster?= Date: Sat, 24 Oct 2026 14:08:51 +0200 Subject: [PATCH 07/13] fixes --- pyproject.toml | 2 +- snakedeploy/client.py | 68 +++++++++++++++++++++---------------------- tests/test_conda.py | 19 ++++++------ 3 files changed, 45 insertions(+), 44 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2a77761..5ec4b55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ publish = { features = ["publish"] } [tool.pixi.feature.dev.tasks] format = "ruff format" check = "ruff check" -test = "/bin/bash tests/test_client.sh" +test = { cmd = "/bin/bash tests/test_client.sh && pytest -v", default-environment = "dev" } build-docs = "sphinx-build -b html docs/ docs/_build/html" diff --git a/snakedeploy/client.py b/snakedeploy/client.py index 02e5726..d3cc615 100644 --- a/snakedeploy/client.py +++ b/snakedeploy/client.py @@ -18,6 +18,40 @@ def get_parser(): + def add_ensure_arg(subparser): + subparser.add_argument( + "--ensure", + action="append", + metavar="PACKAGE_SPEC", + help="Ensure a package version constraint is satisfied after updating. " + "Ensured packages don't need to be present, just their version constraints need to be satisfied. " + "Can be specified multiple times (e.g. --ensure 'python>=3.12').", + ) + + def add_create_pr_args(subparser, entity: str): + subparser.add_argument( + "--create-prs", + action="store_true", + help=f"Create pull request for each updated {entity}. " + "Requires GITHUB_TOKEN and GITHUB_REPOSITORY (the repo name) and one of GITHUB_REF_NAME or GITHUB_BASE_REF " + "(preferring the latter, representing the target branch, e.g. main or master) environment " + "variables to be set (the latter three are available when running as github action). " + "In order to enable further actions (e.g. checks) to run on the generated PRs, the " + "provided GITHUB_TOKEN may not be the default github actions token. See here for " + "options: https://github.com/peter-evans/create-pull-request/blob/main/docs/concepts-guidelines.md#triggering-further-workflow-runs.", + ) + subparser.add_argument( + "--entity-regex", + help=f"Regular expression for deriving an entity name from the {entity} file name " + "(will be used for adding a label and for title and description). Has to contain a group 'entity' " + "(e.g. '(?P.+)/environment.yaml').", + ) + subparser.add_argument( + "--pr-add-label", + action="store_true", + help="Add a label to the PR. Has to be used in combination with --entity-regex.", + ) + parser = argparse.ArgumentParser( description="Snakedeploy: deployment and maintenance related toolbox for Snakemake.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, @@ -175,40 +209,6 @@ def get_parser(): help="Only warn if conda env evaluation fails and go on with the other envs.", ) - def add_create_pr_args(subparser, entity: str): - subparser.add_argument( - "--create-prs", - action="store_true", - help=f"Create pull request for each updated {entity}. " - "Requires GITHUB_TOKEN and GITHUB_REPOSITORY (the repo name) and one of GITHUB_REF_NAME or GITHUB_BASE_REF " - "(preferring the latter, representing the target branch, e.g. main or master) environment " - "variables to be set (the latter three are available when running as github action). " - "In order to enable further actions (e.g. checks) to run on the generated PRs, the " - "provided GITHUB_TOKEN may not be the default github actions token. See here for " - "options: https://github.com/peter-evans/create-pull-request/blob/main/docs/concepts-guidelines.md#triggering-further-workflow-runs.", - ) - subparser.add_argument( - "--entity-regex", - help=f"Regular expression for deriving an entity name from the {entity} file name " - "(will be used for adding a label and for title and description). Has to contain a group 'entity' " - "(e.g. '(?P.+)/environment.yaml').", - ) - subparser.add_argument( - "--pr-add-label", - action="store_true", - help="Add a label to the PR. Has to be used in combination with --entity-regex.", - ) - - def add_ensure_arg(subparser): - subparser.add_argument( - "--ensure", - action="append", - metavar="PACKAGE_SPEC", - help="Ensure a package version constraint is satisfied after updating. " - "Ensured packages don't need to be present, just their version constraints need to be satisfied. " - "Can be specified multiple times (e.g. --ensure 'python>=3.12').", - ) - update_conda_envs = subparsers.add_parser( "update-conda-envs", help="Update given conda environment definition files (in YAML format) " diff --git a/tests/test_conda.py b/tests/test_conda.py index f8ec3f2..5adb862 100644 --- a/tests/test_conda.py +++ b/tests/test_conda.py @@ -28,11 +28,11 @@ def test_parse_invalid_ensure_constraint(): @pytest.mark.parametrize( - ("constraint", "should_pin"), + ("constraint", "should_raise"), [("python<3.12", False), ("python>=3.12", True)], ) -def test_update_pinning_only_when_ensure_constraint_violated( - tmp_path, monkeypatch, constraint, should_pin +def test_update_pinning_always_updates_and_checks_ensure( + tmp_path, monkeypatch, constraint, should_raise ): monkeypatch.chdir(tmp_path) processor = CondaEnvProcessor.__new__(CondaEnvProcessor) @@ -52,12 +52,13 @@ def exec_conda(command): processor.exec_conda = exec_conda ensure = _parse_ensure_constraints([constraint]) - warnings = [] - monkeypatch.setattr("snakedeploy.conda.logger.warning", warnings.append) - processor.update_pinning("environment.yaml", ensure=ensure) + if should_raise: + with pytest.raises(UserError, match="Could not satisfy --ensure constraints"): + processor.update_pinning("environment.yaml", ensure=ensure) + else: + processor.update_pinning("environment.yaml", ensure=ensure) pin_file = tmp_path / "environment.linux-64.pin.txt" - assert pin_file.exists() is should_pin - assert any("list --explicit" in command for command in commands) is should_pin - assert bool(warnings) is should_pin + assert pin_file.exists() is not should_raise + assert any("list --explicit" in command for command in commands) From 879fa0b6806535dac7eddc22894890651627d99f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6ster?= Date: Sat, 24 Oct 2026 15:04:37 +0200 Subject: [PATCH 08/13] fixes --- snakedeploy/client.py | 60 ++++++++------- snakedeploy/conda.py | 166 ++++++++++++++++++++++++++---------------- 2 files changed, 136 insertions(+), 90 deletions(-) diff --git a/snakedeploy/client.py b/snakedeploy/client.py index d3cc615..859deb8 100644 --- a/snakedeploy/client.py +++ b/snakedeploy/client.py @@ -18,39 +18,44 @@ def get_parser(): - def add_ensure_arg(subparser): + def add_ensure_args(subparser): subparser.add_argument( "--ensure", - action="append", + nargs="+", metavar="PACKAGE_SPEC", help="Ensure a package version constraint is satisfied after updating. " "Ensured packages don't need to be present, just their version constraints need to be satisfied. " "Can be specified multiple times (e.g. --ensure 'python>=3.12').", ) + subparser.add_argument( + "--only-if-ensure-is-unsatisfied", + action="store_true", + help="Only update if the ensure constraints are not satisfied.", + ) def add_create_pr_args(subparser, entity: str): - subparser.add_argument( - "--create-prs", - action="store_true", - help=f"Create pull request for each updated {entity}. " - "Requires GITHUB_TOKEN and GITHUB_REPOSITORY (the repo name) and one of GITHUB_REF_NAME or GITHUB_BASE_REF " - "(preferring the latter, representing the target branch, e.g. main or master) environment " - "variables to be set (the latter three are available when running as github action). " - "In order to enable further actions (e.g. checks) to run on the generated PRs, the " - "provided GITHUB_TOKEN may not be the default github actions token. See here for " - "options: https://github.com/peter-evans/create-pull-request/blob/main/docs/concepts-guidelines.md#triggering-further-workflow-runs.", - ) - subparser.add_argument( - "--entity-regex", - help=f"Regular expression for deriving an entity name from the {entity} file name " - "(will be used for adding a label and for title and description). Has to contain a group 'entity' " - "(e.g. '(?P.+)/environment.yaml').", - ) - subparser.add_argument( - "--pr-add-label", - action="store_true", - help="Add a label to the PR. Has to be used in combination with --entity-regex.", - ) + subparser.add_argument( + "--create-prs", + action="store_true", + help=f"Create pull request for each updated {entity}. " + "Requires GITHUB_TOKEN and GITHUB_REPOSITORY (the repo name) and one of GITHUB_REF_NAME or GITHUB_BASE_REF " + "(preferring the latter, representing the target branch, e.g. main or master) environment " + "variables to be set (the latter three are available when running as github action). " + "In order to enable further actions (e.g. checks) to run on the generated PRs, the " + "provided GITHUB_TOKEN may not be the default github actions token. See here for " + "options: https://github.com/peter-evans/create-pull-request/blob/main/docs/concepts-guidelines.md#triggering-further-workflow-runs.", + ) + subparser.add_argument( + "--entity-regex", + help=f"Regular expression for deriving an entity name from the {entity} file name " + "(will be used for adding a label and for title and description). Has to contain a group 'entity' " + "(e.g. '(?P.+)/environment.yaml').", + ) + subparser.add_argument( + "--pr-add-label", + action="store_true", + help="Add a label to the PR. Has to be used in combination with --entity-regex.", + ) parser = argparse.ArgumentParser( description="Snakedeploy: deployment and maintenance related toolbox for Snakemake.", @@ -180,7 +185,7 @@ def add_create_pr_args(subparser, entity: str): default="conda", help="Conda frontend to use.", ) - add_ensure_arg(pin_conda_envs) + add_ensure_args(pin_conda_envs) pin_conda_envs.add_argument( "--create-prs", action="store_true", @@ -230,7 +235,7 @@ def add_create_pr_args(subparser, entity: str): action="store_true", help="also pin the updated environments (see pin-conda-envs subcommand).", ) - add_ensure_arg(update_conda_envs) + add_ensure_args(update_conda_envs) add_create_pr_args(update_conda_envs, "environment") update_conda_envs.add_argument( "--warn-on-error", @@ -329,6 +334,7 @@ def help(return_code=0): pr_add_label=args.pr_add_label, warn_on_error=args.warn_on_error, ensure=args.ensure, + only_if_ensure_is_unsatisfied=args.only_if_ensure_is_unsatisfied, ) elif args.subcommand == "update-conda-envs": update_conda_envs( @@ -339,6 +345,8 @@ def help(return_code=0): entity_regex=args.entity_regex, pr_add_label=args.pr_add_label, warn_on_error=args.warn_on_error, + ensure=args.ensure, + only_if_ensure_is_unsatisfied=args.only_if_ensure_is_unsatisfied, ) elif args.subcommand == "update-snakemake-wrappers": update_snakemake_wrappers( diff --git a/snakedeploy/conda.py b/snakedeploy/conda.py index 33fe724..3f51d78 100644 --- a/snakedeploy/conda.py +++ b/snakedeploy/conda.py @@ -49,6 +49,7 @@ def pin_conda_envs( entity_regex=None, warn_on_error=False, ensure=None, + only_if_ensure_is_unsatisfied=False, ): """Pin given conda envs by creating ..pin.txt files with explicit URLs for all packages in each env.""" @@ -61,6 +62,7 @@ def pin_conda_envs( entity_regex=entity_regex, warn_on_error=warn_on_error, ensure=_parse_ensure_constraints(ensure), + only_if_ensure_is_unsatisfied=only_if_ensure_is_unsatisfied, ) @@ -73,6 +75,7 @@ def update_conda_envs( entity_regex=None, warn_on_error=False, ensure=None, + only_if_ensure_is_unsatisfied=False, ): """Update the given conda env definitions such that all dependencies in them are set to the latest feasible versions.""" @@ -85,6 +88,7 @@ def update_conda_envs( entity_regex=entity_regex, warn_on_error=warn_on_error, ensure=_parse_ensure_constraints(ensure), + only_if_ensure_is_unsatisfied=only_if_ensure_is_unsatisfied, ) @@ -110,6 +114,7 @@ def process( entity_regex: str | None = None, warn_on_error: bool = False, ensure=None, + only_if_ensure_is_unsatisfied: bool = False, ): repo = None if create_prs: @@ -123,69 +128,72 @@ def process( ) errors = [] for conda_env_path in conda_envs: + if create_prs: + if pr_add_label and not entity_regex: + raise UserError( + "Cannot add label to PR without --entity-regex specified." + ) + + assert pin_envs or update_envs, ( + "bug: either pin_envs or update_envs must be True" + ) + mode = "bump" if update_envs else "pin" + pr = PR( + f"perf: auto{mode} {conda_env_path}", + f"Automatic {mode} of {conda_env_path}.", + f"auto{mode}/{conda_env_path.replace('/', '-')}", + repo, + entity=conda_env_path, + label_entity_regex=entity_regex if pr_add_label else None, + ) + else: + pr = None try: - if create_prs: - if pr_add_label and not entity_regex: - raise UserError( - "Cannot add label to PR without --entity-regex specified." - ) - - assert pin_envs or update_envs, ( - "bug: either pin_envs or update_envs must be True" + updated = False + if update_envs: + logger.info(f"Updating {conda_env_path}...") + updated = self.update_env( + conda_env_path, + pr=pr, + ensure=ensure, + only_if_ensure_is_unsatisfied=only_if_ensure_is_unsatisfied, ) - mode = "bump" if update_envs else "pin" - pr = PR( - f"perf: auto{mode} {conda_env_path}", - f"Automatic {mode} of {conda_env_path}.", - f"auto{mode}/{conda_env_path.replace('/', '-')}", - repo, - entity=conda_env_path, - label_entity_regex=entity_regex if pr_add_label else None, + if pin_envs and ( + not update_envs + or updated + or not self.get_pin_file_path(conda_env_path).exists() + ): + logger.info(f"Pinning {conda_env_path}...") + self.update_pinning( + conda_env_path, + pr, + ensure=ensure if not update_envs else None, + only_if_ensure_is_unsatisfied=only_if_ensure_is_unsatisfied + if not update_envs + else False, ) - else: - pr = None - try: - updated = False - if update_envs: - logger.info(f"Updating {conda_env_path}...") - updated = self.update_env( - conda_env_path, - pr=pr, - warn_on_error=warn_on_error, - ensure=ensure, - ) - if pin_envs and ( - not update_envs - or updated - or not self.get_pin_file_path(conda_env_path).exists() - ): - logger.info(f"Pinning {conda_env_path}...") - self.update_pinning( - conda_env_path, - pr, - ensure=ensure if not update_envs else None, - ) - except sp.CalledProcessError as e: - msg = f"Failed for conda env {conda_env_path}:\n{e.stderr}\n{e.stdout}" - if warn_on_error: - logger.warning(msg) - continue - else: - raise UserError(msg) - if create_prs: - assert pr is not None - pr.create() + except sp.CalledProcessError as e: + msg = f"Failed for conda env {conda_env_path}:\n{e.stderr}\n{e.stdout}" + errors.append(UserError(msg)) except UserError as e: errors.append(e) + if create_prs: + assert pr is not None + pr.create() + if errors: - raise UserError(suberrors=errors) + user_error = UserError(suberrors=errors) + if warn_on_error: + logger.warning(str(user_error)) + else: + raise user_error def update_env( self, conda_env_path, pr=None, - warn_on_error=False, ensure=None, + only_if_ensure_is_unsatisfied: bool = False, ): spec_re = re.compile("(?P[^=>< ]+)[ =><]+") with open(conda_env_path, "r") as infile: @@ -220,15 +228,15 @@ def get_pkg_versions(conda_env_path, ensure=None): unsatisfied_constraints = self.get_unsatified_constraints( package_versions=pkg_versions, ensure=ensure ) - if unsatisfied_constraints: - raise UnsatisfiedCondaConstraintsError( - unsatisfied_constraints, conda_env_path - ) self.exec_conda(f"env remove --prefix {tmpdir} -y") - return pkg_versions, package_info + return pkg_versions, package_info, unsatisfied_constraints logger.info("Resolving prior versions...") - prior_pkg_versions, _ = get_pkg_versions(conda_env_path) + prior_pkg_versions, _, prior_unsatisfied_constraints = get_pkg_versions( + conda_env_path, ensure=ensure + ) + if only_if_ensure_is_unsatisfied and not prior_unsatisfied_constraints: + return unconstrained_deps = process_dependencies(lambda name: name) unconstrained_env = dict(conda_env) @@ -239,7 +247,16 @@ def get_pkg_versions(conda_env_path, ensure=None): ) as tmpenv: yaml.dump(unconstrained_env, tmpenv, Dumper=YamlDumper) logger.info("Resolving posterior versions...") - posterior_pkg_versions, posterior_pkg_json = get_pkg_versions(tmpenv.name) + ( + posterior_pkg_versions, + posterior_pkg_json, + posterior_unsatisfied_constraints, + ) = get_pkg_versions(tmpenv.name, ensure=ensure) + + if posterior_unsatisfied_constraints: + raise UnsatisfiedCondaConstraintsError( + posterior_unsatisfied_constraints, conda_env_path + ) def downgraded(): for pkg_name, version in posterior_pkg_versions.items(): @@ -263,11 +280,7 @@ def downgraded(): f"would be downgraded: {', '.join(downgraded)}. Please consider a manual update " "of the environment." ) - if warn_on_error: - logger.warning(msg) - return False - else: - raise UserError(msg) + raise UserError(msg) orig_env = copy.deepcopy(conda_env) @@ -313,10 +326,35 @@ def get_unsatified_constraints(self, package_versions, ensure=None): return unsatisfied_constraints - def update_pinning(self, conda_env_path, pr=None, ensure=None): + def pinning_ensure_is_satisfied(self, conda_env_path, ensure): + pin_file = self.get_pin_file_path(conda_env_path) + if not pin_file.exists(): + return False + if ensure: + with tempfile.TemporaryDirectory(dir=".", prefix=".") as tmpdir: + self.exec_conda(f"create --prefix {tmpdir} --file {pin_file}") + unsatisfied_constraints = self.get_unsatified_constraints( + package_versions=self.get_package_versions( + self.get_package_info(tmpdir) + ), + ensure=ensure, + ) + self.exec_conda(f"env remove --prefix {tmpdir} -y") + if unsatisfied_constraints: + return False + return True + + def update_pinning( + self, conda_env_path, pr=None, ensure=None, only_if_ensure_is_unsatisfied=False + ): pin_file = self.get_pin_file_path(conda_env_path) old_content = None updated = False + if only_if_ensure_is_unsatisfied and self.pinning_ensure_is_satisfied( + conda_env_path, ensure + ): + return False + if pin_file.exists(): with open(pin_file, "r") as infile: old_content = infile.read() From c9ee5e51edb5c0dc442513af5134e97136e51d6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6ster?= Date: Sat, 24 Oct 2026 15:08:00 +0200 Subject: [PATCH 09/13] improve logging --- snakedeploy/conda.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/snakedeploy/conda.py b/snakedeploy/conda.py index 3f51d78..7ff952b 100644 --- a/snakedeploy/conda.py +++ b/snakedeploy/conda.py @@ -151,7 +151,6 @@ def process( try: updated = False if update_envs: - logger.info(f"Updating {conda_env_path}...") updated = self.update_env( conda_env_path, pr=pr, @@ -163,7 +162,6 @@ def process( or updated or not self.get_pin_file_path(conda_env_path).exists() ): - logger.info(f"Pinning {conda_env_path}...") self.update_pinning( conda_env_path, pr, @@ -195,6 +193,7 @@ def update_env( ensure=None, only_if_ensure_is_unsatisfied: bool = False, ): + logger.info(f"Checking {conda_env_path} for updates...") spec_re = re.compile("(?P[^=>< ]+)[ =><]+") with open(conda_env_path, "r") as infile: conda_env = yaml.load(infile, Loader=yaml.SafeLoader) @@ -300,9 +299,10 @@ def downgraded(): is_updated=True, msg=f"perf: update {conda_env_path}.", ) + logger.info(f"Updated {conda_env_path}.") return True else: - logger.info("No updates in env.") + logger.info(f"No updates in {conda_env_path}.") return False def get_pin_file_path(self, conda_env_path): @@ -347,6 +347,7 @@ def pinning_ensure_is_satisfied(self, conda_env_path, ensure): def update_pinning( self, conda_env_path, pr=None, ensure=None, only_if_ensure_is_unsatisfied=False ): + logger.info(f"Checking {conda_env_path} pinning...") pin_file = self.get_pin_file_path(conda_env_path) old_content = None updated = False @@ -380,6 +381,7 @@ def update_pinning( ) if updated: + logger.info(f"Updated pinning for {conda_env_path}.") with open(pin_file, "w") as outfile: outfile.write(new_content) if pr: @@ -394,6 +396,8 @@ def update_pinning( is_updated=old_content is not None, msg=msg, ) + else: + logger.info(f"No updates for pinning of {conda_env_path}.") self.exec_conda(f"env remove --prefix {tmpdir} -y") def exec_conda(self, subcmd): From 874007c68c9ec9b972657372a0068a4b42806f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6ster?= Date: Sat, 24 Oct 2026 15:26:57 +0200 Subject: [PATCH 10/13] docs --- .../workflow_developers/update_conda_envs.rst | 44 +++++++++++++++---- snakedeploy/conda.py | 2 + 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/docs/workflow_developers/update_conda_envs.rst b/docs/workflow_developers/update_conda_envs.rst index aba371c..4cf6de7 100644 --- a/docs/workflow_developers/update_conda_envs.rst +++ b/docs/workflow_developers/update_conda_envs.rst @@ -45,14 +45,42 @@ Or use the ``pin-conda-envs`` command after updating the conda environment files $ snakedeploy pin-conda-envs workflow/envs/*.yaml -The ``--ensure`` option can ensure that package version constraints are -satisfied by the pinned environment. If all constraints are already satisfied -by the environment resolved from the YAML file, pinning is skipped. Otherwise -the environment is pinned (or re-pinned), and a warning is printed if the -result still does not satisfy every constraint. Constraints use a package -name followed by a Conda version specifier; quote specs containing operators -so the shell does not interpret them. Specify the option once per constraint: +============================================== +Ensuring auxiliary package version constraints +============================================== + +Use ``--ensure`` to require package versions to satisfy one or more Conda +version constraints. The constraints are checked against the resolved +environment. If the resulting environment does not satisfy them, the command +fails with an error. + +Constraints use a package name +followed by a Conda version specifier; quote specs containing operators so +the shell does not interpret them. Specify the option once per constraint: + +.. code:: console + + $ snakedeploy pin-conda-envs workflow/envs/*.yaml --ensure 'python>=3.12' 'numpy>=2' + +By default, pinning is updated whenever ``pin-conda-envs`` is run. Add +``--only-if-ensure-is-unsatisfied`` to skip re-pinning when an existing pin +already satisfies all supplied ``--ensure`` constraints. If a constraint is +unsatisfied (or there is no pin file to check), the environment is resolved +and pinned, and the constraints are checked again. The flag is also available +for ``update-conda-envs``; there, the environment is only updated if its +currently resolved versions do not satisfy the constraints. For example: .. code:: console - $ snakedeploy pin-conda-envs workflow/envs/*.yaml --ensure 'python>=3.12' --ensure 'numpy>=2' + $ snakedeploy pin-conda-envs workflow/envs/*.yaml --ensure 'python>=3.12' --only-if-ensure-is-unsatisfied + $ snakedeploy update-conda-envs workflow/envs/*.yaml --ensure 'python>=3.12' --only-if-ensure-is-unsatisfied + +This can be helpful to e.g. ensure that all environments are safe. +For example, to ensure that all environment pins are safe with respect to the Python [emudhra issue](https://discuss.python.org/t/information-for-python-users-regarding-emudhra-emsign-ca-certificate-security-incident/109576), run: + +.. code:: console + + $ snakedeploy pin-conda-envs */*/environment.yaml */*/*/environment.yaml --ensure "certify>=2026.10.19" "pip>=26.2.2" "uv>=0.13.4" "sigstore>=4.5.1" "pixi>=0.82.1" "conda>=26.9.4" --only-if-ensure-is-unsatisfied + +If any environment pin does not satisfy the specified constraints, the command will fail with an error, indicating which constraints were not met. +You can then follow-up with ``snakedeploy update-conda-envs`` of the respective environment files, again using the same ``--ensure`` constraints, which will hopefully finally comply with the constraints. \ No newline at end of file diff --git a/snakedeploy/conda.py b/snakedeploy/conda.py index 7ff952b..8e68df4 100644 --- a/snakedeploy/conda.py +++ b/snakedeploy/conda.py @@ -354,6 +354,7 @@ def update_pinning( if only_if_ensure_is_unsatisfied and self.pinning_ensure_is_satisfied( conda_env_path, ensure ): + logger.info(f"Pinning for {conda_env_path} already satisfies --ensure.") return False if pin_file.exists(): @@ -376,6 +377,7 @@ def update_pinning( updated = old_content != new_content if unsatisfied_constraints: + logger.warning(f"Unsatisfied constraints for {conda_env_path} (full error upon termination).") raise UnsatisfiedCondaConstraintsError( unsatisfied_constraints, conda_env_path ) From 7833a9065a3c315e58bd0beef1ad3e12c9899f4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6ster?= Date: Sat, 24 Oct 2026 15:27:13 +0200 Subject: [PATCH 11/13] fmt --- snakedeploy/conda.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/snakedeploy/conda.py b/snakedeploy/conda.py index 8e68df4..fc71602 100644 --- a/snakedeploy/conda.py +++ b/snakedeploy/conda.py @@ -377,7 +377,9 @@ def update_pinning( updated = old_content != new_content if unsatisfied_constraints: - logger.warning(f"Unsatisfied constraints for {conda_env_path} (full error upon termination).") + logger.warning( + f"Unsatisfied constraints for {conda_env_path} (full error upon termination)." + ) raise UnsatisfiedCondaConstraintsError( unsatisfied_constraints, conda_env_path ) From b0cfda73de932672d3e4080612e208e5b224291f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6ster?= Date: Sat, 24 Oct 2026 15:33:12 +0200 Subject: [PATCH 12/13] fix --- snakedeploy/conda.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snakedeploy/conda.py b/snakedeploy/conda.py index fc71602..6970fcc 100644 --- a/snakedeploy/conda.py +++ b/snakedeploy/conda.py @@ -332,7 +332,7 @@ def pinning_ensure_is_satisfied(self, conda_env_path, ensure): return False if ensure: with tempfile.TemporaryDirectory(dir=".", prefix=".") as tmpdir: - self.exec_conda(f"create --prefix {tmpdir} --file {pin_file}") + self.exec_conda(f"create --yes --prefix {tmpdir} --file {pin_file}") unsatisfied_constraints = self.get_unsatified_constraints( package_versions=self.get_package_versions( self.get_package_info(tmpdir) From 8fea1a1a168e065d99cfcd7cf14aa6207c845009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20K=C3=B6ster?= Date: Wed, 28 Oct 2026 07:42:03 +0100 Subject: [PATCH 13/13] fix --- docs/workflow_developers/update_conda_envs.rst | 8 ++++++-- snakedeploy/conda.py | 7 ++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/workflow_developers/update_conda_envs.rst b/docs/workflow_developers/update_conda_envs.rst index 4cf6de7..16a3919 100644 --- a/docs/workflow_developers/update_conda_envs.rst +++ b/docs/workflow_developers/update_conda_envs.rst @@ -80,7 +80,11 @@ For example, to ensure that all environment pins are safe with respect to the Py .. code:: console - $ snakedeploy pin-conda-envs */*/environment.yaml */*/*/environment.yaml --ensure "certify>=2026.10.19" "pip>=26.2.2" "uv>=0.13.4" "sigstore>=4.5.1" "pixi>=0.82.1" "conda>=26.9.4" --only-if-ensure-is-unsatisfied + $ snakedeploy pin-conda-envs */*/environment.yaml */*/*/environment.yaml --ensure "certifi>=2026.10.19" "pip>=26.2.2" "uv>=0.13.4" "sigstore>=4.5.1" "pixi>=0.82.1" "conda>=26.9.4" --only-if-ensure-is-unsatisfied If any environment pin does not satisfy the specified constraints, the command will fail with an error, indicating which constraints were not met. -You can then follow-up with ``snakedeploy update-conda-envs`` of the respective environment files, again using the same ``--ensure`` constraints, which will hopefully finally comply with the constraints. \ No newline at end of file +You can then follow-up with ``snakedeploy update-conda-envs`` of the respective environment files, again using the same ``--ensure`` constraints, which will hopefully finally comply with the constraints. + +.. note:: + + Use --ensure as the last argument to avoid confusion of the argument parser with position arguments like the environment files. \ No newline at end of file diff --git a/snakedeploy/conda.py b/snakedeploy/conda.py index 6970fcc..9d11440 100644 --- a/snakedeploy/conda.py +++ b/snakedeploy/conda.py @@ -170,14 +170,15 @@ def process( if not update_envs else False, ) + + if create_prs: + assert pr is not None + pr.create() except sp.CalledProcessError as e: msg = f"Failed for conda env {conda_env_path}:\n{e.stderr}\n{e.stdout}" errors.append(UserError(msg)) except UserError as e: errors.append(e) - if create_prs: - assert pr is not None - pr.create() if errors: user_error = UserError(suberrors=errors)