Skip to content

hashable class without deriving from HasUhash - #91

Open
poautran wants to merge 17 commits into
mainfrom
uhash_for_custom_class
Open

poautran wants to merge 17 commits into
mainfrom
uhash_for_custom_class

Conversation

@poautran

@poautran poautran commented Feb 13, 2026 •

Copy link
Copy Markdown
Member

In GitLab by @woutdenolf on Mar 10, 2023, 15:31 GMT+1:

The universal hashing is use for output caching. Expand hashing support and allow registering hash functions for third-party types.

  • a class with __uhash__ becomes universally hashable
  • remove recursive uhash calls and replace with LIFO stack (depth-first search)
  • handle circular references when calculating the uhash
  • make helpers classhashdata and multitype_sorted private
  • register uhash methods with the entry-point group "ewoks.hashing" for types that do not have a __uhash__ methods and are not natively supported

AI used (Claude) for splitting commits, fixing recursion limit (LIFO solution) and circular references (open_depth) and adding support for more builtin types.

NOTE: this would need bumping the major version again. Perhaps wait together with #490 and #491 for 6.0 release.

Assignees: @woutdenolf

Migrated from GitLab: https://gitlab.esrf.fr/workflow/ewoks/ewokscore/-/merge_requests/180

@poautran

Copy link
Copy Markdown
Member Author

In GitLab by @woutdenolf on Mar 10, 2023, 16:34 GMT+1:

added 1 commit

  • c693228 - hashable class without deriving from HasUhash

Compare with previous version (link unavailable after force-push)

@poautran

Copy link
Copy Markdown
Member Author

In GitLab by @woutdenolf on Mar 10, 2023, 16:35 GMT+1:

marked this merge request as draft

@poautran
poautran force-pushed the main branch 2 times, most recently from 8dcd50a to b58fb90 Compare February 13, 2026 15:30
@woutdenolf
woutdenolf force-pushed the uhash_for_custom_class branch from c693228 to e15bcf0 Compare June 5, 2026 01:52
@woutdenolf
woutdenolf marked this pull request as draft June 5, 2026 01:52
@codecov

codecov Bot commented Jun 5, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.66082% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/ewokscore/hashing.py 97.63% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

@woutdenolf woutdenolf changed the title Draft: hashable class without deriving from HasUhash hashable class without deriving from HasUhash Jun 5, 2026
@woutdenolf
woutdenolf force-pushed the uhash_for_custom_class branch 10 times, most recently from 4c2f270 to 6bfdb46 Compare September 8, 2026 17:46
@woutdenolf
woutdenolf force-pushed the uhash_for_custom_class branch from 6bfdb46 to bf69894 Compare September 8, 2026 18:08
@woutdenolf
woutdenolf marked this pull request as ready for review September 8, 2026 18:10
@woutdenolf
woutdenolf requested a review from a team September 8, 2026 18:11
@woutdenolf woutdenolf added this to the v6.0 milestone Sep 8, 2026

@loichuder loichuder left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I can understand that the hashing mechanism can be quite complex depending on the value type and I don't know if I am missing information on how hashing works, but I really hit a wall when reviewing this.

I talked about the tests in an inline comment but also, I really feel like the comments do not help: they are verbose but still ambiguous.

The implementation of _uhash looks sensible but I'd suggest to review the inline comments. Perhaps adding some semantic value by renaming some variables and/or extracting some util functions may be useful as well (e.g numpy arrays, datetime).

Comment thread doc/explanations/implementation.rst Resolved
Comment thread doc/explanations/implementation.rst Resolved

obj = Myclass(1)
assert hashing.uhash(obj) == obj.__uhash__()
assert hashing.uhash([obj]) != hashing.uhash([obj.__uhash__()])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What is the point of this check?

@woutdenolf woutdenolf Sep 11, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'll expand a bit more on this response than on the others as an illustration of the more general respone.

  1. Test that when the top-level value's __uhash__ hands back a UniversalHash, it is returned verbatim:
assert uhash(obj) == obj.__uhash__()
  1. Test in addition that the verbatim returning does not apply to the nesting (list in this case):
assert hashing.uhash([obj]) != hashing.uhash([obj.__uhash__()])

If the verbatim logic is implemented wrong and it would be mangled in with the nesting then this would fail. In the current code you could make it fail like this

diff --git a/src/ewokscore/hashing.py b/src/ewokscore/hashing.py
index bcd62d2..2cb91da 100644
--- a/src/ewokscore/hashing.py
+++ b/src/ewokscore/hashing.py
@@ -157,8 +157,7 @@ def _uhash(value) -> UniversalHash:
 
             # Any other return value is the content of the value
             at_root = False
-            _hash.update(_classhashdata(type(value)))
-            _expand(stack, open_depths, value, (nested,))
+            stack.append(nested)
             continue

This shows exactly my point of "covering the holes". A human would test only 1). The AI understands that the verbatim logic itself should not enter other branches of the code, not now and not in the future when the code gets changed.

This also illustrates my dilemma: on the one hard I don't want to delete this hardened tests, on the other hand good luck reading it all during a review (also when creating the PR, I probably spend much more time on the tests than the actual code) or when you add new features and you need to know where you new test needs to fit in and how the tests of the other branches are affected.

