Add mapping on aggregates - #133
Conversation
|
For context, I've also tested this branch and |
|
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 What I'm currently exploring is to pick one single internal type and normalize several keys to it. e.g 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. |
|
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:
Will also take a look at #116 today. Cheers! |
be10847 to
7aa5c8f
Compare
|
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:
Ready for another look when you have time. Cheers! |
|
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? |
I appreciate the effort, but there's no need to update this every time the branch changes. For now the plan looks like this:
Once that's all merged in master, I'll ping you here so we can release both as part of |
| 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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].
There was a problem hiding this comment.
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"
@injectabledoes 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=NoneThis 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 implementationsMapping[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.
| caches: Mapping[str, Cache] | ||
| ``` | ||
|
|
||
| `Mapping[str, Cache]` includes every qualified implementation, keyed by its qualifier. |
There was a problem hiding this comment.
Qualifiers cannot be just strings, but anything that's hashable so str narrows it down quite a bit.
There was a problem hiding this comment.
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!
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! |
|
@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 |
c4e903d to
765f079
Compare
|
@maldoinc, I've implemented this branch based on our refined design. Please take a look and let me know what you think! |
|
Looks good on the surface. I'll give it a more thorough review soon. |
|
Looks good, thank you @Stuckya |
|
This is now released in 2.11.0 |
|
Thanks @maldoinc ! |
This PR builds on top of
Sequencesupport from branchadd-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 theNonekey.UnknownServiceRequestedErrorwhentyping.Mapping[K, V]is requested.This PR does not add: