Skip to content

feat!: tighten Container's public surface (#565) - #592

Merged
lesnik512 merged 1 commit into
mainfrom
feat/container-surface
Oct 5, 2026
Merged

lesnik512 merged 1 commit into
mainfrom
feat/container-surface

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Closes #565.

Summary

  • New public Container.find_provider(dependency_type), returning the registered provider or None. It delegates to the providers registry lookup, so a child answers the same as its root, and it ignores overrides and the closed state.
  • The registries are private: _providers_registry, _cache_registry, _context_registry. The overrides_registry slot is gone; overrides are reached through _providers_registry.overrides, so there is one name for one object.
  • closed is a read-only property backed by _closed. open(), close_sync() and close_async() set the private field; assigning closed raises AttributeError.
  • Bug: Container(scope=..., parent_container=p, groups=[G]) registered G into the tree-wide registry. It now raises ChildContainerRegistrationError, the same error add_providers raises on a child.
  • Bug: resolve checked closed after the registry lookup, so a closed container asked for an unregistered type raised ProviderNotRegisteredError. Both resolve and resolve_provider now check the closed state first.
  • Contract docstrings for build_child_container, find_container, close_sync, close_async, reset_override and the new closed property.
  • Docs: scopes.md no longer names registry attributes, container.md gains a "Looking up a provider" section, and lifecycle.md, errors-and-exceptions.md and the ChildContainerRegistrationError troubleshooting page cover the constructor case. Four migration entries in to-4.x.md.

Design decisions

  • ChildContainerRegistrationError now has two raise sites, and its old message ("Container.add_providers can only be called on a root container") was wrong for the constructor path. The message is now general and suggests groups= on the root or add_providers. Any test that matched the old text needs updating; the migration entry says so.
  • Cross-module readers of the registries:
    • providers/alias.py uses the new container.find_provider.
    • dependency_graph.collect_errors takes the registry as an argument; Container.validate passes it in.
    • _handle_recursion_error takes the registry as an argument for the same reason.
    • resolver_compiler.py is SLF001-exempt and reads _closed and the registries directly, so the generated resolvers do not pay for a property call.
  • noqa: SLF001 added in two places:
    • providers/factory.py (get_dependencies and iter_validation_issues, one each). Factory.wiring_plan needs the registry itself (plan memo plus the suggester), and the provider hooks receive a Container. Threading the registry through every hook (get_dependencies, redirect_target, iter_validation_issues, and the dependency_graph helpers, about 58 call sites) was too large a change for this issue.
    • container.py __init__, for the child reading parent_container._providers_registry. This is a same-class read next to the existing _lock and _scope_map noqa lines.
  • In resolve, the closed check sits inside the try, as before, so a ContainerClosedError from an Alias type still gets its redirect hops prepended.
  • test_failed_group_registration_does_not_pollute_shared_registry used a child constructor with groups= to provoke a duplicate. That path now raises before registering, so the test goes through add_providers on the root instead and still checks that a failed batch registers nothing.

Hot-path timing

timeit of c.resolve(T) on a warm root (stmt r(tp) with a bound method, best of 21 x 1M, python -S against an origin/main export and this branch, 5 alternating runs, median):

main this branch
cached factory 114.0 ns 114.3 ns
transient factory 130.3 ns 127.2 ns

Both differences are within run-to-run noise (about 2%), so the closed-first order stays.

Downstream

modern-di-grpc reads the registry directly and must switch to container.find_provider(ServicerContext) (tracked in #579):

  • modern_di_grpc/main.py:45
  • tests/test_inject.py:191

Test plan

  • Failing test first: test_child_constructor_with_groups_raises
  • Failing test first: test_resolve_unregistered_type_on_closed_container_raises_closed (resolve_provider already checked first; test_resolve_provider_unregistered_on_closed_container_raises_closed guards it)
  • test_find_provider_returns_registered_provider_or_none, test_closed_is_read_only, test_registries_are_not_public
  • just lint, just lint-ci
  • just test-ci (578 passed, 100% coverage)
  • Guard-tier benchmarks pass (pytest benchmarks/ --ignore=benchmarks/comparative)
  • mkdocs build --strict

@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: d026aae Previous: 6b6bae8 Ratio
benchmarks/test_guard_by_type.py::test_g16_resolve_by_type 4938407.685503681 iter/sec (stddev: 1.3564386618137858e-8) 3260457.4363020025 iter/sec (stddev: 2.562881313164587e-8) 0.66
benchmarks/test_guard_by_type.py::test_g17_resolve_by_type_large_registry 4989555.114785454 iter/sec (stddev: 7.287019695830428e-9) 3345379.823064965 iter/sec (stddev: 2.3037199812108472e-8) 0.67
benchmarks/test_guard_cold.py::test_g8_cold_first_resolve 28551.680136101604 iter/sec (stddev: 0.000043537523519490644) 19972.199535381777 iter/sec (stddev: 0.00005420123255749911) 0.70
benchmarks/test_guard_cold.py::test_g8b_cold_first_resolve_cached 23045.661581024702 iter/sec (stddev: 0.00011389532155588124) 15929.163443361573 iter/sec (stddev: 0.0001250899737435769) 0.69
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[1] 638.6083950434078 iter/sec (stddev: 0.000033232247666545824) 345.1288957057148 iter/sec (stddev: 0.00003575619732955365) 0.54
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[2] 584.0407147946878 iter/sec (stddev: 0.0000541700039184801) 323.1251382731525 iter/sec (stddev: 0.0001643208237976516) 0.55
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[4] 500.99792499226436 iter/sec (stddev: 0.00006881635503768241) 303.40565343582915 iter/sec (stddev: 0.00021911545461739438) 0.61
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[1] 2553.5499643144235 iter/sec (stddev: 0.00013645812803904665) 1925.224628389066 iter/sec (stddev: 0.00017411413888552155) 0.75
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[2] 1945.3061610381121 iter/sec (stddev: 0.0001591873289718167) 1486.1759995513646 iter/sec (stddev: 0.00017960699173028347) 0.76
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[4] 1315.267058838599 iter/sec (stddev: 0.00016286785775797126) 1030.401002703205 iter/sec (stddev: 0.00018547248997487608) 0.78
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[1] 2506.4285191970725 iter/sec (stddev: 0.00017419374628923525) 1867.7688453618966 iter/sec (stddev: 0.00021269549818429573) 0.75
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[2] 1430.4492167217722 iter/sec (stddev: 0.0013177158723345868) 1131.7276576227218 iter/sec (stddev: 0.0013758238078706761) 0.79
benchmarks/test_guard_concurrency.py::test_g15b_concurrent_first_resolve_sibling_children[4] 1030.512442922103 iter/sec (stddev: 0.0002021134023463434) 841.6653114104772 iter/sec (stddev: 0.0001919974716285053) 0.82
benchmarks/test_guard_lifecycle.py::test_g6_build_child_container 1216562.0691942126 iter/sec (stddev: 5.859939241050908e-8) 934982.0276565943 iter/sec (stddev: 3.8517759045937046e-8) 0.77
benchmarks/test_guard_lifecycle.py::test_g6b_build_child_container_auto_scope 1108696.5547968317 iter/sec (stddev: 3.891923102247578e-8) 851047.3701704049 iter/sec (stddev: 3.892838831244081e-8) 0.77
benchmarks/test_guard_lifecycle.py::test_g7_request_lifecycle_batch 3313.413880406987 iter/sec (stddev: 0.000017470981015691367) 2457.019595826527 iter/sec (stddev: 0.00004081942717020917) 0.74
benchmarks/test_guard_lifecycle.py::test_g7c_event_loop_floor_control 75045.3021736796 iter/sec (stddev: 0.0000020771337586261562) 58089.307373110685 iter/sec (stddev: 0.0000016792803452722592) 0.77
benchmarks/test_guard_lifecycle.py::test_g13_teardown_at_scale 57480.091208809106 iter/sec (stddev: 0.0000020683764558980826) 42890.73815290032 iter/sec (stddev: 0.0000018687946096749885) 0.75
benchmarks/test_guard_lifecycle.py::test_g13b_teardown_at_scale_async_no_finalizers 764.4260206904556 iter/sec (stddev: 0.00003279946600047198) 556.6901383417683 iter/sec (stddev: 0.000026268509821701806) 0.73
benchmarks/test_guard_resolve.py::test_g1_transient_resolve 2920995.6126003796 iter/sec (stddev: 2.8633016799226372e-8) 1967768.3480172108 iter/sec (stddev: 2.8281562436705376e-8) 0.67
benchmarks/test_guard_resolve.py::test_g2_cached_resolve 4863925.601398329 iter/sec (stddev: 1.0609551872308175e-8) 2813177.2372624027 iter/sec (stddev: 8.052988898495071e-9) 0.58
benchmarks/test_guard_resolve.py::test_g3_deep_chain 1026416.2594177481 iter/sec (stddev: 4.079585653740863e-8) 698436.1647584069 iter/sec (stddev: 4.578476629415878e-8) 0.68
benchmarks/test_guard_resolve.py::test_g4_wide_resolve 605856.6526810711 iter/sec (stddev: 4.4790071820107344e-7) 438601.83909269725 iter/sec (stddev: 4.755224725919662e-7) 0.72
benchmarks/test_guard_resolve.py::test_g5_cross_scope 2774163.8514061123 iter/sec (stddev: 2.7893142171725838e-8) 1863248.9835879544 iter/sec (stddev: 2.7817473351082116e-8) 0.67
benchmarks/test_guard_resolve.py::test_g9_context_resolve 1987252.6683974515 iter/sec (stddev: 1.4952259786671056e-7) 1389722.2396839291 iter/sec (stddev: 1.6220603917392167e-7) 0.70
benchmarks/test_guard_resolve.py::test_g12_override_active_resolve 994155.6077189809 iter/sec (stddev: 1.1438723919054846e-7) 704292.2846812549 iter/sec (stddev: 4.5443606775615695e-8) 0.71
benchmarks/test_guard_resolve.py::test_g18_alias_hop 4873321.183489031 iter/sec (stddev: 1.0202478312999676e-8) 2822972.3098096806 iter/sec (stddev: 8.430114761076153e-9) 0.58
benchmarks/test_guard_validate.py::test_g10_validate_deep_chain 37927.1751964395 iter/sec (stddev: 0.000017131175186896323) 26247.90571255939 iter/sec (stddev: 0.000023063125486108578) 0.69
benchmarks/test_guard_validate.py::test_g11_validate_wide 21873.309238759535 iter/sec (stddev: 0.000022048712619985966) 15589.023780817508 iter/sec (stddev: 0.00002714007872121336) 0.71

This comment was automatically generated by workflow using github-action-benchmark.

@lesnik512
lesnik512 force-pushed the feat/container-surface branch from 235342f to d026aae Compare October 5, 2026 08:51
@lesnik512
lesnik512 merged commit 634b5d5 into main Oct 5, 2026
9 checks passed
@lesnik512
lesnik512 deleted the feat/container-surface branch October 5, 2026 08: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.

4.0: tighten Container's public surface

1 participant