Comment on lines +84 to +85
assert a == a.uhash
assert a != c.uhash

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should it not check a == b.uhash here ?

@woutdenolf woutdenolf Sep 11, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Because it's a duplicate of a == a.uhash, not an independent property.

class HasUhash(ABC):

    def __eq__(self, other):
        return uhash(self) == uhash(other)



def test_hashing_uncomparable_keys():
"""Mapping keys and set items need no total order between them."""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What does "need no total order" mean?

@woutdenolf woutdenolf Sep 11, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These keys have no total order:

sorted([(1, 2), (1, "b")])
TypeError: '<' not supported between instances of 'str' and 'int'

I replace "total order" by "comparable". It is for sorting purposes, which "total order" would include, but "comparable" is more intuitive.

"""Mapping keys and set items need no total order between them."""
adict = {(1, 2): "a", (1, "b"): "c"}

assert hashing.uhash(adict) == hashing.uhash(dict(reversed(adict.items())))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Well, last comment for this file. I think that the tests are too complex.

For example, I had to run this in the Python console to see the output to understand what the test was trying to achieve.

I'd suggest to review the tests and simplify them by:

  • more obvious generation of the tested data that leaves room for second guessing.
  • document the aim of the tests more clearly through the test name
  • breaking down longer tests. That should help with the point above.

Comment thread src/ewokscore/hashing.py Outdated
Comment on lines +264 to +275
# Hashing `[1, [2]]` pops values from the stack until it is empty. Expanding a
# value replaces it by the marker `)` that closes it, followed by its items in
# reverse, so that the items are popped in order and the marker last:
#
# stack (top last) popped value hashed
# ---------------- ------------ --------------------
# [1, [2]] [1, [2]] "builtins.list"
# ), [2], 1 1 "builtins.int", "0x1"
# ), [2] [2] "builtins.list"
# ), ), 2 2 "builtins.int", "0x2"
# ), ) ) ")"
# ) ) ")"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I thought I understood _expand until I read that.

What is popped values? What is (top last) ?

Is the table showing different examples or subsequent steps?

@woutdenolf woutdenolf Sep 11, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I first wanted to add this example to the PR but then decided to put it in the code (since it is rather complex). At least the LIFO concept with container expansion is covered by that example. Not the more intricate corner cases like handling circular references (e.g. for this _EndOfNesting holds references for the id's in open_depths are valid).

This is not a single _expand call, this shows how [1, [2]] is hashed.

I should either clarify that or move it somewhere else. The example is mainly there to illustrate the LIFO with _expand logic.

popped values: popped from the stack in each iteration of the _uhash() function. I see now that putting it with _expand() really set you on the wrong track.

(top last) should be removed, it is wrong. Left-over from a previous approach that was abandoned.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Done and comment moved.

Comment thread src/ewokscore/hashing.py Outdated
# ), ) ) ")"
# ) ) ")"
def _expand(
stack: List[Any], open_depths: Dict[int, int], value: Any, nested: Iterable

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
stack: List[Any], open_depths: Dict[int, int], value: Any, nested: Iterable
stack: List[Any], open_depths: Dict[int, int], value: Any, value_items: Iterable

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I went for parent and parent_items. I also reviewed all usage of "nested" and replaced it.

Comment thread src/ewokscore/hashing.py
elif isinstance(value, UniversalHash):
_hash.update(repr(value).encode())
elif isinstance(value, enum.Enum):
# The name identifies the member, except for a combination of flags

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What does "except for a combination of flags" mean? What to do in this case?

@woutdenolf woutdenolf Sep 11, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Flag or IntFlag which can be bitwise combined.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is the reason we need name and value

import enum

class Perm(enum.Flag):
    R = 1
    W = 2
    X = 4

combos = [Perm.R | Perm.W, Perm.R | Perm.X, Perm.W | Perm.X]

print([c.name for c in combos])
# python 3.12:      ['R|W', 'R|X', 'W|X']
# python <3.12:  [None, None, None]

Otherwise we could take only the name (as one would expect for an enum).

This is also something I learned (name only would be enough, hence only the flags are mentioned)

class Color(enum.Enum):
    RED = 1
    ALIAS = 1   # not a new member

assert Color.ALIAS is Color.RED
assert Color.ALIAS.name == "RED"

@woutdenolf

woutdenolf commented Sep 11, 2026 •

Copy link
Copy Markdown
Member

Yes the tests are a problem.

Doing this by hand makes it more readable but leaves lots of untested holes. Letting AI cover those holes leads to unreadable tests.

A pattern that appears not just in this PR. Up to now I was thinking to better fill the holes but now I'm on the fence. Or I should find a better way in guiding the AI in the "filling the holes". The problem is that for complex code there are so many possible permutations of branches that if you fill all holes it might always be a struggle to read through the tests.

@woutdenolf
woutdenolf force-pushed the uhash_for_custom_class branch from 714f95d to 1182ffa Compare September 11, 2026 22:46
woutdenolf and others added 2 commits September 12, 2026 00:57
Co-authored-by: Loïc Huder <42204205+loichuder@users.noreply.github.com>

This branch has not been deployed

No deployments
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.

3 participants