Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5c13c95
Implement basic Scope class
kuyugama Jan 9, 2026
e31f5ea
Add Scope class tests
kuyugama Jan 9, 2026
84ecdb0
Add scope merging
kuyugama Jan 9, 2026
e8bca4e
Use NewType from typing_extensions for tests
kuyugama Jan 10, 2026
b07d3e0
Add scope merge tests
kuyugama Jan 10, 2026
a6514f5
Add test for `scope.add_value` value replacement signaling
kuyugama Jan 10, 2026
9ffab7f
Add scope simplifying and copying methods and tests for them
kuyugama Jan 10, 2026
b372410
Rewrite injection value resolution using the Scope class
kuyugama Jan 10, 2026
544a59a
Add scope update method
kuyugama Jan 10, 2026
7e62129
Use `Scope.from_legacy` method to create scope from legacy dictionary…
kuyugama Jan 10, 2026
90879a4
Use `Scope.from_legacy` method to create scope from legacy dicrionary…
kuyugama Jan 10, 2026
1db2bd8
Rewrite scope merging
kuyugama Jan 12, 2026
63ca965
Optimize `Scope.from_legacy`
kuyugama Jan 12, 2026
43c93f0
Mention callable that caused 'async in sync' exception
kuyugama Jan 12, 2026
b8a4748
Add proper resolving for type factories
kuyugama Jan 12, 2026
975cd16
Improve logging messages
kuyugama Jan 12, 2026
f167438
Sort imports
kuyugama Jan 12, 2026
4399b6b
Move TypeFactory and TypeInstance into Type class to keep them together
kuyugama Jan 25, 2026
2334187
Rewrite injection_impl to not use `Scope.from_legacy(...)`
kuyugama Feb 11, 2026
b6ebbca
Raise KeyError in __getitem__ if value is not found
kuyugama Feb 11, 2026
ab00036
Add Scope.__getitem__ method tests
kuyugama Feb 11, 2026
76cf2b1
Use typing_extensions.NewType for type aliases
kuyugama Feb 11, 2026
33a5788
Enforce mutual exclusivity between type instances and factories in Scope
kuyugama Feb 14, 2026
f42ff85
feat: Introduce `fundi.Scope` for explicit dependency injection
kuyugama Mar 7, 2026
b45723e
refactor(configurable): Make configurable_dependency type-safe
kuyugama Mar 12, 2026
857d194
fix(scan): Return scanned side_effects instead of raw functions
kuyugama Mar 12, 2026
ad0ee2f
Fix virtual_context() example in docstring
kuyugama Mar 12, 2026
58ddc87
feat(scope): Enhance add_factory with type inference and scan parameters
kuyugama Mar 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ __pycache__
.python-version
docs/_build
play.py
play*.py
2 changes: 2 additions & 0 deletions docs/advanced.rst
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ but itself is not a coroutine function. In this case you will need
to override ``async_`` property of the dependency:

.. code-block:: python

from fundi import from_

def require_event_data(event: RemoteEvent):
Expand All @@ -177,6 +178,7 @@ For example, if there is function that returns context-manager and type-hint is
to contextlib.AbstractContextManager FunDI will correctly infer it as lifespan dependency.

.. code-block:: python

from fundi import from_

from contextlib import AbstractContextManager
Expand Down
12 changes: 6 additions & 6 deletions docs/howtos/debugging.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ FunDI provides useful functions to help you debug and optimize your dependency i

.. code-block:: python

from fundi import tree, scan
from fundi import Scope, tree, scan

print(tree({"username": "user"}, scan(application)))
print(tree(Scope({"username": "user"}), scan(application)))

Outputs::

Expand All @@ -27,9 +27,9 @@ FunDI provides useful functions to help you debug and optimize your dependency i

.. code-block:: python

from fundi import order, scan
from fundi import Scope, order, scan

print(order({"username": "user"}, scan(application)))
print(order(Scope({"username": "user"}), scan(application)))

Outputs::

Expand Down Expand Up @@ -57,7 +57,7 @@ library adds its injection trace to exception.

from contextlib import ExitStack

from fundi import from_, scan, inject, injection_trace
from fundi import Scope, from_, scan, inject, injection_trace


def require_random_animal() -> str:
Expand All @@ -72,7 +72,7 @@ library adds its injection trace to exception.


try:
inject({}, scan(application))
inject(Scope(), scan(application))
except Exception as e:
print(injection_trace(e))

Expand Down
12 changes: 6 additions & 6 deletions docs/howtos/injection.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Example of synchronous injection:
import secrets
from contextlib import ExitStack

