StashObject, and the architectural decisions behind each fix. Both stem from the same root: Pydantic v2’s internal storage model has behaviors that are safe for typical models but dangerous for StashObject’s identity map + bidirectional relationship pattern.
Relevant versions: Private attribute fix landed in v0.10.14. Shallow repr landed in v0.11.0b1.
Background: Pydantic v2’s Three Storage Dicts
Every Pydantic v2BaseModel instance has three internal dictionaries:
StashObject uses
validate_assignment=True (to run validators on field updates) and extra="allow" (to accept unknown GraphQL fields without crashing). This combination creates the conditions for both issues below.
Issue 1: Private Attributes Destroyed by validate_assignment
Fixed in: v0.10.14
Location: stash_graphql_client/types/base.py
The Problem
StashObject maintains three private attributes for internal bookkeeping:
Originally, these were stored via
object.__setattr__(), which writes directly to __dict__. This works in plain Python objects, but in Pydantic v2 with validate_assignment=True, every field assignment rebuilds the entire __dict__ — silently destroying any non-field data stored there.
How It Manifests
The bug only appears on identity map cache hits, not on fresh object construction:save() call would send unnecessary GraphQL mutations for fields that hadn’t actually changed.
Minimal Reproduction (Pre-Fix)
The Fix: Pydantic PrivateAttr
Convert all three attributes from object.__setattr__() storage to Pydantic PrivateAttr declarations:
PrivateAttr stores values in __pydantic_private__, which is never rebuilt by validate_assignment. All object.__setattr__() calls were replaced with direct assignment (self._attr = value), which Pydantic routes to __pydantic_private__ automatically.
Why It Only Appeared on Cache Hits
Objects constructed with all fields populated don’t exhibit this bug because:model_post_initcreates_snapshotwith real values (not UNSET)- Even if
__dict__is rebuilt and_snapshotfalls through to__pydantic_extra__, the stale copy also has real values - So
current_value == snapshot_valueholds true — dirty tracking appears to work
- Object is constructed minimally (e.g., nested fragment with just
id) - Fields are populated via
setattr()(identity map merge path) model_post_initsnapshot has UNSET for all fieldsmark_clean()creates new snapshot with real values- Next field assignment rebuilds
__dict__, losing the snapshot - Fallback finds the stale all-UNSET snapshot from step 3
find_scenes() or find_galleries() returns objects already in the identity map cache.
Key Lesson
Never useobject.__setattr__()to store state on Pydantic v2 models withvalidate_assignment=True. UsePrivateAttrinstead — it uses__pydantic_private__, the only dict that is guaranteed stable across all Pydantic operations.
Issue 2: Recursive __repr__ Exponential Blowup
Fixed in: v0.11.0b1
Location: stash_graphql_client/types/base.py, all entity type files
The Problem
Pydantic v2’s defaultBaseModel.__repr__() recursively renders every field of every nested model. With StashObject’s bidirectional relationships, this walks the entire object graph:
Real-World Impact
A downstream consumer calledrepr() on changed field values before saving. On a dataset with ~3,000 posts (~1,200 scenes, ~1,800 galleries):
Any consumer calling
repr() — logging, debugging, REPL, pytest assertion output — hits the same problem.
The Fix: Two-Tier Shallow Repr
StashObject now has two repr methods:_short_repr() — Compact Nested Representation
Used when an object appears inside another object’s repr. Collects all set+non-None fields from the __short_repr_fields__ tuple:
TypeName(id='...'). Multi-field tuples show all matching fields — e.g., __short_repr_fields__ = ("id", "name") produces Performer(id='123', name='Jane').
Each entity subclass declares which fields to include:
Fallback: if label field is UNSET or None, falls back to
TypeName(id='123').
__repr__() — Full Representation
Shows all non-UNSET model fields (not just tracked fields), with relationship fields rendered shallowly:
Example Output
Before (Pydantic default — truncated, actual output is megabytes):Design Decisions
Why show all model fields, not just tracked fields? The initial proposal showed only__tracked_fields__, but the final implementation shows all non-UNSET model fields. This provides more complete debugging information — fields like rating100, scene_count, and date are useful in repr output even though they aren’t tracked for dirty checking.
Why sorted() on field names?
Deterministic output across runs. Without sorting, dict iteration order could vary, making log diffs and test assertions unreliable.
Why self.__class__.model_fields instead of self.model_fields?
Pydantic v2.11 deprecated instance-level model_fields access. Using the class-level accessor avoids deprecation warnings.
Why no dirty indicator (* suffix)?
The original proposal included "*" when is_dirty() returns True. This was removed because is_dirty() does field-by-field comparison including list traversal — too much work for a __repr__ method that might be called frequently in logging or debugging contexts. A repr should be fast and side-effect-free.
Why not use __repr_args__ (Pydantic’s hook)?
Pydantic v2’s __repr_args__ returns (field_name, value) tuples that Pydantic then repr()s recursively. There’s no way to control relationship rendering at that level — the only solution is to override __repr__ entirely.
Why truncate scalars at 200 characters (not 60)?
Fields like details can contain multi-paragraph text. The initial proposal used 60 chars, but this was too aggressive — URLs, file paths, and aliases are commonly 80-150 chars. 200 chars provides a better balance.
Why first 2 items in list repr (not count-only)?
Showing tags=[5 Tag] is compact but loses information. Showing tags=[Tag(name='blonde'), Tag(name='brunette'), ..3 more] gives immediate identification of the first two items, which is usually enough to understand the relationship contents without expanding the full list.
What about __str__?
Left as Pydantic’s default (which delegates to __repr__). This means str(obj), f"{obj}", and print(obj) all use the shallow repr — there’s no use case where a consumer wants the multi-MB recursive output.
The Common Thread
Both issues stem from Pydantic v2’sBaseModel being designed for simple data containers, not for objects with:
- Identity map caching — objects are mutated after construction via
setattr() - Bidirectional relationships — objects reference each other (Scene→Performer→Scene)
- Internal bookkeeping state — private attributes that must survive field mutations
- Using
__pydantic_private__(viaPrivateAttr) for internal state that must be stable - Overriding
__repr__to prevent recursive traversal of the relationship graph - Using
__class__.model_fieldsfor introspection to avoid deprecated instance access - Keeping
validate_assignment=Truefor field validation while protecting private state from its dict-rebuilding side effect