Repository: calkit/calkit

Issue:
Conda env check should support less specific versions
Currently this doesn't support vaguer definitions or greater than/less than specifications.

Discussion:

Solution patch:
diff --git a/calkit/__init__.py b/calkit/__init__.py
index b5abafa5..c23ba882 100644
--- a/calkit/__init__.py
+++ b/calkit/__init__.py
@@ -1,4 +1,4 @@
-__version__ = "0.11.0"
+__version__ = "0.11.1"
 
 from .core import *
 from . import git
diff --git a/calkit/conda.py b/calkit/conda.py
index 6621d552..ac86ea0c 100644
--- a/calkit/conda.py
+++ b/calkit/conda.py
@@ -2,14 +2,43 @@
 
 import json
 import os
+import re
 import subprocess
 
+from packaging.specifiers import SpecifierSet
+from packaging.version import Version
 from pydantic import BaseModel
 
 import calkit
 from calkit import ryaml
 
 
+def _check_single(req: str, actual: str, conda: bool = False) -> bool:
+    """Helper function for checking actual versions against requirements."""
+    req_name = re.split("[=<>]", req)[0]
+    req_spec = req.removeprefix(req_name)
+    if conda and req_spec.startswith("="):
+        req_spec = "=" + req_spec
+        if not req_spec.endswith(".*"):
+            req_spec += ".*"
+    actual_name, actual_vers = re.split("[=<>]+", actual, maxsplit=1)
+    if actual_name != req_name:
+        return False
+    actual_spec = actual.removeprefix(actual_name)
+    if conda and actual_spec.startswith("="):
+        actual_spec = "=" + actual_spec
+    version = Version(actual_vers)
+    spec = SpecifierSet(req_spec)
+    return spec.contains(version)
+
+
+def _check_list(req: str, actual: list[str], conda: bool = False) -> bool:
+    for installed in actual:
+        if _check_single(req, installed, conda=conda):
+            return True
+    return False
+
+
 class EnvCheckResult(BaseModel):
     env_exists: bool | None = None
     env_needs_export: bool | None = None
@@ -127,45 +156,23 @@ def check_env(
                 required_conda_deps.append(dep.replace("==", "="))
         log_func("Checking conda dependencies")
         for dep in required_conda_deps:
-            dep_split = dep.split("=")
-            package = dep_split[0]
-            if len(dep_split) > 1:
-                version = dep_split[1]
-            else:
-                version = None
-            if version is not None and dep not in existing_conda_deps:
+            is_okay = _check_list(
+                req=dep, actual=existing_conda_deps, conda=True
+            )
+            if not is_okay:
                 log_func(f"Found missing dependency: {dep}")
                 env_needs_rebuild = True
                 break
-            elif version is None:
-                # TODO: This does not handle specification of only major or
-                # major+minor version
-                if package not in [
-                    d.split("=")[0] for d in existing_conda_deps
-                ]:
-                    log_func(f"Found missing dependency: {dep}")
-                    env_needs_rebuild = True
-                    break
         if not env_needs_rebuild and not relaxed:
             log_func("Checking pip dependencies")
             for dep in required_pip_deps:
-                dep_split = dep.split("==")
-                package = dep_split[0]
-                if len(dep_split) > 1:
-                    version = dep_split[1]
-                else:
-                    version = None
-                if version is not None and dep not in existing_pip_deps:
+                is_okay = _check_list(
+                    req=dep, actual=existing_pip_deps, conda=False
+                )
+                if not is_okay:
                     env_needs_rebuild = True
                     log_func(f"Found missing dependency: {dep}")
                     break
-                elif version is None:
-                    if package not in [
-                        d.split("==")[0] for d in existing_pip_deps
-                    ]:
-                        log_func(f"Found missing dependency: {dep}")
-                        env_needs_rebuild = True
-                        break
     if env_needs_rebuild:
         res.env_needs_rebuild = True
         log_func(f"Rebuilding {env_name} since it does not match spec")


Test patch:
diff --git a/calkit/tests/test_conda.py b/calkit/tests/test_conda.py
index f2e01e78..14d7da4d 100644
--- a/calkit/tests/test_conda.py
+++ b/calkit/tests/test_conda.py
@@ -5,7 +5,25 @@
 
 import pytest
 
-from calkit.conda import check_env
+from calkit.conda import _check_list, _check_single, check_env
+
+
+def test_check_single():
+    assert _check_single("python=3.12", "python=3.12.18", conda=True)
+    assert _check_single("python=3", "python=3.12.18", conda=True)
+    assert _check_single("python=3.12.18", "python=3.12.18", conda=True)
+    assert _check_single("python>=3.12,<3.13", "python==3.12.18", conda=False)
+
+
+def test_check_list():
+    installed = ["python=3.12.1", "numpy=1.0.11"]
+    assert _check_list("python=3", installed, conda=True)
+    assert _check_list("numpy", installed, conda=True)
+    assert not _check_list("pandas", installed, conda=True)
+    installed = ["python==3.12.1", "numpy==1.0.11"]
+    assert _check_list("python>=3", installed, conda=False)
+    assert _check_list("numpy", installed, conda=False)
+    assert not _check_list("pandas", installed, conda=False)
 
 
 def delete_env(name: str):
@@ -124,3 +142,22 @@ def test_check_env(tmp_dir, env_name):
     )
     res = check_env(relaxed=True)
     assert not res.env_needs_rebuild
+    # Make sure we can handle other ways of specifying versions
+    subprocess.check_call(
+        [
+            "calkit",
+            "new",
+            "conda-env",
+            "--overwrite",
+            "-n",
+            env_name,
+            "python=3.12",
+            "--pip",
+            "numpy>=1",
+        ]
+    )
+    res = check_env()
+    assert res.env_needs_rebuild
+    res = check_env()
+    assert not res.env_needs_export
+    assert not res.env_needs_rebuild