Skip to content

Fix Version.difference with a local variant of the same version#953

Open
apoorvdarshan wants to merge 1 commit into
python-poetry:mainfrom
apoorvdarshan:fix-10965-local-version-difference
Open

Fix Version.difference with a local variant of the same version#953
apoorvdarshan wants to merge 1 commit into
python-poetry:mainfrom
apoorvdarshan:fix-10965-local-version-difference

Conversation

@apoorvdarshan

@apoorvdarshan apoorvdarshan commented Jul 2, 2026

Copy link
Copy Markdown

Resolves: python-poetry/poetry#10965

  • Added tests for changed code.
  • Updated documentation for changed code. (internal constraint-arithmetic fix, no user-facing docs)

Problem

As diagnosed by @dimbleby in python-poetry/poetry#10965:

public = Version.parse("2.12.1")
local = Version.parse("2.12.1+cpu")
difference = public.difference(local)
print(difference.allows(local))  # True — wrong

Version.difference() only checks other.allows(self). When other is a local variant of self (which self allows via weak equality, but which does not allow self back), the method returns self unchanged — a constraint that still allows the version that was just subtracted. In the solver this means excluding 2.12.1+cpu makes no progress, which manifests as an infinite loop / unbounded memory growth on poetry lock when the same release is available from multiple sources with different local tags (e.g. torch from PyPI and +cpu from the PyTorch index).

Fix

When self is a non-local version and other is one of its local variants, delegate to the equivalent single-point VersionRange(self, self, include_min=True, include_max=True), whose difference arithmetic can represent the split. The result for the example above is >=2.12.1,<2.12.1+cpu:

difference.allows(Version.parse("2.12.1+cpu"))  # False
difference.allows(Version.parse("2.12.1"))      # True

This matches the approximation VersionRange.difference already produces for the analogous case (e.g. [2.0.0, 2.12.1].difference(2.12.1+cpu)>=2.0.0,<2.12.1+cpu), so the behavior is consistent across constraint types. All other Version.difference paths are unchanged (equal versions, unrelated versions, local-minus-public all behave exactly as before).

Verification

  • New regression test test_difference_local_version (fails on main, passes with this fix).
  • tests/constraints/: 1000 passed; tests/version/: 892 passed; full suite (minus network-dependent tests/integration/): 2917 passed, 1 pre-existing environment failure unrelated to this change (test_valid_trove_classifiers, missing optional trove_classifiers module locally).
  • ruff check / ruff format clean on changed files.

Disclosure

This fix was developed with the assistance of an AI tool (Claude Code), with the approach, diff and test results reviewed and owned by me. I'll respond to review feedback personally.

Version("2.12.1").difference(Version("2.12.1+cpu")) returned self
unchanged, which still allows 2.12.1+cpu — the subtraction made no
progress, sending poetry's solver into an infinite loop when the same
release was available from multiple sources with different local tags.

Delegate to the equivalent single-point VersionRange, whose difference
arithmetic can represent the split, yielding >=2.12.1,<2.12.1+cpu.

Resolves python-poetry/poetry#10965

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • In the new difference branch, consider explicitly checking not self.is_local() and other.is_local() (rather than relying solely on self.allows(other)) so the special-case delegation to VersionRange.difference is clearly limited to the non-local vs local-variant scenario and remains robust if allows() semantics evolve.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In the new `difference` branch, consider explicitly checking `not self.is_local()` and `other.is_local()` (rather than relying solely on `self.allows(other)`) so the special-case delegation to `VersionRange.difference` is clearly limited to the non-local vs local-variant scenario and remains robust if `allows()` semantics evolve.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@apoorvdarshan

Copy link
Copy Markdown
Author

Re the Sourcery suggestion: at that point in the method other.allows(self) has already been ruled out, so self.allows(other) can only be true when self is non-local and other is a local variant of it (weak equality is the only non-strict path in Version.allows). An explicit not self.is_local() and other.is_local() on its own would also match unrelated versions like 2.12.1 vs 3.0.0+cpu, so the allows() check would still be needed — I've kept the tighter form. Happy to spell out the extra checks alongside if maintainers prefer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Infinite loop on poetry lock with specific dependency update

1 participant