Skip to content

Invalid version error with evaluating markers #938

@JP-Ellis

Description

@JP-Ellis

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 -
done

Minimal 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 True

Ultimately, 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:

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions