Skip to content

chore(lint): drop the empty __slots__ DependencyPathMixin was lying about - #428

Merged
lesnik512 merged 1 commit into
mainfrom
chore/mixin-slots-ty-0074
Aug 24, 2026
Merged

chore(lint): drop the empty __slots__ DependencyPathMixin was lying about#428
lesnik512 merged 1 commit into
mainfrom
chore/mixin-slots-ty-0074

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Why

ty 0.0.74 (2026-08-22) began enforcing __slots__, and just lint-ci now fails with
seven unresolved-attribute diagnostics in modern_di/exceptions.py. Bisected:

version result
uvx ty@0.0.71 check clean
uvx ty@0.0.73 check clean
uvx ty@0.0.74 check 7 diagnostics

DependencyPathMixin declared __slots__ = () on a class whose only base is object,
then set _base_message and dependency_path in its own __init__ and args in
prepend_step. The diagnostic is right, not a false positive on a legitimate mixin
pattern: DependencyPathMixin("boom") raises AttributeError today. The empty
__slots__ asserted the class had no instance attributes while its constructor set two.

This is not confined to the Monday canary. ci.yml and scheduled.yml both call
_checks.yml, which runs just installuv lock --upgrade, and uv.lock is
gitignored — so every lane resolves the newest ty. The next PR pushed to this repo goes
red on just install lint-ci regardless of its diff. main only looks green because its
last 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 — ResolutionError carries ('_base_message', 'dependency_path') for the
resolution branch, and ScopeNotInitializedError / ScopeSkippedError declare them
individually 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.
BaseException supplies a __dict__ unconditionally, so these instances already had one
before this change; the empty __slots__ was never what kept it away. Measured cost is 16
bytes per instance on the nine mixin users (e.g. ScopeNotInitializedError 120 → 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_path slots itself" was also
only true for the two scope errors.

Rejected, with the reason each died:

  • Six inline # ty: ignore[unresolved-attribute]. Silences a checker that is telling
    the truth. 7aed548 set the standard here — "Ruff goes quiet because the code got more
    precise, not because it was silenced."
  • A file-wide [[tool.ty.overrides]] for exceptions.py. Same objection, and it would
    hide a genuine typo across 500 lines that are almost entirely attribute assignment.
  • Give the mixin real slots (__slots__ = ("_base_message", "dependency_path")).
    Raises TypeError: multiple bases have instance lay-out conflict at import — 23
    collection errors. ty 0.0.74 diagnoses this too, as instance-layout-conflict.
  • Make the mixin subclass ModernDIError. Types clean and passes ty, but breaks
    test_dependency_path_mixin_is_not_an_exception (the mixin must never be
    except-catchable on its own) and the docs_slug census, which would then demand a
    troubleshooting page for it.
  • Pin ty<0.0.74. Freezes the type checker to preserve a class that was wrong, and
    leaves the canary red every Monday.

Non-goals

  • No guard test. ty runs on every PR and CI resolves it fresh; that is the gate.
    The consequence is accepted knowingly: DependencyPathMixin becomes the only class in
    exceptions.py without __slots__, and a contributor tidying the file may well add the
    line back. Only ty will catch it.
  • No planning/decisions/ entry. The rejected alternatives above are contingent on one
    tool release, not a standing architectural fork; entries there ("no generator creators",
    "alias binds nothing") outlive any dependency version.
  • No release note. No behavior, API, or downstream integration is affected.
  • No upstream ty report. The diagnostic is correct — the code was wrong.
  • No change to uv lock --upgrade in just install. Every lane resolving the newest
    dependency set is intentional, and the canary/PR-lane overlap that follows from it is a
    separate question from this fix.

Verification

just install first, so the local toolchain matches CI (ty 0.0.70 → 0.0.74, ruff
0.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.
  • Instance size, sys.getsizeof on 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

…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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

@lesnik512
lesnik512 merged commit cec6f48 into main Aug 24, 2026
9 checks passed
@lesnik512
lesnik512 deleted the chore/mixin-slots-ty-0074 branch August 24, 2026 12:52
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.

Scheduled dependency check failed

1 participant