Diff custom objects by attributes, matching DeepDiff's object rules - #95
Open
ksco92 wants to merge 5 commits into
Open
Diff custom objects by attributes, matching DeepDiff's object rules#95ksco92 wants to merge 5 commits into
ksco92 wants to merge 5 commits into
Conversation
ksco92
force-pushed
the
feature/66-custom-objects
branch
from
September 6, 2026 14:43
45ee472 to
c421e35
Compare
Custom objects now diff by their attributes instead of raising TypeError, matching DeepDiff's _diff_obj: attribute_added/attribute_removed with root.attr paths, type_changes between two different classes, and the same enumeration DeepDiff uses (instance __dict__ plus non-callable, non-dunder names from dir() up the MRO, or slots for a slots-only class). Objects work inside lists, dicts, and under ignore_order, where they hash and pair by a class-tagged content key so a custom object never matches a plain dict or an instance of another class. Reuses the existing Object value with an ObjectKind marker, a new Attribute path segment, and attribute_added/attribute_removed report categories. Adds an $object golden tag (both readers), 13 hand-designed goldens, a differential-fuzz batch over generated classes, and depth-guard and recursive-object tests. Bumps to 0.12.0.
…on and mappingproxy fixes
Gate the object fallback by an accept-list derived from DeepDiff's _diff
dispatch ladder: only a genuine user-defined class (and an Enum member, which
matches _diff_enum via name/value) is diffed by attributes. Every type DeepDiff
routes to a dedicated handler onix lacks -- a number (complex/Decimal/Fraction),
any iterable (bytes/bytearray/memoryview/range/generator/__iter__), uuid,
ipaddress, a class object, a module, or a bare attribute-less object -- is
refused with a typed, path-naming TypeError rather than reshaped into an object
that would silently report {} for unequal values.
Class identity is now the qualified __module__+__qualname__ plus kind, not the
bare __name__: two same-named classes from different modules, and a dict
subclass versus a same-named object, are type_changes. Routed through one
same_class definition shared by dispatch and Value equality.
Read __dict__ via .copy() (handles a snapshot against property mutation);
propagate any non-AttributeError from a property getter at its path; refuse
class objects before their mappingproxy is read. Drop the unused _path
parameter; build the fuzz classes from one shared init.
ksco92
force-pushed
the
feature/66-custom-objects
branch
from
September 6, 2026 16:03
c421e35 to
34e71a4
Compare
Enum members are diffed by their name/value (matching DeepDiff), not refused, so drop Enum from the raise-list and state the exception. to_dict() returns an attribute dict for every custom object, not only those with a property or class attribute, so state it unconditionally.
…y, dict snapshot Gate the object fallback by the concrete predicates and order of DeepDiff's _diff ladder (concrete number tuple, not the numbers.Number ABC; the Iterable ABC; Enum after both; the getmembers strategy for C types like re.Pattern; refuse only an empty extraction). Key class identity on the type object's address (id(type)) plus module/qualname NUL-joined, so classes sharing a qualified name but not a type object are type_changes, matching 'type(t1) is not type(t2)'. Iterate a dict snapshot so a property getter that mutates a dict under conversion cannot panic pyo3's iterator. A non-mapping __dict__ gets the typed path error. Share the getattr helper, inline the distance wrapper, delegate ccustom to ccustom_id, and correct the docs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #66. Follow-up for the object-view gaps: #99.
Custom objects were the last value type the README's value-types list excluded (onix raised
TypeError). They now diff by their attributes, matching DeepDiff's_diff_obj—attribute_added/attribute_removedwithroot.attrpaths,type_changesbetween two classes that are not the same — inside lists, dicts, and underignore_order.DeepDiff 9.1.0 source, verified
DeepDiff uses three attribute views of one object:
helper.detailed__dict__(instance__dict__plusdir()-derived properties and class attributes, viagetattr) for diffing;deephash._prep_obj(raw__dict__/slots, class-tagged) forignore_order; andserialization.json_convertor_default(public properties, else public__dict__, elseTypeError) forto_json(). Its_get_item_lengthcounts an object's__dict__keys, not its values. This PR implements the diffing view; the hashing and serialization views andto_dict()'s original-instance return are the split follow-up (#99, see Skipped).Accept-list gating
A fallback arm that reshapes whatever reaches it is gated by an explicit accept-list checked against DeepDiff's own
_diffdispatch ladder, so no type is silently reshaped. Cross-checked against the ladder three ways:(int, float, complex, Decimal, Fraction)— not thenumbers.NumberABC — so a class registered with or subclassingnumbers.Numberreaches attribute diffing (matching DeepDiff), whilecomplex/Decimal/Fractionare refused. Iterables use thecollections.abc.IterableABC, which is exactly what DeepDiff's ladder tests.Enumacceptance sits after the number and iterable refusals, matching the ladder (Iterableprecedes theEnumelif), so an iterable-mixin type is not mis-accepted as anEnum._diff_objenumeration strategy is accepted —__dict__,__slots__, and thegetmembersstrategy for a C type with neither (e.g.re.Pattern, diffed byroot.pattern). Only an empty extraction (a bareobject()) is refused, the{}-for-unequal hazard.Accepted → attribute diff: a user-defined class instance, and an
Enummember (matches_diff_enumvianame/value).Refused → typed, path-naming
TypeError:complex/Decimal/Fraction, any iterable (bytes/bytearray/memoryview/range/generators/__iter__types, and a custom non-dictMapping— a deliberate over-refusal, documented),uuid,ipaddress, class objects, modules, a bareobject(). Invariant (rule + tests): nothing that raised before reports{}for two Python-unequal values; an empty extraction and a self-referential object each end-to-end (the latterMaxDepthError).Class identity
DeepDiff's rule istype(t1) is not type(t2)— a comparison of the type objects. onix keys identity onformat!("{module}\0{qualname}\0{id(type)}"): the type object's address (id) is the discriminator, so two classes created under one qualified name (a class defined in a function body,type("E", (), {})twice,make_dataclass) are distinct and reporttype_changes; both type objects are alive for the whole diff (each instance holds a reference), making the address sound within a run. Module and qualname are folded in, NUL-joined (impossible in either), which also removes the earlier dot-separator collision. The renderedold_type/new_typestays the bare__name__. Routed through onesame_classshared by the diff dispatch andValueequality; adictsubclass versus a same-named object is atype_changestoo (name plus kind).Robustness
__dict__read via.copy()at every dict and object level, so a@propertygetter that inserts into a dict being converted (even one levels up) cannot panic pyo3's live-dict iterator — it works over a snapshot, as DeepDiff's_diff_dictdoes over copied key sets. A getter raising anything butAttributeError(aValueError, or aBaseException) propagates at the attribute's path; anAttributeErrorskips that attribute. A__dict__that is not a mapping, and a class object (mappingproxy), get the typed path-naming error.Goldens, fuzz, mutation
$objectgolden tag (both readers); 13 hand-designed cases from live DeepDiff, byte-identical, plustest_conversions.pycases (slots-only, dict+slots, dataclass withdefault_factory, name-mangled_Cls__x, property+class-attribute,Enum, dict-subclass-vs-object, cross-module, two-type("E"), local classes,re.Pattern, registered-number, property raisingValueError/AttributeError, dict-mutating property at one and two levels) asserted against live DeepDiff.ignore_order} = 600 diffs, zero divergences. (Property/class-attribute fuzz widening waits on Match DeepDiff's three object views: storage-view hashing, public-view to_json, original instances from to_dict #99, since such objects diverge on exactly the hashing/serialization views Match DeepDiff's three object views: storage-view hashing, public-view to_json, original instances from to_dict #99 covers.)cargo mutantsscoped to the changed onix-core functions: all caught, zero survivors. The mutation run does not cover onix-py; its enumeration strategies and the accept-list are pinned by thetest_conversions.pycases above.Benchmarks
Release, final head vs 0.11.1, hyperfine (the CLI path is JSON-only, so it measures the dict/scalar path the object arm shares; the object-arm hash writes never execute on these fixtures). No row regresses beyond noise:
ignore_order_10k(all-numeric) is unchanged within noise; the object-arm changes do not touch it.Divergence triage
type_changesby type-object identity and kind,Enum,re.Pattern, the accept-list refusals.AttributeError-raising property skips that attribute vs DeepDiff's whole-objectunprocessed; a custom non-dictMapping, and an object with an unsupported-typed attribute (a directnumbers.Number/ABCMeta subclass's_abc_impl), are over-refused.MaxDepthErrorwhere DeepDiff's identity-based cycle detection returns{}— onix holds a value model, not object identity; deterministic, not a crash.Skipped — tracked in #99
Three behaviors need attribute views onix does not hold, split to #99 (owner decision), documented in README Known limitations and
tests/golden/README.md(which carries the full trigger list):ignore_orderhashing (semantic):[Prop(1), Prop(2)]vs[Prop(2), Prop(3)]→ DeepDiff pairs by the storage view (iterable_item_added/removed); onix by the detailed view (values_changed root[0]._x). Needs per-attribute storage-vs-computed provenance.to_json()whole-object value (semantic):type_changes(WithProp, WithProp2)→ DeepDiff{'p': 10}(public props); onix{'kls': 'k', 'p': 10, 'x': 1}(full view). Needs a public-view render.to_dict()(the issue's requirement): DeepDiff returns the original instances; onix returns attribute dicts (it holds noPyObjectafter conversion). Needs a per-diff original-instance side table.The
to_dict()fuzz comparison stays scoped off the object batch for this reason; #99 re-enables it.Version
0.12.0 (adds a capability), rebased onto 0.11.1.