Raise on async finalizer in close_sync; drop Group cache; slot exceptions#186
Merged
lesnik512 merged 2 commits intoMay 30, 2026
Merged
Conversation
…dd __slots__ to exceptions - CacheItem.close_sync no longer warns-and-returns when it sees an async finalizer. It now raises AsyncFinalizerInSyncCloseError, which the registry collects into the existing FinalizerError aggregation. Cached resources are preserved on the raise path, so users can still recover with `await close_async()` or by switching to `async with`. The silent-no-op pattern is gone. - Group.get_providers no longer caches the introspection result on the class. Dynamic mutation of a Group now sees fresh data. The cost (one cls.__dict__ iteration per Container) is negligible against Container init cost and was the only thing standing between users and a quietly stale provider list. - All exception classes gained __slots__ — both for declaring intent and for a tiny memory win on instances. Marker classes get __slots__ = () for hygiene. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… value CacheItem.cache used None both as the 'not cached' sentinel and as a potentially-valid cached value. Two consequences: 1. A creator that returns None was treated as never-cached: it would be re-created on every resolve and its finalizer would never run. 2. The `if self.cache and ...` guard in close_sync/close_async skipped finalizers for any cached-but-falsy resource (empty dict, 0, False, empty string). Same anti-pattern PR #184 fixed in ContextRegistry. Switched to types.UNSET as the 'not cached' sentinel. Now: - A None-returning creator caches once, returns the cached None on subsequent resolves, and runs its finalizer at close time. - Falsy-but-set cached values (empty dict/list/0/False) get their finalizers called too. Touches the four sites that distinguished cached from not-cached: cache_registry.py (_clear, close_async, close_sync, cached_count) and factory.py (the two cached-value short-circuit reads in resolve). Three new regression tests cover: falsy-sync, falsy-async, and the None-returning creator path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
Summary
Four changes — items #1 and #9 from
plan.md, plus two adjacent cleanups discovered during verification.#1 —
close_syncraises on async finalizersPreviously
CacheItem.close_syncemitted aRuntimeWarningand silently returned when it found a cached resource with an async finalizer. The cache was preserved, but users had no actionable signal — they'd have to know to callclose_async()next. Most won't.Now
CacheItem.close_syncraisesAsyncFinalizerInSyncCloseError(a new exception class). The existingCacheRegistry.close_syncaggregation loop already wraps per-item exceptions intoFinalizerError, so users get one clean error containing the list of offending types and a clear actionable message:Cached resources are preserved on the raise path, so the recovery flow ("call
close_async()to actually clean up") is unchanged — just made explicit instead of silent.Picked "raise" over "unified
close()" per the plan.md options. A unifiedclose()would still have to refuse on async finalizers (you can't await from sync code), so it'd be sugar over a louderclose_sync()— feature creep without solving the actual choice.#9 — drop
Group.providerscacheGroup.get_providers()snapshotted into a class attribute on first call. Dynamic mutation of a Group after the first introspection silently went unseen. Audit confirmed: only one caller (Container.__init__), trivial cost (acls.__dict__iteration over 2-10 attributes), no tests depend on the freeze. Now it just returns a fresh list every call. Pure correctness.Bonus —
__slots__on all exception classesMarker classes (
ModernDIError,ContainerError,RegistrationError,ResolutionError) get__slots__ = (). Subclasses with instance attrs get explicit tuples. Matches the project's existing lean-memory style (Container, registries, dataclasses withslots=True).Bonus —
CacheItem.cacheusesUNSETsentinelNonewas overloaded as both "not cached" and a potentially-valid cached value. Two consequences:Nonewas treated as never-cached — re-created on every resolve, finalizer never ran.if self.cache and ...skipped finalizers for any cached-but-falsy resource (empty dict,0,False, empty string). Same anti-pattern PR Argument suggestions, context falsy/None fix, docs lead withwith#184 fixed inContextRegistry.Migrated
CacheItem.cacheto usetypes.UNSETas the "not cached" sentinel. All four discriminating sites updated (_clear,close_async,close_sync,cached_countincache_registry.py; the two cached-value short-circuit reads infactory.py:resolve). Three new regression tests cover falsy-sync, falsy-async, and the None-returning creator path.Test plan
just lint— clean (ruff + ty)just test— 125 passed (3 new), 100% coveragetests/providers/test_singleton.py::test_request_singletonupdated to expectFinalizerErrorwrappingAsyncFinalizerInSyncCloseErrorand usesis UNSETfor cleared-cache assertiontest_sync_finalizer_exception_does_not_abort_remaining_cleanupcontinues to pass (same aggregation path)FinalizerError(AsyncFinalizerInSyncCloseError); container with None-returning creator caches once and finalizes; container with empty-dict creator finalizes correctly🤖 Generated with Claude Code