pax_global_header00006660000000000000000000000064150677665240014534gustar00rootroot0000000000000052 comment=28b9c2dd5c744778f092b94fdb3794cf214f8172 andi-0.8.0/000077500000000000000000000000001506776652400124545ustar00rootroot00000000000000andi-0.8.0/.git-blame-ignore-revs000066400000000000000000000001241506776652400165510ustar00rootroot00000000000000# applying pre-commit hooks to the project edccf5367a5ffa0de44fe9dd75271a0495c424d2 andi-0.8.0/.github/000077500000000000000000000000001506776652400140145ustar00rootroot00000000000000andi-0.8.0/.github/workflows/000077500000000000000000000000001506776652400160515ustar00rootroot00000000000000andi-0.8.0/.github/workflows/publish.yml000066400000000000000000000015741506776652400202510ustar00rootroot00000000000000# 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.yml000066400000000000000000000031601506776652400175530ustar00rootroot00000000000000# 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/.gitignore000066400000000000000000000002651506776652400144470ustar00rootroot00000000000000# 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.yaml000066400000000000000000000004311506776652400167330ustar00rootroot00000000000000repos: - 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.rst000066400000000000000000000030011506776652400142500ustar00rootroot00000000000000Changes ======= 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/LICENSE000066400000000000000000000027511506776652400134660ustar00rootroot00000000000000Copyright (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.rst000066400000000000000000000524301506776652400141470ustar00rootroot00000000000000==== 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/000077500000000000000000000000001506776652400133675ustar00rootroot00000000000000andi-0.8.0/andi/__init__.py000066400000000000000000000003231506776652400154760ustar00rootroot00000000000000from .andi import CustomBuilder, inspect, plan, Plan, Step from .errors import NonProvidableError __all__ = [ "CustomBuilder", "inspect", "plan", "Plan", "Step", "NonProvidableError", ] andi-0.8.0/andi/andi.py000066400000000000000000000510711506776652400146600ustar00rootroot00000000000000from 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.py000066400000000000000000000047501506776652400152630ustar00rootroot00000000000000from 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.typed000066400000000000000000000000001506776652400150540ustar00rootroot00000000000000andi-0.8.0/andi/typeutils.py000066400000000000000000000125221506776652400160050ustar00rootroot00000000000000import 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.yml000066400000000000000000000001201506776652400146120ustar00rootroot00000000000000comment: layout: "header, diff, tree" coverage: status: project: false andi-0.8.0/pyproject.toml000066400000000000000000000026101506776652400153670ustar00rootroot00000000000000[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/000077500000000000000000000000001506776652400136165ustar00rootroot00000000000000andi-0.8.0/tests/__init__.py000066400000000000000000000000001506776652400157150ustar00rootroot00000000000000andi-0.8.0/tests/test_attrs_support.py000066400000000000000000000006301506776652400201570ustar00rootroot00000000000000import 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.py000066400000000000000000000007501506776652400213140ustar00rootroot00000000000000import 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.py000066400000000000000000000023341506776652400165450ustar00rootroot00000000000000import 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.py000066400000000000000000000051621506776652400167000ustar00rootroot00000000000000from 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.py000066400000000000000000000534011506776652400161640ustar00rootroot00000000000000from 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.py000066400000000000000000000041121506776652400172670ustar00rootroot00000000000000from 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.py000066400000000000000000000003531506776652400153350ustar00rootroot00000000000000from 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.py000066400000000000000000000004561506776652400164430ustar00rootroot00000000000000from __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.py000066400000000000000000000013361506776652400153330ustar00rootroot00000000000000from 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.ini000066400000000000000000000012271506776652400137710ustar00rootroot00000000000000[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