Skip to content

fix(requirements): make Requirement.__hash__ consistent with __eq__ for trailing-zero-equivalent specifiers - #1232

Merged
notatallshaw merged 1 commit into
pypa:mainfrom
r266-tech:fix/requirement-hash-eq-consistency
Jun 15, 2026
Merged

fix(requirements): make Requirement.__hash__ consistent with __eq__ for trailing-zero-equivalent specifiers#1232
notatallshaw merged 1 commit into
pypa:mainfrom
r266-tech:fix/requirement-hash-eq-consistency

Conversation

@r266-tech

Copy link
Copy Markdown
Contributor

Requirement is a public hashable class, but __hash__ violated the a == b ⇒ hash(a) == hash(b) invariant for trailing-zero-equivalent specifiers:

>>> from packaging.requirements import Requirement
>>> a, b = Requirement("foo==1.0.0"), Requirement("foo==1.0.0.0")
>>> a == b
True
>>> hash(a) == hash(b)
False        # -> they don't dedup in sets/dicts
>>> len({a, b})
2

__hash__ hashed str(self.specifier) (non-canonical) via _iter_parts, while __eq__ compares the canonical SpecifierSet. So equal requirements silently failed to deduplicate in sets/dicts used by resolvers and lockfile tooling. The existing test_equivalent_reqs_equal_hashes_unequal_strings docstring already states equivalent reqs should share a hash.

Fix: hash the same components __eq__ uses (canonical name, extras, the SpecifierSet object, url, marker) instead of the rendered string. str(req)/repr(req) are unchanged (they still use _iter_parts).

Regression tests added to EQUIVALENT_DEPENDENCIES (trailing-zero specifiers, alone and combined with extras + marker); all fail on main, pass with the fix. Full suite: 5309 passing.

Equal Requirements with trailing-zero-equivalent specifiers (e.g.
foo==1.0.0 and foo==1.0.0.0) compared equal but hashed differently,
so they failed to deduplicate in sets and dicts. __hash__ hashed the
non-canonical str(self.specifier) via _iter_parts, while __eq__ compares
the canonical SpecifierSet. Hash the same components __eq__ uses.
@r266-tech
r266-tech force-pushed the fix/requirement-hash-eq-consistency branch from 1ac700f to fa40f9d Compare June 7, 2026 17:01
@henryiii

henryiii commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Looks fine to me, also checked with Claude. @notatallshaw would you mind verifying too?

🤖 Claude review 🤖

All tests pass. Here's my review.


Code Review: PR #1232Requirement.__hash__ consistency

Author: Evo (r266-tech) · Base: main · +27 −1 across 3 files

Overview

Fixes a genuine violation of the a == b ⇒ hash(a) == hash(b) invariant. __hash__ was hashing str(self.specifier) (non-canonical) via _iter_parts, while __eq__ compares the canonical SpecifierSet. So Requirement("foo==1.0.0") and Requirement("foo==1.0.0.0") compared equal but hashed differently — silently failing to deduplicate in sets/dicts used by resolvers and lockfile tooling. The fix hashes the same five components __eq__ compares.

Correctness — ✅ Solid

I verified the fix is genuinely consistent, not just superficially aligned:

  • Component-for-component match with __eq__ (requirements.py:154-164): canonicalize_name(name), extras, self.specifier, self.url, self.marker — identical tuple. frozenset(self.extras) is the right call since extras is a set (unhashable), and frozenset hashing matches set equality.
  • The deeper invariant holds transitively. The fix only works if each hashed sub-object is itself hash/eq-consistent. I checked:
    • SpecifierSet.__hash__hash(self._canonical_specs()) and __eq__ also compares canonical specs (specifiers.py:975, 1009). So the trailing-zero case actually resolves at the SpecifierSet level — hashing the object (not its raw string) is what fixes the bug.
    • Marker.__hash__/__eq__ both use str(self) (markers.py:389-396) — consistent.
  • No regression to str/repr — they still use _iter_parts (requirements.py:132-136), unchanged. Good separation; the bug was only in the hash path.
  • Full suite: 5310 passed locally.

Style & conventions — ✅

  • Comment is a complete sentence and explains why (_iter_parts yields non-canonical string), matching repo convention.
  • Changelog entry added under Fixes: with :pull:1232`` — required for bug fixes per CLAUDE.md. ✓
  • Tests added to the existing EQUIVALENT_DEPENDENCIES table, the idiomatic spot. Cases cover trailing-zero alone, >= variant, and combined with extras + marker — exactly the interaction surface that could break.

Minor observations (non-blocking)

  • url and marker in EQUIVALENT_DEPENDENCIES coverage: The new cases exercise specifier/extras/marker but no URL-bearing requirement. Existing DIFFERENT_DEPENDENCIES likely covers url inequality, so this is fine — just noting the hash tuple's url field isn't independently asserted in an equivalence case. Not worth adding.
  • Hash cost: Hashing SpecifierSet/Marker objects is marginally more work than hashing a pre-rendered string, but it's the price of correctness and these objects cache nothing pathological. Negligible.
  • Pickle/getstate interaction: __getstate__ (requirements.py:99) stores str(self) + prereleases override, independent of the hash path — no interaction, correctly untouched.

Verdict

Approve. This is a textbook minimal bug fix: correct root-cause diagnosis (hash path used non-canonical string), the fix mirrors __eq__ exactly, the consistency holds transitively through SpecifierSet/Marker, and the regression tests fail on main / pass here. No risks identified.

@henryiii

Copy link
Copy Markdown
Contributor

I believe this is one of the bugs listed in #1239 (found after this PR)

@notatallshaw
notatallshaw merged commit d8e08df into pypa:main Jun 15, 2026
59 checks passed
@notatallshaw

Copy link
Copy Markdown
Member

Thanks for the PR.

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

Labels

None yet

3 participants