from fundi import from_, scan, inject
from fundi import Scope, from_, scan, inject


def require_unique_id() -> str:
Expand All @@ -36,7 +36,7 @@ Example of synchronous injection:
print(f"Application started with {user_id = } and {username = }")


inject({"username": "Kuyugama"}, scan(application))
inject(Scope({"username": "Kuyugama"}), scan(application))


Example of asynchronous injection:
Expand All @@ -47,7 +47,7 @@ Example of asynchronous injection:
import asyncio
from contextlib import AsyncExitStack

from fundi import from_, scan, ainject
from fundi import Scope, from_, scan, ainject


async def require_user(username: str) -> str:
Expand All @@ -60,7 +60,7 @@ Example of asynchronous injection:


async def main():
ainject({"username": "Kuyugama"}, scan(application))
await ainject(Scope({"username": "Kuyugama"}), scan(application))

if __name__ == "__main__":
asyncio.run(main())
Expand All @@ -72,14 +72,14 @@ Example of injection that produces value:
import secrets
from contextlib import ExitStack

from fundi import scan, inject
from fundi import Scope, scan, inject


def require_unique_id() -> str:
return secrets.token_hex(12)


user_id = inject({}, scan(require_user_id))
user_id = inject(Scope(), scan(require_unique_id))
print("Generated user id is", user_id)

..
Expand Down
12 changes: 8 additions & 4 deletions docs/howtos/overriding.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Example of overriding dependency result:

.. code-block:: python

from fundi import scan, inject
from fundi import Scope, scan, inject

from src import application
from src.models import User
Expand All @@ -35,7 +35,7 @@ Example of overriding dependency result:
username="test_user",
)

inject({"username": test_user.username}, scan(application), override={require_user: test_user})
inject(Scope({"username": test_user.username}), scan(application), override={require_user: test_user})


Example of overriding dependency callable:
Expand All @@ -44,7 +44,7 @@ Example of overriding dependency callable:

from contextlib import ExitStack

from fundi import scan, inject
from fundi import Scope, scan, inject

from src import application
from src.models import User
Expand All @@ -61,5 +61,9 @@ Example of overriding dependency callable:
return test_user


inject({"username": test_user.username}, scan(application), override={require_user: scan(test_require_user)})
inject(
Scope({"username": test_user.username}),
scan(application),
override={require_user: scan(test_require_user)}
)

206 changes: 174 additions & 32 deletions docs/howtos/scope.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,68 +2,210 @@
Scope
*****

A scope (or context) is a dictionary that provides dynamic values that may be used by dependencies.
It should be used to provide values to dependencies through injection context.
A scope (or context) is an object that provides dynamic values that may be used by dependencies.
It should be used to provide values to dependencies through injection context.
For example, it may be events, requests, etc.

It combines concepts from both FastAPI's and Aiogram's dependency injection systems.
Aiogram uses parameter names to provide values to dependencies during injection,
and FastAPI uses annotations.
FunDI allows both parameter names and annotations to be used to fetch values
from context.
The central piece of FunDI's dependency injection system is the :code:`fundi.Scope` object. It has three internal stores for providing values to dependants:

Hint: To fetch values from scope using its annotation use ``FromType[ANNOTATION]``
* Named values: for resolving dependencies by parameter name.
* Typed instances: for resolving dependencies by type annotation.
* Type factories: for resolving dependencies by type annotation, but creating the instance on-demand.

Use this feature with care, as it adds additional cost in computational work.
Creating a Scope
================

Dependant's parameter with no :code:`from_(...)` as default value will be resolved from the **scope**
You can create an empty scope or initialize it with a dictionary of named values, typed instances, or type factories.

Example of dependant that use value from scope:
.. code-block:: python

from fundi import Scope, Type

# Create an empty scope
scope = Scope()

# Create a scope with initial named values
scope = Scope({"user_id": 123, "request_id": "abc"})

# Create a scope with a typed instance
class DBConnection:
...
db_conn = DBConnection()
scope = Scope({DBConnection: Type.instance(db_conn)})

# Create a scope with a type factory
def create_db_connection() -> DBConnection:
return DBConnection()
scope = Scope({DBConnection: Type.factory(create_db_connection)})


Adding Values
=============

You can add named values to the scope using the :code:`add_value` method or by initializing the scope with a dict.

.. code-block:: python

from urllib.request import Request
scope.add_value("user_name", "Alice")

from fundi import scan, inject
Adding Typed Instances
======================

