Repository: conda/conda-lock
Pull request #802: Revendor grayskull to fix parsing of `~=`
Author: login: maresb
Created: 2025-05-14T22:35:54Z | Merged: 2025-05-15T07:41:35Z

<!-- Hello! Thanks for submitting a PR! To help make things go a bit more
     smoothly, we would appreciate it if you follow this template. -->

### Description

Closes #801.

Note that we also pull in a doctest that covers the bug reported in #801.

I ran into a few complications along the way. There were extraneous changes in version-related files. I am guessing that this is due to unpinned build environment dependencies like `setuptools-scm`. For Poetry I had to replace a version metadata call with a hard-coded string. I added a test to ensure that the hard-coded string remains consistent with the version string specified in `vendor.txt`.

Linked issue #801: tilde in dependency no longer parsing
### Checklist

- [x] I added a descriptive title
- [x] I searched open reports and couldn't find a duplicate

### What happened?

I have an environment.yml file that uses `~=` in the pip key. With conda-lock 2 this parsed without error, but version 3 produces the following error

```python
[ins] In [1]: import conda_lock

[ins] In [2]: conda_lock.__version__
Out[2]: '3.0.0'

[ins] In [3]: from conda_lock.src_parser.pyproject_toml import poetry_version_to_conda_version

[ins] In [4]: poetry_version_to_conda_version('~=1.36')
---------------------------------------------------------------------------
InvalidVersion                            Traceback (most recent call last)
Cell In[4], line 1
----> 1 poetry_version_to_conda_version('~=1.36')

File ~/Applications/miniconda3/lib/python3.12/site-packages/conda_lock/src_parser/pyproject_toml.py:108, in poetry_version_to_conda_version(version_string)
    106 if version_string is None:
    107     return None
--> 108 conda_version = encode_poetry_version(version_string)
    109 # Python's '===' is explicitly discouraged in PEP 440:
    110 # <https://peps.python.org/pep-0440/#arbitrary-equality>
    111 # Python's '==' seems equivalent to conda's '==':
    112 # <https://peps.python.org/pep-0440/#version-matching>
    113 # <https://docs.conda.io/projects/conda-build/en/latest/resources/package-spec.html#id4>
    114 conda_version = conda_version.replace("===", "==")

File ~/Applications/miniconda3/lib/python3.12/site-packages/conda_lock/_vendor/grayskull/strategy/parse_poetry_version.py:215, in encode_poetry_version(poetry_specifier)
    212 if poetry_clause.startswith("~"):
    213     # handle ~ operator
    214     target = poetry_clause[1:]
--> 215     floor = coerce_to_semver(target)
    216     ceiling = get_tilde_ceiling(target)
    217     conda_clauses.append(">=" + floor)

File ~/Applications/miniconda3/lib/python3.12/site-packages/conda_lock/_vendor/grayskull/strategy/parse_poetry_version.py:64, in coerce_to_semver(version)
     61 if semver.VersionInfo.is_valid(version):
     62     return version
---> 64 parsed_version = parse_version(version)
     65 vinfo = vdict_to_vinfo(parsed_version)
     66 return str(vinfo)

File ~/Applications/miniconda3/lib/python3.12/site-packages/conda_lock/_vendor/grayskull/strategy/parse_poetry_version.py:40, in parse_version(version)
     38 match = VERSION_REGEX.search(version)
     39 if not match:
---> 40     raise InvalidVersion(f"Could not parse version {version}.")
     42 return {
     43     key: None if value is None else int(value)
     44     for key, value in match.groupdict().items()
     45 }

InvalidVersion: Could not parse version =1.36.
```

### Additional Context

Here's the result from version 2

```python
[ins] In [4]: import conda_lock

[ins] In [5]: conda_lock.__version__
Out[5]: '2.5.6'

[ins] In [6]: from conda_lock.src_parser.pyproject_toml import poetry_version_to_conda_version

[ins] In [7]: poetry_version_to_conda_version('~=1.36')
Out[7]: '>=1.36,<1.37'
```
Linked issue comments: user: login: maresb
created_at: 2025-05-14T21:39:55Z
body: Thanks a lot @AlbertDeFusco for the clear report!

We vendored Grayskull's parsing of Poetry versions. Subsequently there seems to have been an upstream bugfix in https://github.com/conda/grayskull/pull/592 for this issue, plus more work in https://github.com/conda/grayskull/pull/596. It looks like this might be resolved with a simple revendoring...
author_association: CONTRIBUTOR

Diff:
diff --git a/conda_lock/_vendor/conda/_version.py b/conda_lock/_vendor/conda/_version.py
index ece8eb6c7..c78ca0aa9 100644
--- a/conda_lock/_vendor/conda/_version.py
+++ b/conda_lock/_vendor/conda/_version.py
@@ -1,8 +1,13 @@
-# file generated by setuptools_scm
+# file generated by setuptools-scm
 # don't change, don't track in version control
+
+__all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
+
 TYPE_CHECKING = False
 if TYPE_CHECKING:
-    from typing import Tuple, Union
+    from typing import Tuple
+    from typing import Union
+
     VERSION_TUPLE = Tuple[Union[int, str], ...]
 else:
     VERSION_TUPLE = object
diff --git a/conda_lock/_vendor/grayskull/strategy/parse_poetry_version.py b/conda_lock/_vendor/grayskull/strategy/parse_poetry_version.py
index 65aba425f..59b1bbe3e 100644
--- a/conda_lock/_vendor/grayskull/strategy/parse_poetry_version.py
+++ b/conda_lock/_vendor/grayskull/strategy/parse_poetry_version.py
@@ -1,7 +1,8 @@
+from __future__ import annotations
+
 import re
-from typing import Dict, Optional
 
-import semver
+from packaging.version import Version
 
 VERSION_REGEX = re.compile(
     r"""^[vV]?
@@ -21,7 +22,7 @@ class InvalidVersion(BaseException):
     pass
 
 
-def parse_version(version: str) -> Dict[str, Optional[int]]:
+def parse_version(version: str) -> dict[str, int | None]:
     """
     Parses a version string (not necessarily semver) to a dictionary with keys
     "major", "minor", and "patch". "minor" and "patch" are possibly None.
@@ -45,30 +46,49 @@ def parse_version(version: str) -> Dict[str, Optional[int]]:
     }
 
 
