Skip to content

Add mapping on aggregates - #133

Merged
maldoinc merged 2 commits into
maldoinc:masterfrom
Stuckya:feat/inject-mapping-on-aggregates
Apr 30, 2026
Merged

Add mapping on aggregates#133
maldoinc merged 2 commits into
maldoinc:masterfrom
Stuckya:feat/inject-mapping-on-aggregates

Conversation

@Stuckya

@Stuckya Stuckya commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

This PR builds on top of Sequence support from branch add-aggregates, adding mapping support to wireup.

It reuses the exact same architecture as add-aggregates.

This PR adds:

  • Injected[Mapping[Hashable, T]] (collections.abc.Mapping) as a way to receive every qualified implementation of an interface in a single dict, keyed by qualifier. The unqualified default implementation is included under the None key.
  • Targeted hint appended to UnknownServiceRequestedError when typing.Mapping[K, V] is requested.
  • Unit tests.
  • Documentation.

This PR does not add:

  • Benchmarks
  • FastAPI integration test
  • Starlette integration test

@Stuckya

Stuckya commented Apr 18, 2026

Copy link
Copy Markdown
Contributor Author

For context, I've also tested this branch and add-aggregates on my own project and they both seem to work well.

@maldoinc

maldoinc commented Apr 18, 2026

Copy link
Copy Markdown
Owner

Hi @Stuckya.

Collection injection needs a bit more thought, especially around overrides/DX.

Aliasing creates subtle bugs/quirks because users have to know the exact type that was used at the injection site.

Example:

from collections.abc import Sequence

def f(deps: Sequence[Handler]): ...

Now someone tries to override:

from typing import Sequence

with container.override(Sequence[Handler], fake):
    ...

This will not work if those two are treated as different keys internally, even though they look equivalent.

Same issue with Mapping and dict in this MR.

Overriding the individual members before the collection is built is technically the safer approach, but users WILL be able to override these factories as well.

On the other hand, allowing only collection.abc.{Mapping,Sequence} means that using typing.{Sequence,Mapping} will result in unknown dependency errors.

What I'm currently exploring is to pick one single internal type and normalize several keys to it.

e.g typing.Sequence, collection.Sequence map to one single internal type. Same for the map equivalents. I want to see if there's new or other gotchas before I commit to the public api.

In the meantime, if you need this for your current setup, you can replicate the feature manually:

def make_plugin_mapping(
    default_plugin: Plugin,
    external_plugin: Annotated[Plugin, Inject(qualifier="external")]
) -> Mapping[Hashable, Plugin]:
    return {
        None: default_plugin,
        "external": external_plugin,
    }

When this lands you can just drop your own custom factories and things will keep working.

Happy to hear your thoughts, especially if you see any cases where this model would break for your use case.

Also, since we're here would appreciate your feedback on #116 as well if you get the chance to try it.

@Stuckya

Stuckya commented Apr 19, 2026

Copy link
Copy Markdown
Contributor Author

Hi @maldoinc,

Thanks for the reply. I'm on board with the direction. A single canonical import is easier to teach, easier to document, and avoids the silent override. I'm a big advocate for prioritizing DX, so this direction aligns nicely with my thinking.

A few things from my side:

  1. I'll rebase this PR onto your "Drop typing.Sequence[T]" commit in add-aggregates branch. Narrowing my Mapping surface to collections.abc.Mapping[str, T].
  2. +100 on dropping 3.8 (your commit on add-aggregates breaks 3.8). It went EOL 2024. The DX is way cleaner with 3.8 out of our way! Could even consider dropping 3.9 too (IIRC it hit EOL late 2025) and it'd let us drop the types.UnionType version guards in type_analysis.py.
  3. A few things from testing while rebasing:
    • At the factory return-type boundary, if a user-defined factory returns typing.Mapping[str, T] while consumers ask for cabc.Mapping[str, T], the two don't unify and overrides silently fall through. We could mitigate by catching at registration with an error or warning.
    • type PluginList[T] = Sequence[T] (PEP 695) doesn't unwrap to Sequence[T]. get_origin returns the alias itself and the resulting error renders as "Genericalias None.PluginList". This could be interpreted as a bug. The stringification is worth fixing either way; TypeAliasType.__value__ could be a clean unwrap point if you want to support the shape.
    • NewType works. NewType('PluginSeq', Sequence[Plugin]) + a factory returning it registers and injects cleanly. We could add a unit test to capture this behavior.
  4. Small DX follow-up: when resolution fails on a typing.Sequence[...] / typing.Mapping[...] / dict[...] / list[...] shape, raise a targeted hint pointing users at collections.abc. Eases the migration. I'd be happy to open separately.

Will also take a look at #116 today.

Cheers!

@Stuckya
Stuckya force-pushed the feat/inject-mapping-on-aggregates branch 2 times, most recently from be10847 to 7aa5c8f Compare April 19, 2026 14:52
@Stuckya

Stuckya commented Apr 19, 2026

Copy link
Copy Markdown
Contributor Author

Quick update, I've rebased off your branch. I leveraged Claude Code Opus 4.7 this go around to help avoid burn out with some of our back and forth. Surprised with the good results tbh.

This PR now:

  • Narrows the Mapping surface to collections.abc.Mapping[str, T], mirroring your Sequence narrowing.
  • Bumped requires-python = ">=3.9" and dropped the 3.8 classifier.
  • Added a NewType-over-Mapping test to lock in the escape hatch.
  • I ended up including the targeted hint-error in this PR rather than splitting it. Resolution failures on typing.Mapping / typing.Dict / dict[str, T] now append "Did you mean collections.abc.Mapping[str, T]?". Happy to split to a follow-up if you'd rather.

