Skip to content

fix(python): honor custom Equals and GetHashCode in structural values#4850

Merged
dbrattli merged 1 commit into
mainfrom
fix/python-custom-equality-hash-4834
Jul 23, 2026
Merged

fix(python): honor custom Equals and GetHashCode in structural values#4850
dbrattli merged 1 commit into
mainfrom
fix/python-custom-equality-hash-4834

Conversation

@dbrattli

Copy link
Copy Markdown
Collaborator

Fixes #4834

Problem

An F# class can override Equals and GetHashCode without opting into Python's __eq__ and __hash__ protocols. The Python runtime checked the Python protocols first, so such a class reached the inherited object.__hash__ (identity hash), and that identity hash leaked into unions, records and arrays. Union equality was also field-wise Python ==.

Running the reproduction from the issue on main:

class equality: true
class hash: false     <- expected true
union equality: false <- expected true
union hash: false     <- expected true
record equality: true
record hash: false    <- expected true
array equality: true
array hash: false     <- expected true

All eight now print true, matching .NET and the JS/TS targets.

Changes

This is solution 1 from the issue — correct the existing helpers, so already-generated Python code is fixed by upgrading fable-library. No compiler changes. The Python runtime now mirrors the TypeScript one.

fable_library/util.py

  • safe_hash delegates to identity_hash, so F# GetHashCode wins over Python __hash__ (it was the other way round).
  • structural_hash was int32(hash(x)). It now dispatches like TS structuralHash: None/bool/str, then GetHashCode, then numbers, then element-wise array_hash for Array/tuple/list, then Python hash, then identity. array_hash is fixed by the same change. As a side effect, structural_hash on a Python list no longer raises TypeError.

fable_library/union.py

  • Union.Equals compares fields with equal_arrays (F# equals) instead of Python ==.
  • Union.GetHashCode combines number_hash(tag) with structural_hash per field instead of hashing a Python tuple.
  • @tagged_union builds the dataclass with eq=False, so case classes inherit F# equality rather than dataclass field-wise ==.

fable_library/record.py

  • record_get_hashcode combines per-field structural_hash instead of hashing a Python tuple.

Incidental fix

EquatableBase declares __eq__, so Python implicitly sets EquatableBase.__hash__ = None, and that None shadows HashableBase.__hash__ in the MRO. #4805 worked around this inside @tagged_union; with eq=False that workaround no longer applies, so __hash__ is now defined on Union and Record directly. This also fixes records: 9 of the 106 generated record classes in the Python test output have no emitted __hash__ and were unhashable from Python.

Tests

Added Custom Equals and GetHashCode are used by structural values to tests/Python/TestComparison.fs and tests/Js/Main/ComparisonTests.fs. JS/TS already behaved correctly, so that one is a regression guard.

  • Python: 2417 passed (was 2416). Pyright unchanged at 34 errors / 45 warnings, and clean on the three modified library files. ruff format --check clean.
  • JavaScript: 3081 passed. TypeScript: 2860 passed.

Performance

Measured on the path Fable actually generates for union equality (util.equals), 200k iterations:

before after
equals(a, b) 1.45 µs 1.29 µs
safe_hash(a) 0.81 µs 1.28 µs
Python a == b 0.06 µs 1.19 µs

The compiled path is slightly faster. Python-level == on a union case is slower because dataclass tuple comparison is replaced by F# equality — that is the point of the fix.

🤖 Generated with Claude Code

An F# class can override Equals and GetHashCode without opting into
Python's __eq__ and __hash__ protocols. The Python runtime preferred the
Python protocols, so such a class fell through to object.__hash__
(identity), and that identity hash leaked into unions, records and
arrays. Union equality was also field-wise Python ==.

Align the Python runtime with the TypeScript one:

- safe_hash delegates to identity_hash, so F# GetHashCode wins over
  Python __hash__
- structural_hash dispatches like TS structuralHash: None/bool/str, then
  GetHashCode, then numbers, then element-wise array_hash for
  Array/tuple/list, then Python hash, then identity. It no longer raises
  TypeError on a Python list
- Union.Equals compares fields with equal_arrays, and Union.GetHashCode
  combines number_hash(tag) with structural_hash per field instead of
  hashing a Python tuple
- @tagged_union builds the dataclass with eq=False so case classes
  inherit F# equality
- record_get_hashcode combines per-field structural_hash instead of
  hashing a Python tuple

EquatableBase declares __eq__, so Python implicitly sets its __hash__ to
None, and that None shadows HashableBase.__hash__ in the MRO. Define
__hash__ on Union and Record so both stay hashable from Python; records
without a generated __hash__ were unhashable before.

Fixes #4834

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Python Type Checking Results (Pyright)

Metric Value
Total errors 34
Files with errors 4
Excluded files 4
New errors ✅ No
Excluded files with errors (4 files)

These files have known type errors and are excluded from CI. Remove from pyrightconfig.ci.json as errors are fixed.

File Errors Status
temp/tests/Python/test_hash_set.py 18 Excluded
temp/tests/Python/test_applicative.py 12 Excluded
temp/tests/Python/test_nested_and_recursive_pattern.py 2 Excluded
temp/tests/Python/fable_modules/thoth_json_python/encode.py 2 Excluded

@dbrattli
dbrattli merged commit 22ec191 into main Jul 23, 2026
42 checks passed
@dbrattli
dbrattli deleted the fix/python-custom-equality-hash-4834 branch July 23, 2026 20:45
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.

[Python] Structural values ignore custom equality and hash codes

1 participant