-def vdict_to_vinfo(version_dict: Dict[str, Optional[int]]) -> semver.VersionInfo:
-    """
-    Coerces version dictionary to a semver.VersionInfo object. If minor or patch
-    numbers are missing, 0 is substituted in their place.
+def get_padded_base_version(version: str | Version) -> str:
     """
-    ver = {key: 0 if value is None else value for key, value in version_dict.items()}
-    return semver.VersionInfo(**ver)
+    Returns the same PEP440 version padded with zeroes if
+    minor or micro are not specified.
 
-
-def coerce_to_semver(version: str) -> str:
-    """
-    Coerces a version string to a semantic version.
+    >>> get_padded_base_version("0.2.3")
+    '0.2.3'
+    >>> get_padded_base_version("1")
+    '1.0.0'
+    >>> get_padded_base_version("1.2")
+    '1.2.0'
+    >>> get_padded_base_version("1.2.3")
+    '1.2.3'
+    >>> get_padded_base_version("1.2.3.post1")
+    '1.2.3.post1'
+    >>> get_padded_base_version("2!1.2.post1")
+    '2!1.2.0.post1'
     """
-    if semver.VersionInfo.is_valid(version):
-        return version
-
-    parsed_version = parse_version(version)
-    vinfo = vdict_to_vinfo(parsed_version)
-    return str(vinfo)
-
-
-def get_caret_ceiling(target: str) -> str:
+    if not isinstance(version, Version):
+        version = Version(version)
+
+    # Start with the normalized release
+    floor = f"{version.major}.{version.minor}.{version.micro}"
+
+    # Add other components as they appear
+    if version.epoch is not None and version.epoch > 0:
+        floor = f"{version.epoch}!{floor}"  # Add epoch if present
+    if version.pre is not None:
+        floor += (
+            f"{version.pre[0]}{version.pre[1]}"  # Add pre-release (e.g., a1, b1, rc1)
+        )
+    if version.post is not None:
+        floor += f".post{version.post}"  # Add post-release (e.g., .post1)
+    if version.dev is not None:
+        floor += f".dev{version.dev}"  # Add development release (e.g., .dev1)
+    if version.local is not None:
+        floor += f"+{version.local}"  # Add local metadata (e.g., +local)
+    return floor
+
+
+def get_caret_ceiling(version: str | Version) -> str:
     """
-    Accepts a Poetry caret target and returns the exclusive version ceiling.
+    Accepts a Poetry caret version and returns the exclusive version ceiling.
 
     Targets that are invalid semver strings (e.g. "1.2", "0") are handled
     according to the Poetry caret requirements specification, which is based on
@@ -97,34 +117,24 @@ def get_caret_ceiling(target: str) -> str:
     '2.0.0'
     >>> get_caret_ceiling("1.2.3")
     '2.0.0'
+    >>> get_caret_ceiling("1.2.3.post1")
+    '2.0.0'
+    >>> get_caret_ceiling("2!1.2.3.post1")
+    '2.0.0'
     """
-    if not semver.VersionInfo.is_valid(target):
-        target_dict = parse_version(target)
-
-        if target_dict["major"] == 0:
-            if target_dict["minor"] is None:
-                target_dict["major"] += 1
-            elif target_dict["patch"] is None:
-                target_dict["minor"] += 1
-            else:
-                target_dict["patch"] += 1
-            return str(vdict_to_vinfo(target_dict))
-
-        vdict_to_vinfo(target_dict)
-        return str(vdict_to_vinfo(target_dict).bump_major())
-
-    target_vinfo = semver.VersionInfo.parse(target)
-
-    if target_vinfo.major == 0:
-        if target_vinfo.minor == 0:
-            return str(target_vinfo.bump_patch())
-        else:
-            return str(target_vinfo.bump_minor())
+    if not isinstance(version, Version):
+        version = Version(version)
+    # Determine the upper bound
+    if version.major > 0 or len(version.release) == 1:
+        ceiling = f"{version.major + 1}.0.0"
+    elif version.minor > 0 or len(version.release) == 2:
+        ceiling = f"0.{version.minor + 1}.0"
     else:
-        return str(target_vinfo.bump_major())
+        ceiling = f"0.0.{version.micro + 1}"
+    return ceiling
 
 
-def get_tilde_ceiling(target: str) -> str:
+def get_tilde_ceiling(version: str | Version) -> str:
     """
     Accepts a Poetry tilde target and returns the exclusive version ceiling.
 
@@ -136,11 +146,14 @@ def get_tilde_ceiling(target: str) -> str:
     >>> get_tilde_ceiling("1.2.3")
     '1.3.0'
     """
-    target_dict = parse_version(target)
-    if target_dict["minor"]:
-        return str(vdict_to_vinfo(target_dict).bump_minor())
-
-    return str(vdict_to_vinfo(target_dict).bump_major())
+    if not isinstance(version, Version):
+        version = Version(version)
+    # Determine the upper bound based on the specified components
+    if len(version.release) in [2, 3]:  # Major, Minor, Micro, or Major, Minor
+        tilde_ceiling = f"{version.major}.{version.minor + 1}.0"
+    else:  # Major, Minor or Only Major
+        tilde_ceiling = f"{version.major + 1}.0.0"
+    return tilde_ceiling
 
 
 def encode_poetry_version(poetry_specifier: str) -> str:
@@ -150,6 +163,8 @@ def encode_poetry_version(poetry_specifier: str) -> str:
     Example: ^1 => >=1.0.0,<2.0.0
 
     # should be unchanged
+    >>> encode_poetry_version("~=1.1")
+    '~=1.1'
     >>> encode_poetry_version("1.*")
     '1.*'
     >>> encode_poetry_version(">=1,<2")
@@ -158,6 +173,8 @@ def encode_poetry_version(poetry_specifier: str) -> str:
     '==1.2.3'
     >>> encode_poetry_version("!=1.2.3")
     '!=1.2.3'
