pax_global_header 0000666 0000000 0000000 00000000064 15067766524 0014534 g ustar 00root root 0000000 0000000 52 comment=28b9c2dd5c744778f092b94fdb3794cf214f8172
andi-0.8.0/ 0000775 0000000 0000000 00000000000 15067766524 0012454 5 ustar 00root root 0000000 0000000 andi-0.8.0/.git-blame-ignore-revs 0000664 0000000 0000000 00000000124 15067766524 0016551 0 ustar 00root root 0000000 0000000 # applying pre-commit hooks to the project
edccf5367a5ffa0de44fe9dd75271a0495c424d2
andi-0.8.0/.github/ 0000775 0000000 0000000 00000000000 15067766524 0014014 5 ustar 00root root 0000000 0000000 andi-0.8.0/.github/workflows/ 0000775 0000000 0000000 00000000000 15067766524 0016051 5 ustar 00root root 0000000 0000000 andi-0.8.0/.github/workflows/publish.yml 0000664 0000000 0000000 00000001574 15067766524 0020251 0 ustar 00root root 0000000 0000000 # This workflows will upload a Python Package using Twine when a release is created
# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
name: publish
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+"
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/${{ github.event.repository.name }}
permissions:
id-token: write
steps:
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install build
- name: Build package
run: python -m build
- name: Publish package
uses: pypa/gh-action-pypi-publish@release/v1
andi-0.8.0/.github/workflows/test.yml 0000664 0000000 0000000 00000003160 15067766524 0017553 0 ustar 00root root 0000000 0000000 # This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: tox
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14.0-rc.3']
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install tox
- name: tox
run: |
tox -e py
- name: coverage
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
check:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.13']
tox-job: ["mypy", "twinecheck"]
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install tox
- name: tox
run: |
tox -e ${{ matrix.tox-job }}
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pre-commit/action@v3.0.1
andi-0.8.0/.gitignore 0000664 0000000 0000000 00000000265 15067766524 0014447 0 ustar 00root root 0000000 0000000 # Python
*.pyc
*.pyo
/build/
/dist/
*.egg-info
# Mac OS
*.DS_Store
# IDE
/.idea/
/.vscode/
.mypy_cache/
.cache/
.tox/
.pytest_cache/
.coverage
coverage.xml
htmlcov/
.env/
.venv/
andi-0.8.0/.pre-commit-config.yaml 0000664 0000000 0000000 00000000431 15067766524 0016733 0 ustar 00root root 0000000 0000000 repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.13.2
hooks:
- id: ruff-check
args: [ --fix ]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
andi-0.8.0/CHANGES.rst 0000664 0000000 0000000 00000003001 15067766524 0014250 0 ustar 00root root 0000000 0000000 Changes
=======
0.8.0 (2025-10-03)
------------------
* Add support for Python 3.14.
* Move the build system to ``hatchling``.
* Explicitly re-export public names.
* Linting and CI improvements.
0.7.0 (2025-01-30)
------------------
* Drop support for Python 3.8, add support for Python 3.13.
* Non-typed and non-injectable parameters with a default value no longer
trigger an error when building a plan.
0.6.0 (2023-12-26)
------------------
* Drop support for Python 3.5-3.7.
* Add support for dependencies that need to be built using custom callables.
0.5.0 (2023-12-12)
------------------
* Add support for dependency metadata via ``typing.Annotated`` (requires
Python 3.9+).
* Add docs for overrides.
* Add support for Python 3.10-3.12.
* CI improvements.
0.4.1 (2021-02-11)
------------------
* Overrides support in ``andi.plan``
0.4.0 (2020-04-23)
------------------
* ``andi.inspect`` can handle classes now (their ``__init__`` method
is inspected)
* ``andi.plan`` and ``andi.inspect`` can handle objects which are
callable via ``__call__`` method.
0.3.0 (2020-04-03)
------------------
* ``andi.plan`` function replacing ``andi.to_provide``.
* Rewrite README explaining the new approach based in ``plan`` method.
* ``andi.inspect`` return non annotated arguments also.
0.2.0 (2020-02-14)
------------------
* Better attrs support (workaround issue with string type annotations).
* Declare Python 3.8 support.
* More tests; ensure dataclasses support.
0.1 (2019-08-28)
----------------
Initial release.
andi-0.8.0/LICENSE 0000664 0000000 0000000 00000002751 15067766524 0013466 0 ustar 00root root 0000000 0000000 Copyright (c) Scrapinghub
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of ScrapingHub nor the names of its contributors may be used
to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
andi-0.8.0/README.rst 0000664 0000000 0000000 00000052430 15067766524 0014147 0 ustar 00root root 0000000 0000000 ====
andi
====
.. image:: https://img.shields.io/pypi/v/andi.svg
:target: https://pypi.python.org/pypi/andi
:alt: PyPI Version
.. image:: https://img.shields.io/pypi/pyversions/andi.svg
:target: https://pypi.python.org/pypi/andi
:alt: Supported Python Versions
.. image:: https://github.com/scrapinghub/andi/workflows/tox/badge.svg
:target: https://github.com/scrapinghub/andi/actions
:alt: Build Status
.. image:: https://codecov.io/github/scrapinghub/andi/coverage.svg?branch=master
:target: https://codecov.io/gh/scrapinghub/andi
:alt: Coverage report
``andi`` makes easy implementing custom dependency injection mechanisms
where dependencies are expressed using type annotations.
``andi`` is useful as a building block for frameworks, or as a library
which helps to implement dependency injection (thus the name -
ANnotation-based Dependency Injection).
License is BSD 3-clause.
Installation
============
::
pip install andi
andi requires Python >= 3.9.
Goal
====
See the following classes that represents parts of a car
(and the car itself):
.. code-block:: python
class Valves:
pass
class Engine:
def __init__(self, valves):
self.valves = valves
class Wheels:
pass
class Car:
def __init__(self, engine, wheels):
self.engine = engine
self.wheels = wheels
The following would be the usual way of build a ``Car`` instance:
.. code-block:: python
valves = Valves()
engine = Engine(valves)
wheels = Wheels()
car = Car(engine, wheels)
There are some dependencies between the classes: A car requires
and engine and wheels to be built, as well as the engine requires
valves. These are the car dependencies and sub-dependencies.
The question is, could we have an automatic way of building instances?
For example, could we have a ``build`` function that
given the ``Car`` class or any other class would return an instance
even if the class itself has some other dependencies?
.. code-block:: python
car = build(Car) # Andi helps creating this generic build function
``andi`` inspect the dependency tree and creates a plan making easy creating
such a ``build`` function.
This is how this plan for the ``Car`` class would looks like:
1. Invoke ``Valves`` with empty arguments
2. Invoke ``Engine`` using the instance created in 1 as the argument ``valves``
3. Invoke ``Wheels`` with empty arguments
4. Invoke ``Cars`` with the instance created in 2 as the ``engine`` argument and with
the instance created in 3 as the ``wheels`` argument
Type annotations
----------------
But there is a missing piece in the Car example before. How can
``andi`` know that the class ``Valves`` is required to build the
argument ``valves``? A first idea would be to use the argument
name as a hint for the class name
(as `pinject `_ does),
but ``andi`` opts to rely on arguments' type annotations instead.
The classes for ``Car`` should then be rewritten as:
.. code-block:: python
class Valves:
pass
class Engine:
def __init__(self, valves: Valves):
self.valves = valves
class Wheels:
pass
class Car:
def __init__(self, engine: Engine, wheels: Wheels):
self.engine = engine
self.wheels = wheels
Note how now there is a explicit annotation stating that the
``valves`` argument is of type ``Valves``
(same for ``engine`` and ``wheels``).
The ``andi.plan`` function can now create a plan to build the
``Car`` class (ignore the ``is_injectable`` parameter by now):
.. code-block:: python
plan = andi.plan(Car, is_injectable={Engine, Wheels, Valves})
This is what the ``plan`` variable contains:
.. code-block:: python
[(Valves, {}),
(Engine, {'valves': Valves}),
(Wheels, {}),
(Car, {'engine': Engine,
'wheels': Wheels})]
Note how this plan correspond exactly to the 4-steps plan described
in the previous section.
Building from the plan
----------------------
Creating a generic function to build the instances from
a plan generated by ``andi`` is then very easy:
.. code-block:: python
def build(plan):
instances = {}
for fn_or_cls, kwargs_spec in plan:
instances[fn_or_cls] = fn_or_cls(**kwargs_spec.kwargs(instances))
return instances
So let's see putting all the pieces together. The following code
creates an instance of ``Car`` using ``andi``:
.. code-block:: python
plan = andi.plan(Car, is_injectable={Engine, Wheels, Valves})
instances = build(plan)
car = instances[Car]
is_injectable
-------------
It is not always desired for ``andi`` to manage every single annotation found.
Instead is usually better to explicitly declare which types
can be handled by ``andi``. The argument ``is_injectable``
allows to customize this feature.
``andi`` will raise an error on the presence of a dependency that cannot be resolved
because it is not injectable.
Usually is desirable to declare injectabilty by
creating a base class to inherit from. For example,
we could create a base class ``Injectable`` as base
class for the car components:
.. code-block:: python
class Injectable(ABC):
pass
class Valves(Injectable):
pass
class Engine(Injectable):
def __init__(self, valves: Valves):
self.valves = valves
class Wheels(Injectable):
pass
The call to ``andi.plan`` would then be:
.. code-block:: python
is_injectable = lambda cls: issubclass(cls, Injectable)
plan = andi.plan(Car, is_injectable=is_injectable)
Functions and methods
---------------------
Dependency injection is also very useful when applied to functions.
Imagine that you have a function ``drive`` that drives the ``Car``
through the ``Road``:
.. code-block:: python
class Road(Injectable):
...
def drive(car: Car, road: Road, speed):
... # Drive the car through the road
The dependencies has to be resolved before invoking
the ``drive`` function:
.. code-block:: python
plan = andi.plan(drive, is_injectable=is_injectable)
instances = build(plan.dependencies)
Now the ``drive`` function can be invoked:
.. code-block:: python
drive(instances[Car], instances[Road], 100)
Note that ``speed`` argument was not annotated. The resultant plan just won't include it
because the ``andi.plan`` ``full_final_kwargs`` parameter is ``False``
by default. Otherwise, an exception would have been raised (see ``full_final_kwargs`` argument
documentation for more information).
An alternative and more generic way to invoke the drive function
would be:
.. code-block:: python
drive(speed=100, **plan.final_kwargs(instances))
dataclasses and attrs
---------------------
``andi`` supports classes defined using `attrs `_
and also `dataclasses `_.
For example the ``Car`` class could have been defined as:
.. code-block:: python
# attrs class example
@attr.s(auto_attribs=True)
class Car:
engine: Engine
wheels: Wheels
# dataclass example
@dataclass
class Car(Injectable):
engine: Engine
wheels: Wheels
Using ``attrs`` or ``dataclass`` is handy because they avoid
some boilerplate.
Externally provided dependencies
--------------------------------
Retaining the control over object instantiation
could be desired in some cases. For example creating
a database connection could require accessing some
credentials registry or getting the connection from a pool
so you might want to control building
such instances outside of the regular
dependency injection mechanism.
``andi.plan`` allows to specify which types would be
externally provided. Let's see an example:
.. code-block:: python
class DBConnection(ABC):
@abstractmethod
def getConn():
pass
@dataclass
class UsersDAO:
conn: DBConnection
def getUsers():
return self.conn.query("SELECT * FROM USERS")
``UsersDAO`` requires a database connection to run queries.
But the connection will be provided externally from a pool, so we
call then ``andi.plan`` using also the ``externally_provided``
parameter:
.. code-block:: python
plan = andi.plan(UsersDAO, is_injectable=is_injectable,
externally_provided={DBConnection})
The build method should then be modified slightly to be able
to inject externally provided instances:
.. code-block:: python
def build(plan, instances_stock=None):
instances_stock = instances_stock or {}
instances = {}
for fn_or_cls, kwargs_spec in plan:
if fn_or_cls in instances_stock:
instances[fn_or_cls] = instances_stock[fn_or_cls]
else:
instances[fn_or_cls] = fn_or_cls(**kwargs_spec.kwargs(instances))
return instances
Now we are ready to create ``UserDAO`` instances with ``andi``:
.. code-block:: python
plan = andi.plan(UsersDAO, is_injectable=is_injectable,
externally_provided={DBConnection})
dbconnection = DBPool.get_connection()
instances = build(plan.dependencies, {DBConnection: dbconnection})
users_dao = instances[UsersDAO]
users = user_dao.getUsers()
Note that being injectable is not required for externally provided
dependencies.
Optional
--------
``Optional`` type annotations can be used in case of
dependencies that can be optional. For example:
.. code-block:: python
@dataclass
class Dashboard:
conn: Optional[DBConnection]
def showPage():
if self.conn:
self.conn.query("INSERT INTO VISITS ...")
... # renders a HTML page
In this example, the ``Dashboard`` class generates a HTML page to be served, and
also stores the number of visits into a database. Database
could be absent in some environments, but you might want
the dashboard to work even if it cannot log the visits.
When a database connection is possible the plan call would be:
.. code-block:: python
plan = andi.plan(UsersDAO, is_injectable=is_injectable,
externally_provided={DBConnection})
And the following when the connection is absent:
.. code-block:: python
plan = andi.plan(UsersDAO, is_injectable=is_injectable,
externally_provided={})
It is also required to register the type of ``None``
as injectable. Otherwise ``andi.plan`` with raise an exception
saying that "NoneType is not injectable".
.. code-block:: python
Injectable.register(type(None))
Union
-----
``Union`` can also be used to express alternatives. For example:
.. code-block:: python
@dataclass
class UsersDAO:
conn: Union[ProductionDBConnection, DevelopmentDBConnection]
``DevelopmentDBConnection`` will be injected in the absence of
``ProductionDBConnection``.
Annotated
---------
``Annotated`` type annotations can be used to attach arbitrary metadata that
will be preserved in the plan. Occurrences of the same type annotated with
different metadata will not be considered duplicates. For example:
.. code-block:: python
@dataclass
class Dashboard:
conn_main: Annotated[DBConnection, "main DB"]
conn_stats: Annotated[DBConnection, "stats DB"]
The plan will contain both dependencies.
Custom builders
---------------
Sometimes a dependency can't be created directly but needs some additional code
to be built. And that code can also have its own dependencies:
.. code-block:: python
class Wheels:
pass
def wheel_factory(wheel_builder: WheelBuilder) -> Wheels:
return wheel_builder.get_wheels()
As by default ``andi`` can't know how to create a ``Wheels`` instance or that
the plan needs to create a ``WheelBuilder`` instance first, it needs to be told
this with a ``custom_builder_fn`` argument:
.. code-block:: python
custom_builders = {
Wheels: wheel_factory,
}
plan = andi.plan(Car, is_injectable={Engine, Wheels, Valves},
custom_builder_fn=custom_builders.get,
)
``custom_builder_fn`` should be a function that takes a type and returns a factory
for that type.
The build code also needs to know how to build ``Wheels`` instances. A plan step
for an object built with a custom builder uses an instance of the ``andi.CustomBuilder``
wrapper that contains the type to be built in the ``result_class_or_fn`` attribute and
the callable for building it in the ``factory`` attribute:
.. code-block:: python
from andi import CustomBuilder
def build(plan):
instances = {}
for fn_or_cls, kwargs_spec in plan:
if isinstance(fn_or_cls, CustomBuilder):
instances[fn_or_cls.result_class_or_fn] = fn_or_cls.factory(**kwargs_spec.kwargs(instances))
else:
instances[fn_or_cls] = fn_or_cls(**kwargs_spec.kwargs(instances))
return instances
Full final kwargs mode
-------------------------
By default ``andi.plan`` won't fail if it is not able to provide
some of the direct dependencies for the given input (see the
``speed`` argument in one of the examples above).
This behaviour is desired when inspecting functions
for which is already known that some arguments won't be
injectable but they will be provided by other means
(like the ``drive`` function above).
But in other cases is better to be sure that all dependencies
are fulfilled and otherwise fail. Such is the case for classes.
So it is recommended to set ``full_final_kwargs=True`` when invoking
``andi.plan`` for classes.
Overrides
---------
Let's go back to the ``Car`` example. Imagine you want to build a car again.
But this time you want to replace the ``Engine`` because this is
going to be an electric car!. And of course, an electric engine contains a battery
and have no valves at all. This could be the new ``Engine``:
.. code-block:: python
class Battery:
pass
class ElectricEngine(Engine):
def __init__(self, battery: Battery):
self.battery = valves
Andi offers the possibility to replace dependencies when planning,
and this is what is required to build the electric car: we need
to replace any dependency on ``Engine`` by a dependency on ``ElectricEngine``.
This is exactly what overrides offers. Let's see how ``plan`` should
be invoked in this case:
.. code-block:: python
plan = andi.plan(Car, is_injectable=is_injectable,
overrides={Engine: ElectricEngine}.get)
Note that Andi will unroll the new dependencies properly. That is,
``Valves`` and ``Engine`` won't be in the resultant plan but
``ElectricEngine`` and ``Battery`` will.
In summary, overrides offers a way to override the default
dependencies anywhere in the tree, changing them with an
alternative one.
By default overrides are not recursive: overrides aren't applied
over the children of an already overridden dependency. There
is flag to turn recursion on if this is what is desired.
Check ``andi.plan`` documentation for more information.
Why type annotations?
---------------------
``andi`` uses type annotations to declare dependencies (inputs).
It has several advantages, and some limitations as well.
Advantages:
1. Built-in language feature.
2. You're not lying when specifying a type - these
annotations still work as usual type annotations.
3. In many projects you'd annotate arguments anyways, so ``andi`` support
is "for free".
Limitations:
1. Callable can't have two arguments of the same type.
2. This feature could possibly conflict with regular type annotation usages.
If your callable has two arguments of the same type, consider making them
different types. For example, a callable may receive url and html of
a web page:
.. code-block:: python
def parse(html: str, url: str):
# ...
To make it play well with ``andi``, you may define separate types for url
and for html:
.. code-block:: python
class HTML(str):
pass
class URL(str):
pass
def parse(html: HTML, url: URL):
# ...
This is more boilerplate though.
Why doesn't andi handle creation of objects?
--------------------------------------------
Currently ``andi`` just inspects callable and chooses best concrete types
a framework needs to create and pass to a callable, without prescribing how
to create them. This makes ``andi`` useful in various contexts - e.g.
* creation of some objects may require asynchronous functions, and it
may depend on libraries used (asyncio, twisted, etc.)
* in streaming architectures (e.g. based on Kafka) inspection may happen
on one machine, while creation of objects may happen on different nodes
in a distributed system, and then actually running a callable may happen on
yet another machine.
It is hard to design API with enough flexibility for all such use cases.
That said, ``andi`` may provide more helpers in future,
once patterns emerge, even if they're useful only in certain contexts.
Examples: callback based frameworks
-----------------------------------
Spider example
**************
Nothing better than a example to understand how ``andi`` can be useful.
Let's imagine you want to implemented a callback based framework
for writing spiders to crawl web pages.
The basic idea is that there is framework in which the user
can write spiders. Each spider is a collection of callbacks
that can process data from a page, emit extracted data or request new
pages. Then, there is an engine that takes care of downloading
the web pages
and invoking the user defined callbacks, chaining requests
with its corresponding callback.
Let's see an example of an spider to download recipes
from a cooking page:
.. code-block:: python
class MySpider(Spider):
start_url = "htttp://a_page_with_a_list_of_recipes"
def parse(self, response):
for url in recipes_urls_from_page(response)
yield Request(url, callback=parse_recipe)
def parse_recipe(self, response):
yield extract_recipe(response)
It would be handy if the user can define some requirements
just by annotating parameters in the callbacks. And ``andi`` make it
possible.
For example, a particular callback could require access to the cookies:
.. code-block:: python
def parse(self, response: Response, cookies: CookieJar):
# ... Do something with the response and the cookies
In this case, the engine can use ``andi`` to inspect the ``parse`` method, and
detect that ``Response`` and ``CookieJar`` are required.
Then the framework will build them and will invoke the callback.
This functionality would serve to inject into the users callbacks
some components only when they are required.
It could also serve to encapsulate better the user code. For
example, we could just decouple the recipe extraction into
it's own class:
.. code-block:: python
@dataclass
class RecipeExtractor:
response: Response
def to_item():
return extract_recipe(self.response)
The callback could then be defined as:
.. code-block:: python
def parse_recipe(extractor: RecipeExtractor):
yield extractor.to_item()
Note how handy is that with ``andi`` the engine can create
an instance of ``RecipesExtractor`` feeding it with the
declared ``Response`` dependency.
In definitive, using ``andi`` in such a framework
can provide great flexibility to the user
and reduce boilerplate.
Web server example
******************
``andi`` can be useful also for implementing a new
web framework.
Let's imagine a framework where you can declare your sever in a
class like the following:
.. code-block:: python
class MyWeb(Server):
@route("/products")
def productspage(self, request: Request):
... # return the composed page
@route("/sales")
def salespage(self, request: Request):
... # return the composed page
The former case is composed of two endpoints, one for serving
a page with a summary of sales, and a second one to serve
the products list.
Connection to the database can be required
to sever these pages. This logic could be encapsulated
in some classes:
.. code-block:: python
@dataclass
class Products:
conn: DBConnection
def get_products()
return self.conn.query("SELECT ...")
@dataclass
class Sales:
conn: DBConnection
def get_sales()
return self.conn.query("SELECT ...")
Now ``productspage`` and ``salespage`` methods can just declare
that they require these objects:
.. code-block:: python
class MyWeb(Server):
@route("/products")
def productspage(self, request: Request, products: Products):
... # return the composed page
@route("/sales")
def salespage(self, request: Request, sales: Sales):
... # return the composed page
And the framework can then be responsible to fulfill these
dependencies. The flexibility offered would be a great advantage.
As an example, if would be very easy to create a page that requires
both sales and products:
.. code-block:: python
@route("/overview")
def productspage(self, request: Request,
products: Products, sales: Sales):
... # return the composed overview page
Contributing
============
* Source code: https://github.com/scrapinghub/andi
* Issue tracker: https://github.com/scrapinghub/andi/issues
Use tox_ to run tests with different Python versions::
tox
The command above also runs type checks; we use mypy.
.. _tox: https://tox.readthedocs.io
andi-0.8.0/andi/ 0000775 0000000 0000000 00000000000 15067766524 0013367 5 ustar 00root root 0000000 0000000 andi-0.8.0/andi/__init__.py 0000664 0000000 0000000 00000000323 15067766524 0015476 0 ustar 00root root 0000000 0000000 from .andi import CustomBuilder, inspect, plan, Plan, Step
from .errors import NonProvidableError
__all__ = [
"CustomBuilder",
"inspect",
"plan",
"Plan",
"Step",
"NonProvidableError",
]
andi-0.8.0/andi/andi.py 0000664 0000000 0000000 00000051071 15067766524 0014660 0 ustar 00root root 0000000 0000000 from collections import OrderedDict, defaultdict
from dataclasses import dataclass
from inspect import signature, Parameter
from typing import (
Dict,
List,
Optional,
Set,
Type,
Callable,
Union,
Container,
Tuple,
Any,
Mapping,
MutableMapping, # noqa: F401
)
from andi.typeutils import (
get_union_args,
is_union,
get_globalns,
get_unannotated_params,
get_callable_func_obj,
get_type_hints_with_extras,
strip_annotated,
)
from andi.errors import (
NonProvidableError,
CyclicDependencyErrCase,
LackingAnnotationErrCase,
NonInjectableOrExternalErrCase,
)
def inspect(class_or_func: Callable) -> Dict[str, List[Optional[Type]]]:
"""
For each argument of the ``class_or_func`` return a list of possible types.
Non annotated arguments are also returned with an empty list of possible
types.
``class_or_func`` can be
* a function
* a class - in this case ``cls.__init__`` annotations are returned
* a callable object - in this case ``obj.__call__`` annotations
are returned
"""
func = get_callable_func_obj(class_or_func)
globalns = get_globalns(func)
annotations = get_type_hints_with_extras(func, globalns)
for name in get_unannotated_params(func, annotations):
annotations[name] = None
annotations.pop("return", None)
annotations.pop("self", None) # FIXME: pop first argument of methods
annotations.pop("cls", None)
res = {}
for key, tp in annotations.items():
if is_union(tp):
res[key] = get_union_args(tp)
elif tp is None:
res[key] = []
else:
res[key] = [tp]
return res
def _params_with_default_value(class_or_func: Callable) -> Set[str]:
"""Return a set with the names of the parameters of *class_or_func* that
have a default value."""
result: Set[str] = set()
try:
sig = signature(class_or_func)
except ValueError: # e.g. built-in types.
return result
for name, metadata in sig.parameters.items():
if metadata.default is not Parameter.empty:
result.add(name)
return result
ContainerOrCallableType = Union[Container[Callable], Callable[[Callable], bool]]
class KwargsSpec(Dict[str, Callable]):
"""
kwargs specification. Dict with the name of the argument
and the callable that is required to build an instance for such argument.
"""
def kwargs(self, instances: Mapping[Callable, Any]) -> Dict[str, Any]:
"""
Build the kwargs dict based on the spec using the prebuilt
instances provided in the input dictionary.
:param instances: A dict with the already prebuilt dependencies keyed
by its builder
:return: a kwargs dict ready to be passed to a callable
"""
return {name: instances[cls] for name, cls in self.items()}
Step = Tuple[Callable, KwargsSpec]
class Plan(List[Step]):
"""
The resultant plan of executing the ``plan`` function.
It contains a sequence of steps that should be executed in order because
they are dependant among them.
The plan is itself a list of tuples of type
``Tuple[Callable, KwargsSpec]``. Note that
``KwargsSpec`` is almost a ``Dict[str, Callable]``
so each step in the plan corresponds to
(callable_to_invoke, (argument_name -> callable_to_build_the_argument)).
``plan.full_final_kwargs`` is ``True`` if ``final_kwargs`` function
returns a ``kwargs`` dict containing absolutely all arguments required
to invoke the class/function. In other words, returned dict is not a
incomplete set of ``kwargs``.
"""
def __init__(self, *args, full_final_kwargs: bool = False, **kwargs):
self.full_final_kwargs = full_final_kwargs
super().__init__(*args, **kwargs)
@property
def dependencies(self) -> List[Step]:
"""
The plan required to build the dependencies only for the
``plan`` input function/class.
Useful when it is known that not all dependencies for input
function/class could be resolved. In such a case, it is convenient to
execute the plan for the dependencies and then
have a custom execution step for the
input function/class where those non-resolvable dependencies are
provided by other means.
``plan.dependencies`` is the ``plan`` without the last item ``plan[:-1]``
"""
return self[:-1]
def final_kwargs(self, instances: Mapping[Callable, Any]) -> Dict[str, Any]:
"""
Build the kwargs dict required to invoke the class/function
for which the plan was done for.
Equivalent to ``plan[-1][1].kwargs(instances)``
:param instances: A dict with the already prebuilt dependencies keyed
by its builder
:return: a kwargs dict ready to be passed to a callable
"""
return self[-1][1].kwargs(instances)
OverrideFn = Callable[[Callable], Optional[Callable]]
def plan(
class_or_func: Callable,
*,
is_injectable: ContainerOrCallableType,
externally_provided: Optional[ContainerOrCallableType] = None,
full_final_kwargs=False,
overrides: Optional[OverrideFn] = None,
recursive_overrides: bool = False,
custom_builder_fn: Callable[[Callable], Optional[Callable]] = lambda _: None,
) -> Plan:
"""Plan the sequence of instantiation steps required to fulfill the
the arguments of the given function or the arguments of its
constructor if a class is given instead. In other words, this function
makes dependency injection easy. Type annotations are used
to determine which instance must be built to satisfy the dependency.
The plan is a sequence of steps.
Each step in the plan is a tuple with:
* A callable with the
class/function that must be built/invoked in this step
* A ``KwargsSpec`` with all the kwargs required for the
build/invocation process. This is a dictionary-like object with
the argument names as keys
and classes/functions required to build them as values.
The best way to understand a plan is to see how a typical building
function would use it to build the instances::
def build(plan): # Build all the instances from a plan
instances = {}
for fn_or_cls, kwargs_spec in plan:
kwargs = {arg: instances[arg_builder]
for arg, arg_builder in kwargs_spec.items()}
# or alternatively: kwargs = kwargs_spec.kwargs(instances)
instances[fn_or_cls] = fn_or_cls(**kwargs)
return instances
Note that the generated instances dictionary would contain not only the
dependencies for the class/function given as argument, but also all the
dependencies of the dependencies. In other words, the plan function
is able to plan the whole tree of dependencies.
If the argument ``full_final_kwargs`` is True then this function will fail
with ``NonProvidableError`` if not all the required arguments
for the input class/function can be resolved. When
``full_final_kwargs`` is False, this
function provides the plan only for those arguments that could be resolved.
In other words, the step for the input function/class
(which always corresponds with the last step) could be incomplete when
``full_final_kwargs=False`` (for example, when some
arguments are not annotated
because they will be provided by other means).
In such a cases the above proposed ``build`` function won't work.
The plan properties ``dependencies`` and ``final_kwargs`` come to the
rescue in such cases, and the build process would be slightly different::
plan = andi.plan(func, ...)
instances = build(plan.dependencies)
func(
other_arg='value, # argument that is provided manually
**plan.final_kwargs(instances),
)
Any type found in the dependency tree that is injectable can as well
has its own dependencies. If the planner fails to fulfill the dependencies
of any injectable found in the tree, ``NonProvidableError`` is raised,
regardless of a value of ``full_final_kwargs``
argument (even if full_final_kwargs=False).
This function recursively checks for dependencies. If a cyclic dependency
is found, ``NonProvidableError`` is raised.
See a full example in the following doctest:
>>> class A:
... value = 'a'
...
>>> class B:
... def __init__(self, a: A):
... self.a = a
... self.value = 'b'
...
>>> def fn(a: A, b: B, non_annotated):
... assert b.a is a
... return 'Called with {}, {}, {}'.format(a.value, b.value, non_annotated)
...
>>> def build(plan): # Build all the instances from a plan
... instances = {}
... for fn_or_cls, kwargs_spec in plan:
... instances[fn_or_cls] = fn_or_cls(**kwargs_spec.kwargs(instances))
... return instances
...
>>> plan_steps = plan(fn, is_injectable={A, B})
>>> instances = build(plan_steps.dependencies)
>>> # Finally invoking the function with all the dependencies resolved
>>> fn(non_annotated='non_annotated',
... **plan_steps.final_kwargs(instances))
'Called with a, b, non_annotated'
The returned plan can be directly built when ``full_final_kwargs=True``
is given. See the following example:
>>> class C:
... def __init__(self, a: A, b: B):
... self.a = a
... self.b = b
...
>>> plan_steps = plan(C, is_injectable={A, B, C}, full_final_kwargs=True)
>>> instances = build(plan_steps)
>>> c = instances[C] # Instance of C class with all dependencies resolved
>>> assert type(c) is C
>>> assert c.a is instances[A]
>>> assert c.b is instances[B]
>>> assert c.b.a is instances[A] # Instance of A is reused (singleton)
>>> assert plan_steps.full_final_kwargs
:param class_or_func: Class/Function to plan for building/invocation.
:param is_injectable: A predicate or a container that says if a type
is injectable. The planer is responsible to deal
with all types that are injectable when traversing
the dependency graph. It fails with
``NonProvidableError`` if it is not possible to generate
a plan for any of the injectable type found during the inspection.
:param externally_provided: A predicate or a dictionary that says if
the value for the class/function will be provided externally.
The planner won't try to resolve
its dependencies, so it acts as a way to stop dependency planning
for these classes/functions where we don't want it because they will be
provided by other means.
:param full_final_kwargs: If the argument ``full_final_kwargs``
is True then this function fails
with ``NonProvidableError`` if not all the required arguments
for the input class/function can be resolved.
When ``full_final_kwargs`` is False,
this function provides the plan only for those arguments that could
be resolved, so the last task of the plan could be incomplete.
In other words, the kwargs dict returned by the method
``Plan.final_kwargs`` could not contain all required
arguments to build/invoke the input class/function.
:param overrides: A function that maps a class/function to a different
class/function. The function must return None if no special mapping should
be applied for a particular class/function. The suggested remapping serves
to override a particular dependency in the dependency tree, and
update the plan acordingly. For example, you might want to replace class
``PenneWithTomate`` by ``SpaghettiBolognese`` whenever it is find in the
dependendy tree, but updating the plan so that ``SpaghettiBolognese``
gets its dependencies ``Meat`` and ``Spaghetti`` resolved properly.
:param recursive_overrides: If True, ``overrides`` are applied recursively
to the children dependencies of an overriden class/function.
If False, overrides are not applied to the children of an
overriden node in the dependency tree. Consider the following example.
The class ``PriceInEur`` is wanted to be overriden by the class
``PriceInDollar``, but the later requires using ``PriceInEur`` and then
apply an
exchange rate over the price. That is, ``PriceInDollar`` depends on the
class we want to override. If ``recursive_overrides`` is True, then
an error is raised because a cyclid dependency would have been found:
the dependency of ``PriceInDollar``, which is ``PriceInEur``, will
be also overriden by ``PriceInDollar`` override, leading to a cyclic dependency.
That had't be the case if ``recursive_overrides`` would have been False.
In such a case, the override won't have been applied to the dependency of
``PriceInDollar``, so the plan would have succeed.
:param custom_builder_fn: A function that takes a callable and returns a
different callable if that needs to be called instead of the original one
(e.g. when a class needs to be created using a factory instead of its
constructor) and None otherwise.
:return: A plan
"""
is_injectable = _ensure_can_provide_func(is_injectable)
externally_provided = _ensure_can_provide_func(externally_provided)
overrides = overrides or _empty_overrides
class_or_func, overrides = _may_override(
class_or_func, overrides, recursive_overrides
)
plan, _ = _plan(
class_or_func,
is_injectable=is_injectable,
externally_provided=externally_provided,
full_final_kwargs=full_final_kwargs,
dependency_stack=None,
overrides=overrides,
recursive_overrides=recursive_overrides,
custom_builder_fn=custom_builder_fn,
)
return plan
@dataclass(frozen=True)
class CustomBuilder:
result_class_or_fn: Callable
factory: Callable
def _plan(
class_or_func: Callable,
*,
is_injectable: Callable[[Callable], bool],
externally_provided: Callable[[Callable], bool],
full_final_kwargs,
dependency_stack=None,
overrides: Callable[[Callable], Optional[Callable]],
recursive_overrides: bool = False,
custom_builder_fn: Callable[[Callable], Optional[Callable]] = lambda _: None,
custom_builder_result: Optional[Callable] = None,
) -> Tuple[Plan, List[Tuple]]:
dependency_stack = dependency_stack or []
is_root_call = not dependency_stack # For better code reading
plan_od = OrderedDict() # type: MutableMapping[Union[Callable, CustomBuilder], KwargsSpec]
type_for_arg = KwargsSpec()
if externally_provided(strip_annotated(class_or_func)):
return Plan([(class_or_func, KwargsSpec())], full_final_kwargs=True), []
if not custom_builder_result:
plan_key = class_or_func # type: Union[Callable, CustomBuilder]
else:
plan_key = CustomBuilder(custom_builder_result, class_or_func)
# At this point the class/function must be injectable or built by a custom builder for non root cases
assert (
is_root_call
or custom_builder_result
or is_injectable(strip_annotated(class_or_func))
)
if class_or_func in dependency_stack:
return Plan(), [CyclicDependencyErrCase(class_or_func, dependency_stack)]
dependency_stack = dependency_stack + [plan_key]
arguments = inspect(class_or_func)
have_default = _params_with_default_value(class_or_func)
args_errs = defaultdict(list) # type: Dict[str, List[Tuple]]
non_injectable_errs = defaultdict(list) # type: Dict[str, List[Tuple]]
for argname, types in arguments.items():
sel_cls, arg_overrides = _select_type(
types,
is_injectable,
externally_provided,
overrides,
recursive_overrides,
custom_builder_fn,
)
if sel_cls is not None:
errors = [] # type: List[Tuple]
if sel_cls not in plan_od:
run_plan = True
custom_builder = custom_builder_fn(sel_cls)
if custom_builder:
custom_builder_args = inspect(custom_builder)
for arg_types in custom_builder_args.values():
if class_or_func in arg_types:
# Break the cycle by ignoring the custom builder.
# This allows building an object externally and then using it to build
# another object of the same type, via a custom builder.
if not externally_provided(sel_cls):
non_injectable_errs[argname].append(
NonInjectableOrExternalErrCase(
argname, class_or_func, types
)
)
run_plan = False
custom_builder = None
break
if run_plan:
plan, errors = _plan(
custom_builder or sel_cls,
is_injectable=is_injectable,
externally_provided=externally_provided,
full_final_kwargs=True,
dependency_stack=dependency_stack,
overrides=arg_overrides,
recursive_overrides=recursive_overrides,
custom_builder_fn=custom_builder_fn,
custom_builder_result=sel_cls if custom_builder else None,
)
plan_od.update(plan)
if errors:
args_errs[argname].extend(errors)
else:
type_for_arg[argname] = sel_cls
elif argname not in have_default:
if not types:
err_case = LackingAnnotationErrCase(argname, class_or_func) # type: Tuple
else:
err_case = NonInjectableOrExternalErrCase(argname, class_or_func, types)
non_injectable_errs[argname].append(err_case)
# Error managing
if full_final_kwargs:
args_errs.update(non_injectable_errs)
if is_root_call and args_errs:
raise NonProvidableError(class_or_func, args_errs)
# Plan filling
if not args_errs:
plan_od[plan_key] = type_for_arg
plan = Plan(plan_od.items(), full_final_kwargs=not non_injectable_errs)
flatten_errors = [error for errors in args_errs.values() for error in errors]
return plan, flatten_errors
def _select_type(
types,
is_injectable: Callable[[Callable], bool],
externally_provided: Callable[[Callable], bool],
overrides: Callable[[Callable], Optional[Callable]],
recursive_overrides: bool,
custom_builder_fn: Callable[[Callable], Optional[Callable]] = lambda _: None,
) -> Tuple[Optional[Callable], OverrideFn]:
"""
Choose the first type that can be provided. None otherwise. Also return
the overrides function to be used from now on.
"""
for candidate in types:
candidate, new_overrides = _may_override(
candidate, overrides, recursive_overrides
)
candidate_stripped = strip_annotated(candidate)
if (
is_injectable(candidate_stripped)
or externally_provided(candidate_stripped)
or custom_builder_fn(candidate_stripped) is not None
):
return candidate, new_overrides
return None, overrides
def _empty_overrides(class_or_func: Callable) -> Optional[Callable]:
return None
def _may_override(
class_or_func, overrides: OverrideFn, recursive_overrides: bool
) -> Tuple[Callable, OverrideFn]:
"""
May override ``class_or_func`` if ``overrides`` function suggest it.
In such a case, ``overrides`` function is replaced with ``_empty_overrides``
to stop overriding in children if recursive_overrides is disabled.
"""
override = overrides(class_or_func)
under_override = bool(override and override != class_or_func)
class_or_func = override or class_or_func
stop_overriding = not recursive_overrides and under_override
overrides_for_children = _empty_overrides if stop_overriding else overrides
return class_or_func, overrides_for_children
def _ensure_can_provide_func(
cont_or_call: Optional[ContainerOrCallableType],
) -> Callable[[Callable], bool]:
if cont_or_call is None:
return lambda x: False
if isinstance(cont_or_call, Container):
return cont_or_call.__contains__
return cont_or_call
andi-0.8.0/andi/errors.py 0000664 0000000 0000000 00000004750 15067766524 0015263 0 ustar 00root root 0000000 0000000 from collections import namedtuple
class NonProvidableError(TypeError):
"""Raised when a type is not providable"""
def __init__(self, class_or_func, errors_per_argument):
self.class_or_func = class_or_func
self.errors_per_argument = errors_per_argument
super().__init__(_exception_msg(class_or_func, errors_per_argument))
CyclicDependencyErrCase = namedtuple(
"CyclicDependencyErrCase", "class_or_func,dependency_stack"
)
NonInjectableOrExternalErrCase = namedtuple(
"NonInjectableOrExternalErrCase", "argname,class_or_func,types"
)
LackingAnnotationErrCase = namedtuple(
"LackingAnnotationErrCase", "argname, class_or_func"
)
def _class_or_func_str(class_or_func):
init_str = ".__init__()" if isinstance(class_or_func, type) else ""
return "{}{}".format(class_or_func, init_str)
def _cyclic_dependency_error(class_or_func, dependency_stack):
error = "Cyclic dependency found. Dependency graph: {}".format(
" -> ".join(map(str, dependency_stack + [class_or_func]))
)
return error
def _no_injectable_or_external_error(argname, class_or_func, types):
msg = "Any of {} types are required ".format(types)
msg += "for argument '{}' ".format(argname)
msg += "in '{}' ".format(_class_or_func_str(class_or_func))
msg += "but none of them is injectable or externally providable"
return msg
def _argument_lacking_annotation_error(argname, class_or_func):
msg = "Parameter '{}' is lacking annotations in '{}'".format(
argname, _class_or_func_str(class_or_func)
)
return msg
def _exception_msg(class_or_func, arg_errors):
msg = ""
for idx, (arg, errors) in enumerate(arg_errors.items()):
if idx > 0:
msg += "\n"
msg += "Not possible to generate a plan for argument "
msg += "'{}' in '{}'. ".format(arg, _class_or_func_str(class_or_func))
msg += "Causes:"
for idx, err_case in enumerate(errors):
if isinstance(err_case, CyclicDependencyErrCase):
err_msg = _cyclic_dependency_error(*err_case)
elif isinstance(err_case, NonInjectableOrExternalErrCase):
err_msg = _no_injectable_or_external_error(*err_case)
elif isinstance(err_case, LackingAnnotationErrCase):
err_msg = _argument_lacking_annotation_error(*err_case)
else:
raise Exception("Unexpected type of error. This is a bug.")
msg += "\n {}. {}".format(idx, err_msg)
return msg
andi-0.8.0/andi/py.typed 0000664 0000000 0000000 00000000000 15067766524 0015054 0 ustar 00root root 0000000 0000000 andi-0.8.0/andi/typeutils.py 0000664 0000000 0000000 00000012522 15067766524 0016005 0 ustar 00root root 0000000 0000000 import sys
import inspect
import types
import functools
from typing import (
Annotated,
Union,
List,
Callable,
Dict,
Container,
cast,
Type,
get_args,
get_origin,
get_type_hints,
)
def is_union(tp) -> bool:
"""Return True if a passed type is a typing.Union.
>>> is_union(Union[int, str])
True
>>> is_union(str)
False
>>> is_union(Union)
False
>>> is_union(List[str])
False
"""
return hasattr(tp, "__origin__") and tp.__origin__ is Union
def get_type_hints_with_extras(obj, *args, **kwargs):
"""
Like get_type_hints, but sets include_extras=True
"""
kwargs["include_extras"] = True
return get_type_hints(obj, *args, **kwargs)
def get_union_args(tp) -> List:
"""Return a list of typing.Union args."""
return list(tp.__args__)
def issubclass_safe(cls, bases) -> bool:
"""like issubclass, but return False if cls is not a class, instead of
raising an error:
>>> issubclass_safe(Exception, BaseException)
True
>>> issubclass_safe(Exception, ValueError)
False
>>> issubclass_safe(123, BaseException)
False
"""
try:
return issubclass(cls, bases)
except TypeError:
return False
def get_unannotated_params(func, annotations: Container) -> List[str]:
"""Return a list of ``func`` argument names which are not type annotated.
``annotations`` should be a result of get_type_hints call for ``func``.
>>> from typing import get_type_hints
>>> def foo(x, y: str, *, z, w: int, **kwargs): pass
>>> annotations = get_type_hints(foo)
>>> get_unannotated_params(foo, annotations)
['x', 'z']
"""
ARGS_KWARGS = {
inspect.Parameter.VAR_POSITIONAL, # *args argument
inspect.Parameter.VAR_KEYWORD, # **kwargs argument
}
res = []
for name, param in inspect.signature(func).parameters.items():
if name in annotations or param.kind in ARGS_KWARGS:
continue
res.append(name)
return res
def get_globalns(func: Callable) -> Dict:
"""Return the global namespace that will be used for the resolution
of postponed type annotations.
Based on ``typing.get_type_hints`` code, with a workaround for ``attrs``
issue.
"""
ns = dict(_get_globalns_for_attrs(func))
ns.update(_get_globalns_as_get_type_hints(func))
return ns
def _get_globalns_as_get_type_hints(func: Callable) -> Dict:
"""Global namespace resolution extracted from ``get_type_hints`` method.
Python 3.7 (https://github.com/python/cpython/blob/3.7/Lib/typing.py#L981-L988)
Note that this is only supporting functions as input."""
nsobj = func
# Find globalns for the unwrapped object.
while hasattr(nsobj, "__wrapped__"):
nsobj = getattr(nsobj, "__wrapped__")
return getattr(nsobj, "__globals__", {})
def _get_globalns_for_attrs(func: Callable) -> Dict:
"""Adds partial support for postponed type annotations in attrs classes.
Also required to support attrs classes when
``from __future__ import annotations`` is used (default for python 4.0).
See https://github.com/python-attrs/attrs/issues/593"""
if getattr(func, "__module__", None) in sys.modules:
return dict(sys.modules[func.__module__].__dict__)
else:
# Theoretically this can happen if someone writes
# a custom string to func.__module__. In which case
# such attrs might not be fully introspectable
# (w.r.t. typing.get_type_hints) but will still function
# correctly.
return {}
# from typing module (near get_type_hints), but without ModuleType
_FUNCTION_TYPES = (
types.FunctionType,
types.BuiltinFunctionType,
types.MethodType,
types.WrapperDescriptorType,
types.MethodWrapperType,
types.MethodDescriptorType,
)
def get_callable_func_obj(class_or_func: Callable) -> Callable:
"""
Return a function/method which will be invoked
when func(...) is called. The resulting object should be
supported by ``get_type_hints``.
"""
if not callable(class_or_func):
raise TypeError("%r is not callable" % (class_or_func,))
is_class = isinstance(class_or_func, type)
if is_class:
cls = cast(Type, class_or_func)
return cls.__init__
else:
# we need to check some exact types, because some function-like
# object also have __call__ method, while it is better
# not to use it, as get_type_hints support these objects as-is
if isinstance(class_or_func, _FUNCTION_TYPES):
return class_or_func
if isinstance(class_or_func, functools.partial):
raise NotImplementedError(
"functools.partial support is not implemented; "
"%r is passed" % (class_or_func,)
)
if hasattr(class_or_func, "__call__"):
return class_or_func.__call__ # type: ignore
else:
# not sure how to trigger it
raise TypeError("Unexpected callable object %r" % (class_or_func,))
def is_typing_annotated(o: Callable) -> bool:
"""Return True if the input is typing.Annotated"""
return get_origin(o) == Annotated
def strip_annotated(o: Callable) -> Callable:
"""Return the underlying type for Annotated, the input itself otherwise."""
if is_typing_annotated(o):
return get_args(o)[0]
else:
return o
andi-0.8.0/codecov.yml 0000664 0000000 0000000 00000000120 15067766524 0014612 0 ustar 00root root 0000000 0000000 comment:
layout: "header, diff, tree"
coverage:
status:
project: false
andi-0.8.0/pyproject.toml 0000664 0000000 0000000 00000002610 15067766524 0015367 0 ustar 00root root 0000000 0000000 [build-system]
requires = ["hatchling>=1.27.0"]
build-backend = "hatchling.build"
[project]
name = "andi"
version = "0.8.0"
description = "Library for annotation-based dependency injection"
readme = "README.rst"
license = "BSD-3-Clause"
license-files = ["LICENSE"]
authors = [
{ name = "Mikhail Korobov", email = "kmike84@gmail.com" },
]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
requires-python = ">=3.9"
[project.urls]
Homepage = "https://github.com/scrapinghub/andi"
Source = "https://github.com/scrapy/andi"
Tracker = "https://github.com/scrapy/andi/issues"
[tool.bumpversion]
commit = true
tag = true
tag_name = "{new_version}"
[[tool.bumpversion.files]]
filename = 'CHANGES.rst'
search = "\\(unreleased\\)$"
replace = "({now:%Y-%m-%d})"
regex = true
[tool.coverage.run]
branch = true
[tool.hatch.build.targets.sdist]
include = [
"/andi",
"/tests",
"/CHANGES.rst",
"/pytest.ini",
"/tox.ini"
]
[tool.mypy]
implicit_reexport = false
andi-0.8.0/tests/ 0000775 0000000 0000000 00000000000 15067766524 0013616 5 ustar 00root root 0000000 0000000 andi-0.8.0/tests/__init__.py 0000664 0000000 0000000 00000000000 15067766524 0015715 0 ustar 00root root 0000000 0000000 andi-0.8.0/tests/test_attrs_support.py 0000664 0000000 0000000 00000000630 15067766524 0020157 0 ustar 00root root 0000000 0000000 import andi
from tests.types import A_36, B_36
from tests.types_pep563 import A_37, B_37
def test_attrs_str_type_annotations():
assert andi.inspect(B_36.__init__) == {"a": [A_36]}
assert andi.inspect(A_36.__init__) == {"b": [B_36]}
def test_attrs_pep_563_forward_type_annotations():
assert andi.inspect(B_37.__init__) == {"a": [A_37]}
assert andi.inspect(A_37.__init__) == {"b": [B_37]}
andi-0.8.0/tests/test_dataclasses_support.py 0000664 0000000 0000000 00000000750 15067766524 0021314 0 ustar 00root root 0000000 0000000 import andi
from tests.types import ADCnp, BDCnp
from tests.types_pep563 import ADC, ADCStrRef, BDC
def test_dataclasses():
assert andi.inspect(BDCnp.__init__) == {"a": [ADCnp]}
def test_dataclasses_forward_refs():
assert andi.inspect(ADC.__init__) == {"b": [BDC]}
assert andi.inspect(BDC.__init__) == {"a": [ADC]}
assert andi.inspect(ADCnp.__init__) == {"b": [BDCnp]}
def test_dataclasses_py39_str_ref():
assert andi.inspect(ADCStrRef.__init__) == {"b": [BDC]}
andi-0.8.0/tests/test_errors.py 0000664 0000000 0000000 00000002334 15067766524 0016545 0 ustar 00root root 0000000 0000000 import pytest
from andi.errors import (
CyclicDependencyErrCase,
NonInjectableOrExternalErrCase,
LackingAnnotationErrCase,
_class_or_func_str,
_cyclic_dependency_error,
_argument_lacking_annotation_error,
_no_injectable_or_external_error,
_exception_msg,
)
from tests.test_plan import E, A
def test_error_messages():
cyc = CyclicDependencyErrCase(E, [E, A, E])
no_inj = NonInjectableOrExternalErrCase("args", E, [A, E])
lack = LackingAnnotationErrCase("arg", E)
assert isinstance(_cyclic_dependency_error(*cyc), str)
assert isinstance(_argument_lacking_annotation_error(*lack), str)
assert isinstance(_no_injectable_or_external_error(*no_inj), str)
assert isinstance(
_exception_msg(
E,
{
"a": [cyc, no_inj],
"b": [lack, cyc],
},
),
str,
)
with pytest.raises(Exception):
_exception_msg(E, {"arg": ("a", "b")})
def test_class_or_func_str():
assert _class_or_func_str(E) == ".__init__()"
fn_str = _class_or_func_str(test_class_or_func_str)
assert fn_str.startswith("")
andi-0.8.0/tests/test_inspect.py 0000664 0000000 0000000 00000005162 15067766524 0016700 0 ustar 00root root 0000000 0000000 from functools import wraps, partial
from typing import Union, Optional, TypeVar, Type, Annotated
import pytest
import andi
class Foo:
pass
class Bar:
pass
class Baz:
pass
def test_andi():
def func1(x: Foo):
pass
def func2():
pass
def func3(x: Bar, y: Foo):
pass
assert andi.inspect(Foo.__init__) == {}
assert andi.inspect(func1) == {"x": [Foo]}
assert andi.inspect(func2) == {}
assert andi.inspect(func3) == {"x": [Bar], "y": [Foo]}
def test_union():
def func(x: Union[Foo, Bar]):
pass
assert andi.inspect(func) == {"x": [Foo, Bar]}
def test_optional():
def func(x: Optional[Foo]):
pass
assert andi.inspect(func) == {"x": [Foo, type(None)]}
def test_optional_union():
def func(x: Optional[Union[Foo, Baz]]):
pass
assert andi.inspect(func) == {"x": [Foo, Baz, type(None)]}
def test_not_annotated():
def func(x):
pass
assert andi.inspect(func) == {"x": []}
def test_string_types():
def func(x: "Bar"):
pass
assert andi.inspect(func) == {"x": [Bar]}
def test_string_types_with_fn():
"""String type references not supported for __init__ in classes declared
within functions"""
class Fuu:
def __init__(self, bur: "Bur"):
pass
class Bur:
pass
with pytest.raises(NameError):
andi.inspect(Fuu.__init__)
def test_init_methods():
class MyClass:
def __init__(self, x: Foo):
self.x = x
assert andi.inspect(MyClass.__init__) == {"x": [Foo]}
assert andi.inspect(MyClass) == {"x": [Foo]}
def test_classmethod():
T = TypeVar("T")
class MyClass:
@classmethod
def from_foo(cls: Type[T], foo: Foo) -> T:
return cls()
assert andi.inspect(MyClass.from_foo) == {"foo": [Foo]}
def test_decorated():
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@decorator
def func(x: "Bar"):
pass
assert andi.inspect(func) == {"x": [Bar]}
@pytest.mark.xfail(reason="functools.partial support is not implemented")
def test_partial():
def func(x: Foo, y: Bar):
pass
func_nofoo = partial(func, x=Foo())
assert andi.inspect(func_nofoo) == {"y": [Bar]}
def test_callable_object():
class MyClass:
def __call__(self, x: Bar):
pass
obj = MyClass()
assert andi.inspect(obj) == {"x": [Bar]}
def test_annotations():
def f(x: Annotated[int, 42]) -> None:
pass
assert andi.inspect(f) == {"x": [Annotated[int, 42]]}
andi-0.8.0/tests/test_plan.py 0000664 0000000 0000000 00000053401 15067766524 0016164 0 ustar 00root root 0000000 0000000 from functools import partial
from typing import Union, Optional, Dict, Callable, Annotated
import pytest
import andi
from andi import NonProvidableError
from andi.andi import CustomBuilder
from andi.errors import (
CyclicDependencyErrCase,
NonInjectableOrExternalErrCase,
LackingAnnotationErrCase,
)
from tests.utils import build
from collections import OrderedDict
class A:
def __init__(self, e: "E"):
pass
class B:
pass
class C:
def __init__(self, a: A, b: B):
pass
class D:
def __init__(self, a: A, c: C):
pass
class E:
def __init__(self, d: D, c: C, b: B):
pass
ALL = [A, B, C, D, E]
SOME = [A, B, C]
def _final_kwargs_spec(plan):
return plan[-1][1]
def error_causes(exec_info):
"""Return the error causes in a deterministic order"""
errors = sorted(exec_info.value.errors_per_argument.items(), key=lambda t: t[0])
for _, errs in errors:
errs.sort(key=str)
return errors
def test_plan_and_build():
plan = andi.plan(E, is_injectable=lambda x: True, externally_provided={A})
assert plan == [
(A, {}),
(B, {}),
(C, {"a": A, "b": B}),
(D, {"a": A, "c": C}),
(E, {"b": B, "c": C, "d": D}),
]
assert plan.full_final_kwargs
instances = build(plan, {A: ""})
assert isinstance(instances[E], E)
def test_cyclic_dependency():
plan = andi.plan(
E, is_injectable=lambda x: True, externally_provided={A}
) # No error if externally provided
assert plan is not None
with pytest.raises(andi.NonProvidableError) as exec_info:
andi.plan(E, is_injectable=lambda x: True, externally_provided=[])
expected_errors = [
("c", [(CyclicDependencyErrCase(E, [E, C, A]))]),
(
"d",
[
(CyclicDependencyErrCase(E, [E, D, A])),
(CyclicDependencyErrCase(E, [E, D, C, A])),
],
),
]
assert error_causes(exec_info) == expected_errors
with pytest.raises(andi.NonProvidableError) as exec_info:
andi.plan(
E,
is_injectable=lambda x: True,
externally_provided=[],
full_final_kwargs=True,
)
assert error_causes(exec_info) == expected_errors
@pytest.mark.parametrize(
"cls,is_injectable,externally_provided",
[
(E, ALL, SOME),
(C, SOME, ALL),
(E, ALL, ALL),
],
)
def test_plan_similar_for_class_or_func(cls, is_injectable, externally_provided):
is_injectable = is_injectable + [cl.__init__ for cl in is_injectable]
externally_provided = externally_provided + [
cl.__init__ for cl in externally_provided
]
external_deps = {cl: "external" for cl in externally_provided}
plan_cls = andi.plan(
cls, is_injectable=is_injectable, externally_provided=externally_provided
)
plan_func = andi.plan(
cls.__init__,
is_injectable=is_injectable,
externally_provided=externally_provided,
)
plan_func[-1] = (cls, plan_func[-1][1]) # To make plans compatible
assert plan_cls == plan_func
assert plan_cls.full_final_kwargs
assert plan_cls.full_final_kwargs == plan_func.full_final_kwargs
instances = build(plan_cls, external_deps)
assert isinstance(instances[cls], cls) or instances[cls] == "external"
instances = build(plan_func, external_deps)
assert isinstance(instances[cls], cls) or instances[cls] == "external"
def test_cannot_be_provided():
class WithC:
def __init__(self, c: C):
pass
plan = andi.plan(WithC, is_injectable={B, C}, externally_provided={A})
assert dict(plan) == {A: {}, B: {}, C: {"a": A, "b": B}, WithC: {"c": C}}
assert plan.full_final_kwargs
# partial plan also allowed (C is not required to be injectable):
plan = andi.plan(WithC, is_injectable={B}, externally_provided={A})
assert not plan.full_final_kwargs
# But should fail on full_final_kwargs regimen
with pytest.raises(andi.NonProvidableError) as ex_info:
andi.plan(
WithC, is_injectable={B}, externally_provided={A}, full_final_kwargs=True
)
assert error_causes(ex_info) == [
(
"c",
[NonInjectableOrExternalErrCase("c", WithC, [C])],
)
]
# C is injectable, but A and B are not injectable. So an exception is raised:
# every single injectable dependency found must be satisfiable.
with pytest.raises(andi.NonProvidableError) as ex_info:
andi.plan(WithC, is_injectable=[C], full_final_kwargs=True)
assert error_causes(ex_info) == [
(
"c",
[
NonInjectableOrExternalErrCase("a", C, [A]),
NonInjectableOrExternalErrCase("b", C, [B]),
],
),
]
def test_plan_with_optionals():
def fn(a: Optional[str]):
assert a is None
return "invoked!"
plan = andi.plan(fn, is_injectable={type(None), str}, externally_provided={str})
assert plan == [(str, {}), (fn, {"a": str})]
assert plan.full_final_kwargs
plan = andi.plan(fn, is_injectable={type(None)})
assert plan.dependencies == [(type(None), {})]
assert _final_kwargs_spec(plan) == {"a": type(None)}
assert plan.full_final_kwargs
instances = build(plan)
assert instances[type(None)] is None
assert instances[fn] == "invoked!"
with pytest.raises(andi.NonProvidableError) as ex_info:
andi.plan(fn, is_injectable={}, full_final_kwargs=True)
assert error_causes(ex_info) == [
("a", [NonInjectableOrExternalErrCase("a", fn, [str, type(None)])])
]
def test_plan_with_union():
class WithUnion:
def __init__(self, a_or_b: Union[A, B]):
pass
plan = andi.plan(
WithUnion, is_injectable={WithUnion, A, B}, externally_provided={A}
)
assert plan == [(A, {}), (WithUnion, {"a_or_b": A})]
assert plan.full_final_kwargs
plan = andi.plan(WithUnion, is_injectable={WithUnion, B}, externally_provided={A})
assert plan == [(A, {}), (WithUnion, {"a_or_b": A})]
assert plan.full_final_kwargs
plan = andi.plan(WithUnion, is_injectable={WithUnion, B})
assert plan == [(B, {}), (WithUnion, {"a_or_b": B})]
assert plan.full_final_kwargs
plan = andi.plan(WithUnion, is_injectable={WithUnion}, externally_provided={B})
assert plan == [(B, {}), (WithUnion, {"a_or_b": B})]
assert plan.full_final_kwargs
with pytest.raises(andi.NonProvidableError) as ex_info:
andi.plan(WithUnion, is_injectable={WithUnion}, full_final_kwargs=True)
assert error_causes(ex_info) == [
("a_or_b", [NonInjectableOrExternalErrCase("a_or_b", WithUnion, [A, B])])
]
with pytest.raises(andi.NonProvidableError) as ex_info:
andi.plan(WithUnion, is_injectable={}, full_final_kwargs=True)
assert error_causes(ex_info) == [
("a_or_b", [NonInjectableOrExternalErrCase("a_or_b", WithUnion, [A, B])])
]
def test_plan_with_optionals_and_union():
def fn(str_or_b_or_None: Optional[Union[str, B]]):
return str_or_b_or_None
plan = andi.plan(fn, is_injectable={str, B, type(None)})
assert isinstance(build(plan)[fn], str)
plan = andi.plan(fn, is_injectable={B, type(None)})
assert isinstance(build(plan)[fn], B)
plan = andi.plan(fn, is_injectable={B, type(None)}, externally_provided={str})
assert isinstance(build(plan)[fn], str)
plan = andi.plan(fn, is_injectable={type(None)})
assert build(plan)[fn] is None
plan = andi.plan(fn, is_injectable={type(None)}, externally_provided={str})
assert isinstance(build(plan)[fn], str)
plan = andi.plan(fn, is_injectable={type(None)}, externally_provided={str, B})
assert isinstance(build(plan)[fn], str)
plan = andi.plan(fn, is_injectable={type(None)}, externally_provided={B})
assert isinstance(build(plan)[fn], B)
plan = andi.plan(fn, is_injectable={})
assert plan == [(fn, {})]
assert not plan.full_final_kwargs
with pytest.raises(NonProvidableError) as ex_info:
andi.plan(fn, is_injectable={}, full_final_kwargs=True)
assert error_causes(ex_info) == [
(
"str_or_b_or_None",
[
NonInjectableOrExternalErrCase(
"str_or_b_or_None", fn, [str, B, type(None)]
)
],
)
]
def test_externally_provided():
plan = andi.plan(E.__init__, is_injectable=ALL, externally_provided=ALL)
assert dict(plan.dependencies) == {B: {}, C: {}, D: {}}
assert _final_kwargs_spec(plan) == {"b": B, "c": C, "d": D}
assert plan.full_final_kwargs
plan = andi.plan(E.__init__, is_injectable=[], externally_provided=ALL)
assert dict(plan.dependencies) == {B: {}, C: {}, D: {}}
assert _final_kwargs_spec(plan) == {"b": B, "c": C, "d": D}
assert plan.full_final_kwargs
plan = andi.plan(E, is_injectable=ALL, externally_provided=ALL)
assert plan == [(E, {})]
assert _final_kwargs_spec(plan) == {}
assert plan.dependencies == []
assert plan.full_final_kwargs
plan = andi.plan(E, is_injectable=ALL, externally_provided={A, B, C, D})
assert dict(plan).keys() == {B, C, D, E}
assert plan[-1][0] == E
assert _final_kwargs_spec(plan) == {"b": B, "c": C, "d": D}
assert _final_kwargs_spec(plan) == plan[-1][1]
assert plan.full_final_kwargs
plan = andi.plan(E, is_injectable=ALL, externally_provided={A, B, D})
plan_od = OrderedDict(plan)
seq = list(plan_od.keys())
assert seq.index(A) < seq.index(C)
assert seq.index(B) < seq.index(C)
assert seq.index(D) < seq.index(E)
assert seq.index(C) < seq.index(E)
for cls in (A, B, D):
assert plan_od[cls] == {}
assert plan_od[C] == {"a": A, "b": B}
assert plan_od[E] == {"b": B, "c": C, "d": D}
assert plan.full_final_kwargs
def test_plan_for_func():
def fn(other: str, e: E, c: C):
assert other == "yeah!"
assert isinstance(e, E)
assert isinstance(c, C)
plan = andi.plan(fn, is_injectable=ALL, externally_provided={A})
assert _final_kwargs_spec(plan) == {"e": E, "c": C}
assert not plan.full_final_kwargs
instances = build(plan.dependencies, {A: ""})
fn(other="yeah!", **plan.final_kwargs(instances))
with pytest.raises(TypeError):
build(plan, {A: ""})
with pytest.raises(andi.NonProvidableError) as ex_info:
andi.plan(
fn, is_injectable=ALL, externally_provided=[A], full_final_kwargs=True
)
assert error_causes(ex_info) == [
("other", [NonInjectableOrExternalErrCase("other", fn, [str])])
]
def test_plan_non_annotated_args():
class WithNonAnnArgs:
def __init__(
self, a: A, b: B, non_ann, non_ann_def=0, *, non_ann_kw, non_ann_kw_def=1
):
pass
plan = andi.plan(
WithNonAnnArgs.__init__, is_injectable=ALL, externally_provided={A}
)
assert dict(plan.dependencies) == {A: {}, B: {}}
assert _final_kwargs_spec(plan) == {"a": A, "b": B}
assert not plan.full_final_kwargs
plan_class = andi.plan(WithNonAnnArgs, is_injectable=ALL, externally_provided=[A])
assert plan_class.dependencies == plan.dependencies
assert _final_kwargs_spec(plan_class) == _final_kwargs_spec(plan)
assert not plan.full_final_kwargs
with pytest.raises(TypeError):
build(plan)
instances = build(plan.dependencies, instances_stock={A: ""})
o = WithNonAnnArgs(non_ann=None, non_ann_kw=None, **plan.final_kwargs(instances))
assert isinstance(o, WithNonAnnArgs)
with pytest.raises(andi.NonProvidableError) as ex_info:
andi.plan(
WithNonAnnArgs,
is_injectable=ALL,
externally_provided=[A],
full_final_kwargs=True,
)
assert error_causes(ex_info) == [
("non_ann", [LackingAnnotationErrCase("non_ann", WithNonAnnArgs)]),
("non_ann_kw", [LackingAnnotationErrCase("non_ann_kw", WithNonAnnArgs)]),
]
def test_plan_non_injectable_args():
class WithNonInjArgs:
def __init__(
self,
a: A,
b: B,
non_inj: Optional[str],
non_inj_def: int = 0,
*,
non_inj_kw: Optional[str],
non_inj_kw_def: int = 1,
):
pass
plan = andi.plan(
WithNonInjArgs.__init__, is_injectable=ALL, externally_provided={A}
)
assert dict(plan.dependencies) == {A: {}, B: {}}
assert _final_kwargs_spec(plan) == {"a": A, "b": B}
assert not plan.full_final_kwargs
plan_class = andi.plan(WithNonInjArgs, is_injectable=ALL, externally_provided=[A])
assert plan_class.dependencies == plan.dependencies
assert _final_kwargs_spec(plan_class) == _final_kwargs_spec(plan)
assert not plan.full_final_kwargs
with pytest.raises(TypeError):
build(plan)
instances = build(plan.dependencies, instances_stock={A: ""})
o = WithNonInjArgs(non_inj=None, non_inj_kw=None, **plan.final_kwargs(instances))
assert isinstance(o, WithNonInjArgs)
with pytest.raises(andi.NonProvidableError) as ex_info:
andi.plan(
WithNonInjArgs,
is_injectable=ALL,
externally_provided=[A],
full_final_kwargs=True,
)
assert error_causes(ex_info) == [
(
"non_inj",
[
NonInjectableOrExternalErrCase(
"non_inj", WithNonInjArgs, types=[str, type(None)]
)
],
),
(
"non_inj_kw",
[
NonInjectableOrExternalErrCase(
"non_inj_kw", WithNonInjArgs, types=[str, type(None)]
)
],
),
]
def test_plan_nested_non_injectable_args():
class NonInjParent:
def __init__(self, non_inj_def: int = 0, *, non_inj_kw_def: int = 1):
self.non_inj_def = non_inj_def
self.non_inj_kw_def = non_inj_kw_def
class WithNonInjArgs:
def __init__(self, non_inj_parent: NonInjParent):
self.non_inj_parent = non_inj_parent
plan = andi.plan(
WithNonInjArgs.__init__,
is_injectable={NonInjParent},
)
assert dict(plan.dependencies) == {NonInjParent: {}}
assert _final_kwargs_spec(plan) == {"non_inj_parent": NonInjParent}
assert plan.full_final_kwargs
with pytest.raises(TypeError):
build(plan)
instances = build(plan.dependencies)
o = WithNonInjArgs(**plan.final_kwargs(instances))
assert isinstance(o, WithNonInjArgs)
assert o.non_inj_parent.non_inj_def == 0
assert o.non_inj_parent.non_inj_kw_def == 1
with pytest.raises(andi.NonProvidableError) as ex_info:
andi.plan(
WithNonInjArgs,
is_injectable=ALL,
externally_provided=[A],
full_final_kwargs=True,
)
assert error_causes(ex_info) == [
(
"non_inj_parent",
[
NonInjectableOrExternalErrCase(
"non_inj_parent", WithNonInjArgs, types=[NonInjParent]
)
],
),
]
@pytest.mark.parametrize("full_final_kwargs", [[True], [False]])
def test_plan_no_args(full_final_kwargs):
def fn():
return True
plan = andi.plan(fn, is_injectable=[], full_final_kwargs=full_final_kwargs)
assert plan == [(fn, {})]
assert plan.full_final_kwargs
instances = build(plan)
assert instances[fn]
assert fn(**plan.final_kwargs(instances))
@pytest.mark.parametrize("full_final_kwargs", [[True], [False]])
def test_plan_use_fn_as_annotations(full_final_kwargs):
def fn_ann(b: B):
setattr(b, "modified", True)
return b
def fn(b: fn_ann):
return b
plan = andi.plan(fn, is_injectable=[fn_ann, B], full_final_kwargs=full_final_kwargs)
assert plan.full_final_kwargs
instances = build(plan)
assert instances[fn].modified
def test_plan_callable_object():
class MyFunc:
def __call__(self, b: B):
pass
func = MyFunc()
plan = andi.plan(func, is_injectable={B})
assert plan == [(B, {}), (func, {"b": B})]
@pytest.mark.parametrize("recursive_overrides", [False, True])
def test_plan_overrides(recursive_overrides):
plan_fn = partial(andi.plan, recursive_overrides=recursive_overrides)
plan = plan_fn(A, is_injectable=ALL, overrides={A: B}.get)
assert plan == [(B, {})]
plan = plan_fn(C, is_injectable=ALL, overrides={A: B}.get)
assert plan == [(B, {}), (C, {"a": B, "b": B})]
plan = plan_fn(
C, is_injectable=ALL, externally_provided=[A], overrides={A: B, B: A}.get
)
assert plan == [(B, {}), (A, {}), (C, {"a": B, "b": A})] or plan == [
(A, {}),
(B, {}),
(C, {"a": B, "b": A}),
]
# Check cycle detection
with pytest.raises(NonProvidableError) as exec_info:
plan_fn(C, is_injectable=ALL, overrides={A: C}.get)
expected_errors = [("a", [(CyclicDependencyErrCase(C, [C]))])]
assert error_causes(exec_info) == expected_errors
# Check non injectable override
with pytest.raises(NonProvidableError):
plan_fn(C, is_injectable=ALL, overrides={A: str}.get, full_final_kwargs=True)
if recursive_overrides:
# Check overriding dont stops in the children of the overridden node
with pytest.raises(NonProvidableError) as exec_info:
plan = plan_fn(
C, is_injectable=ALL, externally_provided=[A], overrides={C: D}.get
)
expected_errors = [("c", [(CyclicDependencyErrCase(D, [D]))])]
assert error_causes(exec_info) == expected_errors
plan = plan_fn(
E, is_injectable=ALL, externally_provided=[A], overrides={D: C, A: B}.get
)
assert plan == [(B, {}), (C, {"a": B, "b": B}), (E, {"d": C, "c": C, "b": B})]
else:
# Check overriding stops in the children of the overridden node
plan = plan_fn(
C, is_injectable=ALL, externally_provided=[A], overrides={C: D}.get
)
plan2 = plan_fn(
C, is_injectable=ALL, externally_provided=[A], overrides={C: D, A: B}.get
)
assert plan2 == plan
assert plan == [
(A, {}),
(B, {}),
(C, {"a": A, "b": B}),
(D, {"a": A, "c": C}),
] or plan == [(B, {}), (A, {}), (C, {"a": A, "b": B}), (D, {"a": A, "c": C})]
def test_plan_annotations():
class MyFunc:
def __call__(self, b: Annotated[B, 42]):
pass
func = MyFunc()
plan = andi.plan(func, is_injectable={B})
assert plan == [(Annotated[B, 42], {}), (func, {"b": Annotated[B, 42]})]
def test_plan_annotations_duplicate():
class MyFunc:
def __call__(
self,
b: Annotated[B, 42],
b2: Annotated[B, 43],
b3: Annotated[B, 43],
):
pass
func = MyFunc()
plan = andi.plan(func, is_injectable={B})
assert plan == [
(Annotated[B, 42], {}),
(Annotated[B, 43], {}),
(
func,
{
"b": Annotated[B, 42],
"b2": Annotated[B, 43],
"b3": Annotated[B, 43],
},
),
]
class Item:
def __init__(self, field: int = 42):
self.field = field
def get_item_factory(page_cls: Callable) -> Callable:
def item_factory(page: page_cls) -> Item: # type: ignore[valid-type]
return page.to_item() # type: ignore[attr-defined]
return item_factory
def get_custom_builders(page_cls: Callable) -> Dict[Callable, Callable]:
return {
Item: get_item_factory(page_cls),
}
def test_plan_custom_builder():
# Item is built from Page
class Page:
def __init__(self, b: B):
pass
def to_item(self):
return Item()
def fn(item: Item) -> int:
return item.field + 1
custom_builders = get_custom_builders(Page)
plan = andi.plan(
fn,
is_injectable={B, Page},
externally_provided={Item},
custom_builder_fn=custom_builders.get,
)
assert plan == [
(B, {}),
(Page, {"b": B}),
(CustomBuilder(Item, custom_builders[Item]), {"page": Page}),
(fn, {"item": Item}),
]
instances = build(plan)
assert set(instances.keys()) == {B, Page, Item, fn}
assert instances[fn] == 43
def test_plan_custom_builder_modify_item():
# Item is built directly (externally provided)
# Page is built from that Item
# The final Item is built from Page
class Page:
def __init__(self, b: B, item: Item):
self.item = item
def to_item(self):
return Item(self.item.field * 2)
def fn(item: Item) -> int:
return item.field + 1
custom_builders = get_custom_builders(Page)
plan = andi.plan(
fn,
is_injectable={B, Page},
externally_provided={Item},
custom_builder_fn=custom_builders.get,
)
assert plan == [
(B, {}),
(Item, {}),
(Page, {"b": B, "item": Item}),
(CustomBuilder(Item, custom_builders[Item]), {"page": Page}),
(fn, {"item": Item}),
]
instances = build(plan)
assert set(instances.keys()) == {B, Page, Item, fn}
assert instances[fn] == 85
def test_plan_custom_builder_not_externally_provided():
# Item needs to be built directly but it's not externally provided
class Page:
def __init__(self, b: B, item: Item):
self.item = item
def to_item(self):
return Item(self.item.field)
def fn(item: Item) -> int:
return item.field + 1
custom_builders = get_custom_builders(Page)
with pytest.raises(andi.NonProvidableError) as exc_info:
andi.plan(
fn,
is_injectable={B, Page},
externally_provided=set(),
custom_builder_fn=custom_builders.get,
)
assert error_causes(exc_info) == [
("item", [NonInjectableOrExternalErrCase("item", Page, [Item])])
]
andi-0.8.0/tests/test_typeutils.py 0000664 0000000 0000000 00000004112 15067766524 0017267 0 ustar 00root root 0000000 0000000 from typing import Union, Optional, get_type_hints, Annotated
import pytest
from andi.typeutils import (
get_union_args,
get_callable_func_obj,
get_type_hints_with_extras,
)
def test_get_union_args():
assert get_union_args(Union[str, int]) == [str, int]
def test_get_union_args_optional():
assert get_union_args(Optional[Union[str, int]]) == [str, int, None.__class__]
def test_get_callable_func_obj_functions():
def foo():
pass
assert get_callable_func_obj(foo) is foo
def test_get_callable_func_obj_class():
class Foo:
x = 5
def __init__(self):
pass
def meth(self):
pass
@staticmethod
def staticmeth(cls):
pass
foo = Foo()
# happy path
assert get_callable_func_obj(Foo) is Foo.__init__
assert get_callable_func_obj(Foo.meth) is Foo.meth
assert get_callable_func_obj(Foo.staticmeth) is Foo.staticmeth
assert get_callable_func_obj(foo.meth) == foo.meth
assert get_callable_func_obj(foo.staticmeth) is foo.staticmeth
with pytest.raises(TypeError):
get_callable_func_obj(Foo.x) # type: ignore
with pytest.raises(TypeError):
get_callable_func_obj(foo)
def test_get_callable_func_classmethods():
class Foo:
@classmethod
def clsmeth(cls):
pass
foo = Foo()
assert get_callable_func_obj(Foo.clsmeth) == Foo.clsmeth
assert get_callable_func_obj(foo.clsmeth) == foo.clsmeth
def test_get_callable_func_obj_call():
class Foo:
def __init__(self):
pass
def __call__(self):
pass
def meth(self):
pass
foo = Foo()
assert get_callable_func_obj(Foo) is Foo.__init__
assert get_callable_func_obj(foo.meth) == foo.meth
assert get_callable_func_obj(foo) == foo.__call__
def test_get_hint_extras():
def f(x: Annotated[int, 42]) -> None:
pass
hints = get_type_hints(f)
assert hints["x"] is int
hints_annotated = get_type_hints_with_extras(f)
assert hints_annotated["x"] == Annotated[int, 42]
andi-0.8.0/tests/types.py 0000664 0000000 0000000 00000000353 15067766524 0015335 0 ustar 00root root 0000000 0000000 from dataclasses import dataclass
import attr
@attr.s(auto_attribs=True)
class A_36:
b: "B_36"
@attr.s(auto_attribs=True)
class B_36:
a: A_36
@dataclass
class ADCnp:
b: "BDCnp"
@dataclass
class BDCnp:
a: ADCnp
andi-0.8.0/tests/types_pep563.py 0000664 0000000 0000000 00000000456 15067766524 0016443 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from dataclasses import dataclass
import attr
@attr.s(auto_attribs=True)
class A_37:
b: B_37
@attr.s(auto_attribs=True)
class B_37:
a: A_37
@dataclass
class ADC:
b: BDC
@dataclass
class ADCStrRef:
b: "BDC"
@dataclass
class BDC:
a: ADC
andi-0.8.0/tests/utils.py 0000664 0000000 0000000 00000001336 15067766524 0015333 0 ustar 00root root 0000000 0000000 from typing import Optional, Dict, Any, Callable, List
from andi import Step
from andi.andi import CustomBuilder
def build(
plan: List[Step],
instances_stock: Optional[Dict[Callable, Any]] = None,
) -> Dict[Callable, Any]:
"""Build instances dictionary from a plan"""
instances_stock = instances_stock or {}
instances = {}
for cls, kwargs_spec in plan:
if cls in instances_stock:
instances[cls] = instances_stock[cls]
elif isinstance(cls, CustomBuilder):
instances[cls.result_class_or_fn] = cls.factory(
**kwargs_spec.kwargs(instances)
)
else:
instances[cls] = cls(**kwargs_spec.kwargs(instances))
return instances
andi-0.8.0/tox.ini 0000664 0000000 0000000 00000001227 15067766524 0013771 0 ustar 00root root 0000000 0000000 [tox]
envlist = py39,py310,py311,py312,py313,py314,mypy,twinecheck
[testenv]
deps =
pytest
pytest-cov >= 7.0.0
attrs
commands =
pytest \
--cov-report= --cov-report=xml --cov-report=term-missing --cov=andi \
--doctest-modules \
{posargs:andi tests README.rst}
[testenv:mypy]
deps =
mypy==1.18.2
attrs>=18.2.0
pytest
commands = mypy andi tests
[testenv:twinecheck]
basepython = python3
deps =
twine==6.2.0
build==1.3.0
commands =
python -m build --sdist
twine check dist/*
[testenv:pre-commit]
deps = pre-commit
commands = pre-commit run --all-files --show-diff-on-failure
skip_install = true