Repository: pdm-project/pdm

Issue:
pdm add fails with.: packaging.specifiers.InvalidSpecifier: Invalid specifier: '>=0.1.dev126+gaeebca2'
Dear all,

On one of our machines installing one of our internally created wheels, which use git hashes as part of their version information fails with this error message.:

packaging.specifiers.InvalidSpecifier: Invalid specifier: '>=0.1.dev126+gaeebca2'

The output of pipx runpip pdm list ist.:


C:\test\pdmtest2>pipx runpip pdm list
Package            Version
------------------ ---------
blinker            1.5
CacheControl       0.12.11
certifi            2022.12.7
charset-normalizer 2.1.1
commonmark         0.9.1
distlib            0.3.6
filelock           3.8.2
findpython         0.2.2
idna               3.4
importlib-metadata 5.1.0
installer          0.6.0
lockfile           0.12.2
msgpack            1.0.4
packaging          22.0
pdm                2.3.2
pip                22.3.1
platformdirs       2.6.0
Pygments           2.13.0
pyproject_hooks    1.0.0
python-dotenv      0.21.0
requests           2.28.1
requests-toolbelt  0.10.1
resolvelib         0.9.0
rich               12.6.0
setuptools         65.6.3
shellingham        1.5.0
tomli              2.0.1
tomlkit            0.11.6
unearth            0.6.3
urllib3            1.26.13
virtualenv         20.17.1
wheel              0.38.4
zipp               3.11.0

After downgrading packaging to version 21.3 using.: 

<code>
pipx runpip pdm  install packaging==21.3
</code>

It works for me.  I don't think this is a pdm bug, although a addition to the dependencies to avoid this version would be nice.
I mainly put it here, to help others with the same issue.

thanks, 
Franz



Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/pdm-project/pdm/pull/1566?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) Report
Base: **85.27**% // Head: **85.23**% // Decreases project coverage by **`-0.03%`** :warning:
> Coverage data is based on head [(`5b8ae41`)](https://codecov.io/gh/pdm-project/pdm/pull/1566?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) compared to base [(`780cab4`)](https://codecov.io/gh/pdm-project/pdm/commit/780cab4d28293bd8d607e6bba72cf45c31375777?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).
> Patch coverage: 87.50% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1566      +/-   ##
==========================================
- Coverage   85.27%   85.23%   -0.04%     
==========================================
  Files          91       91              
  Lines        8066     8068       +2     
  Branches     1911     1911              
==========================================
- Hits         6878     6877       -1     
- Misses        797      798       +1     
- Partials      391      393       +2     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `85.06% <87.50%> (-0.04%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/pdm-project/pdm/pull/1566?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | Coverage Δ | |
|---|---|---|
| [src/pdm/utils.py](https://codecov.io/gh/pdm-project/pdm/pull/1566/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS91dGlscy5weQ==) | `86.07% <60.00%> (-0.57%)` | :arrow_down: |
| [src/pdm/cli/utils.py](https://codecov.io/gh/pdm-project/pdm/pull/1566/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvdXRpbHMucHk=) | `82.79% <100.00%> (+0.05%)` | :arrow_up: |
| [src/pdm/models/requirements.py](https://codecov.io/gh/pdm-project/pdm/pull/1566/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9tb2RlbHMvcmVxdWlyZW1lbnRzLnB5) | `94.20% <100.00%> (+0.53%)` | :arrow_up: |
| [src/pdm/models/repositories.py](https://codecov.io/gh/pdm-project/pdm/pull/1566/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9tb2RlbHMvcmVwb3NpdG9yaWVzLnB5) | `79.21% <0.00%> (-0.79%)` | :arrow_down: |
| [src/pdm/termui.py](https://codecov.io/gh/pdm-project/pdm/pull/1566/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS90ZXJtdWkucHk=) | `89.85% <0.00%> (-0.73%)` | :arrow_down: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/pdm-project/pdm/pull/1566?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).


Patch:
diff --git a/news/1562.bugfix.md b/news/1562.bugfix.md
new file mode 100644
index 00000000..83b14097
--- /dev/null
+++ b/news/1562.bugfix.md
@@ -0,0 +1,1 @@
+Strip the local part when building a specifier for comparison with the package version. This is not permitted by PEP 508 as implemented by `packaging 22.0`.
diff --git a/src/pdm/cli/utils.py b/src/pdm/cli/utils.py
index 9f7246b7..50e1670e 100644
--- a/src/pdm/cli/utils.py
+++ b/src/pdm/cli/utils.py
@@ -22,7 +22,6 @@ from typing import (
 
 import tomlkit
 from packaging.specifiers import SpecifierSet
-from packaging.version import parse as parse_version
 from resolvelib.structs import DirectedGraph
 from rich.tree import Tree
 
@@ -40,9 +39,15 @@ from pdm.models.requirements import (
 )
 from pdm.models.specifiers import get_specifier
 from pdm.project import Project
-from pdm.utils import is_path_relative_to, normalize_name, url_to_path
+from pdm.utils import (
+    comparable_version,
+    is_path_relative_to,
+    normalize_name,
+    url_to_path,
+)
 
 if TYPE_CHECKING:
+    from packaging.version import Version
     from resolvelib.resolvers import RequirementInformation, ResolutionImpossible
 
     from pdm.compat import Distribution
@@ -508,20 +513,30 @@ def save_version_specifiers(
     :param resolved: the resolved mapping
     :param save_strategy: compatible/wildcard/exact
     """
+
+    def candidate_version(c: Candidate) -> Version:
+        assert c.version is not None
+        return comparable_version(c.version)
+
     for reqs in requirements.values():
         for name, r in reqs.items():
             if r.is_named and not r.specifier:
                 if save_strategy == "exact":
-                    r.specifier = get_specifier(f"=={resolved[name].version}")
+                    r.specifier = get_specifier(
+                        f"=={candidate_version(resolved[name])}"
+                    )
                 elif save_strategy == "compatible":
-                    version = str(resolved[name].version)
-                    parsed = parse_version(version)
-                    if parsed.is_prerelease or parsed.is_devrelease:
-                        r.specifier = get_specifier(f">={version},<{parsed.major + 1}")
+                    version = candidate_version(resolved[name])
+                    if version.is_prerelease or version.is_devrelease:
+                        r.specifier = get_specifier(f">={version},<{version.major + 1}")
                     else:
-                        r.specifier = get_specifier(f"~={parsed.major}.{parsed.minor}")
+                        r.specifier = get_specifier(
+                            f"~={version.major}.{version.minor}"
+                        )
                 elif save_strategy == "minimum":
-                    r.specifier = get_specifier(f">={resolved[name].version}")
+                    r.specifier = get_specifier(
+                        f">={candidate_version(resolved[name])}"
+                    )
 
 
 def check_project_file(project: Project) -> None:
diff --git a/src/pdm/models/requirements.py b/src/pdm/models/requirements.py
index 161c615a..49f0c33a 100644
--- a/src/pdm/models/requirements.py
+++ b/src/pdm/models/requirements.py
@@ -17,7 +17,6 @@ from packaging.requirements import InvalidRequirement
 from packaging.requirements import Requirement as PackageRequirement
 from packaging.specifiers import SpecifierSet
 from packaging.utils import parse_sdist_filename, parse_wheel_filename
-from packaging.version import parse as parse_version
 from unearth import Link
 
 from pdm.compat import Distribution
@@ -28,6 +27,7 @@ from pdm.models.setup import Setup
 from pdm.models.specifiers import PySpecSet, get_specifier
 from pdm.utils import (
     add_ssh_scheme_to_git_uri,
+    comparable_version,
     normalize_name,
     path_to_url,
     path_without_fragments,
@@ -110,11 +110,7 @@ class Requirement:
         """Return a new requirement with the given pinned version."""
         if self.is_pinned or not other_version:
             return self
-        version = parse_version(other_version)
-        normalized = str(version)
-        if version.local:
-            # Remove the local part to accept wider range of prereleases.
-            normalized = normalized.rsplit("+", 1)[0]
+        normalized = comparable_version(other_version)
         return dataclasses.replace(self, specifier=get_specifier(f"=={normalized}"))
 
     def _hash_key(self) -> tuple:
diff --git a/src/pdm/utils.py b/src/pdm/utils.py
index 9e8049a7..d286938d 100644
--- a/src/pdm/utils.py
+++ b/src/pdm/utils.py
@@ -334,6 +334,15 @@ def normalize_name(name: str, lowercase: bool = True) -> str:
     return name.lower() if lowercase else name
 
 
+def comparable_version(version: str) -> Version:
+    """Normalize a version to make it valid in a specifier."""
+    parsed = Version(version)
+    if parsed.local is not None:
+        # strip the local part
+        parsed._version = parsed._version._replace(local=None)
+    return parsed
+
+
 def is_egg_link(dist: Distribution) -> bool:
     """Check if the distribution is an egg-link install"""
     return getattr(dist, "link_file", None) is not None


Test patch:
diff --git a/tests/cli/test_add.py b/tests/cli/test_add.py
index 0a6753ea..1fd3cf98 100644
--- a/tests/cli/test_add.py
+++ b/tests/cli/test_add.py
@@ -314,3 +314,11 @@ def test_add_editable_package_with_extras(project, working_set):
     assert "demo" in working_set
     assert "requests" in working_set
     assert "urllib3" in working_set
+
+
+def test_add_package_with_local_version(project, repository, working_set):
+    repository.add_candidate("foo", "1.0-alpha.0+local")
+    actions.do_add(project, packages=["foo"], save="minimum")
+    assert working_set["foo"].version == "1.0-alpha.0+local"
+    dependencies, _ = project.get_pyproject_dependencies("default")
+    assert dependencies[0] == "foo>=1.0a0"
diff --git a/tests/resolver/test_resolve.py b/tests/resolver/test_resolve.py
index dd125cb6..a9e5ef0f 100644
--- a/tests/resolver/test_resolve.py
+++ b/tests/resolver/test_resolve.py
@@ -67,20 +67,20 @@ def test_resolve_requires_python(resolve):
 
 def test_resolve_allow_prereleases(resolve, repository):
     repository.add_candidate("foo", "1.0.0")
-    repository.add_candidate("foo", "1.1.0a0")
-    repository.add_candidate("bar", "1.0.0b0")
+    repository.add_candidate("foo", "1.1.0-alpha")
+    repository.add_candidate("bar", "1.0.0-beta")
 
     result = resolve(["foo"])
     assert result["foo"].version == "1.0.0"
 
     result = resolve(["foo"], allow_prereleases=True)
-    assert result["foo"].version == "1.1.0a0"
+    assert result["foo"].version == "1.1.0-alpha"
 
     result = resolve(["foo==1.1.0a0"])
-    assert result["foo"].version == "1.1.0a0"
+    assert result["foo"].version == "1.1.0-alpha"
 
     result = resolve(["bar"])
-    assert result["bar"].version == "1.0.0b0"
+    assert result["bar"].version == "1.0.0-beta"
 
     with pytest.raises(Exception):
         resolve(["bar"], allow_prereleases=False)