+    >>> encode_poetry_version("===1.2.3")
+    '===1.2.3'
 
     # strip spaces
     >>> encode_poetry_version(">= 1, < 2")
@@ -186,6 +203,11 @@ def encode_poetry_version(poetry_specifier: str) -> str:
     >>> encode_poetry_version("^1.2.3")
     '>=1.2.3,<2.0.0'
 
+    # handle caret operator with a PEP440 version
+    # correctly
+    >>> encode_poetry_version("^0.8.post1")
+    '>=0.8.0.post1,<0.9.0'
+
     # handle tilde operator correctly
     # examples from Poetry docs
     >>> encode_poetry_version("~1")
@@ -194,7 +216,27 @@ def encode_poetry_version(poetry_specifier: str) -> str:
     '>=1.2.0,<1.3.0'
     >>> encode_poetry_version("~1.2.3")
     '>=1.2.3,<1.3.0'
+
+    # # handle tilde operator with a PEP440 version
+    # # correctly
+    # >>> encode_poetry_version("~0.8.post1")
+    # '>=0.8.0.post1,<0.9.0.a0'
+
+    # handle or operator correctly
+    >>> encode_poetry_version("1.2.3|1.2.4")
+    '1.2.3|1.2.4'
+    >>> encode_poetry_version("^5|| ^6 | ^7")
+    '>=5.0.0,<6.0.0|>=6.0.0,<7.0.0|>=7.0.0,<8.0.0'
     """
+    if "|" in poetry_specifier:
+        poetry_or_clauses = [clause.strip() for clause in poetry_specifier.split("|")]
+        conda_or_clauses = [
+            encode_poetry_version(clause)
+            for clause in poetry_or_clauses
+            if clause != ""
+        ]
+        return "|".join(conda_or_clauses)
+
     poetry_clauses = poetry_specifier.split(",")
 
     conda_clauses = []
@@ -202,18 +244,24 @@ def encode_poetry_version(poetry_specifier: str) -> str:
         poetry_clause = poetry_clause.replace(" ", "")
         if poetry_clause.startswith("^"):
             # handle ^ operator
-            target = poetry_clause[1:]
-            floor = coerce_to_semver(target)
-            ceiling = get_caret_ceiling(target)
+            caret_version = Version(poetry_clause[1:])
+            floor = get_padded_base_version(caret_version)
+            ceiling = get_caret_ceiling(caret_version)
             conda_clauses.append(">=" + floor)
             conda_clauses.append("<" + ceiling)
             continue
 
+        if poetry_clause.startswith("~="):
+            # handle the compatible release operator ~=
+            # before the tilde ~ operator
+            conda_clauses.append(poetry_clause)
+            continue
+
         if poetry_clause.startswith("~"):
             # handle ~ operator
-            target = poetry_clause[1:]
-            floor = coerce_to_semver(target)
-            ceiling = get_tilde_ceiling(target)
+            tilde_version = poetry_clause[1:]
+            floor = get_padded_base_version(tilde_version)
+            ceiling = get_tilde_ceiling(tilde_version)
             conda_clauses.append(">=" + floor)
             conda_clauses.append("<" + ceiling)
             continue
@@ -222,3 +270,480 @@ def encode_poetry_version(poetry_specifier: str) -> str:
         conda_clauses.append(poetry_clause)
 
     return ",".join(conda_clauses)
+
+
+def encode_poetry_platform_to_selector_item(poetry_platform: str) -> str:
+    """
+    Encodes Poetry Platform specifier as a Conda selector.
+
+    Example: "darwin" => "osx"
+    """
+
+    platform_selectors = {"windows": "win", "linux": "linux", "darwin": "osx"}
+    poetry_platform = poetry_platform.lower().strip()
+    if poetry_platform in platform_selectors:
+        return platform_selectors[poetry_platform]
+    else:  # unknown
+        return ""
+
+
+def encode_poetry_python_version_to_selector_item(poetry_specifier: str) -> str:
+    """
+    Encodes Poetry Python version specifier set as a Conda selector.
+
+    Example:
+        ">=3.8,<3.12" => "py>=38 and py<312"
+        ">=3.8,<3.12,!=3.11" => "py>=38 and py<312 and py!=311"
+        "<3.8|>=3.10" => "py<38 or py>=310"
+        "<3.8|>=3.10,!=3.11" => "py<38 or py>=310 and py!=311"
+
+    # handle exact version specifiers correctly
+    >>> encode_poetry_python_version_to_selector_item("3")
+    'py==3'
+    >>> encode_poetry_python_version_to_selector_item("3.8")
+    'py==38'
+    >>> encode_poetry_python_version_to_selector_item("==3.8")
+    'py==38'
+    >>> encode_poetry_python_version_to_selector_item("!=3.8")
+    'py!=38'
+    >>> encode_poetry_python_version_to_selector_item("!=3.8.1")
+    'py!=38'
+
+    # handle caret operator correctly
+    >>> encode_poetry_python_version_to_selector_item("^3.10") # '>=3.10.0,<4.0.0'
+    'py>=310 and py<4'
+
+    # handle tilde operator correctly
+    >>> encode_poetry_python_version_to_selector_item("~3.10") # '>=3.10.0,<3.11.0'
+    'py>=310 and py<311'
+
+    # handle multiple requirements correctly (in "and")
+    >>> encode_poetry_python_version_to_selector_item(">=3.8,<3.12,!=3.11")
+    'py>=38 and py<312 and py!=311'
+
+    # handle multiple requirements in "or" correctly ("and" takes precendence)
+    >>> encode_poetry_python_version_to_selector_item("<3.8|>=3.10,!=3.11")
+    'py<38 or py>=310 and py!=311'
+
+    # handle compatible release operator correctly
+    >>> encode_poetry_python_version_to_selector_item("~=3")
+    'py>=3'
+    >>> encode_poetry_python_version_to_selector_item("~=3.8")
+    'py>=38 and py<4'
+    >>> encode_poetry_python_version_to_selector_item("~=3.8.1")
+    'py==38'
+    >>> encode_poetry_python_version_to_selector_item("~=3.8.0.1")
+    'py==38'
+    >>> encode_poetry_python_version_to_selector_item("~=3.8,!=3.11")
+    'py>=38 and py<4 and py!=311'
+
+    # handle wildcard versions correctly
+    >>> encode_poetry_python_version_to_selector_item("*")
+    ''
+    >>> encode_poetry_python_version_to_selector_item("3.*,!=3.11")
+    'py>=3 and py<4 and py!=311'
+    >>> encode_poetry_python_version_to_selector_item("!=3.*|3.11")
+    'py<3 or py>=4 or py==311'
+    >>> encode_poetry_python_version_to_selector_item("!=3.*,!=4.1")
+    '(py<3 or py>=4) and py!=41'
+
+    """
+
+    if not poetry_specifier:
+        return ""
+
+    version_specifier = encode_poetry_version(poetry_specifier)
+
+    if "|" in version_specifier:
+        poetry_or_clauses = [clause.strip() for clause in version_specifier.split("|")]
+        conda_or_clauses = [
+            encode_poetry_python_version_to_selector_item(clause)
+            for clause in poetry_or_clauses
+            if clause != ""
+        ]
+        conda_or_clauses = " or ".join(conda_or_clauses)
+        return conda_or_clauses
+
+    conda_clauses = version_specifier.split(",")
+
+    conda_selectors = []
+    for conda_clause in conda_clauses:
+        conda_selector = parse_python_version_specifier_to_selector(conda_clause)
+        if conda_selector != "":
+            conda_selectors.append(conda_selector)
+    if len(conda_selectors) > 1:
+        conda_selectors = [
+            conda_selector if " or " not in conda_selector else f"({conda_selector})"
+            for conda_selector in conda_selectors
+        ]
+    selectors = " and ".join(conda_selectors)
+    return selectors
+
+
+def parse_python_version_specifier_to_selector(version_specifier: str):
+    """
+    Take a Python version specifier, PEP 440 compliant.
+
+    Return Python version conda selector.
+
+    If the version_specifier has no operator, the equal operator ==
+    is assumed.
+
+    The version is normalized to "major.minor" (drop patch if present)
+    or only "major" if minor is 0 (e.g. "3.8" -> "38", "3.8.1" -> "38",
+    "3.0" -> "3", "3.0.1" -> "3").
+
+    The compatible release operator ~= is expanded eventually into two selector
+    items if the version has major and minor (e.g. "~=3.8" -> "py>=38 and py<4",
+    but "~=3.8.1" -> "py==38").
+
+    The exact equality operators == and != support the wildcard *
+    in the version (e.g. "*" -> "==*" -> "").
+
+    Examples:
+        ">=3.8"   ->   "py>=38"
+        "3.12"    ->   "py==312"
+        "~=3.8"   ->   "py>=38 and py<4"
+        "~=3.8.1" ->   "py==38"
+        "3.*"     ->   "py>=3 and py<4"
+        "!=3.*"   ->   "py<3 or py>=4"
+
+    >>> parse_python_version_specifier_to_selector(">=3.8")
+    'py>=38'
+    >>> parse_python_version_specifier_to_selector("3.12")
+    'py==312'
+    >>> parse_python_version_specifier_to_selector("<4.0.0")
+    'py<4'
+    >>> parse_python_version_specifier_to_selector("<4.0.0.1")
+    'py<4'
+    >>> parse_python_version_specifier_to_selector(">=3")
+    'py>=3'
+    >>> parse_python_version_specifier_to_selector(">=3.8.0")
+    'py>=38'
+    >>> parse_python_version_specifier_to_selector(">=3.8.0.1")
+    'py>=38'
+    >>> parse_python_version_specifier_to_selector("~=3.8")
+    'py>=38 and py<4'
+    >>> parse_python_version_specifier_to_selector("3.*")
+    'py>=3 and py<4'
+    >>> parse_python_version_specifier_to_selector("!=3.*")
+    'py<3 or py>=4'
+
+    """
+    # Regex to split an optional operator and a whatever version
+    pattern = r"^(?P<operator>\^|~=|~|>=|<=|>|<|!=|===|==|=)?(?P<version>.+)$"
+
+    # Here Specifier or Version are not useful because
+    # Specifier requires an operator, and Version cannot
+    # accept an operator. Doomed to match twice.
+
+    match = re.match(pattern, version_specifier)
+    if not match:
+        raise ValueError(f"Invalid version selector: {version_specifier}")
+
+    # Extract operator and version
+    operator = match.group("operator")
+    version = match.group("version")
+
+    if operator in [None, "=", "==", "==="]:
+        # Default to "==" if no operator is provided or "=", "==="
+        operator = "=="
+        # Check also if there is a wildcard operator "*" (may result in two operators)
+        return expand_operator_wildcard_version_to_selector(operator, version)
+    elif operator == "~=":
+        # Compatible release operator "~=" (may result in two operators)
+        return expand_compatible_release_operator_version_to_selector(version)
+    elif operator == "!=":
+        # Check also if there is a wildcard operator "*" (may result in two operators)
+        return expand_operator_wildcard_version_to_selector(operator, version)
+    return operator_version_to_selector(operator, Version(version))
+
+
+def expand_compatible_release_operator_version_to_selector(
+    version: str | Version,
+) -> str:
+    """
+    Take a Python version, PEP440 compliant.
+
+    The compatible release operator "~=" is implicit.
+
+    The python version should be reasonable and realistic (e.g. "3.11"), but
+    it is true that an esoteric still PEP440 valid version would make grayskull
+    crash, therefore here we parse the Python version as a PEP440 compliant
+    version (e.g. "3.11.3.dev0").
+
+    The compatible release operator ~= is expanded eventually into two selector
+    items if the version has major and minor (e.g. "~=3.8" -> "py>=38 and py<4",
+    but "~=3.8.1" -> "py==38", and "~=3" -> "py>=38").
+
+    The reason why this operator is expanded here and not in
+    encode_poetry_python_version_to_selector_item just after
+    encode_poetry_version is because the selectors for the python
+    version use only major and minor, and therefore the compatible
+    release operator ~= makes sense only in the case of major and
+    minor specified (e.g. "~=3.8" -> "py>=38 and py<4") and just by
+    knowing that we can avoid having to detect cases like:
+        "~=3.8.0.1" -> ">=3.8.0.1, ==3.8.0.*" -> ">=3.8.0.1, <3.8.1.0a" -> "py==38"
+    and expand only for:
+        "~=3.8" -> ">=3.8, ==3.*" -> ">=3.8, <4.0a" -> "py>=38 and py<4"
+        "~=3.0" -> ">=3.0, ==3.*" -> ">=3.0, <4.0a" -> "py>=3 and py<4"
+    in the rest of the cases it's a simple conversion to ">=" operator.
+
+    If we would expand it before, we would receive specifier sets like
+    ">=3.8.0.1, <3.8.1.0a" (among other specifiers) and we would need to
+    detect those cases to avoid rendering to a naive "py>=38 and py<38"
+    which would be an invalid statement.
+
+    Rationale:
+        - generally it would work in this way:
+            ~=2     -> illegal for PEP440
+            ~=2.2   -> ">=2.2, ==2.*" -> ">=2.2, <3.0a"
+            ~=1.4.5 -> ">=1.4.5, ==1.4.*" -> ">=1.4.5, <1.5.0a"
+            ~=0.5.3 -> ">=0.5.3, ==0.5.*" -> ">=0.5.3, <0.6.0a"
+        - considering only python versions and their selectors:
+            ~=3       -> illegal for PEP440     -> ">=3, ==*" -> ">=3" -> "py>=3"
+            ~=3.8     -> ">=3.8, ==3.*"         -> ">=3.8, <4.0a" -> "py>=38 and py<4"
+            ~=3.8.1   -> ">=3.8.1, ==3.8.*"     -> ">=3.8.1, <3.9.0a" -> "py==38"
+            ~=3.8.0.1 -> ">=3.8.0.1, ==3.8.0.*" -> ">=3.8.0.1, <3.8.1.0a" -> "py==38"
+
+    Examples:
+        "3"     ->   "py>=3"
+        "3.8"   ->   "py>=38 and py<4"
+        "3.8.1" ->   "py==38"
+
+    >>> expand_compatible_release_operator_version_to_selector("3")
+    'py>=3'
+    >>> expand_compatible_release_operator_version_to_selector("3.8")
+    'py>=38 and py<4'
+    >>> expand_compatible_release_operator_version_to_selector("3.0")
+    'py>=3 and py<4'
+    >>> expand_compatible_release_operator_version_to_selector("3.8.1")
+    'py==38'
+    >>> expand_compatible_release_operator_version_to_selector("3.8.1.1")
+    'py==38'
+    >>> expand_compatible_release_operator_version_to_selector("3.8a0")
+    'py>=38 and py<4'
+    >>> expand_compatible_release_operator_version_to_selector("3.8.1.post1")
+    'py==38'
+    """
+    if not isinstance(version, Version):
+        version = Version(version)
+
+    # The compatible release operator ~= is expanded eventually
+    # into two selector items if the version has major and minor
+    # even if the minor is 0, because it would be used as a padding
+    # placeholder.
+    # See:
+    # https://packaging.python.org/en/latest/specifications/version-specifiers/#compatible-release
+    if len(version.release) < 3:
+        # "3.8" -> "py>=38 and py<4", and "3" -> "py>=3"
+        lower_bound_operator = ">="
+    else:
+        # "3.8.1" -> "py==38"
+        lower_bound_operator = "=="
+    lower_bound_selector = operator_version_to_selector(lower_bound_operator, version)
+    if len(version.release) == 2:
+        # get version selector, with ">=" operator as lower bound
+        # get the ceiling of the version (major bumped by 1)
+        ceiling_version = version.major + 1
+        # get ceiling version selector, with "<" operator as upper bound
+        upper_bound = operator_version_to_selector("<", Version(f"{ceiling_version}"))
+        return f"{lower_bound_selector} and {upper_bound}"
+    return lower_bound_selector
+
+
+def expand_operator_wildcard_version_to_selector(
+    operator: str | None, version: str | Version
+) -> str:
+    """
+    Take the strict equality operators "==" or "!=" and
+    a Python version ending with ".*", PEP440 compliant.
+
+    "*" is accepted, but "1*" or "1.1*" are not accepted
+    because PEP 440 requires the "*" wildcard to follow a "."
+    because it is meant to represent a "range of versions
+    with common prefix components."
+
+    Wildcards can be expressed as ranges (">=" and "<") using
+    the next significant component.
+
+    Examples:
+         "*" -> "==*" -> ""
+         "1.*" -> ">=1.0.0.a0,<2.0.0"
+         "1.1.*" -> ">=1.1.0.a0,<1.2.0"
+         "1.1.1.*" -> ">=1.1.1.a0,<1.1.2"
+
+    inclusion: 1.1
+            == 1.1        # Equal, so 1.1 matches clause
+            == 1.1.0      # Zero padding expands 1.1 to 1.1.0, so it matches clause
+            == 1.1.dev1   # Not equal (dev-release), so 1.1 does not match clause
+            == 1.1a1      # Not equal (pre-release), so 1.1 does not match clause
+            == 1.1.post1  # Not equal (post-release), so 1.1 does not match clause
+            == 1.1.*      # Same prefix, so 1.1 matches clause
+
+    exclusion: 1.1.post1
+            != 1.1        # Not equal, so 1.1.post1 matches clause
+            != 1.1.post1  # Equal, so 1.1.post1 does not match clause
+            != 1.1.*      # Same prefix, so 1.1.post1 does not match clause
+
+    # In practice, if the star suffix ".*" is used on Python version specifiers
+    # to be rendered as conda selector, we can simplify the calculation according
+    # to which operator is used:
+    #
+    # - equality: it makes sense to expand it only if the version is "{major}.*"
+    #             (e.g. "3.*" -> ">=3.0a0,<4" -> "py>=3 and py<4"). In all the
+    #             other cases it is enough to remove the ".*" and consider as
+    #             usual the "{operator}{major}{minor}" if "minor" is more than
+    #             "0", otherwise "{operator}{major}".
+
+    # - inequality: it makes sense to expand it only if the version is "{major}.*"
+    #             (e.g. "3.*" -> ">=3.0a0,<4" -> "py>=3 and py<4"). In all the
+    #             other cases it is enough to remove the ".*" and consider as
+    #             usual the "{operator}{major}{minor}" if "minor" is more than
+    #             "0", otherwise "{operator}{major}".
+
+    # Equality examples
+
+    >>> expand_operator_wildcard_version_to_selector("==","*") # any
+    ''
+
+    >>> expand_operator_wildcard_version_to_selector("==", "3.*") # >=3.0a0,<4
+    'py>=3 and py<4'
+
+    # >=3.12.0a0,<3.13
+    >>> expand_operator_wildcard_version_to_selector("==", "3.12.*")
+    'py==312'
+
+    # >=3.9.1.0a0,<3.9.2
+    >>> expand_operator_wildcard_version_to_selector("==", "3.9.1.*")
+    'py==39'
+
+    # >=3.9.1.0a0,<3.9.1.2
+    >>> expand_operator_wildcard_version_to_selector("==", "3.9.1.1.*")
+    'py==39'
+
+    # Inequality examples
+
+    >>> expand_operator_wildcard_version_to_selector("!=","*") # none
+    'py<0'
+
+    # <3.0a0|>=4
+    >>> expand_operator_wildcard_version_to_selector("!=", "3.*")
+    'py<3 or py>=4'
+
+    # <3.12.0a0|>=3.13
+    >>> expand_operator_wildcard_version_to_selector("!=", "3.12.*")
+    'py<312 or py>=313'
+
+    # <3.9.1.0a0|>=3.9.2
+    >>> expand_operator_wildcard_version_to_selector("!=", "3.9.1.*")
+    'py<39 or py>=39'
+
+    # <3.9.1.1.0a0|>=3.9.1.2
+    >>> expand_operator_wildcard_version_to_selector("!=", "3.9.1.1.*")
+    'py<39 or py>=39'
+
+    """
+    if version == "*":
+        # This should not happen, as the "*" is stripped away before
+        # to avoid having trivial selectors, but consider it anyway
+        # for general usage.
+        if operator in [None, "", "=", "==", "==="]:
+            return ""
+        else:
+            return "py<0"
+    base_version = version.rstrip(".*")
+    expand_operator_wildcard_version = len(base_version) != len(version)
+    version = Version(base_version)
+    if operator in [None, "", "=", "==", "==="]:
+        # Default to "==" if no operator is provided or "=", "==="
+        operator = "=="
+        # it makes sense to expand it only if the version is "{major}.*"
+        if expand_operator_wildcard_version and len(version.release) == 1:
+            # "3.*" -> ">=3.0a0,<4" -> "py>=3 and py<4"
+            left_bound_selector = operator_version_to_selector(">=", version)
+            # get the ceiling of the version (major bumped by 1)
+            right_version = version.major + 1
+            # get ceiling version selector, with "<" operator as upper bound
+            right_bound_selector = operator_version_to_selector(
+                "<", Version(f"{right_version}")
+            )
+            return f"{left_bound_selector} and {right_bound_selector}"
+    elif operator == "!=":
+        if expand_operator_wildcard_version:
+            left_bound_selector = operator_version_to_selector("<", version)
+            if len(version.release) == 1:
+                # "3.*" -> "<3.0a0|>=4" -> "py<3 or py>=4"
+                # major bumped by 1
+                right_version = version.major + 1
+            elif len(version.release) == 2:
+                # "3.12.*" -> "<3.12.0a0|>=3.13" -> "py<312 or py>=313"
+                # minor bumped by 1
+                right_version = f"{version.major}." + str(version.minor + 1)
+            else:
+                # "3.9.1.*" -> <3.9.1.0a0|>=3.9.2"" -> "py<39 or py>=39"
+                # "3.9.1.1.*" -> <3.9.1.1.0a0|>=3.9.1.2"" -> "py<39 or py>=39"
+                # use the same version in the right bound
+                right_version = f"{version.major}.{version.minor}"
+            # get ceiling version selector, with ">=" operator as upper bound
+            right_bound_selector = operator_version_to_selector(
+                ">=", Version(f"{right_version}")
+            )
+            return f"{left_bound_selector} or {right_bound_selector}"
+    return operator_version_to_selector(operator, version)
+
+
+def operator_version_to_selector(operator: str | None, version: str | Version) -> str:
+    """
+    Consider major, minor, and discard the rest (patch or additional parts)
+    Return only major if minor is "0", otherwise return major.minor
+
+    >>> operator_version_to_selector(">=", "3.8")
+    'py>=38'
+    >>> operator_version_to_selector("==", "3.12")
+    'py==312'
+    >>> operator_version_to_selector("", "3.12")
+    'py==312'
+    >>> operator_version_to_selector("<", "4.0.0")
+    'py<4'
+    >>> operator_version_to_selector("<", "4.0.0.1")
+    'py<4'
+    >>> operator_version_to_selector(">=", "3")
+    'py>=3'
+    >>> operator_version_to_selector(">=", "3.8.0")
+    'py>=38'
+    >>> operator_version_to_selector(">=", "3.8.0.1")
+    'py>=38'
+    >>> operator_version_to_selector(">=", "3.8.0.1post1")
+    'py>=38'
+    >>> operator_version_to_selector(">=", "3.8.0.1a0")
+    'py>=38'
+    >>> operator_version_to_selector("<", "2!4.0.0.1.post1")
+    'py<4'
+    """
+    if operator in [None, "", "=", "==="]:
+        # Default to "==" if no operator is provided or "=", "==="
+        operator = "=="
+    if not isinstance(version, Version):
+        version = Version(version)
+    version_selector = (
+        version.major if version.minor == 0 else f"{version.major}{version.minor}"
+    )
+    return f"py{operator}{version_selector}"
+
+
+def combine_conda_selectors(python_selector: str, platform_selector: str):
+    """
+    Combine selectors based on presence
+    """
+    if python_selector and platform_selector:
+        if " or " in python_selector:
+            python_selector = f"({python_selector})"
+        selector = f"{python_selector} and {platform_selector}"
+    elif python_selector:
+        selector = f"{python_selector}"
+    elif platform_selector:
+        selector = f"{platform_selector}"
+    else:
+        selector = ""
+    return f"  # [{selector}]" if selector else ""
diff --git a/conda_lock/_vendor/poetry/__version__.py b/conda_lock/_vendor/poetry/__version__.py
index 90826ab57..ef9c93164 100644
--- a/conda_lock/_vendor/poetry/__version__.py
+++ b/conda_lock/_vendor/poetry/__version__.py
@@ -1,3 +1,6 @@
 from __future__ import annotations
 
+from conda_lock._vendor.poetry.utils._compat import metadata
+
+
 __version__ = "2.0.1"
diff --git a/conda_lock/_vendor/vendor.txt b/conda_lock/_vendor/vendor.txt
index ed29cecdd..9604166a0 100644
--- a/conda_lock/_vendor/vendor.txt
+++ b/conda_lock/_vendor/vendor.txt
@@ -2,7 +2,7 @@
 cleo==2.1.0
 poetry==2.0.1
 poetry-core==2.0.1
-grayskull==2.7.3
+grayskull==2.9.1
 
 # install conda from github since it's not available on pypi
 git+https://github.com/conda/conda.git@24.7.1
diff --git a/pyproject.toml b/pyproject.toml
index 8c7457d9d..f46cf9529 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -160,7 +160,8 @@ substitute = [
     # Rapidfuzz is a dependency of cleo but is only used to suggest similar names
     # for CLI apps. We don't need this functionality in conda-lock, and removing
     # this import allows us to drop it as a dependency.
-    { match = 'from rapidfuzz.distance import Levenshtein', replace = ''}
+    { match = 'from rapidfuzz.distance import Levenshtein', replace = ''},
+    { match = '__version__ = metadata\.version\("poetry"\)', replace = '__version__ = "2.0.1"' },
 ]
 
 [tool.conda-lock]
diff --git a/scripts/rerun_vendoring.sh b/scripts/rerun_vendoring.sh
index a85638163..1cf710c27 100755
--- a/scripts/rerun_vendoring.sh
+++ b/scripts/rerun_vendoring.sh
@@ -25,6 +25,9 @@ dos2unix conda_lock/_vendor/poetry/core/_vendor/lark/grammars/*
 dos2unix conda_lock/_vendor/poetry/core/_vendor/fastjsonschema/*
 dos2unix conda_lock/_vendor/poetry/core/_vendor/lark/LICENSE
 
+# Make the vendored file compatible with Python 3.9:
+sed -i '1s/^/from __future__ import annotations\n\n/' conda_lock/_vendor/grayskull/strategy/parse_poetry_version.py
+
 echo Downloading missing licenses...
 for package in poetry poetry-core cleo; do
   curl -s "https://raw.githubusercontent.com/python-poetry/${package}/master/LICENSE" > "conda_lock/_vendor/${package}.LICENSE"
diff --git a/tests/test_constraints.py b/tests/test_constraints.py
new file mode 100644
index 000000000..da710cdeb
--- /dev/null
+++ b/tests/test_constraints.py
@@ -0,0 +1,74 @@
+import re
+
+from pathlib import Path
+
+import tomlkit
+
+
+def test_poetry_vendored_version_rule_matches_vendored_version():
+    """Check consistency of the vendoring substitution rule with the vendored version.
+
+    The version from the vendoring substitution rule is defined in pyproject.toml
+    under the list `tool.vendoring.transformations.substitute`. The relevant line
+    looks like:
+
+    ```toml
+    { match = '__version__ = metadata.version("poetry")', replace = '__version__ = "2.0.1"' },
+    ```
+
+    This should match with the vendored version specified in
+    `conda_lock/_vendor/vendor.txt` on the line that looks like:
+
+    ```text
+    poetry==2.0.1
+    ```
+    """
+
+    # Get the project root directory
+    repo_root = Path(__file__).parent.parent
+
+    # Read the pyproject.toml file
+    pyproject_path = repo_root / "pyproject.toml"
+    pyproject_content = tomlkit.parse(pyproject_path.read_text())
+
+    # Extract the version from the substitution rule
+    transform_section = (
+        pyproject_content.get("tool", {})
+        .get("vendoring", {})
+        .get("transformations", {})
+    )
+    substitution_rules = transform_section.get("substitute", [])
+    version_substitution = None
+
+    for rule in substitution_rules:
+        match_value = rule.get("match", "")
+        if r'__version__ = metadata\.version\("poetry"\)' in match_value:
+            version_substitution = rule.get("replace", "")
+            break
+
+    assert version_substitution is not None, (
+        "Could not find version substitution rule in pyproject.toml"
+    )
+
+    # Extract the version number from the substitution string using regex
+    pyproject_version_match = re.search(
+        r'"([0-9]+\.[0-9]+\.[0-9]+)"', version_substitution
+    )
+    assert pyproject_version_match, (
+        f"Could not extract version from substitution: {version_substitution}"
+    )
+    pyproject_version = pyproject_version_match.group(1)
+
+    # Read the vendor.txt file
+    vendor_path = repo_root / "conda_lock" / "_vendor" / "vendor.txt"
+    vendor_content = vendor_path.read_text()
+
+    # Find the poetry version in vendor.txt
+    poetry_line_match = re.search(r"poetry==([0-9]+\.[0-9]+\.[0-9]+)", vendor_content)
+    assert poetry_line_match, "Could not find poetry version in vendor.txt"
+    vendor_version = poetry_line_match.group(1)
+
+    # Check that the versions match
+    assert pyproject_version == vendor_version, (
+        f"Version mismatch: {pyproject_version} in pyproject.toml vs {vendor_version} in vendor.txt"
+    )


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR primarily updates the vendored grayskull dependency and improves version consistency between configuration files. Key changes include:
- Adding a test (tests/test_constraints.py) to verify that the version in the substitution rule in pyproject.toml matches the version in vendor.txt.
- Updating pyproject.toml to include a new substitution rule for the poetry version.
- Revendoring grayskull by updating its version from 2.7.3 to 2.9.1.
- Minor updates in conda_lock/_vendor/poetry/__version__.py and conda_lock/_vendor/conda/_version.py to improve compatibility and maintain consistency.

### Reviewed Changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.

<details>
<summary>Show a summary per file</summary>

| File                                      | Description                                                             |
| ----------------------------------------- | ----------------------------------------------------------------------- |
| tests/test_constraints.py                 | Introduces a test that checks version consistency across config files. |
| pyproject.toml                            | Adds a substitution rule for the poetry version and fixes formatting.   |
| conda_lock/_vendor/vendor.txt              | Updates grayskull dependency from version 2.7.3 to 2.9.1.               |
| conda_lock/_vendor/poetry/__version__.py     | Incorporates a metadata import while keeping the poetry version unchanged. |
| conda_lock/_vendor/conda/_version.py         | Adjusts type hints and updates a comment for clarity.                   |
</details>





state: COMMENTED
author_association: NONE
submitted_at: 2025-05-14T22:36:17Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR revendors grayskull by updating its version and ensuring that the version specified in pyproject.toml remains consistent with that in the vendored files.  
- Bumps grayskull’s version from 2.7.3 to 2.9.1 in vendor.txt.  
- Adds a version substitution rule in pyproject.toml and a corresponding test in tests/test_constraints.py.  
- Adjusts vendored files and scripts to maintain compatibility (e.g. adding a future import for Python 3.9).

### Reviewed Changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

<details>
<summary>Show a summary per file</summary>

| File                                      | Description                                                                                   |
| ----------------------------------------- | --------------------------------------------------------------------------------------------- |
| tests/test_constraints.py                 | Adds a test verifying the consistency between the version in pyproject.toml and vendor.txt.    |
| scripts/rerun_vendoring.sh                | Inserts a future import line for Python 3.9 compatibility in a vendored grayskull file.         |
| pyproject.toml                            | Introduces a substitution rule for the Poetry version matching.                              |
| conda_lock/_vendor/vendor.txt             | Updates grayskull from version 2.7.3 to 2.9.1.                                                |
| conda_lock/_vendor/poetry/__version__.py   | Adds an import for metadata while keeping the version string unchanged.                       |
| conda_lock/_vendor/conda/_version.py        | Updates comments and adds an __all__ directive to reflect the change from setuptools-scm naming.|
</details>





state: COMMENTED
author_association: NONE
submitted_at: 2025-05-14T22:51:19Z

Review comments:
diff_hunk: @@ -0,0 +1,74 @@
+import re
+
+from pathlib import Path
+
+import tomlkit
+
+
+def test_poetry_vendored_version_rule_matches_vendored_version():
+    """Check consistency of the vendoring substitution rule with the vendored version.
+
+    The version from the vendoring substitution rule is defined in pyproject.toml
+    under the list `tool.vendoring.transformations.substitute`. The relevant line
+    looks like:
+
+    ```toml
+    { match = '__version__ = metadata.version("poetry")', replace = '__version__ = "2.0.1"' },
+    ```
+
+    This should match with the vendored version specified in
+    `conda_lock/_vendor/vendor.txt` on the line that looks like:
+
+    ```text
+    poetry==2.0.1
+    ```
+    """
+
+    # Get the project root directory
+    repo_root = Path(__file__).parent.parent
+
+    # Read the pyproject.toml file
+    pyproject_path = repo_root / "pyproject.toml"
+    pyproject_content = tomlkit.parse(pyproject_path.read_text())
+
+    # Extract the version from the substitution rule
+    transform_section = (
+        pyproject_content.get("tool", {})
+        .get("vendoring", {})
+        .get("transformations", {})
+    )
+    substitution_rules = transform_section.get("substitute", [])
+    version_substitution = None
+
+    for rule in substitution_rules:
+        match_value = rule.get("match", "")
+        if r'__version__ = metadata\.version\("poetry"\)' in match_value:
path: tests/test_constraints.py
user: login: Copilot
body: [nitpick] Consider using a regular expression match instead of a substring check to improve robustness and clarity of the version substitution rule detection.
```suggestion
        if re.search(r'__version__ = metadata\.version\("poetry"\)', match_value):
```
created_at: 2025-05-14T22:51:19Z
author_association: NONE
diff_hunk: @@ -25,6 +25,9 @@ dos2unix conda_lock/_vendor/poetry/core/_vendor/lark/grammars/*
 dos2unix conda_lock/_vendor/poetry/core/_vendor/fastjsonschema/*
 dos2unix conda_lock/_vendor/poetry/core/_vendor/lark/LICENSE
 
+# Make the vendored file compatible with Python 3.9:
+sed -i '1s/^/from __future__ import annotations\n\n/' conda_lock/_vendor/grayskull/strategy/parse_poetry_version.py
path: scripts/rerun_vendoring.sh
user: login: Copilot
body: [nitpick] Consider checking if the '__future__' import already exists before inserting it to avoid duplicate import lines if the script is executed multiple times.
```suggestion
if ! grep -q '^from __future__ import annotations$' conda_lock/_vendor/grayskull/strategy/parse_poetry_version.py; then
  sed -i '1s/^/from __future__ import annotations\n\n/' conda_lock/_vendor/grayskull/strategy/parse_poetry_version.py
fi
```
created_at: 2025-05-14T22:51:19Z
author_association: NONE

PR comments:
user: login: netlify[bot]
created_at: 2025-05-14T22:36:00Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *conda-lock* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 59174eb6777aeacc4d2f4bfe4872c65d0f8f2b2f |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/projects/conda-lock/deploys/68251e1fcd3b17000865c504 |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-802--conda-lock.netlify.app](https://deploy-preview-802--conda-lock.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTgwMi0tY29uZGEtbG9jay5uZXRsaWZ5LmFwcCJ9.PB1TQtUzFIh_UaJHXTRfi95phF_qdc__ElZO8pqQU_A)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [conda-lock Preview](https://deploy-preview-802--conda-lock.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify project configuration](https://app.netlify.com/projects/conda-lock/configuration/notifications#deploy-notifications)._
author_association: NONE
user: login: maresb
created_at: 2025-05-14T22:54:33Z
body: @AlbertDeFusco, I expect this to resolve your issue. I'll merge and release this tomorrow. Any feedback would be appreciated but not necessary.
author_association: CONTRIBUTOR