-
Notifications
You must be signed in to change notification settings - Fork 278
Description
Background
I have recently released Pact Python v3 and fairly quickly had people report issues installing the latest version when using pip (but not uv). See:
I have been investigating the issue, and I believe there is an issue with version-like strings when used in Provides-Extra and subsequently in Requires-Dist markers.
Pip replication
The issue can be replicated in Python 3.10 through to 3.13 as follows with the latest pip (25.2 as of writing):
for v in 3.{10..13}; do
echo "==> Testing Python $v"
echo 'pip install -U pip==25.2 && pip install pact-python==3.0.1' | podman run --rm -i python:$v bash -
doneMinimal replication
I am able to reliably replicate the issue in isolation using packaging and exerpts from the Pact Python metadata. Here's a minimal example that raises the error:
from packaging.metadata import parse_email, Metadata
raw_metadata, data = parse_email("""\
Metadata-Version: 2.4
Name: pact-python
Version: 3.0.1
Provides-Extra: v2
Requires-Dist: click~=8.0; extra == 'v2'
""")
metadata = Metadata.from_raw(raw_metadata)
req = metadata.requires_dist[0]
req.marker.evaluate()
# The same error occurs with
req.marker.evaluate({"extra": ""})Traceback
Cell In[1], line 12
10 metadata = Metadata.from_raw(raw_metadata)
11 req = metadata.requires_dist[0]
---> 12 req.marker.evaluate()
File ~/mwe/.venv/lib/python3.13/site-packages/packaging/markers.py:347, in Marker.evaluate(self, environment, context)
344 if "extra" in current_environment and current_environment["extra"] is None:
345 current_environment["extra"] = ""
--> 347 return _evaluate_markers(
348 self._markers, _repair_python_full_version(current_environment)
349 )
File ~/mwe/.venv/lib/python3.13/site-packages/packaging/markers.py:239, in _evaluate_markers(markers, environment)
237 assert isinstance(lhs_value, str), "lhs must be a string"
238 lhs_value, rhs_value = _normalize(lhs_value, rhs_value, key=environment_key)
--> 239 groups[-1].append(_eval_op(lhs_value, op, rhs_value))
240 else:
241 assert marker in ["and", "or"]
File ~/mwe/.venv/lib/python3.13/site-packages/packaging/markers.py:187, in _eval_op(lhs, op, rhs)
185 pass
186 else:
--> 187 return spec.contains(lhs, prereleases=True)
189 oper: Operator | None = _operators.get(op.serialize())
190 if oper is None:
File ~/mwe/.venv/lib/python3.13/site-packages/packaging/specifiers.py:552, in Specifier.contains(self, item, prereleases)
548 prereleases = self.prereleases
550 # Normalize item to a Version, this allows us to have a shortcut for
551 # "2.0" in Specifier(">=2")
--> 552 normalized_item = _coerce_version(item)
554 # Determine if we should be supporting prereleases in this specifier
555 # or not, if we do not support prereleases than we can short circuit
556 # logic if this version is a prereleases.
557 if normalized_item.is_prerelease and not prereleases:
File ~/mwe/.venv/lib/python3.13/site-packages/packaging/specifiers.py:28, in _coerce_version(version)
26 def _coerce_version(version: UnparsedVersion) -> Version:
27 if not isinstance(version, Version):
---> 28 version = Version(version)
29 return version
File ~/mwe/.venv/lib/python3.13/site-packages/packaging/version.py:202, in Version.__init__(self, version)
200 match = self._regex.search(version)
201 if not match:
--> 202 raise InvalidVersion(f"Invalid version: {version!r}")
204 # Store the parsed out pieces of the version
205 self._version = _Version(
206 epoch=int(match.group("epoch")) if match.group("epoch") else 0,
207 release=tuple(int(i) for i in match.group("release").split(".")),
(...) 213 local=_parse_local_version(match.group("local")),
214 )
InvalidVersion: Invalid version: ''
The expected result should be False as no extras are requested, but instead a version parsing error occurs when there should be no version to parse. If the {"extra": "v2"} is passed to evaluate, the error disappears and True is returned, as expected.
It seems unusual that version parsing is being attempted in this situation when handling extras. To validate this, I have also tested with non-version-like names, and these work as expected:
from packaging.metadata import parse_email, Metadata
raw_metadata, data = parse_email("""\
Metadata-Version: 2.4
Name: pact-python
Version: 3.0.1
Provides-Extra: foobar
Requires-Dist: click~=8.0; extra == 'foobar'
""")
metadata = Metadata.from_raw(raw_metadata)
req = metadata.requires_dist[0]
assert req.marker.evaluate() is False
assert req.marker.evaluate({"extra": "foobar"}) is TrueUltimately, I believe the issue is a result of the coerced version parsing done in packaging.specifiers.Specifier.contains. From my reading of the Provides-Extra specification, the value should be remain literal, with no normalisation or additional parsing required.
Related
This may be related to: