Package: clean-ioc
Clean IoCA simple dependency injection library for python that requires nothing of your application code (except that you use typing).Basic Registering and resolvingThere are 4 basic modes of registering a new set of classesImplementationclassUserRepository(abc.ABC):@abc.abstractmethoddefadd(self,user):passclassInMemoryUserRepository(UserRepository):def__init__(self):self.users=[]defadd(self,user):# This is obviously terrible, but it's for demo purposesself.users.append(user)classSqlAlchemyUserRepository(UserRepository):def__init__(self):# Do some db stuff herepassdefadd(self,user):# Do some db stuff herepasscontainer=Container()container.register(UserRepository,InMemoryUserRepository)repository=container.resolve(UserRepository)# This will return an InMemoryUserRepositoryConcrete ClassclassClientDependency:defget_int(self):return10classClient:def__init__(self,dep:ClientDependency):self.dep=depdefget_number(self):returnself.dep.get_int()container=Container()container.register(ClientDependency)container.register(Client)client=container.resolve(Client)client.get_number()# returns 10FactoryclassClientDependency:defget_int(self):return10classClient:def__init__(self,dep:ClientDependency):self.dep=depdefget_number(self):returnself.dep.get_int()defclient_factory(dep:ClientDependency):returnClient(dep=dep)container=Container()container.register(ClientDependency)container.register(Client,factory=client_factory)client=container.resolve(Client)client.get_number()# returns 10InstanceclassClientDependency:def__init__(self,num):self.num=numdefget_int(self):returnself.numclassClient:def__init__(self,dep:ClientDependency):self.dep=depdefget_number(self):returnself.dep.get_int()client_dependency=ClientDependency(num=10)container=Container()container.register(ClientDependency,instance=client_dependency)container.register(Client)client=container.resolve(Client)client.get_number()# returns 10List resolvingIf you have multiple dependencues you can simply define a dependency as a list[T] and you can return all of the instances.classClientDependency:def__init__(self,numbers:list[int]):self.numbers=numbersdefget_numbers(self):returnself.numbersclassClient:def__init__(self,dep:ClientDependency):self.dep=depdefget_numbers(self):returnself.dep.get_numbers()container=Container()container.register(ClientDependency)container.register(Client)container.register(int,instance=1)container.register(int,instance=2)container.register(int,instance=3)client=container.resolve(Client)client.get_numbers()# returns [3, 2, 1]DecoratorsFollows a object orientated decoration pattern, rather than a decoration annotation.
The main reason for this was to allow decotation of registered instancesclassClient:def__init__(self,number:int):self.number=numberdefget_number(self):returnself.numberclassDoubleClientDecorator(Client):def__init__(self,client:Client):self.client=clientdefget_number(self):returnself.client.get_number()*2container=Container()container.register(Client)container.register_decorator(Client,DoubleClientDecorator)container.register(int,instance=10)client=container.resolve(Client)client.get_number()# returns 20Decorators are resolved in order of when first registered. So the first registered decorator is the highest in the class treeclassConcrete:passclassDecoratorOne(Concrete):def__init__(self,child:Concrete):self.child=childclassDecoratorTwo(Concrete):def__init__(self,child:Concrete):self.child=childcontainer=Container()container.register(Concrete)container.register_decorator(Concrete,DecoratorOne)container.register_decorator(Concrete,DecoratorTwo)root=container.resolve(Concrete)type(root)# returns DecoratorOnetype(root.child)# returns DecoratorTwotype(root.child.child)# returns ConcreteSubclasses registrationThis feature allows registration of all subclasses of a giveb typeclassClient(abc.ABC):@abc.abstractmethoddefget_number(self):passclassTenClient(Client):defget_number(self):return10classTwentyClient(Client):defget_number(self):return20container=Container()container.register_subclasses(Client)ten_client=container.resolve(TenClient)ten_client.get_number()# returns 10twenty_client=container.resolve(TwentyClient)twenty_client.get_number()# returns 20# Resolve all subsclasses of Clientclient=container.resolve(list[Client])## [TwentyClient(), TenClient()]LifespansLifespans configure how long and resolved object says alive for
There are 4 lifespan typestransientAlways create a new instancecontainer.register(Client,lifespan=Lifespan.transient)once_per_graph (Default behaviour)Only create one instance throughout the resolve callcontainer.register(Client,lifespan=Lifespan.once_per_graph)scopedOnly create a new instance through the lifetime ascope. When not in a scope the behaviour is the same asonce_per_graph.container.register(Client,lifespan=Lifespan.scoped)singletonOnly one instance of the object is created throughout the lifespan of the containercontainer.register(Client,lifespan=Lifespan.singleton)Note:When registering an instance, then the behaviour is always singletoncontainer.register(int,instance=10)Open GenericsRegisters all generic subclasses of the service type and allows you to resolve with the generic aliasT=TypeVar("T")classHelloCommand:passclassGoodbyeCommand:passclassCommandHandler(Generic[T]):defhandle(self,command:T):passclassHelloCommandHandler(CommandHandler[HelloCommand]):defhandle(self,command:HelloCommand):print('HELLO')classGoodbyeCommandHandler(CommandHandler[GoodbyeCommand]):defhandle(self,command:GoodbyeCommand):print('GOODBYE')container=Container()container.register_open_generic(CommandHandler)h1=container.resolve(CommandHandler[HelloCommand])h2=container.resolve(CommandHandler[GoodbyeCommand])h1.handle(HelloCommand())# prints 'HELLO'h2.handle(GoodbyeCommand())# prints 'GOODBYE'Open Generic DecoratorsAllows you to add decorators to your open generic registrationsT=TypeVar("T")classHelloCommand:passclassGoodbyeCommand:passclassCommandHandler(Generic[T]):defhandle(self,command:T):passclassHelloCommandHandler(CommandHandler[HelloCommand]):defhandle(self,command:HelloCommand):print('HELLO')classGoodbyeCommandHandler(CommandHandler[GoodbyeCommand]):defhandle(self,command:GoodbyeCommand):print('GOODBYE')classAVeryBigCommandHandlerDecorator(Generic[T]):def__init__(self,handler:CommandHandler[T]):self.handler=handlerdefhandle(self,command:T):print('A VERY BIG')self.handler.handle(command=command)container=Container()container.register_open_generic(CommandHandler)container.register_open_generic_decorator(CommandHandler,AVeryBigCommandHandlerDecorator)h1=container.resolve(CommandHandler[HelloCommand])h2=container.resolve(CommandHandler[GoodbyeCommand])h1.handle(HelloCommand())# prints 'A VERY BIG\nHELLO'h2.handle(GoodbyeCommand())# prints 'A VERY BIG\nGOODBYE'ScopesScopes are a machanism where you guarantee that dependency can be temporarily a singleton within the scope. You can also register dependencies that that are only available withon the scope.
Some good use cases for scope lifetimes are:http request in a web servermessage/event if working on a message based system
For instance you could keep an single database connection open for the entire lifetime of the http requestclassDbConnection:defrun_sql(self,statement):# Done some sql Stuffpasscontainer.register(DbConnection,lifespan=Lifespan.scoped)withcontainer.get_scope()asscope:db_conn=scope.resolve(DbConnection)db_conn.run_sql("UPDATE table SET column = 1")Scopes can also be use with asyncioclassAsyncDbConnection:asyncdefrun_sql(self,statement):# Done some sql Stuffpasscontainer.register(AsyncDbConnection,lifespan=Lifespan.scoped)asyncwithcontainer.get_scope()asscope:db_conn=scope.resolve(AsyncDbConnection)awaitdb_conn.run_sql("UPDATE table SET column = 1")Scoped TeardownsWhen you are finished with some dependenies within a scope you might want to perform some teardown action before you exit the scope. For example if we want to close our db connection.classAsyncDbConnection:asyncdefrun_sql(self,statement):# Done some sql Stuffpassasyncdefclose(self):# Close the connectionpassasyncdefclose_connection(conn:AsyncDbConnection):awaitconn.close()container.register(DbConnection,lifespan=Lifespan.scoped,scoped_teardown=close_connection)asyncwithcontainer.get_scope()asscope:db_conn=scope.resolve(AsyncDbConnection)awaitdb_conn.run_sql("UPDATE table SET column = 1")# close connection is run when we exit the scopeNote:When using the scope as an async context manager you need both sync and async teardowns are run, when a scope is used as a normal sync context manager async teardowns are ignoredNamed registrations & Registration filtersBy default the last unnamed registration is what the container will return when resolve is called as below.container=Container()container.register(int,instance=1)container.register(int,instance=2)container.register(int,instance=3)number=container.resolve(int)# returns 3To be more selective of what we return we can add a name to the registration and apply a registration filter when we resolve.A registration filter is simply function that receives aRegistrationand returns aboolFor example if we wanted to get the int named"One"we do the followingcontainer=Container()container.register(int,instance=1,name="One")container.register(int,instance=2,name="Two")container.register(int,instance=3,name="Three")number=container.resolve(int,filter=lambdar:r.name=="One")# returns 1Clean IOC comes with a set of in built registration filters that can be foundhereWe can get the desired behaviour as abovefromclean_ioc.registration_filtersimportwith_namecontainer=Container()container.register(int,instance=1,name="One")container.register(int,instance=2,name="Two")container.register(int,instance=3,name="Three")number=container.resolve(int,filter=with_name("One"))# returns 1Dependency SettingsDependency settings are defined at registration and allow you to define the selection or setting dependenciesclassClient:def__init__(self,number=10):self.number=numberdefget_number(self):returnself.numbercontainer=Container()container.register(int,instance=1,name="One")container.register(int,instance=2)container.register(Client,name="SetsValue",dependency_config={"number":DependencySettings(value_factory=set_value(50))})container.register(Client,name="UsesDefaultValue")container.register(Client,name="IgnoresDefaultParameterValue",dependency_config={"number":DependencySettings(value_factory=dont_use_default_parameter)})container.register(Client,name="UsesRegistrationFilter",dependency_config={"number":DependencySettings(value_factory=dont_use_default_parameter,filter=with_name("One"))})client1=container.resolve(Client,filter=with_name("SetsValue"))client2=container.resolve(Client,filter=with_name("UsesDefaultValue"))client3=container.resolve(Client,filter=with_name("IgnoresDefaultParameterValue"))client4=container.resolve(Client,filter=with_name("UsesRegistrationFilter"))client1.get_number()# returns 50client2.get_number()# returns 10client3.get_number()# returns 2client4.get_number()# returns 1The order of a dependant value is as followsSetting the dependency value_factory to an explicit valueDependencySettings(value_factory=set_value(50))If the falue is a default parameter then the default value factory will use that default parameter valueclassClient:def__init__(self,number=10):self.number=numberIf you don't want to use the default parameter value you can change the value_factory to pybass itDependencySettings(value_factory=dont_use_default_parameter)Going to the container registry to find a registration using the registration filter if, if there is a default value on the dependant paramater you must explicity set.TagsTags can be added to registrations in order to support filtering. This can be useful as a means to filter registrations when resolving lists of a particular typeclassA:passa1=A()a2=A()a3=A()container=Container()container.register(A,instance=a1,tags=[Tag("a","a1")])container.register(A,instance=a2,tags=[Tag("a")])container.register(A,instance=a3)ar1=container.resolve(A,filter=has_tag("a","a1"))# returns a1al1=container.resolve(list[A],filter=has_tag("a"))# returns [a2, a1]al2=container.resolve(list[A],filter=has_tag("a","a1"))# returns [a1]al3=container.resolve(list[A],filter=fn_not(has_tag("a","a1")))# returns [a3, a2]al4=container.resolve(list[A],filter=fn_not(has_tag("a")))# returns [a3]al5=container.resolve(list[A])# returns [a3, a2, a1]Parent Context FiltersRegistrations can also specify that should only apply to certain parents objects by setting the parent_context_filterclassA:passclassB(A):passclassC(A):passclassD:def__init__(self,a:A):self.a=aclassE:def__init__(self,a:A):self.a=acontainer=Container()container.register(A,B,parent_context_filter=parent_implementation_is(E))container.register(A,C,parent_context_filter=parent_implementation_is(D))container.register(D)container.register(E)e=container.resolve(E)d=container.resolve(D)type(e.a)# returns Btype(d.a)# returns CAccessing the Container, Scope and Resolver within dependenciesAccessing container directlyclassClient:def__init__(self,container:Container):self.container=containerdefget_number(self):returnself.container.resolve(int)container.register(int,instance=2)container.register(Client)client=container.resolve(Client)client.get_number()# returns 2Accessing Resolver also returns the containerclassClient:def__init__(self,resolver:Resolver):self.resolver=resolverdefget_number(self):returnself.resolver.resolve(int)container.register(int,instance=2)container.register(Client)client=container.resolve(Client)client.get_number()# returns 2When within a scope, Resolver returns the current scope rather than the containerclassClient:def__init__(self,resolver:Resolver):self.resolver=resolverdefget_number(self):returnself.resolver.resolve(int)container.register(int,instance=2)container.register(Client)client=container.resolve(Client)client.get_number()# returns 2withcontainer.get_scope()asscope:scope.register(int,instance=10)scoped_client=scope.resolve(Client)scoped_client.get_number()# returns 10Scopes can also be used as an async context managerclassClient:asyncdefget_number(self):return10container.register(Client)asyncwithcontainer.get_scope()asscope:scoped_client=scope.resolve(Client)awaitscoped_client.get_number()# returns 10BundlesA bundle is a just a function that accepts a container, it can be used to set up related registrations on the containerclassClientDependency:defget_int(self):return10classClient:def__init__(self,dep:ClientDependency):self.dep=depdefget_number(self):returnself.dep.get_int()defclient_bundle(c:Container):c.register(ClientDependency)c.register(Client)container.apply_bundle(client_bundle)client=container.resolve(Client)client.get_number()# returns 10Helper for bundlesThere is now aBaseBundleclass that gives you a bit more safety around running a module twice etc. Also you might want to pass in instances into the module.
You can find theBaseBundleinclean_ioc.bundlesmodule@dataclassclassClientConfig:url:strclassClient:def__init__(self,config:ClientConfig):self.base_url=config.urldefget_thing(self):# Do some requests stuff herepassclassClientBundle(BaseBundle):def__init__(self,config:ClientConfig):self.config=configdefapply(self,c:Container):c.register(ClientConfig,instance=self.config)c.register(Client)client_config=ClientConfig(url="https://example.com")container.apply_bundle(ClientBundle(config=client_config))client=container.resolve(Client)client.get_thing()Dependency Context (BETA feature)You can inject a special type into your dependants that allows you to inspect the current dependency tree. For instances you can check the parent of the current class you are constructing
One example of where this becomes useful is if injecting a logger, you can get information about the loggers parent to add extra contextclassClient:def__init__(self,logger:logging.Logger):self.logger=loggerdefdo_a_thing(self):self.logger.info('Doing a thing')deflogger_fac(context:DependencyContext):module=context.parent.implementation.__module__returnlogging.getLogger(module)container=Container()container.register(Client)container.register(logging.Logger,factory=logger_fac,lifespan=Lifespan.transient)client=container.resolve(Client)NoteIf using dependency context on your dependency it's recommended that you use a lifespan oftransient, because any other lifespan will create only use the parent of the first resolved instancePre-configurationsPre configurations run a side-effect for a type before the type gets resolved.
This is useful if some python modules have some sort of module level functions that need to be called before the object get createdimportloggingclassClient:def__init__(self,logger:logging.Logger):self.logger=loggerdefdo_a_thing(self):self.logger.info('Doing a thing')deflogger_fac(context:DependencyContext):module=context.parent.implementation.__module__returnlogging.getLogger(module)defconfigure_logging():logging.basicConfig()container=Container()container.register(Client)container.register(logging.Logger,factory=logger_fac,lifespan=Lifespan.transient)container.pre_configure(logging.Logger,configure_logging)client=container.resolve(Client)