You can add instances that will be resolved by their type.

def require_user(request: Request) -> str:
user = request.get_header("User")
.. code-block:: python

if user is None:
raise Unauthorized()
class DBConnection:
...

return user
db_conn = DBConnection()
scope.add_type(db_conn)

# A dependant can now receive the DBConnection instance by type
def my_dependency(db: DBConnection):
...

inject({"request": Request(...)}, scan(require_user))
Adding Type Factories
=====================

Type factories are callables that produce an instance of a certain type when it's requested. This is useful for resources that should be created on-demand.

Dependant that use value resolved by type:
The ``add_factory`` method is highly flexible. You can provide the type explicitly, or ``fundi`` can infer it from the factory's return type annotation.

.. code-block:: python

from urllib.request import Request
# Explicitly providing the type
def create_db_connection() -> DBConnection:
return DBConnection()

from fundi import FromType, scan, inject
scope.add_factory(create_db_connection, DBConnection)

# Inferring the type from the return annotation
def create_another_connection() -> AnotherConnection:
return AnotherConnection()

def require_user(req: FromType[Request]) -> str:
user = req.get_header("User")
scope.add_factory(create_another_connection)

if user is None:
raise Unauthorized()

Additionally, you can pass parameters from the ``scan()`` function directly to ``add_factory`` to control caching and other behaviors.

.. code-block:: python

# Adding a factory with caching enabled
def create_cached_service() -> CachedService:
print("Creating new CachedService instance")
return CachedService()

scope.add_factory(create_cached_service, caching=True)

# The factory will be called only once for the first dependant that needs a CachedService.
# Subsequent requests will receive the cached instance.
def my_dependency(service: CachedService):
...

Mutual Exclusivity
------------------
A type can either have an instance or a factory, but not both. When you add a new instance or factory for a given type, any existing instance or factory for that same type will be removed to prevent conflicts.

Updating a Scope
================

You can update an existing scope with new values, instances, or factories using the :code:`update` method.

.. code-block:: python

import logging
from fundi import Scope, Type

scope = Scope({"user_id": 1})

class Request:
...
request_instance = Request()

def create_logger() -> logging.Logger:
return logging.getLogger(__name__)

scope.update(
{
Request: Type.instance(request_instance),
logging.Logger: Type.factory(create_logger),
},
request_id="xyz",
)
# The scope now contains "user_id", "request_id", Request instance, and a factory for logging.Logger.

Resolving Dependencies
======================

FunDI resolves dependencies from the scope in the following order:

1. By parameter name (matching named values in the scope).
2. By type annotation (matching a typed instance or a type factory in the scope).

Example of a dependant that uses a value from the scope:

.. code-block:: python

from fundi import Scope, inject, scan

def require_user(user_name: str) -> str:
if user_name == "Alice":
return "Welcome, Alice!"
raise ValueError("Unknown user")

scope = Scope({"user_name": "Alice"})
inject(scope, scan(require_user))


Dependant that uses a value resolved by type:

.. code-block:: python

from fundi import Scope, inject, scan

class Request:
def __init__(self, user: str):
self._user = user

def get_header(self, name: str) -> str | None:
if name == "User":
return self._user
return None

def require_user(request: Request) -> str:
user = request.get_header("User")
if user is None:
raise ValueError("Unauthorized")
return user

scope = Scope()
scope.add_type(Request("bob"))
inject(scope, scan(require_user))

inject({"request": Request(...)}, scan(require_user))
.. _from-legacy-scopes:

.. warning::
Working with Legacy Scopes
==========================

If multiple values in the scope share the same type, :code:`FromType[...]` resolution
will prioritize the first match found. Ensure your scope is clean and well-structured.
To maintain backward compatibility, FunDI can automatically convert legacy dictionary-based scopes into :code:`Scope` objects. You can also do this manually using the :code:`from_legacy` class method.

..
.. code-block:: python

from fundi import Scope

legacy_scope = {"request": Request("charlie")}
scope = Scope.from_legacy(legacy_scope)

Merging Scopes
==============

You can merge two scopes using the :code:`|` operator or the :code:`merge` method. If there is a conflict between a typed instance and a factory, the instance will be kept.

.. code-block:: python

Avoid overusing scopes, as it can make debugging more difficult.
scope1 = Scope({"a": 1})
scope2 = Scope({"b": 2})
merged_scope = scope1 | scope2 # or scope1.merge(scope2)
assert merged_scope["a"] == 1
assert merged_scope["b"] == 2
Loading
Loading