Ready for another look when you have time. Cheers!

@Stuckya

Stuckya commented Apr 19, 2026

Copy link
Copy Markdown
Contributor Author

Did some manual cleanup, let me know if you'd like to see any other changes.

I suspect this type of feature warrants a 3.0 major version release?

@maldoinc

Copy link
Copy Markdown
Owner

to help avoid burn out with some of our back and forth

I appreciate the effort, but there's no need to update this every time the branch changes.

For now the plan looks like this:

  • only collection.abc.{sequence,mapping} as the sole collection type
  • no alias/normalization for typing.sequence, list[T], dict[T] etc.
  • When user uses typing.{Sequence,Mapping} the container will error like usual but will tell you that collection.abc.* is the correct type.
  • in the future when PEP 695 is considered (PEP 695 type aliases are not currently not fully supported #134), we may add equivalence for typing.{Sequence,Mapping}
  • drop py38,py39 support

Once that's all merged in master, I'll ping you here so we can release both as part of 2.11.0.

Comment thread docs/pages/interfaces.md Outdated
Comment on lines +252 to +253
Implementations registered without a qualifier have no key to index under, so they are excluded from the map.
Use `Sequence[T]` when you want every implementation regardless of qualifier.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Can I ask your reasoning for why they should be excluded? My original idea was to do Mapping[Hashable, T] then you'd simply get an entry with None as the key for the default implementation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Default keyed by None brings us to parity between our branches, but I don't think it's the best DX.

caches[None] for the default is awkward DX. Spring doesn't have a concept like this. In Spring, every bean has a name.

We could consider a module constant?

from wireup import DEFAULT

caches[DEFAULT].source()

This would keep the Mapping[Hashable, T] api, reads cleanly, and is the minimum change to improve None's ugliness.

Spring's approach would be to derive a canonical name from the class, but that's back to my assumption of Mapping[str, T].

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Happy to explain how I'm thinking about this:

  • Wireup is not Spring so it doesn't map 1:1 here.
  • Spring's keys are bean names which are not appicable here
  • The intent for this is: "Give me all implementations keyed by qualifier"
  • @injectable does register things under a qualifier -> None.
  • so omitting qualifier= is API sugar, not a new registration category
  • it does not require a None-qualified implementation to exist at all
  • users can qualify every implementation if they want

So the "default implementation" is not a special object from the container's point of view, and None is a real qualifier value. It is just one implementation, with the benefit that you can inject it using plain T without requiring additional annotations so the common use case becomes simpler.

Annotated[Cache, Inject(qualifier="redis")]
Annotated[Cache, Inject(qualifier=CacheType.REDIS)]
Annotated[Cache, Inject(qualifier=None)]
Cache  # sugar for qualifier=None

This makes the mapping decision simpler I think:

  • if the map is keyed by qualifier, the None-qualified implementation belongs in it
  • if it does not appear, the map is no longer a full view
  • that would make Mapping[Hashable, T] a filtered view with a special (and arbitrary) rule

It also keeps the collection story consistent:

  • Sequence[T] already means all implementations
  • Mapping[Hashable, T] should mean the same set, keyed by qualifier

I agree that caches[None] reads a little awkwardly, but that is not enough reason to hide one real implementation from the mapping. Users can also qualify all of their implementations if they want; the container does not require a None-qualified implementation to exist.


Richer filtering could be a separate feature later, for example tags or grouped registrations. But for Mapping[Hashable, T], I think "all implementations keyed by qualifier" is the most consistent meaning.

Comment thread docs/pages/interfaces.md Outdated
caches: Mapping[str, Cache]
```

`Mapping[str, Cache]` includes every qualified implementation, keyed by its qualifier.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Qualifiers cannot be just strings, but anything that's hashable so str narrows it down quite a bit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm used to Java/Spring where everything is qualified as a string, so I didn't consider this. Also TIL that I can use any hashable as a qualifier elsewhere!

@Stuckya

Stuckya commented Apr 19, 2026

Copy link
Copy Markdown
Contributor Author

I appreciate the effort, but there's no need to update this every time the branch changes.

You are right of course, staying up-to-date helps me form opinions and test out the DX. I'll wait to update this branch until you weigh in on the above.

Thanks again Aldo, this has been fun!

@maldoinc

Copy link
Copy Markdown
Owner

@Stuckya changes have landed in master. Py38 and 39 are soft removed. Code has not been updated to fully use py310 but the workflows do not test against them anymore. I kept it as-is so that the diff here can stay small. Once this is merged then the codebase/types will also get updated

@Stuckya
Stuckya force-pushed the feat/inject-mapping-on-aggregates branch from c4e903d to 765f079 Compare April 27, 2026 12:43
@Stuckya
Stuckya changed the base branch from add-aggregates to master April 27, 2026 12:44
@Stuckya
Stuckya requested a review from maldoinc April 27, 2026 14:29
@Stuckya

Stuckya commented Apr 29, 2026

Copy link
Copy Markdown
Contributor Author

@maldoinc, I've implemented this branch based on our refined design. Please take a look and let me know what you think!

@maldoinc

Copy link
Copy Markdown
Owner

Looks good on the surface. I'll give it a more thorough review soon.

@maldoinc
maldoinc merged commit aa5f923 into maldoinc:master Apr 30, 2026
@maldoinc

Copy link
Copy Markdown
Owner

Looks good, thank you @Stuckya

@maldoinc

maldoinc commented May 3, 2026

Copy link
Copy Markdown
Owner

This is now released in 2.11.0

@Stuckya

Stuckya commented May 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @maldoinc !
It was a pleasure, I look forward to contributing again in the future.

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.

2 participants