chore(lint): drop the empty __slots__ DependencyPathMixin was lying about - #428
Merged
Conversation
…bout
ty 0.0.74 began enforcing __slots__ and reported seven diagnostics against
DependencyPathMixin, which declared __slots__ = () on a class whose base is
object while its own __init__ set _base_message and dependency_path and
prepend_step set args. The diagnostic is correct: instantiated on its own the
class raises AttributeError. The empty __slots__ asserted the class had no
instance attributes, and it had two.
Deleting the line lets the mixing-in classes' declarations stand on their own
and leaves attributes in slots: __dict__ is still {} after __init__. Instances
grow 16 bytes on the nine mixin users, on the error path only.
The "Empty __slots__" docstring paragraph goes with it. It explained the deleted
line, and its claim that "each concrete error declares the slots itself" was
only true for ScopeNotInitializedError and ScopeSkippedError; the resolution
branch inherits both slots from ResolutionError.
There was a problem hiding this comment.
Benchmark
Details
| Benchmark suite | Current: 41a53f4 | Previous: 4a2c1e9 | Ratio |
|---|---|---|---|
benchmarks/test_guard_by_type.py::test_g16_resolve_by_type |
3050085.9804058424 iter/sec (stddev: 2.4625473416467074e-8) |
3131339.6907784934 iter/sec (stddev: 1.1365197451807737e-8) |
1.03 |
benchmarks/test_guard_by_type.py::test_g17_resolve_by_type_large_registry |
3111789.578046972 iter/sec (stddev: 1.1507882190508495e-8) |
3147319.313263393 iter/sec (stddev: 7.85964384394602e-9) |
1.01 |
benchmarks/test_guard_cold.py::test_g8_cold_first_resolve |
23708.404618756704 iter/sec (stddev: 0.000005288667084810414) |
23973.235395938165 iter/sec (stddev: 0.000004575180533635843) |
1.01 |
benchmarks/test_guard_cold.py::test_g8b_cold_first_resolve_cached |
18396.12594572733 iter/sec (stddev: 0.000004648164719516942) |
18105.615777479623 iter/sec (stddev: 0.0000044389793017204426) |
0.98 |
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[1] |
415.1028191053855 iter/sec (stddev: 0.00017633166568983968) |
436.53711967878576 iter/sec (stddev: 0.00004376833353480268) |
1.05 |
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[2] |
404.1168665749508 iter/sec (stddev: 0.00005030976690176022) |
412.9842194266708 iter/sec (stddev: 0.00003691303690898278) |
1.02 |
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[4] |
362.80363440261647 iter/sec (stddev: 0.00016761279865871675) |
374.0567180121959 iter/sec (stddev: 0.00005454875019712583) |
1.03 |
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[1] |
2078.240597531114 iter/sec (stddev: 0.000033925218165585117) |
2247.321978756456 iter/sec (stddev: 0.000026622962803976113) |
1.08 |
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[2] |
1504.1457453044093 iter/sec (stddev: 0.00018245376935086078) |
1689.1738049662983 iter/sec (stddev: 0.00003349524247985858) |
1.12 |
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[4] |
1063.959273341811 iter/sec (stddev: 0.000054434889234539106) |
1159.2177011632955 iter/sec (stddev: 0.000034696523367120104) |
1.09 |
benchmarks/test_guard_lifecycle.py::test_g6_build_child_container |
691555.2682060807 iter/sec (stddev: 3.5915135285661084e-7) |
760142.8376849189 iter/sec (stddev: 5.114679594958602e-8) |
1.10 |
benchmarks/test_guard_lifecycle.py::test_g6b_build_child_container_auto_scope |
676750.7605619532 iter/sec (stddev: 6.186864146852431e-8) |
691933.8298121989 iter/sec (stddev: 4.453407911727641e-8) |
1.02 |
benchmarks/test_guard_lifecycle.py::test_g7_request_lifecycle_batch |
2299.2675028514777 iter/sec (stddev: 0.00004098085586608626) |
2345.428271158963 iter/sec (stddev: 0.000013313776919238034) |
1.02 |
benchmarks/test_guard_lifecycle.py::test_g7c_event_loop_floor_control |
61315.67498640162 iter/sec (stddev: 0.000002178246465634359) |
62159.37389590821 iter/sec (stddev: 0.0000017000096907421885) |
1.01 |
benchmarks/test_guard_lifecycle.py::test_g13_teardown_at_scale |
44605.8101314776 iter/sec (stddev: 0.0000020779912911941337) |
46405.33363056434 iter/sec (stddev: 0.0000020117795806948063) |
1.04 |
benchmarks/test_guard_resolve.py::test_g1_transient_resolve |
1974811.2820933936 iter/sec (stddev: 2.8804504058559023e-8) |
1966498.8243350547 iter/sec (stddev: 2.049290658233996e-8) |
1.00 |
benchmarks/test_guard_resolve.py::test_g2_cached_resolve |
3417864.528488951 iter/sec (stddev: 8.578300546276392e-9) |
3379691.6626399853 iter/sec (stddev: 1.653888799367545e-8) |
0.99 |
benchmarks/test_guard_resolve.py::test_g3_deep_chain |
731946.0966354978 iter/sec (stddev: 3.278724416363859e-8) |
768473.5124823987 iter/sec (stddev: 4.016224641554935e-8) |
1.05 |
benchmarks/test_guard_resolve.py::test_g4_wide_resolve |
404861.1680440886 iter/sec (stddev: 9.864918043387543e-8) |
404142.75421478914 iter/sec (stddev: 1.0031516053464265e-7) |
1.00 |
benchmarks/test_guard_resolve.py::test_g5_cross_scope |
1592221.0448672334 iter/sec (stddev: 2.329336877468065e-8) |
1618767.210040252 iter/sec (stddev: 2.751064439416132e-8) |
1.02 |
benchmarks/test_guard_resolve.py::test_g9_context_resolve |
822189.8685949196 iter/sec (stddev: 7.509113024691901e-8) |
827570.6485696489 iter/sec (stddev: 5.8830444277978975e-8) |
1.01 |
benchmarks/test_guard_resolve.py::test_g12_override_active_resolve |
493096.9144017105 iter/sec (stddev: 4.821468526080776e-8) |
521772.1018925019 iter/sec (stddev: 4.578861563774051e-8) |
1.06 |
benchmarks/test_guard_resolve.py::test_g18_alias_hop |
2310652.377811995 iter/sec (stddev: 1.3081944332261198e-8) |
2336966.3890724345 iter/sec (stddev: 1.962085317688145e-8) |
1.01 |
benchmarks/test_guard_validate.py::test_g10_validate_deep_chain |
27627.428798602934 iter/sec (stddev: 0.000003363044307140246) |
27043.511874634052 iter/sec (stddev: 0.0000035682812283286334) |
0.98 |
benchmarks/test_guard_validate.py::test_g11_validate_wide |
17055.115008344386 iter/sec (stddev: 0.000004751105358103541) |
16830.00089041036 iter/sec (stddev: 0.000004575965704095057) |
0.99 |
This comment was automatically generated by workflow using github-action-benchmark.
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.
Why
ty0.0.74 (2026-08-22) began enforcing__slots__, andjust lint-cinow fails withseven
unresolved-attributediagnostics inmodern_di/exceptions.py. Bisected:uvx ty@0.0.71 checkuvx ty@0.0.73 checkuvx ty@0.0.74 checkDependencyPathMixindeclared__slots__ = ()on a class whose only base isobject,then set
_base_messageanddependency_pathin its own__init__andargsinprepend_step. The diagnostic is right, not a false positive on a legitimate mixinpattern:
DependencyPathMixin("boom")raisesAttributeErrortoday. The empty__slots__asserted the class had no instance attributes while its constructor set two.This is not confined to the Monday canary.
ci.ymlandscheduled.ymlboth call_checks.yml, which runsjust install→uv lock --upgrade, anduv.lockisgitignored — so every lane resolves the newest
ty. The next PR pushed to this repo goesred on
just install lint-ciregardless of its diff.mainonly looks green because itslast run (2026-08-22 09:30) predates the 0.0.74 release by six hours.
Design
Delete the line. The classes that mix the breadcrumb machinery in already declare both
slots —
ResolutionErrorcarries('_base_message', 'dependency_path')for theresolution branch, and
ScopeNotInitializedError/ScopeSkippedErrordeclare themindividually for the container branch — so nothing downstream needs to change.
The slots discipline survives: after
__init__, a mixin user's__dict__is still{},so both attributes land in the inherited slot descriptors rather than a per-instance dict.
BaseExceptionsupplies a__dict__unconditionally, so these instances already had onebefore this change; the empty
__slots__was never what kept it away. Measured cost is 16bytes per instance on the nine mixin users (e.g.
ScopeNotInitializedError120 → 136),paid only on the error path.
The
Empty __slots__:docstring paragraph goes with the line it explained. Its claim that"each concrete error declares the
_base_message/dependency_pathslots itself" was alsoonly true for the two scope errors.
Rejected, with the reason each died:
# ty: ignore[unresolved-attribute]. Silences a checker that is tellingthe truth. 7aed548 set the standard here — "Ruff goes quiet because the code got more
precise, not because it was silenced."
[[tool.ty.overrides]]forexceptions.py. Same objection, and it wouldhide a genuine typo across 500 lines that are almost entirely attribute assignment.
__slots__ = ("_base_message", "dependency_path")).Raises
TypeError: multiple bases have instance lay-out conflictat import — 23collection errors.
ty0.0.74 diagnoses this too, asinstance-layout-conflict.ModernDIError. Types clean and passesty, but breakstest_dependency_path_mixin_is_not_an_exception(the mixin must never beexcept-catchable on its own) and the
docs_slugcensus, which would then demand atroubleshooting page for it.
ty<0.0.74. Freezes the type checker to preserve a class that was wrong, andleaves the canary red every Monday.
Non-goals
tyruns on every PR and CI resolves it fresh; that is the gate.The consequence is accepted knowingly:
DependencyPathMixinbecomes the only class inexceptions.pywithout__slots__, and a contributor tidying the file may well add theline back. Only
tywill catch it.planning/decisions/entry. The rejected alternatives above are contingent on onetool release, not a standing architectural fork; entries there ("no generator creators",
"alias binds nothing") outlive any dependency version.
tyreport. The diagnostic is correct — the code was wrong.uv lock --upgradeinjust install. Every lane resolving the newestdependency set is intentional, and the canary/PR-lane overlap that follows from it is a
separate question from this fix.
Verification
just installfirst, so the local toolchain matches CI (ty0.0.70 → 0.0.74,ruff0.16.2 → 0.16.4).
just lint-ci— passes:eof-fixer --check,ruff format --check(133 files),ruff check --no-fix,ty check,planning/index.py --check,planning/links.py.uv run ty check --python-version 3.10— passes, matching CI's lint interpreter(locally run on 3.14.7).
just test-ci— 514 passed, 100.00% line coverage over 6077 statements.sys.getsizeofon all nine mixin users: +16 bytes each.__dict__is{}after__init__both before and after this change.No tests added or changed: no behavior changed, and the existing suite passes untouched.
Closes #427